@dainamite/billing 0.17.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +99 -19
- package/dist/modules/billing/acl.js +9 -1
- package/dist/modules/billing/acl.js.map +2 -2
- package/dist/modules/billing/api/accounts/[id]/balance/route.js +62 -0
- package/dist/modules/billing/api/accounts/[id]/balance/route.js.map +7 -0
- package/dist/modules/billing/api/accounts/[id]/credit-status/route.js +56 -0
- package/dist/modules/billing/api/accounts/[id]/credit-status/route.js.map +7 -0
- package/dist/modules/billing/api/accounts/[id]/topups/route.js +36 -0
- package/dist/modules/billing/api/accounts/[id]/topups/route.js.map +7 -0
- package/dist/modules/billing/api/accounts/route.js +7 -0
- package/dist/modules/billing/api/accounts/route.js.map +2 -2
- package/dist/modules/billing/api/export/account/[id]/route.js +48 -3
- package/dist/modules/billing/api/export/account/[id]/route.js.map +2 -2
- package/dist/modules/billing/api/statements/generate/route.js +68 -0
- package/dist/modules/billing/api/statements/generate/route.js.map +7 -0
- package/dist/modules/billing/api/statements/route.js +110 -0
- package/dist/modules/billing/api/statements/route.js.map +7 -0
- package/dist/modules/billing/api/topups/route.js +139 -0
- package/dist/modules/billing/api/topups/route.js.map +7 -0
- package/dist/modules/billing/api/transactions/adjust/route.js +71 -0
- package/dist/modules/billing/api/transactions/adjust/route.js.map +7 -0
- package/dist/modules/billing/api/transactions/route.js +109 -0
- package/dist/modules/billing/api/transactions/route.js.map +7 -0
- package/dist/modules/billing/api/usage/route.js +30 -6
- package/dist/modules/billing/api/usage/route.js.map +2 -2
- package/dist/modules/billing/backend/billing/accounts/[id]/page.js +16 -1
- package/dist/modules/billing/backend/billing/accounts/[id]/page.js.map +2 -2
- package/dist/modules/billing/backend/billing/accounts/create/page.js +8 -1
- package/dist/modules/billing/backend/billing/accounts/create/page.js.map +2 -2
- package/dist/modules/billing/backend/billing/accounts/page.js +26 -1
- package/dist/modules/billing/backend/billing/accounts/page.js.map +2 -2
- package/dist/modules/billing/backend/billing/statements/[id]/page.js +129 -0
- package/dist/modules/billing/backend/billing/statements/[id]/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/statements/[id]/page.meta.js +21 -0
- package/dist/modules/billing/backend/billing/statements/[id]/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/statements/page.js +152 -0
- package/dist/modules/billing/backend/billing/statements/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/statements/page.meta.js +18 -0
- package/dist/modules/billing/backend/billing/statements/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/topups/[id]/page.js +90 -0
- package/dist/modules/billing/backend/billing/topups/[id]/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/topups/[id]/page.meta.js +21 -0
- package/dist/modules/billing/backend/billing/topups/[id]/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/topups/page.js +147 -0
- package/dist/modules/billing/backend/billing/topups/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/topups/page.meta.js +18 -0
- package/dist/modules/billing/backend/billing/topups/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/transactions/[id]/page.js +114 -0
- package/dist/modules/billing/backend/billing/transactions/[id]/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/transactions/[id]/page.meta.js +21 -0
- package/dist/modules/billing/backend/billing/transactions/[id]/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/transactions/page.js +149 -0
- package/dist/modules/billing/backend/billing/transactions/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/transactions/page.meta.js +18 -0
- package/dist/modules/billing/backend/billing/transactions/page.meta.js.map +7 -0
- package/dist/modules/billing/commands/accounts.js +26 -1
- package/dist/modules/billing/commands/accounts.js.map +2 -2
- package/dist/modules/billing/commands/index.js +3 -0
- package/dist/modules/billing/commands/index.js.map +2 -2
- package/dist/modules/billing/commands/invoices.js +21 -1
- package/dist/modules/billing/commands/invoices.js.map +2 -2
- package/dist/modules/billing/commands/statements.js +74 -0
- package/dist/modules/billing/commands/statements.js.map +7 -0
- package/dist/modules/billing/commands/topups.js +139 -0
- package/dist/modules/billing/commands/topups.js.map +7 -0
- package/dist/modules/billing/commands/transactions.js +119 -0
- package/dist/modules/billing/commands/transactions.js.map +7 -0
- package/dist/modules/billing/commands/usage.js +72 -0
- package/dist/modules/billing/commands/usage.js.map +2 -2
- package/dist/modules/billing/components/AccountForm.js +53 -1
- package/dist/modules/billing/components/AccountForm.js.map +2 -2
- package/dist/modules/billing/components/PrepaidPanel.js +314 -0
- package/dist/modules/billing/components/PrepaidPanel.js.map +7 -0
- package/dist/modules/billing/components/accountNames.js +20 -0
- package/dist/modules/billing/components/accountNames.js.map +7 -0
- package/dist/modules/billing/data/entities.js +290 -0
- package/dist/modules/billing/data/entities.js.map +2 -2
- package/dist/modules/billing/data/entityIds.js +6 -1
- package/dist/modules/billing/data/entityIds.js.map +2 -2
- package/dist/modules/billing/data/validators.js +107 -2
- package/dist/modules/billing/data/validators.js.map +2 -2
- package/dist/modules/billing/events.js +52 -0
- package/dist/modules/billing/events.js.map +2 -2
- package/dist/modules/billing/i18n/en.json +112 -4
- package/dist/modules/billing/i18n/pl.json +112 -4
- package/dist/modules/billing/index.js +1 -1
- package/dist/modules/billing/index.js.map +2 -2
- package/dist/modules/billing/lib/accountStatus.js +106 -0
- package/dist/modules/billing/lib/accountStatus.js.map +7 -0
- package/dist/modules/billing/lib/balanceEvents.js +48 -0
- package/dist/modules/billing/lib/balanceEvents.js.map +7 -0
- package/dist/modules/billing/lib/balanceLedger.js +136 -0
- package/dist/modules/billing/lib/balanceLedger.js.map +7 -0
- package/dist/modules/billing/lib/balanceStatus.js +29 -0
- package/dist/modules/billing/lib/balanceStatus.js.map +7 -0
- package/dist/modules/billing/lib/billRunEngine.js +40 -2
- package/dist/modules/billing/lib/billRunEngine.js.map +2 -2
- package/dist/modules/billing/lib/creditStatus.js +65 -0
- package/dist/modules/billing/lib/creditStatus.js.map +7 -0
- package/dist/modules/billing/lib/money.js +47 -0
- package/dist/modules/billing/lib/money.js.map +7 -0
- package/dist/modules/billing/lib/prepaidConfig.js +45 -0
- package/dist/modules/billing/lib/prepaidConfig.js.map +7 -0
- package/dist/modules/billing/lib/prepaidConsume.js +214 -0
- package/dist/modules/billing/lib/prepaidConsume.js.map +7 -0
- package/dist/modules/billing/lib/prepaidPeriodClose.js +285 -0
- package/dist/modules/billing/lib/prepaidPeriodClose.js.map +7 -0
- package/dist/modules/billing/lib/topupCapture.js +62 -0
- package/dist/modules/billing/lib/topupCapture.js.map +7 -0
- package/dist/modules/billing/lib/topupReceipt.js +97 -0
- package/dist/modules/billing/lib/topupReceipt.js.map +7 -0
- package/dist/modules/billing/lib/topupTransition.js +19 -0
- package/dist/modules/billing/lib/topupTransition.js.map +7 -0
- package/dist/modules/billing/migrations/Migration20260619000000_billing_prepaid.js +110 -0
- package/dist/modules/billing/migrations/Migration20260619000000_billing_prepaid.js.map +7 -0
- package/dist/modules/billing/setup.js +32 -1
- package/dist/modules/billing/setup.js.map +2 -2
- package/dist/modules/billing/subscribers/prepaid-topup-cancelled.js +26 -0
- package/dist/modules/billing/subscribers/prepaid-topup-cancelled.js.map +7 -0
- package/dist/modules/billing/subscribers/prepaid-topup-captured.js +87 -0
- package/dist/modules/billing/subscribers/prepaid-topup-captured.js.map +7 -0
- package/dist/modules/billing/subscribers/prepaid-topup-expired.js +26 -0
- package/dist/modules/billing/subscribers/prepaid-topup-expired.js.map +7 -0
- package/dist/modules/billing/subscribers/prepaid-topup-failed.js +26 -0
- package/dist/modules/billing/subscribers/prepaid-topup-failed.js.map +7 -0
- package/dist/modules/billing/workers/reconcile-prepaid-balances.js +50 -0
- package/dist/modules/billing/workers/reconcile-prepaid-balances.js.map +7 -0
- package/package.json +9 -9
- package/src/modules/billing/__tests__/commands/invoices.test.ts +3 -3
- package/src/modules/billing/__tests__/commands/topups.test.ts +104 -0
- package/src/modules/billing/__tests__/commands/transactions.test.ts +107 -0
- package/src/modules/billing/__tests__/helpers/prepaidMockEm.ts +235 -0
- package/src/modules/billing/__tests__/lib/accountStatus.test.ts +93 -0
- package/src/modules/billing/__tests__/lib/balanceLedger.test.ts +125 -0
- package/src/modules/billing/__tests__/lib/balanceStatus.test.ts +65 -0
- package/src/modules/billing/__tests__/lib/creditStatus.test.ts +111 -0
- package/src/modules/billing/__tests__/lib/money.test.ts +56 -0
- package/src/modules/billing/__tests__/lib/prepaidConsume.test.ts +179 -0
- package/src/modules/billing/__tests__/lib/prepaidPeriodClose.test.ts +232 -0
- package/src/modules/billing/__tests__/lib/topupCapture.test.ts +123 -0
- package/src/modules/billing/__tests__/lib/topupReceipt.test.ts +84 -0
- package/src/modules/billing/__tests__/setup.test.ts +28 -2
- package/src/modules/billing/acl.ts +8 -0
- package/src/modules/billing/api/accounts/[id]/balance/route.ts +74 -0
- package/src/modules/billing/api/accounts/[id]/credit-status/route.ts +71 -0
- package/src/modules/billing/api/accounts/[id]/topups/route.ts +49 -0
- package/src/modules/billing/api/accounts/route.ts +7 -0
- package/src/modules/billing/api/export/account/[id]/route.ts +47 -2
- package/src/modules/billing/api/statements/generate/route.ts +78 -0
- package/src/modules/billing/api/statements/route.ts +117 -0
- package/src/modules/billing/api/topups/route.ts +154 -0
- package/src/modules/billing/api/transactions/adjust/route.ts +80 -0
- package/src/modules/billing/api/transactions/route.ts +116 -0
- package/src/modules/billing/api/usage/route.ts +36 -5
- package/src/modules/billing/backend/billing/accounts/[id]/page.tsx +28 -0
- package/src/modules/billing/backend/billing/accounts/create/page.tsx +7 -0
- package/src/modules/billing/backend/billing/accounts/page.tsx +32 -1
- package/src/modules/billing/backend/billing/statements/[id]/page.meta.ts +19 -0
- package/src/modules/billing/backend/billing/statements/[id]/page.tsx +198 -0
- package/src/modules/billing/backend/billing/statements/page.meta.ts +16 -0
- package/src/modules/billing/backend/billing/statements/page.tsx +190 -0
- package/src/modules/billing/backend/billing/topups/[id]/page.meta.ts +19 -0
- package/src/modules/billing/backend/billing/topups/[id]/page.tsx +151 -0
- package/src/modules/billing/backend/billing/topups/page.meta.ts +16 -0
- package/src/modules/billing/backend/billing/topups/page.tsx +182 -0
- package/src/modules/billing/backend/billing/transactions/[id]/page.meta.ts +19 -0
- package/src/modules/billing/backend/billing/transactions/[id]/page.tsx +179 -0
- package/src/modules/billing/backend/billing/transactions/page.meta.ts +16 -0
- package/src/modules/billing/backend/billing/transactions/page.tsx +187 -0
- package/src/modules/billing/commands/accounts.ts +42 -1
- package/src/modules/billing/commands/index.ts +3 -0
- package/src/modules/billing/commands/invoices.ts +26 -2
- package/src/modules/billing/commands/statements.ts +101 -0
- package/src/modules/billing/commands/topups.ts +181 -0
- package/src/modules/billing/commands/transactions.ts +152 -0
- package/src/modules/billing/commands/usage.ts +114 -4
- package/src/modules/billing/components/AccountForm.tsx +66 -0
- package/src/modules/billing/components/PrepaidPanel.tsx +382 -0
- package/src/modules/billing/components/accountNames.ts +23 -0
- package/src/modules/billing/data/entities.ts +383 -0
- package/src/modules/billing/data/entityIds.ts +5 -0
- package/src/modules/billing/data/validators.ts +144 -0
- package/src/modules/billing/events.ts +52 -0
- package/src/modules/billing/i18n/en.json +112 -4
- package/src/modules/billing/i18n/pl.json +112 -4
- package/src/modules/billing/index.ts +1 -1
- package/src/modules/billing/lib/accountStatus.ts +155 -0
- package/src/modules/billing/lib/balanceEvents.ts +74 -0
- package/src/modules/billing/lib/balanceLedger.ts +258 -0
- package/src/modules/billing/lib/balanceStatus.ts +78 -0
- package/src/modules/billing/lib/billRunEngine.ts +66 -5
- package/src/modules/billing/lib/creditStatus.ts +153 -0
- package/src/modules/billing/lib/money.ts +73 -0
- package/src/modules/billing/lib/prepaidConfig.ts +58 -0
- package/src/modules/billing/lib/prepaidConsume.ts +299 -0
- package/src/modules/billing/lib/prepaidPeriodClose.ts +453 -0
- package/src/modules/billing/lib/topupCapture.ts +117 -0
- package/src/modules/billing/lib/topupReceipt.ts +153 -0
- package/src/modules/billing/lib/topupTransition.ts +31 -0
- package/src/modules/billing/migrations/Migration20260619000000_billing_prepaid.ts +131 -0
- package/src/modules/billing/setup.ts +45 -0
- package/src/modules/billing/subscribers/prepaid-topup-cancelled.ts +31 -0
- package/src/modules/billing/subscribers/prepaid-topup-captured.ts +120 -0
- package/src/modules/billing/subscribers/prepaid-topup-expired.ts +31 -0
- package/src/modules/billing/subscribers/prepaid-topup-failed.ts +32 -0
- package/src/modules/billing/workers/reconcile-prepaid-balances.ts +68 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {
|
|
2
|
+
computeBalanceStatus,
|
|
3
|
+
detectBalanceCrossing,
|
|
4
|
+
resolveLowBalanceThreshold,
|
|
5
|
+
} from '../../lib/balanceStatus'
|
|
6
|
+
|
|
7
|
+
describe('balanceStatus — classification', () => {
|
|
8
|
+
it('classifies ok / low / exhausted', () => {
|
|
9
|
+
expect(computeBalanceStatus('100.0000', '10.0000')).toBe('ok')
|
|
10
|
+
expect(computeBalanceStatus('10.0000', '10.0000')).toBe('low') // boundary is low
|
|
11
|
+
expect(computeBalanceStatus('5.0000', '10.0000')).toBe('low')
|
|
12
|
+
expect(computeBalanceStatus('0.0000', '10.0000')).toBe('exhausted')
|
|
13
|
+
expect(computeBalanceStatus('-3.0000', '10.0000')).toBe('exhausted')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('threshold 0 disables the low band (only ok / exhausted)', () => {
|
|
17
|
+
expect(computeBalanceStatus('0.0001', '0')).toBe('ok')
|
|
18
|
+
expect(computeBalanceStatus('0', '0')).toBe('exhausted')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('resolves threshold: account row → config default → 0', () => {
|
|
22
|
+
expect(resolveLowBalanceThreshold('25.0000', '10')).toBe('25.0000')
|
|
23
|
+
expect(resolveLowBalanceThreshold(null, '10')).toBe('10')
|
|
24
|
+
expect(resolveLowBalanceThreshold(null, null)).toBe('0')
|
|
25
|
+
expect(resolveLowBalanceThreshold(undefined, undefined)).toBe('0')
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('balanceStatus — downward crossings (events fire once)', () => {
|
|
30
|
+
it('fires exhausted only when crossing from > 0 to <= 0', () => {
|
|
31
|
+
expect(detectBalanceCrossing('5.0000', '-3.0000', '10.0000')).toEqual({
|
|
32
|
+
lowCrossedDown: false,
|
|
33
|
+
exhaustedCrossedDown: true,
|
|
34
|
+
})
|
|
35
|
+
// Already negative — pushing further does NOT re-fire (no spam).
|
|
36
|
+
expect(detectBalanceCrossing('-3.0000', '-8.0000', '10.0000')).toEqual({
|
|
37
|
+
lowCrossedDown: false,
|
|
38
|
+
exhaustedCrossedDown: false,
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('fires low only when landing in the (0, threshold] band', () => {
|
|
43
|
+
expect(detectBalanceCrossing('100.0000', '8.0000', '10.0000')).toEqual({
|
|
44
|
+
lowCrossedDown: true,
|
|
45
|
+
exhaustedCrossedDown: false,
|
|
46
|
+
})
|
|
47
|
+
// Jumped past low straight to exhausted → only exhausted fires.
|
|
48
|
+
expect(detectBalanceCrossing('100.0000', '-1.0000', '10.0000')).toEqual({
|
|
49
|
+
lowCrossedDown: false,
|
|
50
|
+
exhaustedCrossedDown: true,
|
|
51
|
+
})
|
|
52
|
+
// Already low, dropping further within the band → no re-fire.
|
|
53
|
+
expect(detectBalanceCrossing('8.0000', '4.0000', '10.0000')).toEqual({
|
|
54
|
+
lowCrossedDown: false,
|
|
55
|
+
exhaustedCrossedDown: false,
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('does not fire low when threshold is 0', () => {
|
|
60
|
+
expect(detectBalanceCrossing('100.0000', '1.0000', '0')).toEqual({
|
|
61
|
+
lowCrossedDown: false,
|
|
62
|
+
exhaustedCrossedDown: false,
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import {
|
|
2
|
+
computeAvailableCredit,
|
|
3
|
+
computeCreditSnapshot,
|
|
4
|
+
computeCreditStatus,
|
|
5
|
+
computeCreditUsed,
|
|
6
|
+
detectCreditCrossing,
|
|
7
|
+
prepaidCreditUsed,
|
|
8
|
+
} from '../../lib/creditStatus'
|
|
9
|
+
|
|
10
|
+
describe('creditStatus — credit_used per mode', () => {
|
|
11
|
+
it('prepaid: max(0, −balance)', () => {
|
|
12
|
+
expect(prepaidCreditUsed('100.0000')).toBe('0.0000')
|
|
13
|
+
expect(prepaidCreditUsed('0.0000')).toBe('0.0000')
|
|
14
|
+
expect(prepaidCreditUsed('-30.0000')).toBe('30.0000')
|
|
15
|
+
expect(computeCreditUsed('prepaid', { balance: '-12.5000' })).toBe('12.5000')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('postpaid: Σ outstanding', () => {
|
|
19
|
+
expect(computeCreditUsed('postpaid', { outstanding: '4200.0000' })).toBe('4200.0000')
|
|
20
|
+
expect(computeCreditUsed('postpaid', { outstanding: null })).toBe('0.0000')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('available = limit − used (both modes)', () => {
|
|
24
|
+
expect(computeAvailableCredit('5000.0000', '4200.0000')).toBe('800.0000')
|
|
25
|
+
expect(computeAvailableCredit('50.0000', '53.0000')).toBe('-3.0000')
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('creditStatus — status thresholds', () => {
|
|
30
|
+
it('within / near / over', () => {
|
|
31
|
+
expect(computeCreditStatus('800.0000', '0')).toBe('within_limit')
|
|
32
|
+
expect(computeCreditStatus('100.0000', '100.0000')).toBe('near_limit')
|
|
33
|
+
expect(computeCreditStatus('50.0000', '100.0000')).toBe('near_limit')
|
|
34
|
+
expect(computeCreditStatus('0.0000', '100.0000')).toBe('over_limit')
|
|
35
|
+
expect(computeCreditStatus('-3.0000', '0')).toBe('over_limit')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('prepaid snapshot: exhausted funds but within credit line', () => {
|
|
39
|
+
// balance -3, credit_limit 50 → used 3, available 47, within_limit.
|
|
40
|
+
const snap = computeCreditSnapshot({
|
|
41
|
+
mode: 'prepaid',
|
|
42
|
+
creditLimit: '50.0000',
|
|
43
|
+
nearLimitBuffer: '0',
|
|
44
|
+
balance: '-3.0000',
|
|
45
|
+
})
|
|
46
|
+
expect(snap).toEqual({
|
|
47
|
+
mode: 'prepaid',
|
|
48
|
+
creditLimit: '50.0000',
|
|
49
|
+
creditUsed: '3.0000',
|
|
50
|
+
availableCredit: '47.0000',
|
|
51
|
+
creditStatus: 'within_limit',
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('postpaid snapshot from outstanding', () => {
|
|
56
|
+
const snap = computeCreditSnapshot({
|
|
57
|
+
mode: 'postpaid',
|
|
58
|
+
creditLimit: '5000.0000',
|
|
59
|
+
nearLimitBuffer: '1000.0000',
|
|
60
|
+
outstanding: '4200.0000',
|
|
61
|
+
})
|
|
62
|
+
expect(snap.creditUsed).toBe('4200.0000')
|
|
63
|
+
expect(snap.availableCredit).toBe('800.0000')
|
|
64
|
+
expect(snap.creditStatus).toBe('near_limit')
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('creditStatus — over_limit upward crossing', () => {
|
|
69
|
+
it('fires once when used crosses the limit (prepaid balance below −limit)', () => {
|
|
70
|
+
// limit 50: balance -45 → used 45 (under); balance -55 → used 55 (over).
|
|
71
|
+
expect(
|
|
72
|
+
detectCreditCrossing({
|
|
73
|
+
creditLimit: '50.0000',
|
|
74
|
+
nearLimitBuffer: '0',
|
|
75
|
+
usedBefore: '45.0000',
|
|
76
|
+
usedAfter: '55.0000',
|
|
77
|
+
}).overLimitCrossedUp,
|
|
78
|
+
).toBe(true)
|
|
79
|
+
// Already over — no re-fire.
|
|
80
|
+
expect(
|
|
81
|
+
detectCreditCrossing({
|
|
82
|
+
creditLimit: '50.0000',
|
|
83
|
+
nearLimitBuffer: '0',
|
|
84
|
+
usedBefore: '55.0000',
|
|
85
|
+
usedAfter: '60.0000',
|
|
86
|
+
}).overLimitCrossedUp,
|
|
87
|
+
).toBe(false)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('no crossing when there is no credit line (limit 0)', () => {
|
|
91
|
+
expect(
|
|
92
|
+
detectCreditCrossing({
|
|
93
|
+
creditLimit: '0',
|
|
94
|
+
nearLimitBuffer: '0',
|
|
95
|
+
usedBefore: '0.0000',
|
|
96
|
+
usedAfter: '5.0000',
|
|
97
|
+
}),
|
|
98
|
+
).toEqual({ overLimitCrossedUp: false, nearLimitCrossedUp: false })
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('near_limit crossing into the buffer band', () => {
|
|
102
|
+
expect(
|
|
103
|
+
detectCreditCrossing({
|
|
104
|
+
creditLimit: '100.0000',
|
|
105
|
+
nearLimitBuffer: '20.0000',
|
|
106
|
+
usedBefore: '70.0000', // available 30
|
|
107
|
+
usedAfter: '85.0000', // available 15 → near
|
|
108
|
+
}).nearLimitCrossedUp,
|
|
109
|
+
).toBe(true)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {
|
|
2
|
+
addMoney,
|
|
3
|
+
absMoney,
|
|
4
|
+
compareMoney,
|
|
5
|
+
formatMoney,
|
|
6
|
+
fromUnits,
|
|
7
|
+
negateMoney,
|
|
8
|
+
subMoney,
|
|
9
|
+
toUnits,
|
|
10
|
+
} from '../../lib/money'
|
|
11
|
+
|
|
12
|
+
describe('money — exact decimal arithmetic', () => {
|
|
13
|
+
it('round-trips numeric(18,4) strings through units', () => {
|
|
14
|
+
expect(toUnits('12.3400')).toBe(123400)
|
|
15
|
+
expect(fromUnits(123400)).toBe('12.3400')
|
|
16
|
+
expect(fromUnits(toUnits('0.0001'))).toBe('0.0001')
|
|
17
|
+
expect(fromUnits(toUnits('-7.5000'))).toBe('-7.5000')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('formats whole + negative + sub-cent values', () => {
|
|
21
|
+
expect(formatMoney('100')).toBe('100.0000')
|
|
22
|
+
expect(formatMoney(0)).toBe('0.0000')
|
|
23
|
+
expect(formatMoney('-3')).toBe('-3.0000')
|
|
24
|
+
expect(fromUnits(-0)).toBe('0.0000') // no negative zero
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('adds/subtracts without floating-point drift', () => {
|
|
28
|
+
// 0.1 + 0.2 !== 0.3 in IEEE-754 — units math makes it exact.
|
|
29
|
+
expect(addMoney('0.1000', '0.2000')).toBe('0.3000')
|
|
30
|
+
let acc = '0.0000'
|
|
31
|
+
for (let i = 0; i < 100; i++) acc = addMoney(acc, '0.0010')
|
|
32
|
+
expect(acc).toBe('0.1000')
|
|
33
|
+
expect(subMoney('100.0000', '0.0010')).toBe('99.9990')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('negates and takes absolute value', () => {
|
|
37
|
+
expect(negateMoney('5.0000')).toBe('-5.0000')
|
|
38
|
+
expect(negateMoney('-5.0000')).toBe('5.0000')
|
|
39
|
+
expect(negateMoney('0')).toBe('0.0000')
|
|
40
|
+
expect(absMoney('-12.3400')).toBe('12.3400')
|
|
41
|
+
expect(absMoney('12.3400')).toBe('12.3400')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('compares values', () => {
|
|
45
|
+
expect(compareMoney('1.0000', '2.0000')).toBe(-1)
|
|
46
|
+
expect(compareMoney('2.0000', '2.0000')).toBe(0)
|
|
47
|
+
expect(compareMoney('3.0000', '2.0000')).toBe(1)
|
|
48
|
+
expect(compareMoney('-0.0001', '0')).toBe(-1)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('treats null/undefined as zero', () => {
|
|
52
|
+
expect(toUnits(null)).toBe(0)
|
|
53
|
+
expect(toUnits(undefined)).toBe(0)
|
|
54
|
+
expect(addMoney('5', null as unknown as string)).toBe('5.0000')
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { consumePrepaidUsage, type PrepaidConsumeInput } from '../../lib/prepaidConsume'
|
|
2
|
+
import { createPrepaidMockEm } from '../helpers/prepaidMockEm'
|
|
3
|
+
|
|
4
|
+
const TENANT = '11111111-1111-4111-8111-111111111111'
|
|
5
|
+
const ORG = '22222222-2222-4222-8222-222222222222'
|
|
6
|
+
const ACCT = '33333333-3333-4333-8333-333333333333'
|
|
7
|
+
|
|
8
|
+
function makeAccount(overrides: Record<string, unknown> = {}) {
|
|
9
|
+
return {
|
|
10
|
+
id: ACCT,
|
|
11
|
+
tenantId: TENANT,
|
|
12
|
+
organizationId: ORG,
|
|
13
|
+
currencyCode: 'EUR',
|
|
14
|
+
billingMode: 'prepaid',
|
|
15
|
+
creditLimit: '0',
|
|
16
|
+
...overrides,
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function flatUsageItem() {
|
|
21
|
+
return {
|
|
22
|
+
id: 'item-1',
|
|
23
|
+
type: 'usage',
|
|
24
|
+
uomCode: 'api_request',
|
|
25
|
+
isActive: true,
|
|
26
|
+
deletedAt: null,
|
|
27
|
+
rateJson: { unit_price: 0.001 },
|
|
28
|
+
description: 'API requests',
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function baseInput(overrides: Partial<PrepaidConsumeInput> = {}): PrepaidConsumeInput {
|
|
33
|
+
return {
|
|
34
|
+
organizationId: ORG,
|
|
35
|
+
tenantId: TENANT,
|
|
36
|
+
billAccountId: ACCT,
|
|
37
|
+
uomCode: 'api_request',
|
|
38
|
+
quantity: 8000,
|
|
39
|
+
periodStart: new Date('2026-06-01'),
|
|
40
|
+
periodEnd: new Date('2026-06-30'),
|
|
41
|
+
lineDescription: null,
|
|
42
|
+
sourceRef: null,
|
|
43
|
+
...overrides,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const balanceRow = (balance: string, threshold: string | null = null) => ({
|
|
48
|
+
tenantId: TENANT,
|
|
49
|
+
billAccountId: ACCT,
|
|
50
|
+
balance,
|
|
51
|
+
lowBalanceThreshold: threshold,
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('consumePrepaidUsage — happy path', () => {
|
|
55
|
+
it('rates synchronously, debits, returns the remaining balance', async () => {
|
|
56
|
+
const em = createPrepaidMockEm({
|
|
57
|
+
account: makeAccount(),
|
|
58
|
+
balance: balanceRow('100.0000'),
|
|
59
|
+
usageItem: flatUsageItem(),
|
|
60
|
+
})
|
|
61
|
+
const { result, events } = await consumePrepaidUsage(em as never, makeAccount() as never, baseInput())
|
|
62
|
+
|
|
63
|
+
expect(result.deduplicated).toBe(false)
|
|
64
|
+
expect(result.ratedAmount).toBe('8.0000') // 8000 × 0.001
|
|
65
|
+
expect(result.balance).toBe('92.0000')
|
|
66
|
+
expect(result.balanceStatus).toBe('ok')
|
|
67
|
+
expect(result.creditStatus).toBe('within_limit')
|
|
68
|
+
expect(result.warning).toBeNull()
|
|
69
|
+
expect(result.tierBreakdown.length).toBeGreaterThan(0)
|
|
70
|
+
// One usage record + one debit transaction.
|
|
71
|
+
expect(em.usages).toHaveLength(1)
|
|
72
|
+
expect(em.transactions).toHaveLength(1)
|
|
73
|
+
expect(em.transactions[0].amount).toBe('-8.0000')
|
|
74
|
+
// usage.rated event emitted (no threshold crossings on a healthy balance).
|
|
75
|
+
expect(events.map((e) => e.id)).toEqual(['billing.usage.rated'])
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('consumePrepaidUsage — never rejected', () => {
|
|
80
|
+
it('records usage with a 0 debit + warning when no item matches', async () => {
|
|
81
|
+
const em = createPrepaidMockEm({
|
|
82
|
+
account: makeAccount(),
|
|
83
|
+
balance: balanceRow('100.0000'),
|
|
84
|
+
usageItem: null, // no matching item
|
|
85
|
+
})
|
|
86
|
+
const { result } = await consumePrepaidUsage(em as never, makeAccount() as never, baseInput())
|
|
87
|
+
|
|
88
|
+
expect(result.warning).toBe('no_matching_item')
|
|
89
|
+
expect(result.ratedAmount).toBe('0.0000')
|
|
90
|
+
expect(result.balance).toBe('100.0000') // unchanged
|
|
91
|
+
expect(em.usages).toHaveLength(1) // still recorded
|
|
92
|
+
expect(em.transactions[0].amount).toBe('0.0000')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('lets the balance go negative and reports exhausted', async () => {
|
|
96
|
+
const em = createPrepaidMockEm({
|
|
97
|
+
account: makeAccount(),
|
|
98
|
+
balance: balanceRow('5.0000'),
|
|
99
|
+
usageItem: flatUsageItem(),
|
|
100
|
+
})
|
|
101
|
+
const { result, events } = await consumePrepaidUsage(
|
|
102
|
+
em as never,
|
|
103
|
+
makeAccount() as never,
|
|
104
|
+
baseInput(),
|
|
105
|
+
)
|
|
106
|
+
expect(result.balance).toBe('-3.0000') // 5 − 8
|
|
107
|
+
expect(result.balanceStatus).toBe('exhausted')
|
|
108
|
+
expect(events.map((e) => e.id)).toContain('billing.balance.exhausted')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('emits balance.low when crossing into the low band', async () => {
|
|
112
|
+
const em = createPrepaidMockEm({
|
|
113
|
+
account: makeAccount(),
|
|
114
|
+
balance: balanceRow('15.0000', '10.0000'),
|
|
115
|
+
usageItem: flatUsageItem(),
|
|
116
|
+
})
|
|
117
|
+
const { result, events } = await consumePrepaidUsage(
|
|
118
|
+
em as never,
|
|
119
|
+
makeAccount() as never,
|
|
120
|
+
baseInput(),
|
|
121
|
+
)
|
|
122
|
+
expect(result.balance).toBe('7.0000') // 15 − 8
|
|
123
|
+
expect(result.balanceStatus).toBe('low')
|
|
124
|
+
expect(events.map((e) => e.id)).toContain('billing.balance.low')
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('consumePrepaidUsage — idempotency', () => {
|
|
129
|
+
it('a duplicate sourceRef does NOT double-debit and returns the prior balance_after', async () => {
|
|
130
|
+
const em = createPrepaidMockEm({
|
|
131
|
+
account: makeAccount(),
|
|
132
|
+
balance: balanceRow('92.0000'),
|
|
133
|
+
usageItem: flatUsageItem(),
|
|
134
|
+
existingUsage: { id: 'usage-prior', sourceRef: 'batch-42', billAccountId: ACCT },
|
|
135
|
+
})
|
|
136
|
+
// Seed the paired transaction the prior consume wrote.
|
|
137
|
+
em.transactions.push({
|
|
138
|
+
id: 'txn-prior',
|
|
139
|
+
usageId: 'usage-prior',
|
|
140
|
+
billAccountId: ACCT,
|
|
141
|
+
amount: '-8.0000',
|
|
142
|
+
balanceAfter: '92.0000',
|
|
143
|
+
metadata: { usage_tier_breakdown: [{ tierIndex: 0 }], warning: undefined },
|
|
144
|
+
createdAt: new Date('2026-06-10'),
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
const { result } = await consumePrepaidUsage(
|
|
148
|
+
em as never,
|
|
149
|
+
makeAccount() as never,
|
|
150
|
+
baseInput({ sourceRef: 'batch-42' }),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
expect(result.deduplicated).toBe(true)
|
|
154
|
+
expect(result.id).toBe('usage-prior')
|
|
155
|
+
expect(result.ratedAmount).toBe('8.0000')
|
|
156
|
+
expect(result.balance).toBe('92.0000')
|
|
157
|
+
// No new usage record, no new transaction.
|
|
158
|
+
expect(em.usages.filter((u) => u.id !== 'usage-prior')).toHaveLength(0)
|
|
159
|
+
expect(em.transactions).toHaveLength(1)
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
describe('consumePrepaidUsage — credit line', () => {
|
|
164
|
+
it('reports within_limit while exhausted on funds (credit covers the overdraft)', async () => {
|
|
165
|
+
const em = createPrepaidMockEm({
|
|
166
|
+
account: makeAccount({ creditLimit: '50.0000' }),
|
|
167
|
+
balance: balanceRow('5.0000'),
|
|
168
|
+
usageItem: flatUsageItem(),
|
|
169
|
+
})
|
|
170
|
+
const { result, events } = await consumePrepaidUsage(
|
|
171
|
+
em as never,
|
|
172
|
+
makeAccount({ creditLimit: '50.0000' }) as never,
|
|
173
|
+
baseInput(),
|
|
174
|
+
)
|
|
175
|
+
expect(result.balanceStatus).toBe('exhausted') // balance -3
|
|
176
|
+
expect(result.creditStatus).toBe('within_limit') // used 3 of 50
|
|
177
|
+
expect(events.map((e) => e.id)).not.toContain('billing.credit.over_limit')
|
|
178
|
+
})
|
|
179
|
+
})
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { processPrepaidAccount } from '../../lib/prepaidPeriodClose'
|
|
2
|
+
import { createPrepaidMockEm, type MockEm } from '../helpers/prepaidMockEm'
|
|
3
|
+
|
|
4
|
+
const TENANT = 'ten-1'
|
|
5
|
+
const ORG = 'org-1'
|
|
6
|
+
const ACCT = 'acct-1'
|
|
7
|
+
|
|
8
|
+
function prepaidAccount(overrides: Record<string, unknown> = {}) {
|
|
9
|
+
return {
|
|
10
|
+
id: ACCT,
|
|
11
|
+
tenantId: TENANT,
|
|
12
|
+
organizationId: ORG,
|
|
13
|
+
currencyCode: 'EUR',
|
|
14
|
+
billingMode: 'prepaid',
|
|
15
|
+
billCycle: 'monthly',
|
|
16
|
+
nextBillDate: new Date('2026-06-01'),
|
|
17
|
+
lastBillDate: null,
|
|
18
|
+
creditLimit: '0',
|
|
19
|
+
updatedAt: new Date('2026-05-01'),
|
|
20
|
+
...overrides,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const billRun = { id: 'run-1' }
|
|
25
|
+
const realParams = {
|
|
26
|
+
asOfDate: new Date('2026-06-01'),
|
|
27
|
+
mode: 'real' as const,
|
|
28
|
+
triggeredBy: 'manual' as const,
|
|
29
|
+
catchUp: false,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function recurringItem() {
|
|
33
|
+
return {
|
|
34
|
+
id: 'item-rec',
|
|
35
|
+
type: 'recurring',
|
|
36
|
+
billStartDate: new Date('2026-01-01'),
|
|
37
|
+
billEndDate: null,
|
|
38
|
+
billedToDate: null,
|
|
39
|
+
isActive: true,
|
|
40
|
+
deletedAt: null,
|
|
41
|
+
rateJson: { unit_price: 20 },
|
|
42
|
+
description: 'Platform fee',
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Seed the period's real-time movements: a 100 top-up + 103 usage (May 2026).
|
|
47
|
+
function seedWindowActivity(em: MockEm) {
|
|
48
|
+
em.transactions.push(
|
|
49
|
+
{
|
|
50
|
+
id: 't-top',
|
|
51
|
+
type: 'topup',
|
|
52
|
+
amount: '100.0000',
|
|
53
|
+
billAccountId: ACCT,
|
|
54
|
+
createdAt: new Date('2026-05-02'),
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: 't-use',
|
|
58
|
+
type: 'usage',
|
|
59
|
+
amount: '-103.0000',
|
|
60
|
+
billAccountId: ACCT,
|
|
61
|
+
createdAt: new Date('2026-05-15'),
|
|
62
|
+
},
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('processPrepaidAccount — period close (spec story step 6)', () => {
|
|
67
|
+
it('charges recurring to the balance and builds the statement', async () => {
|
|
68
|
+
const account = prepaidAccount()
|
|
69
|
+
const item = recurringItem()
|
|
70
|
+
const em = createPrepaidMockEm({
|
|
71
|
+
account,
|
|
72
|
+
balance: { tenantId: TENANT, billAccountId: ACCT, balance: '-3.0000' },
|
|
73
|
+
items: [item],
|
|
74
|
+
existingStatement: null,
|
|
75
|
+
})
|
|
76
|
+
seedWindowActivity(em)
|
|
77
|
+
|
|
78
|
+
const result = await processPrepaidAccount(em as never, account as never, billRun as never, realParams)
|
|
79
|
+
|
|
80
|
+
expect(result.status).toBe('success')
|
|
81
|
+
if (result.status === 'failed') throw new Error('unexpected failure')
|
|
82
|
+
expect(result.statementId).toBeTruthy()
|
|
83
|
+
expect(result.statementEvents.map((e) => e.id)).toEqual(['billing.statement.generated'])
|
|
84
|
+
|
|
85
|
+
// The recurring charge hit the balance with a deterministic source_ref.
|
|
86
|
+
const recurringTxn = em.transactions.find((t) => t.type === 'recurring')
|
|
87
|
+
expect(recurringTxn).toBeTruthy()
|
|
88
|
+
expect(recurringTxn!.amount).toBe('-20.0000')
|
|
89
|
+
expect(recurringTxn!.sourceRef).toBe('recurring-item-rec-2026-05-31')
|
|
90
|
+
|
|
91
|
+
// The statement chains: opening 0, +100 topups, −103 usage, −20 recurring = −23.
|
|
92
|
+
const statement = em.persisted.find(
|
|
93
|
+
(e) => (e as { status?: string }).status === 'generated',
|
|
94
|
+
) as Record<string, unknown>
|
|
95
|
+
expect(statement).toBeTruthy()
|
|
96
|
+
expect(statement.openingBalance).toBe('0.0000')
|
|
97
|
+
expect(statement.totalTopups).toBe('100.0000')
|
|
98
|
+
expect(statement.totalUsage).toBe('103.0000')
|
|
99
|
+
expect(statement.totalRecurring).toBe('20.0000')
|
|
100
|
+
expect(statement.closingBalance).toBe('-23.0000')
|
|
101
|
+
|
|
102
|
+
// billed_to_date guard set; the cycle advanced.
|
|
103
|
+
expect(item.billedToDate).toEqual(new Date('2026-05-31'))
|
|
104
|
+
expect((account.nextBillDate as Date).toISOString().slice(0, 7)).toBe('2026-07')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('still generates a statement with no recurring items (usage-only account)', async () => {
|
|
108
|
+
const account = prepaidAccount()
|
|
109
|
+
const em = createPrepaidMockEm({
|
|
110
|
+
account,
|
|
111
|
+
balance: { tenantId: TENANT, billAccountId: ACCT, balance: '-3.0000' },
|
|
112
|
+
items: [],
|
|
113
|
+
})
|
|
114
|
+
seedWindowActivity(em)
|
|
115
|
+
|
|
116
|
+
const result = await processPrepaidAccount(em as never, account as never, billRun as never, realParams)
|
|
117
|
+
if (result.status === 'failed') throw new Error('unexpected failure')
|
|
118
|
+
expect(result.statementId).toBeTruthy()
|
|
119
|
+
const statement = em.persisted.find(
|
|
120
|
+
(e) => (e as { status?: string }).status === 'generated',
|
|
121
|
+
) as Record<string, unknown>
|
|
122
|
+
expect(statement.totalRecurring).toBe('0.0000')
|
|
123
|
+
expect(statement.closingBalance).toBe('-3.0000') // 0 + 100 − 103 − 0
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('chains the opening balance from the prior statement closing', async () => {
|
|
127
|
+
// Prior month closed at −23. This month: +50 top-ups, −40 usage, −20 recurring
|
|
128
|
+
// → opening −23, closing −23 + 50 − 40 − 20 = −33. (A created_at sum would
|
|
129
|
+
// wrongly drop the prior recurring and overstate the opening.)
|
|
130
|
+
const account = prepaidAccount()
|
|
131
|
+
const em = createPrepaidMockEm({
|
|
132
|
+
account,
|
|
133
|
+
balance: { tenantId: TENANT, billAccountId: ACCT, balance: '-13.0000' },
|
|
134
|
+
items: [recurringItem()],
|
|
135
|
+
existingStatement: null,
|
|
136
|
+
priorStatement: { id: 'stmt-may', closingBalance: '-23.0000' },
|
|
137
|
+
})
|
|
138
|
+
em.transactions.push(
|
|
139
|
+
{ id: 't-top2', type: 'topup', amount: '50.0000', billAccountId: ACCT, createdAt: new Date('2026-05-10') },
|
|
140
|
+
{ id: 't-use2', type: 'usage', amount: '-40.0000', billAccountId: ACCT, createdAt: new Date('2026-05-20') },
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
const result = await processPrepaidAccount(em as never, account as never, billRun as never, realParams)
|
|
144
|
+
if (result.status === 'failed') throw new Error('unexpected failure')
|
|
145
|
+
const statement = em.persisted.find(
|
|
146
|
+
(e) => (e as { status?: string }).status === 'generated',
|
|
147
|
+
) as Record<string, unknown>
|
|
148
|
+
expect(statement.openingBalance).toBe('-23.0000')
|
|
149
|
+
expect(statement.totalTopups).toBe('50.0000')
|
|
150
|
+
expect(statement.totalUsage).toBe('40.0000')
|
|
151
|
+
expect(statement.totalRecurring).toBe('20.0000')
|
|
152
|
+
expect(statement.closingBalance).toBe('-33.0000')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('fires a balance.low crossing when the recurring charge drops into the low band', async () => {
|
|
156
|
+
// Balance 25 with threshold 10; the €20 recurring fee drops it to 5 → low.
|
|
157
|
+
const account = prepaidAccount()
|
|
158
|
+
const em = createPrepaidMockEm({
|
|
159
|
+
account,
|
|
160
|
+
balance: { tenantId: TENANT, billAccountId: ACCT, balance: '25.0000', lowBalanceThreshold: '10.0000' },
|
|
161
|
+
items: [recurringItem()],
|
|
162
|
+
})
|
|
163
|
+
const result = await processPrepaidAccount(em as never, account as never, billRun as never, realParams)
|
|
164
|
+
if (result.status === 'failed') throw new Error('unexpected failure')
|
|
165
|
+
expect(result.statementEvents.map((e) => e.id)).toContain('billing.balance.low')
|
|
166
|
+
expect(result.statementEvents.map((e) => e.id)).toContain('billing.statement.generated')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('includes manual adjustments in the statement math (closing stays consistent)', async () => {
|
|
170
|
+
// Window has a +10 adjustment. opening 0, topups 100, usage 103, adj +10,
|
|
171
|
+
// recurring 20 → closing 0 + 100 + 10 − 103 − 20 = −13.
|
|
172
|
+
const account = prepaidAccount()
|
|
173
|
+
const em = createPrepaidMockEm({
|
|
174
|
+
account,
|
|
175
|
+
balance: { tenantId: TENANT, billAccountId: ACCT, balance: '7.0000' },
|
|
176
|
+
items: [recurringItem()],
|
|
177
|
+
})
|
|
178
|
+
seedWindowActivity(em)
|
|
179
|
+
em.transactions.push({
|
|
180
|
+
id: 't-adj',
|
|
181
|
+
type: 'adjustment',
|
|
182
|
+
amount: '10.0000',
|
|
183
|
+
billAccountId: ACCT,
|
|
184
|
+
createdAt: new Date('2026-05-12'),
|
|
185
|
+
})
|
|
186
|
+
const result = await processPrepaidAccount(em as never, account as never, billRun as never, realParams)
|
|
187
|
+
if (result.status === 'failed') throw new Error('unexpected failure')
|
|
188
|
+
const statement = em.persisted.find(
|
|
189
|
+
(e) => (e as { status?: string }).status === 'generated',
|
|
190
|
+
) as Record<string, unknown>
|
|
191
|
+
expect(statement.totalAdjustments).toBe('10.0000')
|
|
192
|
+
expect(statement.closingBalance).toBe('-13.0000')
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('skips when a statement already exists for the period (anti-duplicate)', async () => {
|
|
196
|
+
const account = prepaidAccount()
|
|
197
|
+
const em = createPrepaidMockEm({
|
|
198
|
+
account,
|
|
199
|
+
balance: { tenantId: TENANT, billAccountId: ACCT, balance: '-3.0000' },
|
|
200
|
+
items: [recurringItem()],
|
|
201
|
+
existingStatement: { id: 'stmt-prior' },
|
|
202
|
+
})
|
|
203
|
+
seedWindowActivity(em)
|
|
204
|
+
|
|
205
|
+
const result = await processPrepaidAccount(em as never, account as never, billRun as never, realParams)
|
|
206
|
+
expect(result.status).toBe('skipped_existing_statement')
|
|
207
|
+
if (result.status === 'failed') throw new Error('unexpected failure')
|
|
208
|
+
expect(result.statementId).toBe('stmt-prior')
|
|
209
|
+
// No recurring charge applied.
|
|
210
|
+
expect(em.transactions.find((t) => t.type === 'recurring')).toBeUndefined()
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('dry run computes nothing persisted and does not advance', async () => {
|
|
214
|
+
const account = prepaidAccount()
|
|
215
|
+
const em = createPrepaidMockEm({
|
|
216
|
+
account,
|
|
217
|
+
balance: { tenantId: TENANT, billAccountId: ACCT, balance: '-3.0000' },
|
|
218
|
+
items: [recurringItem()],
|
|
219
|
+
})
|
|
220
|
+
seedWindowActivity(em)
|
|
221
|
+
|
|
222
|
+
const result = await processPrepaidAccount(em as never, account as never, billRun as never, {
|
|
223
|
+
...realParams,
|
|
224
|
+
mode: 'dry',
|
|
225
|
+
})
|
|
226
|
+
if (result.status === 'failed') throw new Error('unexpected failure')
|
|
227
|
+
expect(result.statementId).toBeNull()
|
|
228
|
+
expect(em.transactions.find((t) => t.type === 'recurring')).toBeUndefined()
|
|
229
|
+
// Cycle not advanced (still June 1).
|
|
230
|
+
expect((account.nextBillDate as Date).toISOString().slice(0, 10)).toBe('2026-06-01')
|
|
231
|
+
})
|
|
232
|
+
})
|