144 lines
5.6 KiB
TypeScript
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,
|
|
}
|
|
}
|