272 lines
10 KiB
TypeScript
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)
|