feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
53
server/supabase/functions/payple-webhook/index.test.ts
Normal file
53
server/supabase/functions/payple-webhook/index.test.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue