d3ro-voice/server/supabase/functions/payple-checkout/index.ts
2026-08-29 18:33:45 +09:00

224 lines
7.6 KiB
TypeScript

import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts'
import { createServiceRoleClient } from '../_shared/quota.ts'
import {
calcSubscriptionPeriod,
generateOrderId,
getPaypleConfig,
parsePaypleTimestamp,
paypleAuth,
paypleBilling,
PaypleBillingError,
paypleLookupBillingKey,
payplePaymentEventDigest,
payplePaymentEventId,
payplePayerNumber,
PaypleConfigurationError,
TIER_GOODS_NAME,
TIER_PRICE,
} from '../_shared/payple.ts'
interface CheckoutRequest {
payer_id?: unknown
tier?: unknown
idempotency_key?: unknown
}
interface OperationReservation {
created?: boolean
operation_id?: string
state?: string
reason?: string
}
interface ApplyResult {
applied?: boolean
duplicate?: boolean
reason?: string
}
function jsonResponse(body: Record<string, unknown>, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
function validIdempotencyKey(value: unknown): value is string {
return typeof value === 'string' && /^[A-Za-z0-9._:-]{12,160}$/.test(value)
}
Deno.serve(async (req: Request) => {
const preflight = handleCorsPreflightRequest(req)
if (preflight) return preflight
if (req.method !== 'POST') return jsonResponse({ error: 'method_not_allowed' }, 405)
let operationId: string | null = null
let providerOrderId: string | null = null
let externalChargeCompleted = false
const serviceClient = createServiceRoleClient()
try {
const user = await requireUser(req)
const body = await req.json() as CheckoutRequest
if (
typeof body.payer_id !== 'string'
|| body.payer_id.length < 8
|| body.payer_id.length > 255
|| (body.tier !== 'pro' && body.tier !== 'pro_plus')
|| (body.idempotency_key !== undefined && !validIdempotencyKey(body.idempotency_key))
) {
return jsonResponse({ error: 'invalid_request' }, 400)
}
// Configuration is validated before reserving state or contacting Payple.
// There are deliberately no bundled/test credential fallbacks.
const config = getPaypleConfig()
const idempotencyKey = body.idempotency_key
?? `payple-checkout:${crypto.randomUUID()}`
const orderId = generateOrderId(user.id)
providerOrderId = orderId
const { data: reservationData, error: reservationError } = await serviceClient.rpc(
'reserve_payment_provider_operation',
{
p_user_id: user.id,
p_provider: 'payple',
p_operation_type: 'checkout',
p_requested_tier: body.tier,
p_idempotency_key: idempotencyKey,
p_provider_order_id: orderId,
// Bind the reserved order to this billing key before any charge. The
// PUSERINFO lookup below still verifies its authenticated user owner.
p_provider_resource_id: body.payer_id,
},
)
if (reservationError) throw new Error('payment_reservation_failed')
const reservation = reservationData as OperationReservation | null
if (!reservation?.created || typeof reservation.operation_id !== 'string') {
return jsonResponse({
error: reservation?.reason ?? 'payment_operation_in_progress',
state: reservation?.state ?? 'rejected',
}, 409)
}
operationId = reservation.operation_id
const price = TIER_PRICE[body.tier]
const goodsName = TIER_GOODS_NAME[body.tier]
const billingKeyAuth = await paypleAuth(config, { payWork: 'PUSERINFO' })
const billingKey = await paypleLookupBillingKey(config, billingKeyAuth, body.payer_id)
const expectedPayerNumbers = [user.id, await payplePayerNumber(user.id)]
if (
billingKey.PCD_PAY_RST !== 'success'
|| billingKey.PCD_PAYER_ID !== body.payer_id
|| !billingKey.PCD_PAYER_NO
|| !expectedPayerNumbers.includes(billingKey.PCD_PAYER_NO)
) {
throw new Error('payple_billing_key_owner_mismatch')
}
const auth = await paypleAuth(config, { simpleFlag: true })
const billingResult = await paypleBilling(config, auth, {
payerId: body.payer_id,
amount: price,
orderId,
goodsName,
})
externalChargeCompleted = true
const { error: chargedError } = await serviceClient.rpc('mark_payment_provider_operation', {
p_operation_id: operationId,
p_state: 'charged',
p_external_reference: billingResult.PCD_PAY_OID || orderId,
p_error_code: null,
})
if (chargedError) throw new Error('payment_operation_update_failed')
if (
billingResult.PCD_PAY_OID !== orderId
|| billingResult.PCD_PAY_TOTAL !== String(price)
|| (billingResult.PCD_PAYER_ID && billingResult.PCD_PAYER_ID !== body.payer_id)
) {
throw new Error('payple_charge_response_mismatch')
}
const eventDate = billingResult.PCD_PAY_TIME
? parsePaypleTimestamp(billingResult.PCD_PAY_TIME)
: new Date()
const { start, end } = calcSubscriptionPeriod(eventDate)
const { data: applyData, error: applyError } = await serviceClient.rpc(
'apply_payment_provider_event',
{
p_user_id: user.id,
p_provider: 'payple',
p_event_id: payplePaymentEventId(orderId),
p_event_created_at: eventDate.toISOString(),
p_event_type: 'payment.completed',
p_payload_digest: await payplePaymentEventDigest({
orderId,
payerId: body.payer_id,
payType: 'card',
amount: price,
}),
p_provider_resource_id: body.payer_id,
p_tier: body.tier,
p_status: 'active',
p_entitled: true,
p_current_period_start: start,
p_current_period_end: end,
p_cancel_at: null,
p_auto_renewing: true,
p_provider_customer_id: body.payer_id,
p_provider_order_id: orderId,
p_store_product_id: null,
p_store_purchase_id: null,
p_operation_id: operationId,
},
)
if (applyError) throw new Error('entitlement_persistence_failed')
const applied = applyData as ApplyResult | null
if (!applied?.applied && !applied?.duplicate) {
return jsonResponse({
error: 'payment_requires_reconciliation',
reason: applied?.reason ?? 'entitlement_not_applied',
order_id: orderId,
}, 409)
}
return jsonResponse({
success: true,
tier: body.tier,
order_id: orderId,
amount: price,
})
} catch (error) {
const chargeOutcomeUnknown = error instanceof PaypleBillingError && !error.definitive
if (operationId && chargeOutcomeUnknown) {
await serviceClient.rpc('mark_payment_provider_operation', {
p_operation_id: operationId,
p_state: 'external_created',
p_external_reference: providerOrderId,
p_error_code: null,
})
} else if (operationId && !externalChargeCompleted) {
await serviceClient.rpc('mark_payment_provider_operation', {
p_operation_id: operationId,
p_state: 'failed',
p_external_reference: null,
p_error_code: error instanceof PaypleConfigurationError
? 'payple_not_configured'
: 'payple_checkout_failed',
})
}
if (error && typeof error === 'object' && 'status' in error && 'message' in error) {
return authErrorResponse(error as AuthError, corsHeaders)
}
if (error instanceof PaypleConfigurationError) {
return jsonResponse({ error: error.code }, 503)
}
return jsonResponse({
error: externalChargeCompleted || chargeOutcomeUnknown
? 'payment_requires_reconciliation'
: 'payple_checkout_failed',
}, externalChargeCompleted || chargeOutcomeUnknown ? 409 : 502)
}
})