fix(payple-renew): reconcile renewals whose charge outcome was left unknown
This commit is contained in:
parent
06e96c58f4
commit
f4724ddf53
4 changed files with 980 additions and 234 deletions
318
server/supabase/functions/payple-renew/renewal.test.ts
Normal file
318
server/supabase/functions/payple-renew/renewal.test.ts
Normal file
|
|
@ -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<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')
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue