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