import { createServiceRoleClient } from '../_shared/quota.ts' import { getPaypleConfig, paypleAuth, paypleLookupPayment, payplePaymentEventDigest, PaypleConfigurationError, PaypleVerificationError, resolvePaypleOrderDate, sha256Text, TIER_PRICE, type PayplePaymentLookupResult, } from '../_shared/payple.ts' import { classifyPaypleWebhook, type CorrelatedPayment, decideWebhookTransition, normalizeTier, type PaypleProviderEventArgs, type PaypleWebhookPayload, stringValue, validateReconciledPaypleEvent, } from './webhook-policy.ts' // Re-exported so existing importers of the handler module keep working; the // rules themselves live in webhook-policy.ts. export { classifyPaypleWebhook, validateReconciledPaypleEvent } export interface ProviderApplyResult { applied?: boolean duplicate?: boolean reason?: string } export interface IgnoredProviderEvent { userId: string eventId: string eventCreatedAt: string eventType: string payloadDigest: string providerResourceId: string } /** * IO port of the webhook handler. The handler orchestrates; adapters talk to * Supabase and Payple. Tests substitute an in-memory implementation. */ export interface PaypleWebhookPorts { correlatePayment(orderId: string, payerId: string | null): Promise lookupPayment(params: { orderId: string payType: 'card' | 'transfer' payTime: string | null }): Promise applyProviderEvent( args: PaypleProviderEventArgs & { p_payload_digest: string }, ): Promise recordIgnoredEvent(event: IgnoredProviderEvent): Promise now(): Date } function jsonResponse(body: Record, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' }, }) } type ServiceClient = ReturnType async function readCurrentPaypleOrder( serviceClient: ServiceClient, userId: string, ): Promise { const { data, error } = await serviceClient .from('subscriptions') .select('payple_pay_oid') .eq('user_id', userId) .maybeSingle() if (error) throw new Error('subscription_lookup_failed') return stringValue(data?.payple_pay_oid) } async function correlatePayment( serviceClient: ServiceClient, orderId: string, payerId: string | null, ): Promise { const { data: operation, error: operationError } = await serviceClient .from('payment_provider_operations') .select('id, user_id, requested_tier, provider_resource_id') .eq('provider', 'payple') .eq('provider_order_id', orderId) .maybeSingle() if (operationError) throw new Error('payment_operation_lookup_failed') const operationTier = normalizeTier(operation?.requested_tier) const operationPayerId = stringValue(operation?.provider_resource_id) ?? payerId if (operation?.user_id && operationTier && operationPayerId) { const userId = operation.user_id as string return { user_id: userId, tier: operationTier, operation_id: operation.id as string, payer_id: operationPayerId, current_order_id: await readCurrentPaypleOrder(serviceClient, userId), } } const query = serviceClient .from('subscriptions') .select('user_id, tier, payple_payer_id') .eq('payple_pay_oid', orderId) const { data: subscription, error: subscriptionError } = await query.maybeSingle() if (subscriptionError) throw new Error('subscription_lookup_failed') const subscriptionTier = normalizeTier(subscription?.tier) const subscriptionPayerId = stringValue(subscription?.payple_payer_id) ?? payerId if (!subscription?.user_id || !subscriptionTier || !subscriptionPayerId) return null return { user_id: subscription.user_id as string, tier: subscriptionTier, operation_id: null, payer_id: subscriptionPayerId, // Matched through payple_pay_oid, so this order is the current one. current_order_id: orderId, } } export function createSupabasePaypleWebhookPorts(): PaypleWebhookPorts { const serviceClient = createServiceRoleClient() return { correlatePayment: (orderId, payerId) => correlatePayment(serviceClient, orderId, payerId), async lookupPayment({ orderId, payType, payTime }) { const config = getPaypleConfig() const payDate = resolvePaypleOrderDate(orderId, payTime ?? undefined) const auth = await paypleAuth(config, { payCheckFlag: true }) return await paypleLookupPayment(config, auth, { orderId, payType, payDate }) }, async applyProviderEvent(args) { const { data, error } = await serviceClient.rpc('apply_payment_provider_event', args) if (error) throw new Error('payple_entitlement_apply_failed') return data as ProviderApplyResult | null }, async recordIgnoredEvent(event) { // Replay-protected audit record that neither touches entitlement nor // advances the provider ordering cursor. const { error } = await serviceClient.rpc('record_payment_provider_observation', { p_user_id: event.userId, p_provider: 'payple', p_event_id: event.eventId, p_event_created_at: event.eventCreatedAt, p_event_type: event.eventType, p_payload_digest: event.payloadDigest, p_provider_resource_id: event.providerResourceId, }) if (error) throw new Error('payple_event_record_failed') }, now: () => new Date(), } } export function createPaypleWebhookHandler( makePorts: () => PaypleWebhookPorts, ): (req: Request) => Promise { return async (req) => { if (req.method !== 'POST') return jsonResponse({ error: 'method_not_allowed' }, 405) if (!(req.headers.get('content-type') ?? '').toLowerCase().includes('application/json')) { return jsonResponse({ error: 'unsupported_content_type' }, 415) } const rawPayload = await req.text() if (!rawPayload || rawPayload.length > 64 * 1024) { return jsonResponse({ error: 'invalid_payload' }, 400) } let payload: PaypleWebhookPayload try { payload = JSON.parse(rawPayload) as PaypleWebhookPayload } catch { return jsonResponse({ error: 'invalid_json' }, 400) } const kind = classifyPaypleWebhook(payload) if (kind === 'unsupported') return jsonResponse({ received: true, ignored: 'unsupported_event' }) if (kind === 'billing_key_revoked') { // Payple's documented PUSERDEL webhook has no signature and no transaction // identifier that can be reconciled through PayChkAct. It is therefore not // authorized to mutate entitlement; payple-manage applies the verified // result of the server-originated PUSERDEL API call instead. return jsonResponse({ received: true, ignored: 'non_authoritative_billing_key_event' }) } const orderId = stringValue(payload.PCD_PAY_OID) const payType = stringValue(payload.PCD_PAY_TYPE) if ( !orderId || !/^[A-Za-z0-9._-]{8,64}$/.test(orderId) || (payType !== 'card' && payType !== 'transfer') ) { return jsonResponse({ error: 'invalid_payload' }, 400) } try { const ports = makePorts() // Reject unknown order IDs before consuming Payple's authenticated lookup // rate limit. Every accepted order must have originated in our operation // ledger or be the current order on an existing Payple subscription. const correlated = await ports.correlatePayment(orderId, stringValue(payload.PCD_PAYER_ID)) if (!correlated) return jsonResponse({ error: 'payment_not_registered' }, 422) const lookup = await ports.lookupPayment({ orderId, payType, payTime: stringValue(payload.PCD_PAY_TIME), }) validateReconciledPaypleEvent(payload, lookup) const transition = decideWebhookTransition({ kind, orderId, lookup, correlated, expectedAmount: TIER_PRICE[correlated.tier], now: ports.now(), }) if (transition.kind === 'reject') { return jsonResponse({ error: transition.error }, transition.status) } if (transition.kind === 'ignore') { await ports.recordIgnoredEvent({ userId: correlated.user_id, eventId: transition.eventId, eventCreatedAt: transition.eventCreatedAt, eventType: transition.eventType, payloadDigest: await sha256Text(JSON.stringify({ payload, lookup })), providerResourceId: transition.providerResourceId, }) return jsonResponse({ received: true, applied: false, duplicate: false, ignored: transition.reason, reason: transition.reason, }) } const payloadDigest = kind === 'cancellation' ? await sha256Text(JSON.stringify({ payload, lookup })) : await payplePaymentEventDigest({ orderId, payerId: correlated.payer_id, payType: lookup.PCD_PAY_TYPE, amount: transition.amount, }) const result = await ports.applyProviderEvent({ ...transition.args, p_payload_digest: payloadDigest, }) return jsonResponse({ received: true, applied: result?.applied ?? false, duplicate: result?.duplicate ?? false, reason: result?.reason, }) } catch (error) { if (error instanceof PaypleConfigurationError) { return jsonResponse({ error: error.code }, 503) } if (error instanceof PaypleVerificationError) { return jsonResponse({ error: error.code }, 401) } return jsonResponse({ error: 'payple_webhook_processing_failed' }, 500) } } } export const paypleWebhookHandler = createPaypleWebhookHandler(createSupabasePaypleWebhookPorts) if (import.meta.main) Deno.serve(paypleWebhookHandler)