From 957e136789134c10f0e3e1dac1c638755d8686d1 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 28 Sep 2026 02:16:20 +0900 Subject: [PATCH] fix(iap): acknowledge Google Play purchases only after the entitlement is persisted --- .../_shared/google-play-apply.test.ts | 334 ++++++++++++++++++ .../functions/_shared/google-play-apply.ts | 144 ++++++++ .../_shared/google-play-purchase-store.ts | 124 +++++++ .../supabase/functions/_shared/google-play.ts | 21 ++ .../functions/google-play-rtdn/index.ts | 105 ++---- server/supabase/functions/iap-verify/index.ts | 83 ++--- 6 files changed, 667 insertions(+), 144 deletions(-) create mode 100644 server/supabase/functions/_shared/google-play-apply.test.ts create mode 100644 server/supabase/functions/_shared/google-play-apply.ts create mode 100644 server/supabase/functions/_shared/google-play-purchase-store.ts diff --git a/server/supabase/functions/_shared/google-play-apply.test.ts b/server/supabase/functions/_shared/google-play-apply.test.ts new file mode 100644 index 0000000..738b431 --- /dev/null +++ b/server/supabase/functions/_shared/google-play-apply.test.ts @@ -0,0 +1,334 @@ +import { + applyGooglePlayPurchase, + GOOGLE_PLAY_ACKNOWLEDGED_STATE, + type GooglePlayPurchaseApi, + type GooglePlayPurchaseStore, + type StoredGooglePlayPurchase, + type VerifiedGooglePlayPurchaseRecord, +} from './google-play-apply.ts' +import { GooglePlayVerificationError, sha256Hex, type NormalizedGooglePlayPurchase } from './google-play.ts' +import { + classifyGooglePlayPersistenceError, + createSupabaseGooglePlayPurchaseStore, + GooglePlayPurchasePersistenceError, +} from './google-play-purchase-store.ts' + +function assert(condition: boolean, message: string): asserts condition { + if (!condition) throw new Error(message) +} + +const USER_ID = '11111111-2222-3333-4444-555555555555' +const PRODUCT_ID = 'd3ro_voice_pro_monthly' +const TOKEN = 'purchase-token-0001' +const PENDING_ACK = 'ACKNOWLEDGEMENT_STATE_PENDING' + +function purchase(overrides: Partial = {}): NormalizedGooglePlayPurchase { + return { + platform: 'google_play', + productId: PRODUCT_ID, + tier: 'pro', + storeTransactionId: 'GPA.1234-5678-9012-34567', + purchaseState: 'purchased', + purchaseAt: '2029-12-01T00:00:00Z', + expiresAt: '2030-01-01T00:00:00Z', + autoRenewing: true, + acknowledged: false, + entitled: true, + verification: { + subscriptionState: 'SUBSCRIPTION_STATE_ACTIVE', + acknowledgementState: PENDING_ACK, + lineItems: [{ productId: PRODUCT_ID, expiryTime: '2030-01-01T00:00:00Z' }], + }, + linkedPurchaseToken: null, + ...overrides, + } +} + +class FakePlayApi implements GooglePlayPurchaseApi { + verifyCalls = 0 + acknowledged: Array<{ productId: string; purchaseToken: string }> = [] + failAcknowledge = false + ownsLookup: ((token: string) => Promise) | null = null + constructor(private readonly log: string[], private readonly result: NormalizedGooglePlayPurchase) {} + verify( + _userId: string, + _productId: string, + _purchaseToken: string, + ownsExpiredPurchaseToken: (token: string) => Promise, + ): Promise { + this.verifyCalls += 1 + this.ownsLookup = ownsExpiredPurchaseToken + this.log.push('verify') + return Promise.resolve(this.result) + } + acknowledge(productId: string, purchaseToken: string): Promise { + this.log.push('acknowledge') + if (this.failAcknowledge) { + return Promise.reject(new GooglePlayVerificationError('google_play_acknowledgement_failed', 502)) + } + this.acknowledged.push({ productId, purchaseToken }) + return Promise.resolve() + } +} + +class FakeStore implements GooglePlayPurchaseStore { + applied: VerifiedGooglePlayPurchaseRecord[] = [] + marked: Array<{ userId: string; purchaseToken: string }> = [] + ownsQueries: Array<{ userId: string; purchaseToken: string }> = [] + reject: GooglePlayPurchasePersistenceError | null = null + result: StoredGooglePlayPurchase = { applied: true, purchase_id: 'p-1', acknowledged: false } + constructor(private readonly log: string[]) {} + ownsPurchaseToken(userId: string, purchaseToken: string): Promise { + this.ownsQueries.push({ userId, purchaseToken }) + return Promise.resolve(true) + } + applyVerified(record: VerifiedGooglePlayPurchaseRecord): Promise { + this.log.push('apply') + if (this.reject) return Promise.reject(this.reject) + this.applied.push(record) + return Promise.resolve(this.result) + } + markAcknowledged(userId: string, purchaseToken: string): Promise { + this.log.push('mark') + this.marked.push({ userId, purchaseToken }) + return Promise.resolve() + } +} + +function setup(verified: NormalizedGooglePlayPurchase = purchase()) { + const log: string[] = [] + const playApi = new FakePlayApi(log, verified) + const store = new FakeStore(log) + return { log, playApi, store } +} + +const input = { userId: USER_ID, productId: PRODUCT_ID, purchaseToken: TOKEN } + +Deno.test('purchase rejected by the database is never acknowledged with Google', async () => { + for (const code of ['active_subscription_other_provider', 'purchase_owned_by_other_user'] as const) { + const { log, playApi, store } = setup() + store.reject = new GooglePlayPurchasePersistenceError(code) + let caught: unknown = null + try { + await applyGooglePlayPurchase({ playApi, store }, input) + } catch (error) { + caught = error + } + assert(caught instanceof GooglePlayPurchasePersistenceError && caught.code === code, `${code} must propagate`) + assert(playApi.acknowledged.length === 0, `${code}: rejected purchase must stay unacknowledged`) + assert(store.marked.length === 0, `${code}: rejected purchase must not be marked acknowledged`) + assert(log.join(',') === 'verify,apply', `${code}: unexpected call order ${log.join(',')}`) + } +}) + +Deno.test('accepted purchase is persisted unacknowledged, then acknowledged, then marked', async () => { + const { log, playApi, store } = setup() + const result = await applyGooglePlayPurchase({ playApi, store }, input) + + assert(log.join(',') === 'verify,apply,acknowledge,mark', `unexpected call order ${log.join(',')}`) + assert(store.applied.length === 1, 'purchase must be persisted once') + const persisted = store.applied[0] + assert(persisted.userId === USER_ID && persisted.purchaseToken === TOKEN, 'persisted identity must match') + assert(persisted.purchase.acknowledged === false, 'persistence must record acknowledged=false before Google ack') + assert( + persisted.purchase.verification.acknowledgementState === GOOGLE_PLAY_ACKNOWLEDGED_STATE, + 'entitled receipt must be stored in its canonical post-acknowledgement form', + ) + assert( + playApi.acknowledged.length === 1 + && playApi.acknowledged[0].productId === PRODUCT_ID + && playApi.acknowledged[0].purchaseToken === TOKEN, + 'Google must be acknowledged for the verified product and token', + ) + assert(store.marked.length === 1 && store.marked[0].userId === USER_ID, 'acknowledgement must be recorded') + assert(result.acknowledgedNow, 'result must report the acknowledgement') + assert(result.purchase.acknowledged, 'returned purchase must be acknowledged') + assert( + result.stored !== null && result.stored.acknowledged === true && result.stored.purchase_id === 'p-1', + 'returned stored row must reflect the acknowledgement', + ) +}) + +Deno.test('duplicate and stale persistence results are still acknowledged', async () => { + const results: StoredGooglePlayPurchase[] = [ + { applied: false, duplicate: true, purchase_id: 'p-1', acknowledged: false }, + { applied: false, duplicate: false, reason: 'stale_provider_event', purchase_id: 'p-1', acknowledged: false }, + ] + for (const stored of results) { + const { playApi, store } = setup() + store.result = stored + await applyGooglePlayPurchase({ playApi, store }, input) + assert(playApi.acknowledged.length === 1, 'stored purchase must be acknowledged to avoid auto-refund') + assert(store.marked.length === 1, 'acknowledgement must be recorded') + } +}) + +Deno.test('failed Google acknowledgement leaves the stored purchase unacknowledged and propagates', async () => { + const { log, playApi, store } = setup() + playApi.failAcknowledge = true + let caught: unknown = null + try { + await applyGooglePlayPurchase({ playApi, store }, input) + } catch (error) { + caught = error + } + assert( + caught instanceof GooglePlayVerificationError && caught.code === 'google_play_acknowledgement_failed' + && caught.status === 502, + 'acknowledgement failure must propagate as 502', + ) + assert(store.applied.length === 1 && store.applied[0].purchase.acknowledged === false, 'row stays acknowledged=false') + assert(store.marked.length === 0, 'failed acknowledgement must not be recorded') + assert(log.join(',') === 'verify,apply,acknowledge', `unexpected call order ${log.join(',')}`) +}) + +Deno.test('already acknowledged purchase is persisted as acknowledged without another Google call', async () => { + const { playApi, store } = setup(purchase({ + acknowledged: true, + verification: { + subscriptionState: 'SUBSCRIPTION_STATE_ACTIVE', + acknowledgementState: GOOGLE_PLAY_ACKNOWLEDGED_STATE, + }, + })) + const result = await applyGooglePlayPurchase({ playApi, store }, input) + assert(store.applied[0].purchase.acknowledged === true, 'acknowledged purchase must persist acknowledged=true') + assert(playApi.acknowledged.length === 0 && store.marked.length === 0, 'no second acknowledgement') + assert(!result.acknowledgedNow && result.purchase.acknowledged, 'result must stay acknowledged') +}) + +Deno.test('non-entitled purchase is persisted verbatim and never acknowledged', async () => { + const { playApi, store } = setup(purchase({ entitled: false, purchaseState: 'pending' })) + const result = await applyGooglePlayPurchase({ playApi, store }, input) + assert( + store.applied[0].purchase.verification.acknowledgementState === PENDING_ACK, + 'non-entitled receipt must keep Google acknowledgement state', + ) + assert(playApi.acknowledged.length === 0 && store.marked.length === 0, 'non-entitled purchase must not be acknowledged') + assert(!result.purchase.acknowledged, 'result must stay unacknowledged') +}) + +Deno.test('preverified purchase skips Google verification but keeps the persist-then-acknowledge order', async () => { + const { log, playApi, store } = setup() + await applyGooglePlayPurchase({ playApi, store }, { ...input, preverified: purchase() }) + assert(playApi.verifyCalls === 0, 'preverified purchase must not be verified again') + assert(log.join(',') === 'apply,acknowledge,mark', `unexpected call order ${log.join(',')}`) +}) + +Deno.test('expired-token ownership lookup is scoped to the purchasing user', async () => { + const { playApi, store } = setup() + await applyGooglePlayPurchase({ playApi, store }, input) + assert(playApi.ownsLookup !== null, 'verify must receive an ownership lookup') + assert(await playApi.ownsLookup('expired-token-0001'), 'lookup must delegate to the store') + assert( + store.ownsQueries.length === 1 + && store.ownsQueries[0].userId === USER_ID + && store.ownsQueries[0].purchaseToken === 'expired-token-0001', + 'lookup must be scoped to the purchasing user', + ) +}) + +Deno.test('database exception messages map to stable persistence codes', () => { + assert( + classifyGooglePlayPersistenceError('purchase_owned_by_other_user') === 'purchase_owned_by_other_user', + 'owner conflict must map', + ) + assert( + classifyGooglePlayPersistenceError('linked_purchase_owned_by_other_user') === 'purchase_owned_by_other_user', + 'linked owner conflict keeps its historical mapping', + ) + assert( + classifyGooglePlayPersistenceError('active_subscription_other_provider') === 'active_subscription_other_provider', + 'provider conflict must map', + ) + assert( + classifyGooglePlayPersistenceError('provider_event_payload_mismatch') === 'purchase_persistence_failed', + 'other failures must map to the generic code', + ) +}) + +interface RecordedCall { + kind: 'rpc' | 'update' + name: string + args: Record + filters: Array<[string, string, unknown]> +} + +function fakeSupabaseClient(calls: RecordedCall[], rpcError: { message: string } | null = null) { + const client = { + rpc(name: string, args: Record) { + calls.push({ kind: 'rpc', name, args, filters: [] }) + return Promise.resolve(rpcError + ? { data: null, error: rpcError } + : { data: { applied: true, purchase_id: 'p-1', acknowledged: false }, error: null }) + }, + from(table: string) { + return { + update(values: Record) { + const call: RecordedCall = { kind: 'update', name: table, args: values, filters: [] } + calls.push(call) + const chain = { + eq(column: string, value: unknown) { + call.filters.push(['eq', column, value]) + return chain + }, + is(column: string, value: unknown) { + call.filters.push(['is', column, value]) + return Promise.resolve({ error: null }) + }, + } + return chain + }, + } + }, + } + return client as unknown as Parameters[0] +} + +Deno.test('Supabase store maps the RPC with hashed tokens and records acknowledgement by direct update', async () => { + const calls: RecordedCall[] = [] + const store = createSupabaseGooglePlayPurchaseStore(fakeSupabaseClient(calls)) + const record = { + userId: USER_ID, + purchaseToken: TOKEN, + purchase: purchase({ linkedPurchaseToken: 'linked-token-0001' }), + } + const stored = await store.applyVerified(record) + await store.markAcknowledged(USER_ID, TOKEN) + + const tokenHash = await sha256Hex(TOKEN) + const [rpc, update] = calls + assert(rpc.kind === 'rpc' && rpc.name === 'apply_verified_google_play_purchase', 'must call the purchase RPC') + assert(rpc.args.p_token_hash === tokenHash, 'token must be hashed') + assert(rpc.args.p_linked_token_hash === await sha256Hex('linked-token-0001'), 'linked token must be hashed') + assert(rpc.args.p_purchase_token === TOKEN && rpc.args.p_user_id === USER_ID, 'identity must be mapped') + assert(rpc.args.p_acknowledged === false, 'acknowledged flag must be forwarded') + assert(stored !== null && stored.purchase_id === 'p-1', 'RPC row must be returned') + assert(calls.filter((call) => call.kind === 'rpc').length === 1, 'acknowledgement must not re-enter the RPC') + assert(update.kind === 'update' && update.name === 'iap_purchases', 'acknowledgement must update iap_purchases') + assert(typeof update.args.acknowledged_at === 'string', 'acknowledged_at must be set') + assert( + JSON.stringify(update.filters) === JSON.stringify([ + ['eq', 'platform', 'google_play'], + ['eq', 'user_id', USER_ID], + ['eq', 'token_hash', tokenHash], + ['is', 'acknowledged_at', null], + ]), + `unexpected update filters ${JSON.stringify(update.filters)}`, + ) +}) + +Deno.test('Supabase store turns RPC rejections into persistence errors', async () => { + const store = createSupabaseGooglePlayPurchaseStore( + fakeSupabaseClient([], { message: 'active_subscription_other_provider' }), + ) + let caught: unknown = null + try { + await store.applyVerified({ userId: USER_ID, purchaseToken: TOKEN, purchase: purchase() }) + } catch (error) { + caught = error + } + assert( + caught instanceof GooglePlayPurchasePersistenceError && caught.code === 'active_subscription_other_provider', + 'RPC rejection must surface as a typed persistence error', + ) +}) diff --git a/server/supabase/functions/_shared/google-play-apply.ts b/server/supabase/functions/_shared/google-play-apply.ts new file mode 100644 index 0000000..eeb2785 --- /dev/null +++ b/server/supabase/functions/_shared/google-play-apply.ts @@ -0,0 +1,144 @@ +// Google Play purchase-application use case shared by iap-verify (client +// initiated) and google-play-rtdn (Pub/Sub initiated). +// +// Ordering policy (the reason this lives in one place): +// 1. verify the token with Google (or reuse a caller-supplied verification), +// 2. persist the purchase and route the entitlement with acknowledged=false, +// 3. acknowledge with Google only after the database accepted the purchase, +// 4. record the acknowledgement on the stored purchase row. +// A purchase the database rejects (another user owns it, another payment +// provider holds or is creating the subscription, ...) is never acknowledged, +// so Google's automatic refund of unacknowledged purchases still applies. +// +// IO is behind two ports so the policy is unit-testable without Google or +// Supabase: GooglePlayPurchaseApi (Google Play Developer API) and +// GooglePlayPurchaseStore (iap_purchases + apply_verified_google_play_purchase). + +import type { NormalizedGooglePlayPurchase } from './google-play.ts' + +export const GOOGLE_PLAY_ACKNOWLEDGED_STATE = 'ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED' + +export interface GooglePlayPurchaseApi { + verify( + userId: string, + productId: string, + purchaseToken: string, + ownsExpiredPurchaseToken: (expiredPurchaseToken: string) => Promise, + ): Promise + acknowledge(productId: string, purchaseToken: string): Promise +} + +export interface VerifiedGooglePlayPurchaseRecord { + userId: string + purchaseToken: string + purchase: NormalizedGooglePlayPurchase +} + +/** Row returned by apply_verified_google_play_purchase (opaque to the use case). */ +export type StoredGooglePlayPurchase = Record | null + +export interface GooglePlayPurchaseStore { + /** True when `purchaseToken` is a Google Play purchase already stored for `userId`. */ + ownsPurchaseToken(userId: string, purchaseToken: string): Promise + /** + * Persists the verified purchase and routes its entitlement. Throws when the + * database rejects the purchase; nothing is stored in that case. + */ + applyVerified(record: VerifiedGooglePlayPurchaseRecord): Promise + /** Records a successful Google acknowledgement on the stored purchase row. */ + markAcknowledged(userId: string, purchaseToken: string): Promise +} + +export interface ApplyGooglePlayPurchaseDeps { + playApi: GooglePlayPurchaseApi + store: GooglePlayPurchaseStore +} + +export interface ApplyGooglePlayPurchaseInput { + userId: string + productId: string + purchaseToken: string + /** Verification already performed by the caller (RTDN out-of-app path). */ + preverified?: NormalizedGooglePlayPurchase | null +} + +export interface ApplyGooglePlayPurchaseResult { + /** Purchase as it stands after this call (acknowledged when acknowledged here). */ + purchase: NormalizedGooglePlayPurchase + stored: StoredGooglePlayPurchase + acknowledgedNow: boolean +} + +function withAcknowledgedVerification( + purchase: NormalizedGooglePlayPurchase, +): NormalizedGooglePlayPurchase { + return { + ...purchase, + verification: { + ...purchase.verification, + acknowledgementState: GOOGLE_PLAY_ACKNOWLEDGED_STATE, + }, + } +} + +/** + * Receipt snapshot persisted for a purchase. + * + * The provider-event id (platform, token hash, state, expiry, entitlement) + * does not include the acknowledgement state, but the provider-event payload + * digest covers the whole verification document. An entitled purchase is + * therefore stored in its post-acknowledgement form, which is the same form + * Google returns on every later verification (client retry, RTDN) and the form + * earlier deployments stored. Keeping one canonical form avoids + * `provider_event_payload_mismatch` across the acknowledgement transition. + * Whether Google actually acknowledged it is tracked by + * iap_purchases.acknowledged_at (p_acknowledged + markAcknowledged). + */ +export function persistableGooglePlayPurchase( + purchase: NormalizedGooglePlayPurchase, +): NormalizedGooglePlayPurchase { + return purchase.entitled ? withAcknowledgedVerification(purchase) : purchase +} + +export function requiresGooglePlayAcknowledgement(purchase: NormalizedGooglePlayPurchase): boolean { + return purchase.entitled && !purchase.acknowledged +} + +export async function applyGooglePlayPurchase( + deps: ApplyGooglePlayPurchaseDeps, + input: ApplyGooglePlayPurchaseInput, +): Promise { + const { playApi, store } = deps + const { userId, productId, purchaseToken } = input + + const verified = input.preverified ?? await playApi.verify( + userId, + productId, + purchaseToken, + (expiredPurchaseToken) => store.ownsPurchaseToken(userId, expiredPurchaseToken), + ) + + // Persist first. A rejection throws here, before Google is told anything. + const stored = await store.applyVerified({ + userId, + purchaseToken, + purchase: persistableGooglePlayPurchase(verified), + }) + + // Any successful persistence (applied, duplicate, or ignored as stale) keeps + // a valid stored purchase, so it must be acknowledged to avoid an automatic + // refund. A failed acknowledgement propagates; the row stays unacknowledged + // and the next verification (client retry or RTDN) acknowledges it. + if (!requiresGooglePlayAcknowledgement(verified)) { + return { purchase: verified, stored, acknowledgedNow: false } + } + + await playApi.acknowledge(verified.productId, purchaseToken) + await store.markAcknowledged(userId, purchaseToken) + + return { + purchase: { ...withAcknowledgedVerification(verified), acknowledged: true }, + stored: stored === null ? null : { ...stored, acknowledged: true }, + acknowledgedNow: true, + } +} diff --git a/server/supabase/functions/_shared/google-play-purchase-store.ts b/server/supabase/functions/_shared/google-play-purchase-store.ts new file mode 100644 index 0000000..1a80ff7 --- /dev/null +++ b/server/supabase/functions/_shared/google-play-purchase-store.ts @@ -0,0 +1,124 @@ +// Supabase adapter for GooglePlayPurchaseStore: iap_purchases lookups, the +// apply_verified_google_play_purchase RPC parameter mapping (token hashing +// included), and acknowledgement bookkeeping. + +import type { createClient } from '@supabase/supabase-js' +import { sha256Hex } from './google-play.ts' +import type { + GooglePlayPurchaseStore, + StoredGooglePlayPurchase, + VerifiedGooglePlayPurchaseRecord, +} from './google-play-apply.ts' + +type SupabaseClient = ReturnType + +export type GooglePlayPersistenceErrorCode = + | 'purchase_owned_by_other_user' + | 'active_subscription_other_provider' + | 'purchase_persistence_failed' + +export class GooglePlayPurchasePersistenceError extends Error { + constructor(public readonly code: GooglePlayPersistenceErrorCode) { + super(code) + this.name = 'GooglePlayPurchasePersistenceError' + } +} + +/** + * Maps a database exception message to the domain code callers act on. + * `linked_purchase_owned_by_other_user` intentionally maps to + * `purchase_owned_by_other_user` (substring match, as before extraction). + */ +export function classifyGooglePlayPersistenceError(message: string): GooglePlayPersistenceErrorCode { + if (message.includes('purchase_owned_by_other_user')) return 'purchase_owned_by_other_user' + if (message.includes('active_subscription_other_provider')) return 'active_subscription_other_provider' + return 'purchase_persistence_failed' +} + +export interface RegisteredGooglePlayPurchase { + userId: string + productId: string +} + +export interface SupabaseGooglePlayPurchaseStore extends GooglePlayPurchaseStore { + /** Stored Google Play purchase for this token regardless of owner, or null. */ + findPurchase(purchaseToken: string): Promise +} + +export function createSupabaseGooglePlayPurchaseStore( + client: SupabaseClient, +): SupabaseGooglePlayPurchaseStore { + return { + async findPurchase(purchaseToken) { + const { data, error } = await client + .from('iap_purchases') + .select('user_id, product_id') + .eq('platform', 'google_play') + .eq('token_hash', await sha256Hex(purchaseToken)) + .maybeSingle() + if (error) throw new Error('purchase_lookup_failed') + if (data === null) return null + const row = data as { user_id?: unknown; product_id?: unknown } + if (typeof row.user_id !== 'string' || typeof row.product_id !== 'string') { + throw new Error('purchase_lookup_failed') + } + return { userId: row.user_id, productId: row.product_id } + }, + + async ownsPurchaseToken(userId, purchaseToken) { + const { data, error } = await client + .from('iap_purchases') + .select('id') + .eq('platform', 'google_play') + .eq('user_id', userId) + .eq('token_hash', await sha256Hex(purchaseToken)) + .maybeSingle() + if (error) throw new Error('expired_purchase_lookup_failed') + return data !== null + }, + + async applyVerified(record: VerifiedGooglePlayPurchaseRecord): Promise { + const { userId, purchaseToken, purchase } = record + const { data, error } = await client.rpc('apply_verified_google_play_purchase', { + p_user_id: userId, + p_platform: purchase.platform, + p_product_id: purchase.productId, + p_store_transaction_id: purchase.storeTransactionId, + p_token_hash: await sha256Hex(purchaseToken), + p_linked_token_hash: purchase.linkedPurchaseToken + ? await sha256Hex(purchase.linkedPurchaseToken) + : null, + p_purchase_token: purchaseToken, + p_purchase_state: purchase.purchaseState, + p_purchase_at: purchase.purchaseAt, + p_expires_at: purchase.expiresAt, + p_auto_renewing: purchase.autoRenewing, + p_acknowledged: purchase.acknowledged, + p_tier: purchase.tier, + p_entitled: purchase.entitled, + p_verification: purchase.verification, + }) + if (error) { + throw new GooglePlayPurchasePersistenceError( + classifyGooglePlayPersistenceError(error.message ?? ''), + ) + } + return data !== null && typeof data === 'object' && !Array.isArray(data) + ? data as Record + : null + }, + + async markAcknowledged(userId, purchaseToken) { + // Direct update, not a second RPC call: re-applying the purchase would + // re-enter the provider-event ledger for bookkeeping only. + const { error } = await client + .from('iap_purchases') + .update({ acknowledged_at: new Date().toISOString() }) + .eq('platform', 'google_play') + .eq('user_id', userId) + .eq('token_hash', await sha256Hex(purchaseToken)) + .is('acknowledged_at', null) + if (error) throw new Error('purchase_acknowledgement_record_failed') + }, + } +} diff --git a/server/supabase/functions/_shared/google-play.ts b/server/supabase/functions/_shared/google-play.ts index b5a87f6..c1c5af9 100644 --- a/server/supabase/functions/_shared/google-play.ts +++ b/server/supabase/functions/_shared/google-play.ts @@ -416,3 +416,24 @@ export async function verifyGooglePlaySubscription( ownsExpiredPurchaseToken, ) } + +/** + * Google Play Developer API adapter for the purchase-application use case + * (structurally satisfies GooglePlayPurchaseApi in google-play-apply.ts). + */ +export function createGooglePlayPurchaseApi(fetchImpl: typeof fetch = fetch): { + verify( + userId: string, + productId: string, + purchaseToken: string, + ownsExpiredPurchaseToken: (expiredPurchaseToken: string) => Promise, + ): Promise + acknowledge(productId: string, purchaseToken: string): Promise +} { + return { + verify: (userId, productId, purchaseToken, ownsExpiredPurchaseToken) => + verifyGooglePlaySubscription(userId, productId, purchaseToken, fetchImpl, ownsExpiredPurchaseToken), + acknowledge: (productId, purchaseToken) => + acknowledgeGooglePlaySubscription(productId, purchaseToken, fetchImpl), + } +} diff --git a/server/supabase/functions/google-play-rtdn/index.ts b/server/supabase/functions/google-play-rtdn/index.ts index dea0de1..8d23ba5 100644 --- a/server/supabase/functions/google-play-rtdn/index.ts +++ b/server/supabase/functions/google-play-rtdn/index.ts @@ -1,14 +1,17 @@ import { createServiceRoleClient } from '../_shared/quota.ts' import { - acknowledgeGooglePlaySubscription, + createGooglePlayPurchaseApi, fetchGooglePlaySubscription, GOOGLE_PLAY_PRODUCT_TIERS, GooglePlayVerificationError, - sha256Hex, - verifyGooglePlaySubscription, verifyGooglePlaySubscriptionPayload, type NormalizedGooglePlayPurchase, } from '../_shared/google-play.ts' +import { applyGooglePlayPurchase } from '../_shared/google-play-apply.ts' +import { + createSupabaseGooglePlayPurchaseStore, + type RegisteredGooglePlayPurchase, +} from '../_shared/google-play-purchase-store.ts' import { GooglePubSubError, parseGooglePlayRtdn, @@ -27,6 +30,7 @@ Deno.serve(async (req: Request) => { let eventId: string | null = null const serviceClient = createServiceRoleClient() + const purchaseStore = createSupabaseGooglePlayPurchaseStore(serviceClient) try { await verifyGooglePubSubIdentity(req) const notification = parseGooglePlayRtdn(await req.json()) @@ -34,16 +38,9 @@ Deno.serve(async (req: Request) => { return jsonResponse({ success: true, test: true }) } - const currentTokenHash = await sha256Hex(notification.purchaseToken) - const { data: knownPurchase, error: purchaseError } = await serviceClient - .from('iap_purchases') - .select('user_id, product_id') - .eq('platform', 'google_play') - .eq('token_hash', currentTokenHash) - .maybeSingle() - if (purchaseError) throw new Error('purchase_lookup_failed') - - let purchaseRecord = knownPurchase as { user_id?: unknown; product_id?: unknown } | null + let purchaseRecord: RegisteredGooglePlayPurchase | null = await purchaseStore.findPurchase( + notification.purchaseToken, + ) let preverifiedPurchase: NormalizedGooglePlayPurchase | null = null // Google Play subscriptions-center re-subscriptions can notify the server @@ -65,46 +62,21 @@ Deno.serve(async (req: Request) => { throw new GooglePubSubError('purchase_not_registered', 503) } - const { data: previousPurchase, error: previousError } = await serviceClient - .from('iap_purchases') - .select('user_id') - .eq('platform', 'google_play') - .eq('token_hash', await sha256Hex(expiredToken)) - .maybeSingle() - if (previousError) throw new Error('previous_purchase_lookup_failed') - const previous = previousPurchase as { user_id?: unknown } | null - if (typeof previous?.user_id !== 'string') { + const previous = await purchaseStore.findPurchase(expiredToken) + if (!previous) { throw new GooglePubSubError('purchase_not_registered', 503) } - purchaseRecord = { - user_id: previous.user_id, - product_id: recognizedItems[0].productId, - } + const productId = recognizedItems[0].productId as string + purchaseRecord = { userId: previous.userId, productId } preverifiedPurchase = await verifyGooglePlaySubscriptionPayload( - previous.user_id, - recognizedItems[0].productId as string, + previous.userId, + productId, verification, (candidate) => Promise.resolve(candidate === expiredToken), ) } - if (typeof purchaseRecord.user_id !== 'string' || typeof purchaseRecord.product_id !== 'string') { - throw new Error('purchase_lookup_failed') - } - - const ownsExpiredPurchaseToken = async (expiredToken: string): Promise => { - const { data: prior, error: priorError } = await serviceClient - .from('iap_purchases') - .select('id') - .eq('platform', 'google_play') - .eq('user_id', purchaseRecord.user_id as string) - .eq('token_hash', await sha256Hex(expiredToken)) - .maybeSingle() - if (priorError) throw new Error('previous_purchase_lookup_failed') - return prior !== null - } - const { data: insertedEvent, error: insertError } = await serviceClient .from('store_notification_events') .insert({ @@ -133,44 +105,15 @@ Deno.serve(async (req: Request) => { eventId = insertedRecord.id } - let purchase = preverifiedPurchase ?? await verifyGooglePlaySubscription( - purchaseRecord.user_id, - purchaseRecord.product_id, - notification.purchaseToken, - fetch, - ownsExpiredPurchaseToken, + await applyGooglePlayPurchase( + { playApi: createGooglePlayPurchaseApi(fetch), store: purchaseStore }, + { + userId: purchaseRecord.userId, + productId: purchaseRecord.productId, + purchaseToken: notification.purchaseToken, + preverified: preverifiedPurchase, + }, ) - if (purchase.entitled && !purchase.acknowledged) { - await acknowledgeGooglePlaySubscription(purchase.productId, notification.purchaseToken) - purchase = { - ...purchase, - acknowledged: true, - verification: { - ...purchase.verification, - acknowledgementState: 'ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED', - }, - } - } - const { error: applyError } = await serviceClient.rpc('apply_verified_google_play_purchase', { - p_user_id: purchaseRecord.user_id, - p_platform: purchase.platform, - p_product_id: purchase.productId, - p_store_transaction_id: purchase.storeTransactionId, - p_token_hash: currentTokenHash, - p_linked_token_hash: purchase.linkedPurchaseToken - ? await sha256Hex(purchase.linkedPurchaseToken) - : null, - p_purchase_token: notification.purchaseToken, - p_purchase_state: purchase.purchaseState, - p_purchase_at: purchase.purchaseAt, - p_expires_at: purchase.expiresAt, - p_auto_renewing: purchase.autoRenewing, - p_acknowledged: purchase.acknowledged, - p_tier: purchase.tier, - p_entitled: purchase.entitled, - p_verification: purchase.verification, - }) - if (applyError) throw new Error('purchase_persistence_failed') if (!eventId) throw new Error('notification_lookup_failed') const { error: completeError } = await serviceClient diff --git a/server/supabase/functions/iap-verify/index.ts b/server/supabase/functions/iap-verify/index.ts index 2e550cd..df9643b 100644 --- a/server/supabase/functions/iap-verify/index.ts +++ b/server/supabase/functions/iap-verify/index.ts @@ -2,11 +2,14 @@ import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts' import { authErrorResponse, requireUser, type AuthError } from '../_shared/auth.ts' import { createServiceRoleClient } from '../_shared/quota.ts' import { - acknowledgeGooglePlaySubscription, + createGooglePlayPurchaseApi, GooglePlayVerificationError, - sha256Hex, - verifyGooglePlaySubscription, } from '../_shared/google-play.ts' +import { applyGooglePlayPurchase } from '../_shared/google-play-apply.ts' +import { + createSupabaseGooglePlayPurchaseStore, + GooglePlayPurchasePersistenceError, +} from '../_shared/google-play-purchase-store.ts' interface VerifyPurchaseRequest { platform?: unknown @@ -40,69 +43,20 @@ Deno.serve(async (req: Request) => { return jsonResponse({ error: 'invalid_request' }, 400) } - const serviceClient = createServiceRoleClient() - const ownsExpiredPurchaseToken = async (expiredToken: string): Promise => { - const { data: previous, error: previousError } = await serviceClient - .from('iap_purchases') - .select('id') - .eq('platform', 'google_play') - .eq('user_id', user.id) - .eq('token_hash', await sha256Hex(expiredToken)) - .maybeSingle() - if (previousError) throw new Error('expired_purchase_lookup_failed') - return previous !== null - } - - let purchase = await verifyGooglePlaySubscription( - user.id, - body.productId, - body.purchaseToken, - fetch, - ownsExpiredPurchaseToken, + const { purchase, stored } = await applyGooglePlayPurchase( + { + playApi: createGooglePlayPurchaseApi(fetch), + store: createSupabaseGooglePlayPurchaseStore(createServiceRoleClient()), + }, + { + userId: user.id, + productId: body.productId, + purchaseToken: body.purchaseToken, + }, ) - if (purchase.entitled && !purchase.acknowledged) { - await acknowledgeGooglePlaySubscription(purchase.productId, body.purchaseToken) - purchase = { - ...purchase, - acknowledged: true, - verification: { - ...purchase.verification, - acknowledgementState: 'ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED', - }, - } - } - const { data, error } = await serviceClient.rpc('apply_verified_google_play_purchase', { - p_user_id: user.id, - p_platform: purchase.platform, - p_product_id: purchase.productId, - p_store_transaction_id: purchase.storeTransactionId, - p_token_hash: await sha256Hex(body.purchaseToken), - p_linked_token_hash: purchase.linkedPurchaseToken - ? await sha256Hex(purchase.linkedPurchaseToken) - : null, - p_purchase_token: body.purchaseToken, - p_purchase_state: purchase.purchaseState, - p_purchase_at: purchase.purchaseAt, - p_expires_at: purchase.expiresAt, - p_auto_renewing: purchase.autoRenewing, - p_acknowledged: purchase.acknowledged, - p_tier: purchase.tier, - p_entitled: purchase.entitled, - p_verification: purchase.verification, - }) - - if (error) { - if (error.message.includes('purchase_owned_by_other_user')) { - return jsonResponse({ error: 'purchase_owned_by_other_user' }, 409) - } - if (error.message.includes('active_subscription_other_provider')) { - return jsonResponse({ error: 'active_subscription_other_provider' }, 409) - } - throw new Error('purchase_persistence_failed') - } return jsonResponse({ - purchase: data, + purchase: stored, verification: { product_id: purchase.productId, purchase_state: purchase.purchaseState, @@ -122,6 +76,9 @@ Deno.serve(async (req: Request) => { return authErrorResponse(error as AuthError, corsHeaders) } } + if (error instanceof GooglePlayPurchasePersistenceError && error.code !== 'purchase_persistence_failed') { + return jsonResponse({ error: error.code }, 409) + } if (error instanceof GooglePlayVerificationError) { return jsonResponse({ error: error.code }, error.status) }