196 lines
6.9 KiB
TypeScript
196 lines
6.9 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'
|
|
import {
|
|
chargeMatchesOrder,
|
|
decideReservation,
|
|
isBillingKeyOwnedBy,
|
|
parseCheckoutRequest,
|
|
planCheckoutFailure,
|
|
} from './checkout-policy.ts'
|
|
|
|
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' },
|
|
})
|
|
}
|
|
|
|
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 input = parseCheckoutRequest(await req.json())
|
|
if (!input) 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 = input.idempotencyKey
|
|
?? `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: input.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: input.payerId,
|
|
},
|
|
)
|
|
if (reservationError) throw new Error('payment_reservation_failed')
|
|
// The reservation is the single serialization point: it rejects an
|
|
// in-flight operation, another provider's ownership, and an active paid
|
|
// Payple period ('subscription_already_active') before any charge.
|
|
const reservation = decideReservation(reservationData)
|
|
if (reservation.kind === 'reject') {
|
|
return jsonResponse(reservation.body, reservation.status)
|
|
}
|
|
operationId = reservation.operationId
|
|
|
|
const price = TIER_PRICE[input.tier]
|
|
const goodsName = TIER_GOODS_NAME[input.tier]
|
|
const billingKeyAuth = await paypleAuth(config, { payWork: 'PUSERINFO' })
|
|
const billingKey = await paypleLookupBillingKey(config, billingKeyAuth, input.payerId)
|
|
const expectedPayerNumbers = [user.id, await payplePayerNumber(user.id)]
|
|
if (!isBillingKeyOwnedBy(billingKey, input.payerId, expectedPayerNumbers)) {
|
|
throw new Error('payple_billing_key_owner_mismatch')
|
|
}
|
|
const auth = await paypleAuth(config, { simpleFlag: true })
|
|
const billingResult = await paypleBilling(config, auth, {
|
|
payerId: input.payerId,
|
|
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 (!chargeMatchesOrder(billingResult, { orderId, amount: price, payerId: input.payerId })) {
|
|
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: input.payerId,
|
|
payType: 'card',
|
|
amount: price,
|
|
}),
|
|
p_provider_resource_id: input.payerId,
|
|
p_tier: input.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: input.payerId,
|
|
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: input.tier,
|
|
order_id: orderId,
|
|
amount: price,
|
|
})
|
|
} catch (error) {
|
|
const plan = planCheckoutFailure(
|
|
{
|
|
hasOperation: operationId !== null,
|
|
externalChargeCompleted,
|
|
chargeOutcomeUnknown: error instanceof PaypleBillingError && !error.definitive,
|
|
configurationError: error instanceof PaypleConfigurationError,
|
|
},
|
|
error instanceof PaypleConfigurationError ? error.code : 'payple_not_configured',
|
|
)
|
|
if (operationId && plan.mark?.state === 'external_created') {
|
|
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 && plan.mark?.state === 'failed') {
|
|
await serviceClient.rpc('mark_payment_provider_operation', {
|
|
p_operation_id: operationId,
|
|
p_state: 'failed',
|
|
p_external_reference: null,
|
|
p_error_code: plan.mark.errorCode,
|
|
})
|
|
}
|
|
if (error && typeof error === 'object' && 'status' in error && 'message' in error) {
|
|
return authErrorResponse(error as AuthError, corsHeaders)
|
|
}
|
|
return jsonResponse({ error: plan.error }, plan.status)
|
|
}
|
|
})
|