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',
|
||||||
|
)
|
||||||
|
})
|
||||||
144
server/supabase/functions/_shared/google-play-apply.ts
Normal file
144
server/supabase/functions/_shared/google-play-apply.ts
Normal file
|
|
@ -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<boolean>,
|
||||||
|
): Promise<NormalizedGooglePlayPurchase>
|
||||||
|
acknowledge(productId: string, purchaseToken: string): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown> | null
|
||||||
|
|
||||||
|
export interface GooglePlayPurchaseStore {
|
||||||
|
/** True when `purchaseToken` is a Google Play purchase already stored for `userId`. */
|
||||||
|
ownsPurchaseToken(userId: string, purchaseToken: string): Promise<boolean>
|
||||||
|
/**
|
||||||
|
* 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<StoredGooglePlayPurchase>
|
||||||
|
/** Records a successful Google acknowledgement on the stored purchase row. */
|
||||||
|
markAcknowledged(userId: string, purchaseToken: string): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ApplyGooglePlayPurchaseResult> {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
124
server/supabase/functions/_shared/google-play-purchase-store.ts
Normal file
124
server/supabase/functions/_shared/google-play-purchase-store.ts
Normal file
|
|
@ -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<typeof createClient>
|
||||||
|
|
||||||
|
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<RegisteredGooglePlayPurchase | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<StoredGooglePlayPurchase> {
|
||||||
|
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<string, unknown>
|
||||||
|
: 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')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -416,3 +416,24 @@ export async function verifyGooglePlaySubscription(
|
||||||
ownsExpiredPurchaseToken,
|
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<boolean>,
|
||||||
|
): Promise<NormalizedGooglePlayPurchase>
|
||||||
|
acknowledge(productId: string, purchaseToken: string): Promise<void>
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
verify: (userId, productId, purchaseToken, ownsExpiredPurchaseToken) =>
|
||||||
|
verifyGooglePlaySubscription(userId, productId, purchaseToken, fetchImpl, ownsExpiredPurchaseToken),
|
||||||
|
acknowledge: (productId, purchaseToken) =>
|
||||||
|
acknowledgeGooglePlaySubscription(productId, purchaseToken, fetchImpl),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
import { createServiceRoleClient } from '../_shared/quota.ts'
|
import { createServiceRoleClient } from '../_shared/quota.ts'
|
||||||
import {
|
import {
|
||||||
acknowledgeGooglePlaySubscription,
|
createGooglePlayPurchaseApi,
|
||||||
fetchGooglePlaySubscription,
|
fetchGooglePlaySubscription,
|
||||||
GOOGLE_PLAY_PRODUCT_TIERS,
|
GOOGLE_PLAY_PRODUCT_TIERS,
|
||||||
GooglePlayVerificationError,
|
GooglePlayVerificationError,
|
||||||
sha256Hex,
|
|
||||||
verifyGooglePlaySubscription,
|
|
||||||
verifyGooglePlaySubscriptionPayload,
|
verifyGooglePlaySubscriptionPayload,
|
||||||
type NormalizedGooglePlayPurchase,
|
type NormalizedGooglePlayPurchase,
|
||||||
} from '../_shared/google-play.ts'
|
} 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 {
|
import {
|
||||||
GooglePubSubError,
|
GooglePubSubError,
|
||||||
parseGooglePlayRtdn,
|
parseGooglePlayRtdn,
|
||||||
|
|
@ -27,6 +30,7 @@ Deno.serve(async (req: Request) => {
|
||||||
|
|
||||||
let eventId: string | null = null
|
let eventId: string | null = null
|
||||||
const serviceClient = createServiceRoleClient()
|
const serviceClient = createServiceRoleClient()
|
||||||
|
const purchaseStore = createSupabaseGooglePlayPurchaseStore(serviceClient)
|
||||||
try {
|
try {
|
||||||
await verifyGooglePubSubIdentity(req)
|
await verifyGooglePubSubIdentity(req)
|
||||||
const notification = parseGooglePlayRtdn(await req.json())
|
const notification = parseGooglePlayRtdn(await req.json())
|
||||||
|
|
@ -34,16 +38,9 @@ Deno.serve(async (req: Request) => {
|
||||||
return jsonResponse({ success: true, test: true })
|
return jsonResponse({ success: true, test: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentTokenHash = await sha256Hex(notification.purchaseToken)
|
let purchaseRecord: RegisteredGooglePlayPurchase | null = await purchaseStore.findPurchase(
|
||||||
const { data: knownPurchase, error: purchaseError } = await serviceClient
|
notification.purchaseToken,
|
||||||
.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 preverifiedPurchase: NormalizedGooglePlayPurchase | null = null
|
let preverifiedPurchase: NormalizedGooglePlayPurchase | null = null
|
||||||
|
|
||||||
// Google Play subscriptions-center re-subscriptions can notify the server
|
// 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)
|
throw new GooglePubSubError('purchase_not_registered', 503)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: previousPurchase, error: previousError } = await serviceClient
|
const previous = await purchaseStore.findPurchase(expiredToken)
|
||||||
.from('iap_purchases')
|
if (!previous) {
|
||||||
.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') {
|
|
||||||
throw new GooglePubSubError('purchase_not_registered', 503)
|
throw new GooglePubSubError('purchase_not_registered', 503)
|
||||||
}
|
}
|
||||||
|
|
||||||
purchaseRecord = {
|
const productId = recognizedItems[0].productId as string
|
||||||
user_id: previous.user_id,
|
purchaseRecord = { userId: previous.userId, productId }
|
||||||
product_id: recognizedItems[0].productId,
|
|
||||||
}
|
|
||||||
preverifiedPurchase = await verifyGooglePlaySubscriptionPayload(
|
preverifiedPurchase = await verifyGooglePlaySubscriptionPayload(
|
||||||
previous.user_id,
|
previous.userId,
|
||||||
recognizedItems[0].productId as string,
|
productId,
|
||||||
verification,
|
verification,
|
||||||
(candidate) => Promise.resolve(candidate === expiredToken),
|
(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<boolean> => {
|
|
||||||
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
|
const { data: insertedEvent, error: insertError } = await serviceClient
|
||||||
.from('store_notification_events')
|
.from('store_notification_events')
|
||||||
.insert({
|
.insert({
|
||||||
|
|
@ -133,44 +105,15 @@ Deno.serve(async (req: Request) => {
|
||||||
eventId = insertedRecord.id
|
eventId = insertedRecord.id
|
||||||
}
|
}
|
||||||
|
|
||||||
let purchase = preverifiedPurchase ?? await verifyGooglePlaySubscription(
|
await applyGooglePlayPurchase(
|
||||||
purchaseRecord.user_id,
|
{ playApi: createGooglePlayPurchaseApi(fetch), store: purchaseStore },
|
||||||
purchaseRecord.product_id,
|
{
|
||||||
notification.purchaseToken,
|
userId: purchaseRecord.userId,
|
||||||
fetch,
|
productId: purchaseRecord.productId,
|
||||||
ownsExpiredPurchaseToken,
|
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')
|
if (!eventId) throw new Error('notification_lookup_failed')
|
||||||
|
|
||||||
const { error: completeError } = await serviceClient
|
const { error: completeError } = await serviceClient
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,14 @@ import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
||||||
import { authErrorResponse, requireUser, type AuthError } from '../_shared/auth.ts'
|
import { authErrorResponse, requireUser, type AuthError } from '../_shared/auth.ts'
|
||||||
import { createServiceRoleClient } from '../_shared/quota.ts'
|
import { createServiceRoleClient } from '../_shared/quota.ts'
|
||||||
import {
|
import {
|
||||||
acknowledgeGooglePlaySubscription,
|
createGooglePlayPurchaseApi,
|
||||||
GooglePlayVerificationError,
|
GooglePlayVerificationError,
|
||||||
sha256Hex,
|
|
||||||
verifyGooglePlaySubscription,
|
|
||||||
} from '../_shared/google-play.ts'
|
} 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 {
|
interface VerifyPurchaseRequest {
|
||||||
platform?: unknown
|
platform?: unknown
|
||||||
|
|
@ -40,69 +43,20 @@ Deno.serve(async (req: Request) => {
|
||||||
return jsonResponse({ error: 'invalid_request' }, 400)
|
return jsonResponse({ error: 'invalid_request' }, 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
const serviceClient = createServiceRoleClient()
|
const { purchase, stored } = await applyGooglePlayPurchase(
|
||||||
const ownsExpiredPurchaseToken = async (expiredToken: string): Promise<boolean> => {
|
{
|
||||||
const { data: previous, error: previousError } = await serviceClient
|
playApi: createGooglePlayPurchaseApi(fetch),
|
||||||
.from('iap_purchases')
|
store: createSupabaseGooglePlayPurchaseStore(createServiceRoleClient()),
|
||||||
.select('id')
|
},
|
||||||
.eq('platform', 'google_play')
|
{
|
||||||
.eq('user_id', user.id)
|
userId: user.id,
|
||||||
.eq('token_hash', await sha256Hex(expiredToken))
|
productId: body.productId,
|
||||||
.maybeSingle()
|
purchaseToken: body.purchaseToken,
|
||||||
if (previousError) throw new Error('expired_purchase_lookup_failed')
|
},
|
||||||
return previous !== null
|
|
||||||
}
|
|
||||||
|
|
||||||
let purchase = await verifyGooglePlaySubscription(
|
|
||||||
user.id,
|
|
||||||
body.productId,
|
|
||||||
body.purchaseToken,
|
|
||||||
fetch,
|
|
||||||
ownsExpiredPurchaseToken,
|
|
||||||
)
|
)
|
||||||
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({
|
return jsonResponse({
|
||||||
purchase: data,
|
purchase: stored,
|
||||||
verification: {
|
verification: {
|
||||||
product_id: purchase.productId,
|
product_id: purchase.productId,
|
||||||
purchase_state: purchase.purchaseState,
|
purchase_state: purchase.purchaseState,
|
||||||
|
|
@ -122,6 +76,9 @@ Deno.serve(async (req: Request) => {
|
||||||
return authErrorResponse(error as AuthError, corsHeaders)
|
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) {
|
if (error instanceof GooglePlayVerificationError) {
|
||||||
return jsonResponse({ error: error.code }, error.status)
|
return jsonResponse({ error: error.code }, error.status)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue