// Pure decision rules for the Payple webhook edge function. No IO lives here: // index.ts owns the Supabase/Payple calls and asks these functions what a // reconciled webhook means for the user's entitlement, so the rules (which // order may revoke a subscription, which amount is acceptable, ...) are // unit-testable without a network or a database. import { calcSubscriptionPeriod, parsePaypleTimestamp, payplePaymentEventId, PaypleVerificationError, type PayplePaymentLookupResult, } from '../_shared/payple.ts' export interface PaypleWebhookPayload { PCD_PAY_RST?: unknown PCD_PAY_CODE?: unknown PCD_PAY_MSG?: unknown PCD_PAY_TYPE?: unknown PCD_PAY_OID?: unknown PCD_PAY_TOTAL?: unknown PCD_PAYER_ID?: unknown PCD_PAYER_NO?: unknown PCD_PAY_TIME?: unknown PCD_PAY_WORK?: unknown PCD_PAYCANCEL_FLAG?: unknown PCD_PAY_CARDTRADENUM?: unknown } export type WebhookKind = 'payment' | 'cancellation' | 'billing_key_revoked' | 'unsupported' /** The webhook kinds that are reconciled against Payple and may change entitlement. */ export type ReconciledWebhookKind = 'payment' | 'cancellation' export type PaypleTier = 'pro' | 'pro_plus' export interface CorrelatedPayment { user_id: string tier: PaypleTier operation_id: string | null payer_id: string /** * subscriptions.payple_pay_oid at correlation time: the order that funds the * current paid period. null when the user has no Payple order on record. */ current_order_id: string | null } export function stringValue(value: unknown): string | null { return typeof value === 'string' && value.length > 0 ? value : null } export function normalizeTier(value: unknown): PaypleTier | null { if (value === 'pro') return 'pro' if (value === 'pro_plus' || value === 'team') return 'pro_plus' return null } export function classifyPaypleWebhook(payload: PaypleWebhookPayload): WebhookKind { if (payload.PCD_PAY_WORK === 'PUSERDEL') return 'billing_key_revoked' if ( payload.PCD_PAYCANCEL_FLAG === 'Y' || (typeof payload.PCD_PAY_CODE === 'string' && payload.PCD_PAY_CODE.startsWith('PAYC')) ) { return 'cancellation' } if (payload.PCD_PAY_RST === 'success' && payload.PCD_PAY_OID) return 'payment' return 'unsupported' } export function validateReconciledPaypleEvent( payload: PaypleWebhookPayload, lookup: PayplePaymentLookupResult, ): void { const orderId = stringValue(payload.PCD_PAY_OID) const payType = stringValue(payload.PCD_PAY_TYPE) const payerId = stringValue(payload.PCD_PAYER_ID) if (!orderId || lookup.PCD_PAY_OID !== orderId || lookup.PCD_PAY_RST !== 'success') { throw new PaypleVerificationError('payple_webhook_order_mismatch') } if (payType && lookup.PCD_PAY_TYPE !== payType) { throw new PaypleVerificationError('payple_webhook_type_mismatch') } if (payerId && lookup.PCD_PAYER_ID && lookup.PCD_PAYER_ID !== payerId) { throw new PaypleVerificationError('payple_webhook_payer_mismatch') } const payloadTotal = stringValue(payload.PCD_PAY_TOTAL) if (payloadTotal && lookup.PCD_PAY_TOTAL && Number(payloadTotal) !== Number(lookup.PCD_PAY_TOTAL)) { throw new PaypleVerificationError('payple_webhook_amount_mismatch') } } /** Payple's PayChkAct state is the only authority that an order was canceled. */ export function isAuthoritativeCancellation(lookup: PayplePaymentLookupResult): boolean { return lookup.PCD_PAY_STATE === '승인취소완료' || lookup.PCD_PAY_STATE === 'canceled' } /** * apply_payment_provider_event arguments, minus p_payload_digest (the digest * is an async hash computed by the IO layer from the event it forwards). */ export type PaypleProviderEventArgs = { p_user_id: string p_provider: 'payple' p_event_id: string p_event_created_at: string p_event_type: 'payment.completed' | 'webhook.payment_canceled' p_provider_resource_id: string p_tier: 'free' | PaypleTier p_status: 'active' | 'canceled' p_entitled: boolean p_current_period_start: string | null p_current_period_end: string p_cancel_at: string | null p_auto_renewing: boolean p_provider_customer_id: string p_provider_order_id: string p_store_product_id: null p_store_purchase_id: null p_operation_id: string | null } export interface WebhookTransitionInput { kind: ReconciledWebhookKind orderId: string lookup: PayplePaymentLookupResult correlated: CorrelatedPayment /** Payple price of the correlated tier; undefined when the tier has no price. */ expectedAmount: number | undefined now: Date } export type IgnoredWebhookReason = 'canceled_order_not_current' export type WebhookTransition = | { kind: 'reject'; status: 401 | 409 | 422; error: string } | { kind: 'ignore' reason: IgnoredWebhookReason eventId: string eventCreatedAt: string eventType: 'webhook.payment_canceled' providerResourceId: string } | { kind: 'apply'; amount: number; args: PaypleProviderEventArgs } function cancellationEventId(orderId: string, lookup: PayplePaymentLookupResult): string { return `cancel:${orderId}:${lookup.PCD_PAY_STATE ?? 'confirmed'}` } /** * Decides what a reconciled Payple webhook does to the subscription. * * - The Payple lookup's payer must be the correlated payer (401). * - A payment must match the correlated tier's price exactly (422). * - A cancellation must be confirmed by Payple's own lookup state (409). * - A confirmed cancellation revokes entitlement only when the canceled order * is the one that funds the current period (subscriptions.payple_pay_oid). * A refund of an older order (last month's checkout, a duplicate charge) — * or of any order while no current order is on record — is ignored, because * every order on the same billing key shares the payer id and would * otherwise pass the database's resource-ownership check. */ export function decideWebhookTransition(input: WebhookTransitionInput): WebhookTransition { const { kind, orderId, lookup, correlated, now } = input if (lookup.PCD_PAYER_ID && lookup.PCD_PAYER_ID !== correlated.payer_id) { return { kind: 'reject', status: 401, error: 'payment_owner_mismatch' } } const amount = Number(lookup.PCD_PAY_TOTAL) if (kind === 'payment' && (!Number.isFinite(amount) || amount !== input.expectedAmount)) { return { kind: 'reject', status: 422, error: 'payment_amount_mismatch' } } const paymentTime = lookup.PCD_PAY_TIME ? parsePaypleTimestamp(lookup.PCD_PAY_TIME) : now if (kind === 'cancellation') { if (!isAuthoritativeCancellation(lookup)) { return { kind: 'reject', status: 409, error: 'cancellation_not_confirmed' } } const eventId = cancellationEventId(orderId, lookup) const eventCreatedAt = now.toISOString() if (correlated.current_order_id !== orderId) { return { kind: 'ignore', reason: 'canceled_order_not_current', eventId, eventCreatedAt, eventType: 'webhook.payment_canceled', providerResourceId: correlated.payer_id, } } return { kind: 'apply', amount, args: { ...baseArgs(correlated, orderId), p_event_id: eventId, p_event_created_at: eventCreatedAt, p_event_type: 'webhook.payment_canceled', p_tier: 'free', p_status: 'canceled', p_entitled: false, p_current_period_start: null, p_current_period_end: eventCreatedAt, p_cancel_at: eventCreatedAt, p_auto_renewing: false, }, } } const { start, end } = calcSubscriptionPeriod(paymentTime) return { kind: 'apply', amount, args: { ...baseArgs(correlated, orderId), p_event_id: payplePaymentEventId(orderId), p_event_created_at: paymentTime.toISOString(), p_event_type: 'payment.completed', p_tier: correlated.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, }, } } function baseArgs(correlated: CorrelatedPayment, orderId: string) { return { p_user_id: correlated.user_id, p_provider: 'payple' as const, p_provider_resource_id: correlated.payer_id, p_provider_customer_id: correlated.payer_id, p_provider_order_id: orderId, p_store_product_id: null, p_store_purchase_id: null, p_operation_id: correlated.operation_id, } }