318 lines
13 KiB
TypeScript
318 lines
13 KiB
TypeScript
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<never>(() => {}),
|
|
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')
|
|
})
|