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

124 lines
4.7 KiB
TypeScript

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