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
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,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue