feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View file

@ -0,0 +1,53 @@
import {
classifyPaypleWebhook,
validateReconciledPaypleEvent,
} from './index.ts'
import { PaypleVerificationError } from '../_shared/payple.ts'
function assert(condition: boolean, message: string): asserts condition {
if (!condition) throw new Error(message)
}
Deno.test('Payple webhook classifies only documented state-changing events', () => {
assert(classifyPaypleWebhook({ PCD_PAY_WORK: 'PUSERDEL' }) === 'billing_key_revoked', 'key revoke')
assert(classifyPaypleWebhook({ PCD_PAY_CODE: 'PAYC0000' }) === 'cancellation', 'cancel')
assert(classifyPaypleWebhook({
PCD_PAY_RST: 'success', PCD_PAY_OID: 'D3RO-20260821153045-user-nonce',
}) === 'payment', 'payment')
assert(classifyPaypleWebhook({ PCD_PAY_RST: 'error' }) === 'unsupported', 'failure ignored')
})
Deno.test('Payple webhook payload cannot override the official lookup result', () => {
const payload = {
PCD_PAY_RST: 'success',
PCD_PAY_OID: 'D3RO-20260821153045-user-nonce',
PCD_PAY_TYPE: 'card',
PCD_PAYER_ID: 'payer-verified',
PCD_PAY_TOTAL: '9900',
}
const lookup = {
PCD_PAY_RST: 'success' as const,
PCD_PAY_CODE: 'PCHK0000',
PCD_PAY_MSG: '결제 완료',
PCD_PAY_OID: payload.PCD_PAY_OID,
PCD_PAY_TYPE: 'card' as const,
PCD_PAYER_ID: 'payer-verified',
PCD_PAY_TOTAL: '9900',
PCD_PAY_TIME: '20260821153045',
}
validateReconciledPaypleEvent(payload, lookup)
for (const tampered of [
{ ...payload, PCD_PAY_OID: 'D3RO-20260821153045-other-nonce' },
{ ...payload, PCD_PAYER_ID: 'payer-attacker' },
{ ...payload, PCD_PAY_TOTAL: '29900' },
{ ...payload, PCD_PAY_TYPE: 'transfer' },
]) {
let error: unknown
try {
validateReconciledPaypleEvent(tampered, lookup)
} catch (caught) {
error = caught
}
assert(error instanceof PaypleVerificationError, 'tampered webhook must fail closed')
}
})

View file

@ -1,113 +1,272 @@
// server/supabase/functions/payple-webhook/index.ts
// Payple 웹훅 수신 — 결제완료, 취소, 빌링키 등록/해지 이벤트 처리.
// Payple 관리자에서 웹훅 URL을 등록해야 함.
// verify_jwt = false (외부 Payple 서버에서 호출)
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
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: 'success' | 'error'
PCD_PAY_CODE: string
PCD_PAY_MSG: string
PCD_PAY_TYPE: string
PCD_PAY_OID: string
PCD_PAY_TOTAL?: string
PCD_PAYER_ID?: string
PCD_PAYER_NO?: string // 우리가 전달한 user_id
PCD_PAY_CARDNAME?: string
PCD_PAY_CARDNUM?: string
PCD_PAY_TIME?: string // 결제 시간 (YYYYMMDDHHMMSS)
// 웹훅 이벤트 구분용
PCD_PAY_WORK?: string // 'AUTH' (등록), 'CERT' (등록+결제)
PCD_PAYCANCEL_FLAG?: string // 'Y' (취소 이벤트)
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: { ...corsHeaders, 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' },
})
}
// @ts-expect-error — Deno 런타임 전역
Deno.serve(async (req: Request) => {
const preflight = handleCorsPreflightRequest(req)
if (preflight) return preflight
function stringValue(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null
}
if (req.method !== 'POST') {
return jsonResponse({ error: 'Method not allowed' }, 405)
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 {
const payload = (await req.json()) as PaypleWebhookPayload
const serviceClient = createServiceRoleClient()
// 취소 이벤트
if (payload.PCD_PAYCANCEL_FLAG === 'Y') {
if (payload.PCD_PAY_RST === 'success' && payload.PCD_PAY_OID) {
// 주문번호로 구독 찾아서 상태 변경
const { data: sub } = await serviceClient
.from('subscriptions')
.select('user_id')
.eq('payple_pay_oid', payload.PCD_PAY_OID)
.maybeSingle()
if (sub) {
await serviceClient
.from('subscriptions')
.update({
status: 'canceled',
tier: 'free',
updated_at: new Date().toISOString(),
})
.eq('user_id', sub.user_id)
await serviceClient
.from('profiles')
.update({ tier: 'free', updated_at: new Date().toISOString() })
.eq('id', sub.user_id)
}
}
return jsonResponse({ received: true, event: 'cancel' })
}
// 결제 완료 이벤트
if (payload.PCD_PAY_RST === 'success' && payload.PCD_PAYER_ID) {
// payer_id(빌링키)로 구독 찾기
const { data: sub } = await serviceClient
.from('subscriptions')
.select('user_id, tier')
.eq('payple_payer_id', payload.PCD_PAYER_ID)
.maybeSingle()
if (sub && payload.PCD_PAY_OID) {
// 주문번호 + 구독 기간 갱신 (정기결제 갱신 시)
const now = new Date()
const end = new Date(now)
end.setMonth(end.getMonth() + 1)
await serviceClient
.from('subscriptions')
.update({
payple_pay_oid: payload.PCD_PAY_OID,
status: 'active',
current_period_start: now.toISOString(),
current_period_end: end.toISOString(),
renewal_failures: 0,
updated_at: now.toISOString(),
})
.eq('user_id', sub.user_id)
}
return jsonResponse({ received: true, event: 'payment_complete' })
}
// 그 외 이벤트는 로깅만
return jsonResponse({ received: true, event: 'unknown' })
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
return jsonResponse({ error: message }, 500)
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)