d3ro-voice/server/supabase/functions/_shared/google-play-apply.ts

144 lines
5.6 KiB
TypeScript

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