124 lines
4.7 KiB
TypeScript
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')
|
|
},
|
|
}
|
|
}
|