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

272 lines
10 KiB
TypeScript

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<string, unknown>, 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<typeof createServiceRoleClient>,
orderId: string,
payerId: string | null,
): Promise<CorrelatedPayment | null> {
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<Response> {
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)