fix(iap): acknowledge Google Play purchases only after the entitlement is persisted
This commit is contained in:
parent
1eb22af1f3
commit
957e136789
6 changed files with 667 additions and 144 deletions
334
server/supabase/functions/_shared/google-play-apply.test.ts
Normal file
334
server/supabase/functions/_shared/google-play-apply.test.ts
Normal file
|
|
@ -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> = {}): 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<boolean>) | null = null
|
||||
constructor(private readonly log: string[], private readonly result: NormalizedGooglePlayPurchase) {}
|
||||
verify(
|
||||
_userId: string,
|
||||
_productId: string,
|
||||
_purchaseToken: string,
|
||||
ownsExpiredPurchaseToken: (token: string) => Promise<boolean>,
|
||||
): Promise<NormalizedGooglePlayPurchase> {
|
||||
this.verifyCalls += 1
|
||||
this.ownsLookup = ownsExpiredPurchaseToken
|
||||
this.log.push('verify')
|
||||
return Promise.resolve(this.result)
|
||||
}
|
||||
acknowledge(productId: string, purchaseToken: string): Promise<void> {
|
||||
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<boolean> {
|
||||
this.ownsQueries.push({ userId, purchaseToken })
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
applyVerified(record: VerifiedGooglePlayPurchaseRecord): Promise<StoredGooglePlayPurchase> {
|
||||
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<void> {
|
||||
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<string, unknown>
|
||||
filters: Array<[string, string, unknown]>
|
||||
}
|
||||
|
||||
function fakeSupabaseClient(calls: RecordedCall[], rpcError: { message: string } | null = null) {
|
||||
const client = {
|
||||
rpc(name: string, args: Record<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
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<typeof createSupabaseGooglePlayPurchaseStore>[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',
|
||||
)
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue