import { createServiceRoleClient } from '../_shared/quota.ts' import { calcSubscriptionPeriod, getPaypleConfig, parsePaypleTimestamp, paypleAuth, paypleLookupPayment, payplePaymentEventDigest, payplePaymentEventId, PaypleConfigurationError, PaypleVerificationError, resolvePaypleOrderDate, sha256Text, TIER_PRICE, type PayplePaymentLookupResult, } from '../_shared/payple.ts' 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 } interface CorrelatedPayment { user_id: string tier: 'pro' | 'pro_plus' operation_id: string | null payer_id: string } interface ProviderApplyResult { applied?: boolean duplicate?: boolean reason?: string } type WebhookKind = 'payment' | 'cancellation' | 'billing_key_revoked' | 'unsupported' function jsonResponse(body: Record, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' }, }) } function stringValue(value: unknown): string | null { return typeof value === 'string' && value.length > 0 ? value : null } function normalizeTier(value: unknown): 'pro' | 'pro_plus' | 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') } } async function correlatePayment( serviceClient: ReturnType, 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) { return { user_id: operation.user_id as string, tier: operationTier, operation_id: operation.id as string, payer_id: operationPayerId, } } 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, } } export async function paypleWebhookHandler(req: Request): Promise { 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) } const serviceClient = createServiceRoleClient() try { // 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 correlatePayment( serviceClient, orderId, stringValue(payload.PCD_PAYER_ID), ) if (!correlated) return jsonResponse({ error: 'payment_not_registered' }, 422) const config = getPaypleConfig() const payDate = resolvePaypleOrderDate(orderId, stringValue(payload.PCD_PAY_TIME) ?? undefined) const auth = await paypleAuth(config, { payCheckFlag: true }) const lookup = await paypleLookupPayment(config, auth, { orderId, payType, payDate }) validateReconciledPaypleEvent(payload, lookup) if (lookup.PCD_PAYER_ID && lookup.PCD_PAYER_ID !== correlated.payer_id) { return jsonResponse({ error: 'payment_owner_mismatch' }, 401) } const expectedAmount = TIER_PRICE[correlated.tier] const lookupAmount = Number(lookup.PCD_PAY_TOTAL) if (kind === 'payment' && (!Number.isFinite(lookupAmount) || lookupAmount !== expectedAmount)) { return jsonResponse({ error: 'payment_amount_mismatch' }, 422) } const paymentTime = lookup.PCD_PAY_TIME ? parsePaypleTimestamp(lookup.PCD_PAY_TIME) : new Date() const authoritativeCanceled = lookup.PCD_PAY_STATE === '승인취소완료' || lookup.PCD_PAY_STATE === 'canceled' if (kind === 'cancellation' && !authoritativeCanceled) { return jsonResponse({ error: 'cancellation_not_confirmed' }, 409) } const eventTime = kind === 'cancellation' ? new Date() : paymentTime const eventId = kind === 'cancellation' ? `cancel:${orderId}:${lookup.PCD_PAY_STATE ?? 'confirmed'}` : payplePaymentEventId(orderId) const payloadDigest = kind === 'cancellation' ? await sha256Text(JSON.stringify({ payload, lookup })) : await payplePaymentEventDigest({ orderId, payerId: correlated.payer_id, payType: lookup.PCD_PAY_TYPE, amount: lookupAmount, }) const { start, end } = calcSubscriptionPeriod(paymentTime) const { data: applyData, error: applyError } = await serviceClient.rpc( 'apply_payment_provider_event', { p_user_id: correlated.user_id, p_provider: 'payple', p_event_id: eventId, p_event_created_at: eventTime.toISOString(), p_event_type: kind === 'cancellation' ? 'webhook.payment_canceled' : 'payment.completed', p_payload_digest: payloadDigest, p_provider_resource_id: correlated.payer_id, p_tier: kind === 'cancellation' ? 'free' : correlated.tier, p_status: kind === 'cancellation' ? 'canceled' : 'active', p_entitled: kind !== 'cancellation', p_current_period_start: kind === 'cancellation' ? null : start, p_current_period_end: kind === 'cancellation' ? eventTime.toISOString() : end, p_cancel_at: kind === 'cancellation' ? eventTime.toISOString() : null, p_auto_renewing: kind !== 'cancellation', 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, }, ) if (applyError) throw new Error('payple_entitlement_apply_failed') const result = applyData as ProviderApplyResult | null 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) } } if (import.meta.main) Deno.serve(paypleWebhookHandler)