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') })