From f4724ddf53187af8160f23020fd240fc3f7c52e2 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 28 Sep 2026 00:53:47 +0900 Subject: [PATCH] fix(payple-renew): reconcile renewals whose charge outcome was left unknown --- .../functions/payple-renew/adapters.ts | 185 +++++++++ .../supabase/functions/payple-renew/index.ts | 334 +++++----------- .../functions/payple-renew/renewal.test.ts | 318 +++++++++++++++ .../functions/payple-renew/renewal.ts | 377 ++++++++++++++++++ 4 files changed, 980 insertions(+), 234 deletions(-) create mode 100644 server/supabase/functions/payple-renew/adapters.ts create mode 100644 server/supabase/functions/payple-renew/renewal.test.ts create mode 100644 server/supabase/functions/payple-renew/renewal.ts diff --git a/server/supabase/functions/payple-renew/adapters.ts b/server/supabase/functions/payple-renew/adapters.ts new file mode 100644 index 0000000..8fb0519 --- /dev/null +++ b/server/supabase/functions/payple-renew/adapters.ts @@ -0,0 +1,185 @@ +// server/supabase/functions/payple-renew/adapters.ts +// renewal.ts 포트의 구현 — Supabase 서비스 롤 RPC 저장소와 Payple HTTP 게이트웨이. + +import type { createServiceRoleClient } from '../_shared/quota.ts' +import { + type PaypleAuthResult, + paypleAuth, + paypleBilling, + PaypleBillingError, + type PaypleConfig, + paypleLookupPayment, + payplePaymentEventDigest, + payplePaymentEventId, + resolvePaypleOrderDate, + sha256Text, + TIER_PRICE, +} from '../_shared/payple.ts' +import { + BILLING_TIMEOUT_MS, + type ChargeReceipt, + type ChargeRequest, + classifyLookupFailure, + interpretLookup, + LOOKUP_TIMEOUT_MS, + type LookupOutcome, + MAX_RENEWAL_FAILURES, + type RenewalOperation, + type RenewalPaymentApplication, + type RenewalPaymentGateway, + type RenewalReservation, + type RenewalStore, + withTimeout, +} from './renewal.ts' + +type ServiceClient = ReturnType + +interface OperationReservationRow { + created?: boolean + operation_id?: string + reason?: string +} + +export function toRenewalReservation(data: unknown): RenewalReservation { + const row = (data ?? null) as OperationReservationRow | null + const operationId = typeof row?.operation_id === 'string' ? row.operation_id : null + if (row?.created && operationId) return { kind: 'created', operationId } + if (row?.reason === 'idempotent_replay' && operationId) return { kind: 'replay', operationId } + return { kind: 'rejected', reason: row?.reason ?? 'renewal_operation_in_progress' } +} + +export function createSupabaseRenewalStore(client: ServiceClient): RenewalStore { + return { + async reserveRenewal(candidate, idempotencyKey, orderId) { + const { data, error } = await client.rpc('reserve_payment_provider_operation', { + p_user_id: candidate.userId, + p_provider: 'payple', + p_operation_type: 'renewal', + p_requested_tier: candidate.tier, + p_idempotency_key: idempotencyKey, + p_provider_order_id: orderId, + p_provider_resource_id: candidate.resourceId, + }) + if (error) throw new Error('renewal_reservation_failed') + return toRenewalReservation(data) + }, + + async getOperation(operationId): Promise { + const { data, error } = await client + .from('payment_provider_operations') + .select('id, state, provider_order_id, expires_at') + .eq('id', operationId) + .eq('provider', 'payple') + .maybeSingle() + if (error) throw new Error('renewal_operation_lookup_failed') + const row = data as Record | null + if (!row || typeof row.id !== 'string' || typeof row.state !== 'string') return null + return { + id: row.id, + state: row.state, + providerOrderId: typeof row.provider_order_id === 'string' ? row.provider_order_id : null, + expiresAt: typeof row.expires_at === 'string' ? row.expires_at : null, + } + }, + + async markOperation(operationId, state, externalReference, errorCode) { + const { error } = await client.rpc('mark_payment_provider_operation', { + p_operation_id: operationId, + p_state: state, + p_external_reference: externalReference, + p_error_code: errorCode, + }) + return !error + }, + + async recordRenewalFailure(candidate, operationId, at) { + const { error } = await client.rpc('record_payment_provider_renewal_failure', { + p_user_id: candidate.userId, + p_provider: 'payple', + p_event_id: `renewal-failed:${operationId}`, + p_event_created_at: at.toISOString(), + p_payload_digest: await sha256Text(`${operationId}:payple_renewal_failed`), + p_provider_resource_id: candidate.resourceId, + p_error_code: 'payple_renewal_failed', + p_failure_threshold: MAX_RENEWAL_FAILURES, + p_operation_id: operationId, + }) + return !error + }, + + async applyRenewalPayment(application: RenewalPaymentApplication) { + const { candidate, orderId } = application + const { data, error } = await client.rpc('apply_payment_provider_event', { + p_user_id: candidate.userId, + p_provider: 'payple', + p_event_id: payplePaymentEventId(orderId), + p_event_created_at: application.eventTime.toISOString(), + p_event_type: 'payment.completed', + p_payload_digest: await payplePaymentEventDigest({ + orderId, + payerId: candidate.payerId, + payType: 'card', + amount: TIER_PRICE[candidate.tier], + }), + p_provider_resource_id: candidate.resourceId, + p_tier: candidate.tier, + p_status: 'active', + p_entitled: true, + p_current_period_start: application.periodStart, + p_current_period_end: application.periodEnd, + p_cancel_at: null, + p_auto_renewing: true, + p_provider_customer_id: candidate.payerId, + p_provider_order_id: orderId, + p_store_product_id: null, + p_store_purchase_id: null, + p_operation_id: application.operationId, + }) + if (error) throw new Error('renewal_entitlement_apply_failed') + const result = data as { applied?: boolean; duplicate?: boolean } | null + return { applied: result?.applied === true, duplicate: result?.duplicate === true } + }, + } +} + +/** + * Payple 게이트웨이를 만든다. 빌링 인증은 기존과 같이 실행 시작 시 한 번 받고 + * (실패하면 실행 전체가 실패), 결과조회 인증은 재조정이 필요할 때만 받는다. + */ +export async function createPaypleRenewalGateway( + config: PaypleConfig, +): Promise { + const billingAuth = await paypleAuth(config, { simpleFlag: true }) + let lookupAuth: PaypleAuthResult | null = null + + return { + async charge(request: ChargeRequest): Promise { + const billing = await withTimeout( + paypleBilling(config, billingAuth, request), + BILLING_TIMEOUT_MS, + () => new PaypleBillingError('payple_billing_timeout_unknown', false), + ) + return { + orderId: billing.PCD_PAY_OID, + total: billing.PCD_PAY_TOTAL, + payerId: billing.PCD_PAYER_ID, + payTime: billing.PCD_PAY_TIME, + } + }, + + async lookup(orderId: string): Promise { + try { + const payDate = resolvePaypleOrderDate(orderId) + lookupAuth ??= await paypleAuth(config, { payCheckFlag: true }) + const result = await withTimeout( + paypleLookupPayment(config, lookupAuth, { orderId, payType: 'card', payDate }), + LOOKUP_TIMEOUT_MS, + () => new Error('payple_lookup_timeout'), + ) + return interpretLookup(result) + } catch (error) { + return { kind: classifyLookupFailure(error) } + } + }, + } +} diff --git a/server/supabase/functions/payple-renew/index.ts b/server/supabase/functions/payple-renew/index.ts index e54e397..9075516 100644 --- a/server/supabase/functions/payple-renew/index.ts +++ b/server/supabase/functions/payple-renew/index.ts @@ -1,36 +1,20 @@ import { corsHeaders } from '../_shared/cors.ts' import { createServiceRoleClient } from '../_shared/quota.ts' import { - calcSubscriptionPeriod, generateOrderId, getPaypleConfig, - parsePaypleTimestamp, - paypleAuth, - paypleBilling, - PaypleBillingError, PaypleConfigurationError, - payplePaymentEventDigest, - payplePaymentEventId, sha256Text, - TIER_GOODS_NAME, - TIER_PRICE, } from '../_shared/payple.ts' +import { createPaypleRenewalGateway, createSupabaseRenewalStore } from './adapters.ts' +import { + normalizeRenewalCandidate, + type RenewalDeps, + type RenewalResult, + renewSubscription, +} from './renewal.ts' -const MAX_RENEWAL_FAILURES = 3 - -interface RenewalResult { - userId: string - tier: string - success: boolean - orderId?: string - error?: string -} - -interface OperationReservation { - created?: boolean - operation_id?: string - reason?: string -} +type ServiceClient = ReturnType function jsonResponse(body: Record, status = 200): Response { return new Response(JSON.stringify(body), { @@ -39,237 +23,117 @@ function jsonResponse(body: Record, status = 200): Response { }) } -Deno.serve(async (req: Request) => { - if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders }) - if (req.method !== 'POST') return jsonResponse({ error: 'method_not_allowed' }, 405) - +function isAuthorizedCron(req: Request): boolean { const token = (req.headers.get('authorization') ?? '').replace(/^Bearer\s+/i, '') const cronSecret = Deno.env.get('CRON_SECRET')?.trim() ?? '' - if (!cronSecret || token.length !== cronSecret.length) { - return jsonResponse({ error: 'unauthorized' }, 401) - } + if (!cronSecret || token.length !== cronSecret.length) return false let mismatch = 0 for (let index = 0; index < token.length; index += 1) { mismatch |= token.charCodeAt(index) ^ cronSecret.charCodeAt(index) } - if (mismatch !== 0) return jsonResponse({ error: 'unauthorized' }, 401) + return mismatch === 0 +} + +/** End-of-term cancellations are revoked through the same serialized RPC. */ +async function expireEndedSubscriptions(serviceClient: ServiceClient, now: Date): Promise { + const { data: endingSubscriptions, error: endingError } = await serviceClient + .from('subscriptions') + .select('user_id, tier, provider_resource_id, current_period_start, current_period_end') + .eq('provider', 'payple') + .eq('auto_renewing', false) + .not('provider_resource_id', 'is', null) + .lte('current_period_end', now.toISOString()) + if (endingError) throw new Error('ending_subscription_query_failed') + for (const subscription of endingSubscriptions ?? []) { + if ( + typeof subscription.user_id !== 'string' + || typeof subscription.provider_resource_id !== 'string' + ) continue + const eventId = `scheduled-expire:${subscription.provider_resource_id}:${subscription.current_period_end}` + const { error: expireError } = await serviceClient.rpc('apply_payment_provider_event', { + p_user_id: subscription.user_id, + p_provider: 'payple', + p_event_id: eventId.slice(0, 255), + p_event_created_at: now.toISOString(), + p_event_type: 'subscription.scheduled_expiry', + p_payload_digest: await sha256Text(eventId), + p_provider_resource_id: subscription.provider_resource_id, + p_tier: 'free', + p_status: 'expired', + p_entitled: false, + p_current_period_start: subscription.current_period_start, + p_current_period_end: subscription.current_period_end, + p_cancel_at: subscription.current_period_end, + p_auto_renewing: false, + p_provider_customer_id: null, + p_provider_order_id: null, + p_store_product_id: null, + p_store_purchase_id: null, + p_operation_id: null, + }) + if (expireError) throw new Error('scheduled_expiry_failed') + } + return endingSubscriptions?.length ?? 0 +} + +async function selectDueRenewals( + serviceClient: ServiceClient, + now: Date, +): Promise[]> { + const { data, error } = await serviceClient + .from('subscriptions') + .select( + 'user_id, tier, payple_payer_id, provider_resource_id, current_period_end, renewal_failures', + ) + .eq('provider', 'payple') + .eq('auto_renewing', true) + .in('status', ['active', 'past_due']) + .not('payple_payer_id', 'is', null) + .lte('current_period_end', now.toISOString()) + if (error) throw new Error('renewal_query_failed') + return (data ?? []) as Record[] +} + +export async function paypleRenewHandler(req: Request): Promise { + if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders }) + if (req.method !== 'POST') return jsonResponse({ error: 'method_not_allowed' }, 405) + if (!isAuthorizedCron(req)) return jsonResponse({ error: 'unauthorized' }, 401) const serviceClient = createServiceRoleClient() const now = new Date() const results: RenewalResult[] = [] try { - // End-of-term cancellations are revoked through the same serialized RPC. - const { data: endingSubscriptions, error: endingError } = await serviceClient - .from('subscriptions') - .select('user_id, tier, provider_resource_id, current_period_start, current_period_end') - .eq('provider', 'payple') - .eq('auto_renewing', false) - .not('provider_resource_id', 'is', null) - .lte('current_period_end', now.toISOString()) - if (endingError) throw new Error('ending_subscription_query_failed') - for (const subscription of endingSubscriptions ?? []) { - if ( - typeof subscription.user_id !== 'string' - || typeof subscription.provider_resource_id !== 'string' - ) continue - const eventId = `scheduled-expire:${subscription.provider_resource_id}:${subscription.current_period_end}` - const { error: expireError } = await serviceClient.rpc('apply_payment_provider_event', { - p_user_id: subscription.user_id, - p_provider: 'payple', - p_event_id: eventId.slice(0, 255), - p_event_created_at: now.toISOString(), - p_event_type: 'subscription.scheduled_expiry', - p_payload_digest: await sha256Text(eventId), - p_provider_resource_id: subscription.provider_resource_id, - p_tier: 'free', - p_status: 'expired', - p_entitled: false, - p_current_period_start: subscription.current_period_start, - p_current_period_end: subscription.current_period_end, - p_cancel_at: subscription.current_period_end, - p_auto_renewing: false, - p_provider_customer_id: null, - p_provider_order_id: null, - p_store_product_id: null, - p_store_purchase_id: null, - p_operation_id: null, - }) - if (expireError) throw new Error('scheduled_expiry_failed') + const expired = await expireEndedSubscriptions(serviceClient, now) + const dueRenewals = await selectDueRenewals(serviceClient, now) + if (!dueRenewals.length) { + return jsonResponse({ renewed: 0, failed: 0, expired, results }) } - const { data: expiredSubscriptions, error: queryError } = await serviceClient - .from('subscriptions') - .select( - 'user_id, tier, payple_payer_id, provider_resource_id, current_period_end, renewal_failures', - ) - .eq('provider', 'payple') - .eq('auto_renewing', true) - .in('status', ['active', 'past_due']) - .not('payple_payer_id', 'is', null) - .lte('current_period_end', now.toISOString()) - if (queryError) throw new Error('renewal_query_failed') - if (!expiredSubscriptions?.length) { - return jsonResponse({ renewed: 0, failed: 0, expired: endingSubscriptions?.length ?? 0, results }) + const deps: RenewalDeps = { + store: createSupabaseRenewalStore(serviceClient), + gateway: await createPaypleRenewalGateway(getPaypleConfig()), + now: () => new Date(), + newOrderId: (userId) => generateOrderId(userId), } - const config = getPaypleConfig() - const auth = await paypleAuth(config, { simpleFlag: true }) - - for (const subscription of expiredSubscriptions) { - const userId = typeof subscription.user_id === 'string' ? subscription.user_id : '' - const tier = subscription.tier === 'pro' || subscription.tier === 'pro_plus' - ? subscription.tier - : null - const payerId = typeof subscription.payple_payer_id === 'string' - ? subscription.payple_payer_id - : null - const resourceId = typeof subscription.provider_resource_id === 'string' - ? subscription.provider_resource_id - : null - if (!userId || !tier || !payerId || !resourceId) { - results.push({ userId, tier: String(subscription.tier), success: false, error: 'invalid_subscription' }) + for (const row of dueRenewals) { + const candidate = normalizeRenewalCandidate(row) + if (!candidate) { + results.push({ + userId: typeof row.user_id === 'string' ? row.user_id : '', + tier: String(row.tier), + success: false, + error: 'invalid_subscription', + }) continue } - - const attempt = Number(subscription.renewal_failures ?? 0) + 1 - const periodKey = new Date(subscription.current_period_end as string).getTime() - const idempotencyKey = `payple-renew:${userId}:${periodKey}:attempt${attempt}` - const orderId = generateOrderId(userId) - let operationId: string | null = null - let charged = false - try { - const { data: reservationData, error: reservationError } = await serviceClient.rpc( - 'reserve_payment_provider_operation', - { - p_user_id: userId, - p_provider: 'payple', - p_operation_type: 'renewal', - p_requested_tier: tier, - p_idempotency_key: idempotencyKey, - p_provider_order_id: orderId, - p_provider_resource_id: resourceId, - }, - ) - if (reservationError) throw new Error('renewal_reservation_failed') - const reservation = reservationData as OperationReservation | null - if (!reservation?.created || typeof reservation.operation_id !== 'string') { - results.push({ - userId, - tier, - success: false, - error: reservation?.reason ?? 'renewal_operation_in_progress', - }) - continue - } - operationId = reservation.operation_id - - const billing = await paypleBilling(config, auth, { - payerId, - amount: TIER_PRICE[tier], - orderId, - goodsName: TIER_GOODS_NAME[tier], - }) - charged = true - const { error: chargedError } = await serviceClient.rpc('mark_payment_provider_operation', { - p_operation_id: operationId, - p_state: 'charged', - p_external_reference: billing.PCD_PAY_OID || orderId, - p_error_code: null, - }) - if (chargedError) throw new Error('renewal_operation_update_failed') - if ( - billing.PCD_PAY_OID !== orderId - || billing.PCD_PAY_TOTAL !== String(TIER_PRICE[tier]) - || (billing.PCD_PAYER_ID && billing.PCD_PAYER_ID !== payerId) - ) throw new Error('renewal_response_mismatch') - - const eventTime = billing.PCD_PAY_TIME - ? parsePaypleTimestamp(billing.PCD_PAY_TIME) - : new Date() - const { start, end } = calcSubscriptionPeriod(eventTime) - - const { data: applyData, error: applyError } = await serviceClient.rpc( - 'apply_payment_provider_event', - { - p_user_id: userId, - p_provider: 'payple', - p_event_id: payplePaymentEventId(orderId), - p_event_created_at: eventTime.toISOString(), - p_event_type: 'payment.completed', - p_payload_digest: await payplePaymentEventDigest({ - orderId, - payerId, - payType: 'card', - amount: TIER_PRICE[tier], - }), - p_provider_resource_id: resourceId, - p_tier: tier, - p_status: 'active', - p_entitled: true, - p_current_period_start: start, - p_current_period_end: end, - p_cancel_at: null, - p_auto_renewing: true, - p_provider_customer_id: payerId, - p_provider_order_id: orderId, - p_store_product_id: null, - p_store_purchase_id: null, - p_operation_id: operationId, - }, - ) - if (applyError) throw new Error('renewal_entitlement_apply_failed') - const applied = applyData as { applied?: boolean; duplicate?: boolean; reason?: string } | null - if (!applied?.applied && !applied?.duplicate) { - throw new Error('renewal_requires_reconciliation') - } - results.push({ userId, tier, success: true, orderId }) - } catch (error) { - const chargeOutcomeUnknown = error instanceof PaypleBillingError && !error.definitive - if (operationId && !charged) { - if (chargeOutcomeUnknown) { - await serviceClient.rpc('mark_payment_provider_operation', { - p_operation_id: operationId, - p_state: 'external_created', - p_external_reference: orderId, - p_error_code: null, - }) - } else if (error instanceof PaypleBillingError && error.definitive) { - const eventTime = new Date() - await serviceClient.rpc('record_payment_provider_renewal_failure', { - p_user_id: userId, - p_provider: 'payple', - p_event_id: `renewal-failed:${operationId}`, - p_event_created_at: eventTime.toISOString(), - p_payload_digest: await sha256Text(`${operationId}:payple_renewal_failed`), - p_provider_resource_id: resourceId, - p_error_code: 'payple_renewal_failed', - p_failure_threshold: MAX_RENEWAL_FAILURES, - p_operation_id: operationId, - }) - } else { - await serviceClient.rpc('mark_payment_provider_operation', { - p_operation_id: operationId, - p_state: 'failed', - p_external_reference: null, - p_error_code: 'payple_renewal_failed', - }) - } - } - results.push({ - userId, - tier, - success: false, - error: charged || chargeOutcomeUnknown - ? 'renewal_requires_reconciliation' - : 'payple_renewal_failed', - }) - } + results.push(await renewSubscription(deps, candidate)) } return jsonResponse({ renewed: results.filter((result) => result.success).length, failed: results.filter((result) => !result.success).length, - expired: endingSubscriptions?.length ?? 0, + expired, results, }) } catch (error) { @@ -278,4 +142,6 @@ Deno.serve(async (req: Request) => { } return jsonResponse({ error: 'payple_renewal_processing_failed' }, 500) } -}) +} + +if (import.meta.main) Deno.serve(paypleRenewHandler) diff --git a/server/supabase/functions/payple-renew/renewal.test.ts b/server/supabase/functions/payple-renew/renewal.test.ts new file mode 100644 index 0000000..bc109ec --- /dev/null +++ b/server/supabase/functions/payple-renew/renewal.test.ts @@ -0,0 +1,318 @@ +import { + classifyLookupFailure, + decideReplay, + interpretLookup, + type LookupOutcome, + normalizeRenewalCandidate, + type RenewalCandidate, + type RenewalDeps, + type RenewalOperation, + type RenewalPaymentApplication, + type RenewalReservation, + renewalIdempotencyKey, + renewSubscription, + withTimeout, +} from './renewal.ts' +import { toRenewalReservation } from './adapters.ts' +import { PaypleBillingError, PaypleVerificationError, TIER_PRICE } from '../_shared/payple.ts' + +function assert(condition: boolean, message: string): asserts condition { + if (!condition) throw new Error(message) +} + +const NOW = new Date('2026-09-28T01:00:00.000Z') +const ORIGINAL_ORDER = 'D3RO-20260927100000-user0001-aaaaaaaa' +const NEW_ORDER = 'D3RO-20260928100000-user0001-bbbbbbbb' + +const candidate: RenewalCandidate = { + userId: 'user0001-0000-0000-0000-000000000000', + tier: 'pro', + payerId: 'payer-1', + resourceId: 'payer-1', + currentPeriodEnd: '2026-09-27T00:00:00.000Z', + renewalFailures: 0, +} + +interface Harness { + deps: RenewalDeps + calls: { + reserved: string[] + charges: string[] + lookups: string[] + marks: Array<{ id: string; state: string }> + failures: string[] + applied: RenewalPaymentApplication[] + } +} + +function harness(options: { + reservation: RenewalReservation + operation?: RenewalOperation | null + lookup?: LookupOutcome + charge?: () => Promise<{ orderId: string; total: string; payerId?: string; payTime?: string }> +}): Harness { + const calls: Harness['calls'] = { + reserved: [], + charges: [], + lookups: [], + marks: [], + failures: [], + applied: [], + } + const deps: RenewalDeps = { + now: () => NOW, + newOrderId: () => NEW_ORDER, + store: { + reserveRenewal: (_c, key) => { + calls.reserved.push(key) + return Promise.resolve(options.reservation) + }, + getOperation: () => Promise.resolve(options.operation ?? null), + markOperation: (id, state) => { + calls.marks.push({ id, state }) + return Promise.resolve(true) + }, + recordRenewalFailure: (_c, operationId) => { + calls.failures.push(operationId) + return Promise.resolve(true) + }, + applyRenewalPayment: (application) => { + calls.applied.push(application) + return Promise.resolve({ applied: true, duplicate: false }) + }, + }, + gateway: { + charge: (request) => { + calls.charges.push(request.orderId) + return options.charge + ? options.charge() + : Promise.resolve({ orderId: request.orderId, total: String(request.amount) }) + }, + lookup: (orderId) => { + calls.lookups.push(orderId) + return Promise.resolve(options.lookup ?? { kind: 'unknown' }) + }, + }, + } + return { deps, calls } +} + +function parkedOperation(state: string, expiresAt: string): RenewalOperation { + return { id: 'op-parked', state, providerOrderId: ORIGINAL_ORDER, expiresAt } +} + +const EXPIRED = '2026-09-27T01:15:00.000Z' +const LIVE = '2026-09-28T01:10:00.000Z' + +// ── 회귀: 결과 불명 작업이 영구히 건너뛰어지던 버그 ───────────── + +Deno.test('replayed external_created renewal past its lease records a failure when Payple has no charge', async () => { + const { deps, calls } = harness({ + reservation: { kind: 'replay', operationId: 'op-parked' }, + operation: parkedOperation('external_created', EXPIRED), + lookup: { kind: 'not_charged' }, + }) + const result = await renewSubscription(deps, candidate) + assert(calls.lookups.length === 1 && calls.lookups[0] === ORIGINAL_ORDER, 'looks up the original order') + assert(calls.failures.length === 1 && calls.failures[0] === 'op-parked', 'counts a renewal failure') + assert(calls.charges.length === 0, 'never charges again inside a replay') + assert(calls.applied.length === 0, 'grants nothing') + assert(!result.success && result.error === 'payple_renewal_failed', 'reports the failure') +}) + +Deno.test('replayed reserved renewal killed mid-run applies the charge Payple confirms', async () => { + const { deps, calls } = harness({ + reservation: { kind: 'replay', operationId: 'op-parked' }, + operation: parkedOperation('reserved', EXPIRED), + lookup: { + kind: 'charged', + payment: { + orderId: ORIGINAL_ORDER, + total: String(TIER_PRICE.pro), + payerId: 'payer-1', + payTime: '20260927100005', + }, + }, + }) + const result = await renewSubscription(deps, candidate) + assert(result.success && result.orderId === ORIGINAL_ORDER, 'renewal succeeds with the original order') + assert(calls.applied.length === 1, 'applies once') + assert(calls.applied[0].operationId === 'op-parked', 'closes the parked operation') + assert(calls.applied[0].orderId === ORIGINAL_ORDER, 'uses the charged order, not a fresh one') + assert(calls.failures.length === 0, 'no failure recorded') +}) + +Deno.test('replayed failed renewal is reconciled instead of skipped forever', async () => { + const { deps, calls } = harness({ + reservation: { kind: 'replay', operationId: 'op-parked' }, + operation: parkedOperation('failed', EXPIRED), + lookup: { kind: 'not_charged' }, + }) + const result = await renewSubscription(deps, candidate) + assert(calls.failures.length === 1, 'failure counted so the attempt key advances') + assert(result.error === 'payple_renewal_failed', 'reported') +}) + +Deno.test('replay with a live lease is left to the run that owns it', async () => { + const { deps, calls } = harness({ + reservation: { kind: 'replay', operationId: 'op-parked' }, + operation: parkedOperation('external_created', LIVE), + lookup: { kind: 'not_charged' }, + }) + const result = await renewSubscription(deps, candidate) + assert(calls.lookups.length === 0 && calls.failures.length === 0, 'untouched') + assert(result.error === 'renewal_operation_in_progress', 'in progress') +}) + +Deno.test('replay whose lookup is still inconclusive changes nothing', async () => { + const { deps, calls } = harness({ + reservation: { kind: 'replay', operationId: 'op-parked' }, + operation: parkedOperation('external_created', EXPIRED), + lookup: { kind: 'unknown' }, + }) + const result = await renewSubscription(deps, candidate) + assert(calls.failures.length === 0 && calls.applied.length === 0, 'no state change') + assert(result.error === 'renewal_requires_reconciliation', 'flagged') +}) + +Deno.test('replay confirmed with a wrong amount is not auto-applied', async () => { + const { deps, calls } = harness({ + reservation: { kind: 'replay', operationId: 'op-parked' }, + operation: parkedOperation('external_created', EXPIRED), + lookup: { kind: 'charged', payment: { orderId: ORIGINAL_ORDER, total: '1' } }, + }) + const result = await renewSubscription(deps, candidate) + assert(calls.applied.length === 0 && calls.failures.length === 0, 'left for manual review') + assert(result.error === 'renewal_requires_reconciliation', 'flagged') +}) + +Deno.test('replay of an applied operation is a no-op', async () => { + const { deps, calls } = harness({ + reservation: { kind: 'replay', operationId: 'op-parked' }, + operation: parkedOperation('applied', EXPIRED), + }) + const result = await renewSubscription(deps, candidate) + assert(calls.lookups.length === 0 && calls.applied.length === 0, 'untouched') + assert(result.error === 'idempotent_replay', 'reported as replay') +}) + +// ── 기존 동작 보존 ───────────────────────────────────── + +Deno.test('fresh renewal charges and applies once', async () => { + const { deps, calls } = harness({ reservation: { kind: 'created', operationId: 'op-new' } }) + const result = await renewSubscription(deps, candidate) + assert(result.success && result.orderId === NEW_ORDER, 'success') + assert(calls.reserved[0] === renewalIdempotencyKey(candidate), 'uses the attempt key') + assert(calls.marks.some((mark) => mark.state === 'charged'), 'marked charged') + assert(calls.applied.length === 1 && calls.applied[0].operationId === 'op-new', 'applied') +}) + +Deno.test('unknown charge outcome parks the operation without counting a failure', async () => { + const { deps, calls } = harness({ + reservation: { kind: 'created', operationId: 'op-new' }, + charge: () => Promise.reject(new PaypleBillingError('payple_billing_transport_unknown', false)), + }) + const result = await renewSubscription(deps, candidate) + assert(calls.marks.length === 1 && calls.marks[0].state === 'external_created', 'parked') + assert(calls.failures.length === 0, 'not counted yet') + assert(result.error === 'renewal_requires_reconciliation', 'flagged') +}) + +Deno.test('definitive decline records a renewal failure', async () => { + const { deps, calls } = harness({ + reservation: { kind: 'created', operationId: 'op-new' }, + charge: () => Promise.reject(new PaypleBillingError('payple_billing_declined', true)), + }) + const result = await renewSubscription(deps, candidate) + assert(calls.failures.length === 1 && calls.failures[0] === 'op-new', 'counted') + assert(result.error === 'payple_renewal_failed', 'reported') +}) + +Deno.test('rejected reservation reports its reason', async () => { + const { deps, calls } = harness({ + reservation: { kind: 'rejected', reason: 'payment_operation_in_progress' }, + }) + const result = await renewSubscription(deps, candidate) + assert(calls.charges.length === 0, 'no charge') + assert(result.error === 'payment_operation_in_progress', 'reason passed through') +}) + +// ── 순수 정책 ────────────────────────────────────────── + +Deno.test('reservation rows map replays to reconciliation', () => { + const replay = toRenewalReservation({ + created: false, operation_id: 'op-1', state: 'external_created', reason: 'idempotent_replay', + }) + assert(replay.kind === 'replay' && replay.operationId === 'op-1', 'replay') + const created = toRenewalReservation({ created: true, operation_id: 'op-2' }) + assert(created.kind === 'created', 'created') + const rejected = toRenewalReservation({ created: false, reason: 'provider_not_owner' }) + assert(rejected.kind === 'rejected' && rejected.reason === 'provider_not_owner', 'rejected') + const empty = toRenewalReservation(null) + assert(empty.kind === 'rejected' && empty.reason === 'renewal_operation_in_progress', 'default') +}) + +Deno.test('decideReplay only waits on live in-flight leases', () => { + const op = (state: string, expiresAt: string | null): RenewalOperation => ({ + id: 'op', state, providerOrderId: ORIGINAL_ORDER, expiresAt, + }) + assert(decideReplay(op('reserved', LIVE), NOW) === 'in_progress', 'live reserved') + assert(decideReplay(op('charged', LIVE), NOW) === 'in_progress', 'live charged') + assert(decideReplay(op('reserved', EXPIRED), NOW) === 'reconcile', 'expired reserved') + assert(decideReplay(op('external_created', null), NOW) === 'reconcile', 'no lease') + assert(decideReplay(op('failed', LIVE), NOW) === 'reconcile', 'failed') + assert(decideReplay(op('applied', EXPIRED), NOW) === 'already_applied', 'applied') +}) + +Deno.test('lookup classification treats only an unconfirmed transaction as not charged', () => { + assert( + classifyLookupFailure(new PaypleVerificationError('payple_lookup_mismatch')) === 'not_charged', + 'mismatch', + ) + assert( + classifyLookupFailure(new PaypleVerificationError('payple_lookup_http_502')) === 'unknown', + 'http', + ) + assert(classifyLookupFailure(new TypeError('network')) === 'unknown', 'transport') + assert( + interpretLookup({ PCD_PAY_OID: ORIGINAL_ORDER, PCD_PAY_STATE: '승인취소완료' }).kind === 'not_charged', + 'canceled', + ) + assert(interpretLookup({ PCD_PAY_OID: ORIGINAL_ORDER, PCD_PAY_TOTAL: '9900' }).kind === 'charged', 'paid') +}) + +Deno.test('attempt key advances only with recorded failures', () => { + const first = renewalIdempotencyKey(candidate) + assert(first === renewalIdempotencyKey({ ...candidate }), 'stable') + assert(first !== renewalIdempotencyKey({ ...candidate, renewalFailures: 1 }), 'advances') + assert(first.endsWith(':attempt1'), 'attempt number') +}) + +Deno.test('candidate normalization rejects incomplete rows', () => { + const row = { + user_id: candidate.userId, + tier: 'pro', + payple_payer_id: 'payer-1', + provider_resource_id: 'payer-1', + current_period_end: candidate.currentPeriodEnd, + renewal_failures: 2, + } + assert(normalizeRenewalCandidate(row)?.renewalFailures === 2, 'valid') + assert(normalizeRenewalCandidate({ ...row, tier: 'free' }) === null, 'free tier') + assert(normalizeRenewalCandidate({ ...row, payple_payer_id: null }) === null, 'no payer') +}) + +Deno.test('withTimeout turns a hung call into the supplied error', async () => { + let error: unknown + try { + await withTimeout( + new Promise(() => {}), + 5, + () => new PaypleBillingError('payple_billing_timeout_unknown', false), + ) + } catch (caught) { + error = caught + } + assert(error instanceof PaypleBillingError && !error.definitive, 'non-definitive timeout') + assert(await withTimeout(Promise.resolve(7), 1_000, () => new Error('x')) === 7, 'passes through') +}) diff --git a/server/supabase/functions/payple-renew/renewal.ts b/server/supabase/functions/payple-renew/renewal.ts new file mode 100644 index 0000000..41abc94 --- /dev/null +++ b/server/supabase/functions/payple-renew/renewal.ts @@ -0,0 +1,377 @@ +// server/supabase/functions/payple-renew/renewal.ts +// Payple 정기 갱신 유스케이스 — 순수 정책 + 포트(RenewalStore, RenewalPaymentGateway). +// IO(Supabase RPC, Payple HTTP)는 adapters.ts가 구현하고, 이 모듈은 결정만 내린다. + +import { + calcSubscriptionPeriod, + parsePaypleTimestamp, + PaypleBillingError, + PaypleVerificationError, + TIER_GOODS_NAME, + TIER_PRICE, +} from '../_shared/payple.ts' + +export const MAX_RENEWAL_FAILURES = 3 +/** Payple 빌링 호출 상한. 멈춘 호출이 크론 전체 실행 시간을 잡아먹지 않게 한다. */ +export const BILLING_TIMEOUT_MS = 30_000 +/** Payple 결과조회(PayChkAct) 호출 상한. */ +export const LOOKUP_TIMEOUT_MS = 30_000 + +// ── 도메인 타입 ───────────────────────────────────────── + +export type RenewableTier = 'pro' | 'pro_plus' + +export interface RenewalCandidate { + userId: string + tier: RenewableTier + payerId: string + resourceId: string + currentPeriodEnd: string + renewalFailures: number +} + +export interface RenewalResult { + userId: string + tier: string + success: boolean + orderId?: string + error?: string +} + +export type RenewalOperationState = + | 'reserved' + | 'external_created' + | 'charged' + | 'applied' + | 'failed' + +export interface RenewalOperation { + id: string + state: string + providerOrderId: string | null + expiresAt: string | null +} + +export type RenewalReservation = + | { kind: 'created'; operationId: string } + | { kind: 'replay'; operationId: string } + | { kind: 'rejected'; reason: string } + +export interface RenewalPaymentApplication { + candidate: RenewalCandidate + orderId: string + operationId: string + eventTime: Date + periodStart: string + periodEnd: string +} + +export interface ChargeRequest { + payerId: string + amount: number + orderId: string + goodsName: string +} + +export interface ChargeReceipt { + orderId: string + total: string + payerId?: string + payTime?: string +} + +export type LookupOutcome = + | { kind: 'charged'; payment: ChargeReceipt } + | { kind: 'not_charged' } + | { kind: 'unknown' } + +// ── 포트 ─────────────────────────────────────────────── + +/** 결제 운영 원장 + 구독 엔타이틀먼트 저장소 포트. */ +export interface RenewalStore { + reserveRenewal( + candidate: RenewalCandidate, + idempotencyKey: string, + orderId: string, + ): Promise + getOperation(operationId: string): Promise + /** false면 저장소가 상태 전이를 거부했거나 실패했다. */ + markOperation( + operationId: string, + state: 'external_created' | 'charged' | 'failed', + externalReference: string | null, + errorCode: string | null, + ): Promise + /** 갱신 실패를 한 번 기록한다(실패 카운터 증가, 3회 누적 시 회수). false면 기록되지 않았다. */ + recordRenewalFailure( + candidate: RenewalCandidate, + operationId: string, + at: Date, + ): Promise + applyRenewalPayment( + application: RenewalPaymentApplication, + ): Promise<{ applied: boolean; duplicate: boolean }> +} + +/** Payple 빌링/결과조회 포트. */ +export interface RenewalPaymentGateway { + /** 결과가 불명확하면 PaypleBillingError(definitive=false)를 던진다. */ + charge(request: ChargeRequest): Promise + lookup(orderId: string): Promise +} + +export interface RenewalDeps { + store: RenewalStore + gateway: RenewalPaymentGateway + now: () => Date + newOrderId: (userId: string) => string +} + +// ── 순수 정책 ────────────────────────────────────────── + +export function normalizeRenewalCandidate(row: Record): RenewalCandidate | null { + const userId = typeof row.user_id === 'string' ? row.user_id : '' + const tier = row.tier === 'pro' || row.tier === 'pro_plus' ? row.tier : null + const payerId = typeof row.payple_payer_id === 'string' ? row.payple_payer_id : null + const resourceId = typeof row.provider_resource_id === 'string' ? row.provider_resource_id : null + const currentPeriodEnd = typeof row.current_period_end === 'string' ? row.current_period_end : null + if (!userId || !tier || !payerId || !resourceId || !currentPeriodEnd) return null + return { + userId, + tier, + payerId, + resourceId, + currentPeriodEnd, + renewalFailures: Number(row.renewal_failures ?? 0), + } +} + +/** + * 같은 기간·같은 시도 번호면 같은 키가 나온다. 시도 번호는 기록된 실패 횟수로만 + * 전진하므로, 결과 불명 작업은 재조정(reconcileReplay)으로 반드시 결론을 내야 한다. + */ +export function renewalIdempotencyKey(candidate: RenewalCandidate): string { + const attempt = candidate.renewalFailures + 1 + const periodKey = new Date(candidate.currentPeriodEnd).getTime() + return `payple-renew:${candidate.userId}:${periodKey}:attempt${attempt}` +} + +const IN_FLIGHT_STATES: ReadonlySet = new Set(['reserved', 'external_created', 'charged']) + +export type ReplayDecision = 'already_applied' | 'in_progress' | 'reconcile' + +/** + * 멱등 재생된 작업을 어떻게 다룰지 결정한다. + * - applied: 이미 반영됨 → 건드리지 않는다. + * - 진행 중 상태 + 리스 유효: 다른 실행이 처리 중일 수 있다 → 건너뛴다. + * - 그 외(리스 만료, failed): Payple 결과조회로 결론을 낸다. + */ +export function decideReplay(operation: RenewalOperation, now: Date): ReplayDecision { + if (operation.state === 'applied') return 'already_applied' + if (IN_FLIGHT_STATES.has(operation.state)) { + const expiresAt = operation.expiresAt ? new Date(operation.expiresAt).getTime() : Number.NaN + if (Number.isFinite(expiresAt) && expiresAt > now.getTime()) return 'in_progress' + } + return 'reconcile' +} + +const CANCELED_PAY_STATES: ReadonlySet = new Set(['승인취소완료', 'canceled']) + +/** 결과조회 성공 응답을 결론으로 바꾼다. 취소된 거래는 청구되지 않은 것으로 본다. */ +export function interpretLookup(result: { + PCD_PAY_OID: string + PCD_PAY_TOTAL?: string + PCD_PAYER_ID?: string + PCD_PAY_TIME?: string + PCD_PAY_STATE?: string +}): LookupOutcome { + if (result.PCD_PAY_STATE && CANCELED_PAY_STATES.has(result.PCD_PAY_STATE)) { + return { kind: 'not_charged' } + } + return { + kind: 'charged', + payment: { + orderId: result.PCD_PAY_OID, + total: result.PCD_PAY_TOTAL ?? '', + payerId: result.PCD_PAYER_ID, + payTime: result.PCD_PAY_TIME, + }, + } +} + +/** + * 결과조회 실패를 분류한다. Payple이 거래를 성공으로 확인해 주지 않은 경우 + * (payple_lookup_mismatch)만 "청구 없음"으로 확정한다 — 웹훅도 같은 결과로는 + * 엔타이틀먼트를 주지 않는다. 전송/HTTP/파싱 오류는 다음 실행에서 다시 본다. + */ +export function classifyLookupFailure(error: unknown): 'not_charged' | 'unknown' { + if (error instanceof PaypleVerificationError && error.code === 'payple_lookup_mismatch') { + return 'not_charged' + } + return 'unknown' +} + +export function receiptMatchesCandidate( + receipt: ChargeReceipt, + orderId: string, + candidate: RenewalCandidate, +): boolean { + return receipt.orderId === orderId + && receipt.total === String(TIER_PRICE[candidate.tier]) + && (!receipt.payerId || receipt.payerId === candidate.payerId) +} + +export async function withTimeout( + task: Promise, + timeoutMs: number, + onTimeout: () => Error, +): Promise { + let timer: ReturnType | undefined + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(onTimeout()), timeoutMs) + }) + try { + return await Promise.race([task, timeout]) + } finally { + if (timer !== undefined) clearTimeout(timer) + } +} + +// ── 유스케이스 ───────────────────────────────────────── + +function buildApplication( + candidate: RenewalCandidate, + orderId: string, + operationId: string, + receipt: ChargeReceipt, + now: Date, +): RenewalPaymentApplication { + const eventTime = receipt.payTime ? parsePaypleTimestamp(receipt.payTime) : now + const { start, end } = calcSubscriptionPeriod(eventTime) + return { candidate, orderId, operationId, eventTime, periodStart: start, periodEnd: end } +} + +async function applyConfirmedCharge( + deps: RenewalDeps, + candidate: RenewalCandidate, + orderId: string, + operationId: string, + receipt: ChargeReceipt, +): Promise { + const applied = await deps.store.applyRenewalPayment( + buildApplication(candidate, orderId, operationId, receipt, deps.now()), + ) + if (!applied.applied && !applied.duplicate) { + throw new Error('renewal_requires_reconciliation') + } + return { userId: candidate.userId, tier: candidate.tier, success: true, orderId } +} + +/** + * 멱등 재생된 갱신 작업에 결론을 낸다. 결과가 불명확한 채로 남은 작업 + * (external_created/reserved 리스 만료, failed)은 Payple 결과조회로 확인해 + * 청구됐으면 반영하고, 청구되지 않았으면 실패를 기록해 시도 번호를 전진시킨다. + */ +export async function reconcileReplay( + deps: RenewalDeps, + candidate: RenewalCandidate, + operationId: string, +): Promise { + const base = { userId: candidate.userId, tier: candidate.tier } + const operation = await deps.store.getOperation(operationId) + if (!operation) return { ...base, success: false, error: 'renewal_requires_reconciliation' } + + const decision = decideReplay(operation, deps.now()) + if (decision === 'already_applied') return { ...base, success: false, error: 'idempotent_replay' } + if (decision === 'in_progress') { + return { ...base, success: false, error: 'renewal_operation_in_progress' } + } + + const orderId = operation.providerOrderId + const outcome: LookupOutcome = orderId + ? await deps.gateway.lookup(orderId) + : { kind: 'not_charged' } + + if (outcome.kind === 'unknown') { + return { ...base, success: false, error: 'renewal_requires_reconciliation' } + } + if (outcome.kind === 'not_charged') { + const recorded = await deps.store.recordRenewalFailure(candidate, operation.id, deps.now()) + return { + ...base, + success: false, + error: recorded ? 'payple_renewal_failed' : 'renewal_requires_reconciliation', + } + } + + // 청구가 확인됐다. 금액·주문·결제자가 어긋나면 자동 반영하지 않는다. + if (!orderId || !receiptMatchesCandidate(outcome.payment, orderId, candidate)) { + return { ...base, success: false, error: 'renewal_requires_reconciliation' } + } + try { + return await applyConfirmedCharge(deps, candidate, orderId, operation.id, outcome.payment) + } catch { + return { ...base, success: false, error: 'renewal_requires_reconciliation' } + } +} + +/** 구독 한 건을 갱신한다. 새 작업이면 청구하고, 재생된 작업이면 재조정한다. */ +export async function renewSubscription( + deps: RenewalDeps, + candidate: RenewalCandidate, +): Promise { + const { userId, tier, payerId } = candidate + const idempotencyKey = renewalIdempotencyKey(candidate) + const orderId = deps.newOrderId(userId) + let operationId: string | null = null + let charged = false + try { + const reservation = await deps.store.reserveRenewal(candidate, idempotencyKey, orderId) + if (reservation.kind === 'replay') { + return await reconcileReplay(deps, candidate, reservation.operationId) + } + if (reservation.kind === 'rejected') { + return { userId, tier, success: false, error: reservation.reason } + } + operationId = reservation.operationId + + const receipt = await deps.gateway.charge({ + payerId, + amount: TIER_PRICE[tier], + orderId, + goodsName: TIER_GOODS_NAME[tier], + }) + charged = true + const marked = await deps.store.markOperation( + operationId, + 'charged', + receipt.orderId || orderId, + null, + ) + if (!marked) throw new Error('renewal_operation_update_failed') + if (!receiptMatchesCandidate(receipt, orderId, candidate)) { + throw new Error('renewal_response_mismatch') + } + return await applyConfirmedCharge(deps, candidate, orderId, operationId, receipt) + } catch (error) { + const chargeOutcomeUnknown = error instanceof PaypleBillingError && !error.definitive + if (operationId && !charged) { + if (chargeOutcomeUnknown) { + // 결과 불명: 실패로 세지 않고 남겨 둔다. 다음 실행에서 멱등 재생 → reconcileReplay가 결론을 낸다. + await deps.store.markOperation(operationId, 'external_created', orderId, null) + } else if (error instanceof PaypleBillingError && error.definitive) { + await deps.store.recordRenewalFailure(candidate, operationId, deps.now()) + } else { + await deps.store.markOperation(operationId, 'failed', null, 'payple_renewal_failed') + } + } + return { + userId, + tier, + success: false, + error: charged || chargeOutcomeUnknown + ? 'renewal_requires_reconciliation' + : 'payple_renewal_failed', + } + } +}