// 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', } } }