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
|
|
@ -1,14 +1,17 @@
|
|||
import { createServiceRoleClient } from '../_shared/quota.ts'
|
||||
import {
|
||||
acknowledgeGooglePlaySubscription,
|
||||
createGooglePlayPurchaseApi,
|
||||
fetchGooglePlaySubscription,
|
||||
GOOGLE_PLAY_PRODUCT_TIERS,
|
||||
GooglePlayVerificationError,
|
||||
sha256Hex,
|
||||
verifyGooglePlaySubscription,
|
||||
verifyGooglePlaySubscriptionPayload,
|
||||
type NormalizedGooglePlayPurchase,
|
||||
} from '../_shared/google-play.ts'
|
||||
import { applyGooglePlayPurchase } from '../_shared/google-play-apply.ts'
|
||||
import {
|
||||
createSupabaseGooglePlayPurchaseStore,
|
||||
type RegisteredGooglePlayPurchase,
|
||||
} from '../_shared/google-play-purchase-store.ts'
|
||||
import {
|
||||
GooglePubSubError,
|
||||
parseGooglePlayRtdn,
|
||||
|
|
@ -27,6 +30,7 @@ Deno.serve(async (req: Request) => {
|
|||
|
||||
let eventId: string | null = null
|
||||
const serviceClient = createServiceRoleClient()
|
||||
const purchaseStore = createSupabaseGooglePlayPurchaseStore(serviceClient)
|
||||
try {
|
||||
await verifyGooglePubSubIdentity(req)
|
||||
const notification = parseGooglePlayRtdn(await req.json())
|
||||
|
|
@ -34,16 +38,9 @@ Deno.serve(async (req: Request) => {
|
|||
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 purchaseRecord: RegisteredGooglePlayPurchase | null = await purchaseStore.findPurchase(
|
||||
notification.purchaseToken,
|
||||
)
|
||||
let preverifiedPurchase: NormalizedGooglePlayPurchase | null = null
|
||||
|
||||
// Google Play subscriptions-center re-subscriptions can notify the server
|
||||
|
|
@ -65,46 +62,21 @@ Deno.serve(async (req: Request) => {
|
|||
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') {
|
||||
const previous = await purchaseStore.findPurchase(expiredToken)
|
||||
if (!previous) {
|
||||
throw new GooglePubSubError('purchase_not_registered', 503)
|
||||
}
|
||||
|
||||
purchaseRecord = {
|
||||
user_id: previous.user_id,
|
||||
product_id: recognizedItems[0].productId,
|
||||
}
|
||||
const productId = recognizedItems[0].productId as string
|
||||
purchaseRecord = { userId: previous.userId, productId }
|
||||
preverifiedPurchase = await verifyGooglePlaySubscriptionPayload(
|
||||
previous.user_id,
|
||||
recognizedItems[0].productId as string,
|
||||
previous.userId,
|
||||
productId,
|
||||
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<boolean> => {
|
||||
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({
|
||||
|
|
@ -133,44 +105,15 @@ Deno.serve(async (req: Request) => {
|
|||
eventId = insertedRecord.id
|
||||
}
|
||||
|
||||
let purchase = preverifiedPurchase ?? await verifyGooglePlaySubscription(
|
||||
purchaseRecord.user_id,
|
||||
purchaseRecord.product_id,
|
||||
notification.purchaseToken,
|
||||
fetch,
|
||||
ownsExpiredPurchaseToken,
|
||||
await applyGooglePlayPurchase(
|
||||
{ playApi: createGooglePlayPurchaseApi(fetch), store: purchaseStore },
|
||||
{
|
||||
userId: purchaseRecord.userId,
|
||||
productId: purchaseRecord.productId,
|
||||
purchaseToken: notification.purchaseToken,
|
||||
preverified: preverifiedPurchase,
|
||||
},
|
||||
)
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue