// 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 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 } 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 { 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 : 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') }, } }