130 lines
4.4 KiB
TypeScript
130 lines
4.4 KiB
TypeScript
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
|
import { authErrorResponse, requireUser, type AuthError } from '../_shared/auth.ts'
|
|
import { createServiceRoleClient } from '../_shared/quota.ts'
|
|
import {
|
|
acknowledgeGooglePlaySubscription,
|
|
GooglePlayVerificationError,
|
|
sha256Hex,
|
|
verifyGooglePlaySubscription,
|
|
} from '../_shared/google-play.ts'
|
|
|
|
interface VerifyPurchaseRequest {
|
|
platform?: unknown
|
|
productId?: unknown
|
|
purchaseToken?: unknown
|
|
}
|
|
|
|
function jsonResponse(body: Record<string, unknown>, status = 200): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
|
})
|
|
}
|
|
|
|
Deno.serve(async (req: Request) => {
|
|
const preflight = handleCorsPreflightRequest(req)
|
|
if (preflight) return preflight
|
|
|
|
if (req.method !== 'POST') {
|
|
return jsonResponse({ error: 'method_not_allowed' }, 405)
|
|
}
|
|
|
|
try {
|
|
const user = await requireUser(req)
|
|
const body = await req.json() as VerifyPurchaseRequest
|
|
if (
|
|
body.platform !== 'google_play'
|
|
|| typeof body.productId !== 'string'
|
|
|| typeof body.purchaseToken !== 'string'
|
|
) {
|
|
return jsonResponse({ error: 'invalid_request' }, 400)
|
|
}
|
|
|
|
const serviceClient = createServiceRoleClient()
|
|
const ownsExpiredPurchaseToken = async (expiredToken: string): Promise<boolean> => {
|
|
const { data: previous, error: previousError } = await serviceClient
|
|
.from('iap_purchases')
|
|
.select('id')
|
|
.eq('platform', 'google_play')
|
|
.eq('user_id', user.id)
|
|
.eq('token_hash', await sha256Hex(expiredToken))
|
|
.maybeSingle()
|
|
if (previousError) throw new Error('expired_purchase_lookup_failed')
|
|
return previous !== null
|
|
}
|
|
|
|
let purchase = await verifyGooglePlaySubscription(
|
|
user.id,
|
|
body.productId,
|
|
body.purchaseToken,
|
|
fetch,
|
|
ownsExpiredPurchaseToken,
|
|
)
|
|
if (purchase.entitled && !purchase.acknowledged) {
|
|
await acknowledgeGooglePlaySubscription(purchase.productId, body.purchaseToken)
|
|
purchase = {
|
|
...purchase,
|
|
acknowledged: true,
|
|
verification: {
|
|
...purchase.verification,
|
|
acknowledgementState: 'ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED',
|
|
},
|
|
}
|
|
}
|
|
const { data, error } = await serviceClient.rpc('apply_verified_google_play_purchase', {
|
|
p_user_id: user.id,
|
|
p_platform: purchase.platform,
|
|
p_product_id: purchase.productId,
|
|
p_store_transaction_id: purchase.storeTransactionId,
|
|
p_token_hash: await sha256Hex(body.purchaseToken),
|
|
p_linked_token_hash: purchase.linkedPurchaseToken
|
|
? await sha256Hex(purchase.linkedPurchaseToken)
|
|
: null,
|
|
p_purchase_token: body.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) {
|
|
if (error.message.includes('purchase_owned_by_other_user')) {
|
|
return jsonResponse({ error: 'purchase_owned_by_other_user' }, 409)
|
|
}
|
|
if (error.message.includes('active_subscription_other_provider')) {
|
|
return jsonResponse({ error: 'active_subscription_other_provider' }, 409)
|
|
}
|
|
throw new Error('purchase_persistence_failed')
|
|
}
|
|
|
|
return jsonResponse({
|
|
purchase: data,
|
|
verification: {
|
|
product_id: purchase.productId,
|
|
purchase_state: purchase.purchaseState,
|
|
entitled: purchase.entitled,
|
|
acknowledged: purchase.acknowledged,
|
|
},
|
|
finish_transaction: false,
|
|
server_acknowledged: purchase.acknowledged,
|
|
})
|
|
} catch (error) {
|
|
if (error && typeof error === 'object' && 'status' in error && 'message' in error) {
|
|
const candidate = error as { status: unknown; message: unknown }
|
|
if (
|
|
(candidate.status === 401 || candidate.status === 403)
|
|
&& typeof candidate.message === 'string'
|
|
) {
|
|
return authErrorResponse(error as AuthError, corsHeaders)
|
|
}
|
|
}
|
|
if (error instanceof GooglePlayVerificationError) {
|
|
return jsonResponse({ error: error.code }, error.status)
|
|
}
|
|
return jsonResponse({ error: 'purchase_verification_failed' }, 500)
|
|
}
|
|
})
|