111 lines
5 KiB
TypeScript
111 lines
5 KiB
TypeScript
import { latestPaidTierByUser, withPaidTier } from './paid-tier.ts'
|
|
import {
|
|
type ChargeRequest,
|
|
normalizeRenewalCandidate,
|
|
type RenewalDeps,
|
|
type RenewalPaymentApplication,
|
|
renewSubscription,
|
|
} from './renewal.ts'
|
|
import { TIER_PRICE } from '../_shared/payple.ts'
|
|
|
|
function assert(condition: boolean, message: string): asserts condition {
|
|
if (!condition) throw new Error(message)
|
|
}
|
|
|
|
const USER = 'user0019-0000-0000-0000-000000000000'
|
|
const OTHER_USER = 'user0020-0000-0000-0000-000000000000'
|
|
const ORDER = 'D3RO-20260928100000-user0019-cccccccc'
|
|
|
|
/** A Payple Pro subscriber whose row tier a back-office edit rewrote to pro_plus. */
|
|
const tamperedDueRow: Record<string, unknown> = {
|
|
user_id: USER,
|
|
tier: 'pro_plus',
|
|
payple_payer_id: 'payer-19',
|
|
provider_resource_id: 'payer-19',
|
|
current_period_end: '2026-09-27T00:00:00.000Z',
|
|
renewal_failures: 0,
|
|
}
|
|
|
|
const appliedOperations: Record<string, unknown>[] = [
|
|
{ user_id: USER, requested_tier: 'pro', updated_at: '2026-08-27T00:00:05.000Z', created_at: '2026-08-27T00:00:00.000Z' },
|
|
{ user_id: OTHER_USER, requested_tier: 'pro_plus', updated_at: '2026-09-01T00:00:00.000Z', created_at: '2026-09-01T00:00:00.000Z' },
|
|
]
|
|
|
|
function chargingHarness(): { deps: RenewalDeps; charges: ChargeRequest[]; applied: RenewalPaymentApplication[] } {
|
|
const charges: ChargeRequest[] = []
|
|
const applied: RenewalPaymentApplication[] = []
|
|
const deps: RenewalDeps = {
|
|
now: () => new Date('2026-09-28T01:00:00.000Z'),
|
|
newOrderId: () => ORDER,
|
|
store: {
|
|
reserveRenewal: () => Promise.resolve({ kind: 'created', operationId: 'op-19' }),
|
|
getOperation: () => Promise.resolve(null),
|
|
markOperation: () => Promise.resolve(true),
|
|
recordRenewalFailure: () => Promise.resolve(true),
|
|
applyRenewalPayment: (application) => {
|
|
applied.push(application)
|
|
return Promise.resolve({ applied: true, duplicate: false })
|
|
},
|
|
},
|
|
gateway: {
|
|
charge: (request) => {
|
|
charges.push(request)
|
|
return Promise.resolve({ orderId: request.orderId, total: String(request.amount) })
|
|
},
|
|
lookup: () => Promise.resolve({ kind: 'unknown' }),
|
|
},
|
|
}
|
|
return { deps, charges, applied }
|
|
}
|
|
|
|
// ── 회귀: 관리자 등급 수정이 다음 갱신 청구 금액을 바꾸던 버그 ─────────
|
|
|
|
Deno.test('renewal charges the tier the customer last paid for, not an edited row tier', async () => {
|
|
const candidate = normalizeRenewalCandidate(
|
|
withPaidTier(tamperedDueRow, latestPaidTierByUser(appliedOperations)),
|
|
)
|
|
assert(candidate !== null, 'candidate is valid')
|
|
const { deps, charges, applied } = chargingHarness()
|
|
|
|
const result = await renewSubscription(deps, candidate)
|
|
|
|
assert(result.success, `renewal succeeds: ${JSON.stringify(result)}`)
|
|
assert(charges.length === 1, 'charged once')
|
|
assert(charges[0].amount === TIER_PRICE.pro, `charged the pro price, got ${charges[0].amount}`)
|
|
assert(applied.length === 1 && applied[0].candidate.tier === 'pro', 'entitlement renewed at the paid tier')
|
|
})
|
|
|
|
Deno.test('legacy subscriptions without a payment ledger entry keep their row tier', () => {
|
|
const row = { ...tamperedDueRow, user_id: 'legacy-user', tier: 'pro' }
|
|
const adjusted = withPaidTier(row, latestPaidTierByUser(appliedOperations))
|
|
assert(adjusted === row, 'row is returned unchanged')
|
|
assert(normalizeRenewalCandidate(adjusted)?.tier === 'pro', 'legacy tier kept')
|
|
})
|
|
|
|
Deno.test('latest applied payment operation wins per user', () => {
|
|
const tiers = latestPaidTierByUser([
|
|
{ user_id: USER, requested_tier: 'pro', updated_at: '2026-07-01T00:00:00.000Z', created_at: '2026-07-01T00:00:00.000Z' },
|
|
{ user_id: USER, requested_tier: 'pro_plus', updated_at: '2026-09-01T00:00:00.000Z', created_at: '2026-09-01T00:00:00.000Z' },
|
|
{ user_id: USER, requested_tier: 'pro', updated_at: '2026-08-01T00:00:00.000Z', created_at: '2026-08-01T00:00:00.000Z' },
|
|
])
|
|
assert(tiers.get(USER) === 'pro_plus', 'most recently applied upgrade is charged')
|
|
})
|
|
|
|
Deno.test('same applied time falls back to the later created operation', () => {
|
|
const tiers = latestPaidTierByUser([
|
|
{ user_id: USER, requested_tier: 'pro_plus', updated_at: '2026-09-01T00:00:00.000Z', created_at: '2026-08-31T00:00:00.000Z' },
|
|
{ user_id: USER, requested_tier: 'pro', updated_at: '2026-09-01T00:00:00.000Z', created_at: '2026-09-01T00:00:00.000Z' },
|
|
])
|
|
assert(tiers.get(USER) === 'pro', 'later-created operation wins the tie')
|
|
})
|
|
|
|
Deno.test('malformed ledger rows are ignored', () => {
|
|
const tiers = latestPaidTierByUser([
|
|
{ user_id: USER, requested_tier: 'free', updated_at: '2026-09-01T00:00:00.000Z' },
|
|
{ user_id: USER, requested_tier: null, updated_at: '2026-09-02T00:00:00.000Z' },
|
|
{ user_id: USER, requested_tier: 'pro_plus', updated_at: 'not-a-date' },
|
|
{ user_id: 42, requested_tier: 'pro', updated_at: '2026-09-03T00:00:00.000Z' },
|
|
{ user_id: USER, requested_tier: 'pro', updated_at: '2026-08-01T00:00:00.000Z' },
|
|
])
|
|
assert(tiers.size === 1 && tiers.get(USER) === 'pro', 'only the valid row counts')
|
|
})
|