// 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, } }