377 lines
13 KiB
TypeScript
377 lines
13 KiB
TypeScript
// 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<RenewalReservation>
|
|
getOperation(operationId: string): Promise<RenewalOperation | null>
|
|
/** false면 저장소가 상태 전이를 거부했거나 실패했다. */
|
|
markOperation(
|
|
operationId: string,
|
|
state: 'external_created' | 'charged' | 'failed',
|
|
externalReference: string | null,
|
|
errorCode: string | null,
|
|
): Promise<boolean>
|
|
/** 갱신 실패를 한 번 기록한다(실패 카운터 증가, 3회 누적 시 회수). false면 기록되지 않았다. */
|
|
recordRenewalFailure(
|
|
candidate: RenewalCandidate,
|
|
operationId: string,
|
|
at: Date,
|
|
): Promise<boolean>
|
|
applyRenewalPayment(
|
|
application: RenewalPaymentApplication,
|
|
): Promise<{ applied: boolean; duplicate: boolean }>
|
|
}
|
|
|
|
/** Payple 빌링/결과조회 포트. */
|
|
export interface RenewalPaymentGateway {
|
|
/** 결과가 불명확하면 PaypleBillingError(definitive=false)를 던진다. */
|
|
charge(request: ChargeRequest): Promise<ChargeReceipt>
|
|
lookup(orderId: string): Promise<LookupOutcome>
|
|
}
|
|
|
|
export interface RenewalDeps {
|
|
store: RenewalStore
|
|
gateway: RenewalPaymentGateway
|
|
now: () => Date
|
|
newOrderId: (userId: string) => string
|
|
}
|
|
|
|
// ── 순수 정책 ──────────────────────────────────────────
|
|
|
|
export function normalizeRenewalCandidate(row: Record<string, unknown>): 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<string> = 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<string> = 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<T>(
|
|
task: Promise<T>,
|
|
timeoutMs: number,
|
|
onTimeout: () => Error,
|
|
): Promise<T> {
|
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
const timeout = new Promise<never>((_, 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<RenewalResult> {
|
|
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<RenewalResult> {
|
|
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<RenewalResult> {
|
|
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',
|
|
}
|
|
}
|
|
}
|