import { createServiceRoleClient } from '../_shared/quota.ts' import { acknowledgeGooglePlaySubscription, fetchGooglePlaySubscription, GOOGLE_PLAY_PRODUCT_TIERS, GooglePlayVerificationError, sha256Hex, verifyGooglePlaySubscription, verifyGooglePlaySubscriptionPayload, type NormalizedGooglePlayPurchase, } from '../_shared/google-play.ts' import { GooglePubSubError, parseGooglePlayRtdn, verifyGooglePubSubIdentity, } from '../_shared/google-pubsub.ts' function jsonResponse(body: Record, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' }, }) } Deno.serve(async (req: Request) => { if (req.method !== 'POST') return jsonResponse({ error: 'method_not_allowed' }, 405) let eventId: string | null = null const serviceClient = createServiceRoleClient() try { await verifyGooglePubSubIdentity(req) const notification = parseGooglePlayRtdn(await req.json()) if (notification.kind === 'test') { return jsonResponse({ success: true, test: true }) } const currentTokenHash = await sha256Hex(notification.purchaseToken) const { data: knownPurchase, error: purchaseError } = await serviceClient .from('iap_purchases') .select('user_id, product_id') .eq('platform', 'google_play') .eq('token_hash', currentTokenHash) .maybeSingle() if (purchaseError) throw new Error('purchase_lookup_failed') let purchaseRecord = knownPurchase as { user_id?: unknown; product_id?: unknown } | null let preverifiedPurchase: NormalizedGooglePlayPurchase | null = null // Google Play subscriptions-center re-subscriptions can notify the server // before the app has registered the new token. Map those only through the // prior token that our database already owns; never guess a user. if (!purchaseRecord) { const verification = await fetchGooglePlaySubscription(notification.purchaseToken) const recognizedItems = (verification.lineItems ?? []).filter((item) => ( typeof item.productId === 'string' && GOOGLE_PLAY_PRODUCT_TIERS[item.productId] !== undefined )) const expiredToken = verification.outOfAppPurchaseContext?.expiredPurchaseToken if ( recognizedItems.length !== 1 || typeof expiredToken !== 'string' || expiredToken.length < 8 || expiredToken.length > 4096 ) { throw new GooglePubSubError('purchase_not_registered', 503) } const { data: previousPurchase, error: previousError } = await serviceClient .from('iap_purchases') .select('user_id') .eq('platform', 'google_play') .eq('token_hash', await sha256Hex(expiredToken)) .maybeSingle() if (previousError) throw new Error('previous_purchase_lookup_failed') const previous = previousPurchase as { user_id?: unknown } | null if (typeof previous?.user_id !== 'string') { throw new GooglePubSubError('purchase_not_registered', 503) } purchaseRecord = { user_id: previous.user_id, product_id: recognizedItems[0].productId, } preverifiedPurchase = await verifyGooglePlaySubscriptionPayload( previous.user_id, recognizedItems[0].productId as string, verification, (candidate) => Promise.resolve(candidate === expiredToken), ) } if (typeof purchaseRecord.user_id !== 'string' || typeof purchaseRecord.product_id !== 'string') { throw new Error('purchase_lookup_failed') } const ownsExpiredPurchaseToken = async (expiredToken: string): Promise => { const { data: prior, error: priorError } = await serviceClient .from('iap_purchases') .select('id') .eq('platform', 'google_play') .eq('user_id', purchaseRecord.user_id as string) .eq('token_hash', await sha256Hex(expiredToken)) .maybeSingle() if (priorError) throw new Error('previous_purchase_lookup_failed') return prior !== null } const { data: insertedEvent, error: insertError } = await serviceClient .from('store_notification_events') .insert({ platform: 'google_play', message_id: notification.messageId, event_type: `subscription:${notification.notificationType}`, }) .select('id') .single() if (insertError) { if (insertError.code !== '23505') throw new Error('notification_insert_failed') const { data: existingEvent, error: existingError } = await serviceClient .from('store_notification_events') .select('id, processed_at') .eq('platform', 'google_play') .eq('message_id', notification.messageId) .single() if (existingError || !existingEvent) throw new Error('notification_lookup_failed') const existingRecord = existingEvent as { id?: unknown; processed_at?: unknown } if (existingRecord.processed_at) return jsonResponse({ success: true, duplicate: true }) if (typeof existingRecord.id !== 'string') throw new Error('notification_lookup_failed') eventId = existingRecord.id } else { const insertedRecord = insertedEvent as { id?: unknown } if (typeof insertedRecord.id !== 'string') throw new Error('notification_insert_failed') eventId = insertedRecord.id } let purchase = preverifiedPurchase ?? await verifyGooglePlaySubscription( purchaseRecord.user_id, purchaseRecord.product_id, notification.purchaseToken, fetch, ownsExpiredPurchaseToken, ) if (purchase.entitled && !purchase.acknowledged) { await acknowledgeGooglePlaySubscription(purchase.productId, notification.purchaseToken) purchase = { ...purchase, acknowledged: true, verification: { ...purchase.verification, acknowledgementState: 'ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED', }, } } const { error: applyError } = await serviceClient.rpc('apply_verified_google_play_purchase', { p_user_id: purchaseRecord.user_id, p_platform: purchase.platform, p_product_id: purchase.productId, p_store_transaction_id: purchase.storeTransactionId, p_token_hash: currentTokenHash, p_linked_token_hash: purchase.linkedPurchaseToken ? await sha256Hex(purchase.linkedPurchaseToken) : null, p_purchase_token: notification.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 (applyError) throw new Error('purchase_persistence_failed') if (!eventId) throw new Error('notification_lookup_failed') const { error: completeError } = await serviceClient .from('store_notification_events') .update({ processed_at: new Date().toISOString(), processing_error: null }) .eq('id', eventId) if (completeError) throw new Error('notification_completion_failed') return jsonResponse({ success: true }) } catch (error) { if (eventId) { const errorCode = error instanceof GooglePlayVerificationError || error instanceof GooglePubSubError ? error.code : 'notification_processing_failed' await serviceClient .from('store_notification_events') .update({ processing_error: errorCode }) .eq('id', eventId) } if (error instanceof GooglePubSubError || error instanceof GooglePlayVerificationError) { return jsonResponse({ error: error.code }, error.status) } return jsonResponse({ error: 'notification_processing_failed' }, 500) } })