@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,104 @@
|
|
|
1
|
+
import { createTopupCommand } from '../../commands/topups'
|
|
2
|
+
import { createPrepaidMockEm, type MockEm } 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 prepaidAccount(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 makeCtx(em: MockEm, gateway: unknown) {
|
|
21
|
+
const dataEngine = {
|
|
22
|
+
markOrmEntityChange: jest.fn(),
|
|
23
|
+
flushOrmEntityChanges: jest.fn(async () => undefined),
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
auth: { sub: '99999999-9999-4999-8999-999999999999', tenantId: TENANT, orgId: ORG },
|
|
27
|
+
container: {
|
|
28
|
+
resolve: jest.fn((name: string) => {
|
|
29
|
+
if (name === 'em') return em
|
|
30
|
+
if (name === 'dataEngine') return dataEngine
|
|
31
|
+
if (name === 'paymentGatewayService') return gateway
|
|
32
|
+
return null
|
|
33
|
+
}),
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const baseInput = {
|
|
39
|
+
tenantId: TENANT,
|
|
40
|
+
organizationId: ORG,
|
|
41
|
+
billAccountId: ACCT,
|
|
42
|
+
amount: 100,
|
|
43
|
+
successUrl: 'https://app.example/return',
|
|
44
|
+
cancelUrl: 'https://app.example/cancel',
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('createTopupCommand', () => {
|
|
48
|
+
it('registers a pending top-up and opens an automatic-capture session', async () => {
|
|
49
|
+
const em = createPrepaidMockEm({ account: prepaidAccount(), topup: null })
|
|
50
|
+
const gateway = {
|
|
51
|
+
createPaymentSession: jest.fn(async (_input: unknown) => ({
|
|
52
|
+
transaction: { id: 'gw-txn-1' },
|
|
53
|
+
session: { sessionId: 'sess-1', redirectUrl: 'https://pay.example/x', clientSecret: null },
|
|
54
|
+
})),
|
|
55
|
+
}
|
|
56
|
+
const ctx = makeCtx(em, gateway)
|
|
57
|
+
const result = await createTopupCommand.execute(baseInput as never, ctx as never)
|
|
58
|
+
|
|
59
|
+
expect(result.status).toBe('pending')
|
|
60
|
+
expect(result.transactionId).toBe('gw-txn-1')
|
|
61
|
+
expect(result.redirectUrl).toBe('https://pay.example/x')
|
|
62
|
+
expect(result.paymentId).toBeTruthy()
|
|
63
|
+
|
|
64
|
+
// The gateway was asked for an automatic-capture prepaid_topup session.
|
|
65
|
+
const sessionArg = gateway.createPaymentSession.mock.calls[0][0] as Record<string, unknown>
|
|
66
|
+
expect(sessionArg.captureMethod).toBe('automatic')
|
|
67
|
+
expect(sessionArg.amount).toBe(100)
|
|
68
|
+
expect((sessionArg.metadata as Record<string, unknown>).kind).toBe('prepaid_topup')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('rejects a top-up on a postpaid account with 409', async () => {
|
|
72
|
+
const em = createPrepaidMockEm({ account: prepaidAccount({ billingMode: 'postpaid' }) })
|
|
73
|
+
const gateway = { createPaymentSession: jest.fn() }
|
|
74
|
+
const ctx = makeCtx(em, gateway)
|
|
75
|
+
await expect(
|
|
76
|
+
createTopupCommand.execute(baseInput as never, ctx as never),
|
|
77
|
+
).rejects.toMatchObject({
|
|
78
|
+
status: 409,
|
|
79
|
+
body: expect.objectContaining({ code: 'billing.account.not_prepaid' }),
|
|
80
|
+
})
|
|
81
|
+
expect(gateway.createPaymentSession).not.toHaveBeenCalled()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('is idempotent on sourceRef — returns the prior top-up without a new session', async () => {
|
|
85
|
+
const existing = {
|
|
86
|
+
id: 'topup-prior',
|
|
87
|
+
paymentId: 'pay-prior',
|
|
88
|
+
status: 'pending',
|
|
89
|
+
gatewayTransactionId: 'gw-prior',
|
|
90
|
+
metadata: { redirect_url: 'https://pay.example/prior', client_secret: null },
|
|
91
|
+
}
|
|
92
|
+
const em = createPrepaidMockEm({ account: prepaidAccount(), topup: existing })
|
|
93
|
+
const gateway = { createPaymentSession: jest.fn() }
|
|
94
|
+
const ctx = makeCtx(em, gateway)
|
|
95
|
+
|
|
96
|
+
const result = await createTopupCommand.execute(
|
|
97
|
+
{ ...baseInput, sourceRef: 'req-7' } as never,
|
|
98
|
+
ctx as never,
|
|
99
|
+
)
|
|
100
|
+
expect(result.topupId).toBe('topup-prior')
|
|
101
|
+
expect(result.redirectUrl).toBe('https://pay.example/prior')
|
|
102
|
+
expect(gateway.createPaymentSession).not.toHaveBeenCalled()
|
|
103
|
+
})
|
|
104
|
+
})
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { adjustTransactionCommand } from '../../commands/transactions'
|
|
2
|
+
import { createPrepaidMockEm, type MockEm } 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 prepaidAccount(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
|
+
type CtxAuth = { sub?: string; tenantId?: string; orgId?: string } | null
|
|
21
|
+
|
|
22
|
+
function makeCtx(em: MockEm, auth: CtxAuth = { sub: '99999999-9999-4999-8999-999999999999', tenantId: TENANT, orgId: ORG }) {
|
|
23
|
+
const dataEngine = {
|
|
24
|
+
markOrmEntityChange: jest.fn(),
|
|
25
|
+
flushOrmEntityChanges: jest.fn(async () => undefined),
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
auth,
|
|
29
|
+
container: {
|
|
30
|
+
resolve: jest.fn((name: string) => {
|
|
31
|
+
if (name === 'em') return em
|
|
32
|
+
if (name === 'dataEngine') return dataEngine
|
|
33
|
+
return null
|
|
34
|
+
}),
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('adjustTransactionCommand', () => {
|
|
40
|
+
it('debits the balance and returns the new balance + status', async () => {
|
|
41
|
+
const em = createPrepaidMockEm({
|
|
42
|
+
account: prepaidAccount(),
|
|
43
|
+
balance: { tenantId: TENANT, billAccountId: ACCT, balance: '100.0000' },
|
|
44
|
+
})
|
|
45
|
+
const ctx = makeCtx(em)
|
|
46
|
+
const result = await adjustTransactionCommand.execute(
|
|
47
|
+
{ tenantId: TENANT, organizationId: ORG, billAccountId: ACCT, amount: -30, description: 'Goodwill debit' } as never,
|
|
48
|
+
ctx as never,
|
|
49
|
+
)
|
|
50
|
+
expect(result.balance).toBe('70.0000')
|
|
51
|
+
expect(result.balanceStatus).toBe('ok')
|
|
52
|
+
expect(result.transactionId).toBeTruthy()
|
|
53
|
+
expect(em.transactions).toHaveLength(1)
|
|
54
|
+
expect(em.transactions[0].type).toBe('adjustment')
|
|
55
|
+
expect(em.transactions[0].amount).toBe('-30.0000')
|
|
56
|
+
expect(em.transactions[0].userId).toBe('99999999-9999-4999-8999-999999999999')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
// The CPQ↔billing connector runs as a system identity, not a user: it calls
|
|
60
|
+
// the command bus with `auth: null` and carries tenant/organization in the
|
|
61
|
+
// payload instead. Open Mercato 0.6.7 hardened ensureTenantScope from
|
|
62
|
+
// fail-open to fail-closed, but kept `auth: null` a no-op — this test pins
|
|
63
|
+
// that, because the connector has no other guarantee than prose. If a future
|
|
64
|
+
// release closes this branch too, the connector breaks and this fails first.
|
|
65
|
+
it('accepts a system-identity call (auth: null) without a scope error', async () => {
|
|
66
|
+
const em = createPrepaidMockEm({
|
|
67
|
+
account: prepaidAccount(),
|
|
68
|
+
balance: { tenantId: TENANT, billAccountId: ACCT, balance: '100.0000' },
|
|
69
|
+
})
|
|
70
|
+
const ctx = makeCtx(em, null)
|
|
71
|
+
const result = await adjustTransactionCommand.execute(
|
|
72
|
+
{ tenantId: TENANT, organizationId: ORG, billAccountId: ACCT, amount: -10, description: 'Connector debit' } as never,
|
|
73
|
+
ctx as never,
|
|
74
|
+
)
|
|
75
|
+
expect(result.balance).toBe('90.0000')
|
|
76
|
+
expect(em.transactions).toHaveLength(1)
|
|
77
|
+
expect(em.transactions[0].userId).toBeFalsy()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('credits the balance', async () => {
|
|
81
|
+
const em = createPrepaidMockEm({
|
|
82
|
+
account: prepaidAccount(),
|
|
83
|
+
balance: { tenantId: TENANT, billAccountId: ACCT, balance: '-5.0000' },
|
|
84
|
+
})
|
|
85
|
+
const ctx = makeCtx(em)
|
|
86
|
+
const result = await adjustTransactionCommand.execute(
|
|
87
|
+
{ tenantId: TENANT, organizationId: ORG, billAccountId: ACCT, amount: 25, description: 'Refund credit' } as never,
|
|
88
|
+
ctx as never,
|
|
89
|
+
)
|
|
90
|
+
expect(result.balance).toBe('20.0000')
|
|
91
|
+
expect(em.transactions[0].amount).toBe('25.0000')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('rejects a non-zero amount on a postpaid account with 409', async () => {
|
|
95
|
+
const em = createPrepaidMockEm({ account: prepaidAccount({ billingMode: 'postpaid' }) })
|
|
96
|
+
const ctx = makeCtx(em)
|
|
97
|
+
await expect(
|
|
98
|
+
adjustTransactionCommand.execute(
|
|
99
|
+
{ tenantId: TENANT, organizationId: ORG, billAccountId: ACCT, amount: 10, description: 'x' } as never,
|
|
100
|
+
ctx as never,
|
|
101
|
+
),
|
|
102
|
+
).rejects.toMatchObject({
|
|
103
|
+
status: 409,
|
|
104
|
+
body: expect.objectContaining({ code: 'billing.account.not_prepaid' }),
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
})
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory mock EntityManager for the prepaid ledger libs (SPEC-002).
|
|
3
|
+
*
|
|
4
|
+
* Models the subset of operations `balanceLedger` / `prepaidConsume` /
|
|
5
|
+
* `accountStatus` touch: the raw `INSERT … ON CONFLICT` + `UPDATE … RETURNING`
|
|
6
|
+
* balance statements, the `SUM(amount)` reconciliation query, the postpaid
|
|
7
|
+
* `SUM(outstanding_amount)` query, plus findOne/find/create/persist/flush.
|
|
8
|
+
*
|
|
9
|
+
* The balance math reuses the real `money` helpers so the mock's rounding
|
|
10
|
+
* matches production exactly — that is the whole point of the reconciliation
|
|
11
|
+
* tests.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { addMoney } from '../../lib/money'
|
|
15
|
+
|
|
16
|
+
type Row = Record<string, unknown>
|
|
17
|
+
|
|
18
|
+
export type MockEmOptions = {
|
|
19
|
+
account?: Row | null
|
|
20
|
+
/** Pre-seed the balance row: { tenantId, billAccountId, balance, lowBalanceThreshold? }. */
|
|
21
|
+
balance?: Row | null
|
|
22
|
+
/** The active type=usage item returned by findUsageItem (or null). */
|
|
23
|
+
usageItem?: Row | null
|
|
24
|
+
/** An existing usage record returned for a matching sourceRef (dedup tests). */
|
|
25
|
+
existingUsage?: Row | null
|
|
26
|
+
/** A BillingTopup returned by findOne (capture tests). */
|
|
27
|
+
topup?: Row | null
|
|
28
|
+
/** Billing items returned by find(BillingItem) (period-close tests). */
|
|
29
|
+
items?: Row[]
|
|
30
|
+
/** An existing BillingStatement for the SAME period returned by findOne (anti-dup tests). */
|
|
31
|
+
existingStatement?: Row | null
|
|
32
|
+
/** A PRIOR BillingStatement (periodEnd < periodStart) for opening-chaining tests. */
|
|
33
|
+
priorStatement?: Row | null
|
|
34
|
+
/** Module config values keyed by name (e.g. { 'prepaid.topup_tax_rate': 23 }). */
|
|
35
|
+
configs?: Record<string, unknown>
|
|
36
|
+
/** Postpaid outstanding total returned by the SUM(outstanding_amount) query. */
|
|
37
|
+
postpaidOutstanding?: string | number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type MockEm = {
|
|
41
|
+
// stores
|
|
42
|
+
transactions: Row[]
|
|
43
|
+
usages: Row[]
|
|
44
|
+
balances: Map<string, Row>
|
|
45
|
+
persisted: Row[]
|
|
46
|
+
// jest mocks
|
|
47
|
+
findOne: jest.Mock
|
|
48
|
+
find: jest.Mock
|
|
49
|
+
create: jest.Mock
|
|
50
|
+
persist: jest.Mock
|
|
51
|
+
flush: jest.Mock
|
|
52
|
+
execute: jest.Mock
|
|
53
|
+
nativeUpdate: jest.Mock
|
|
54
|
+
transactional: jest.Mock
|
|
55
|
+
fork: () => MockEm
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function key(tenantId: unknown, billAccountId: unknown): string {
|
|
59
|
+
return `${tenantId}:${billAccountId}`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createPrepaidMockEm(options: MockEmOptions = {}): MockEm {
|
|
63
|
+
let idCounter = 0
|
|
64
|
+
const transactions: Row[] = []
|
|
65
|
+
const usages: Row[] = []
|
|
66
|
+
const persisted: Row[] = []
|
|
67
|
+
const balances = new Map<string, Row>()
|
|
68
|
+
if (options.balance) {
|
|
69
|
+
balances.set(key(options.balance.tenantId, options.balance.billAccountId), {
|
|
70
|
+
balance: '0.0000',
|
|
71
|
+
lowBalanceThreshold: null,
|
|
72
|
+
lastMovementAt: null,
|
|
73
|
+
...options.balance,
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
if (options.existingUsage) usages.push(options.existingUsage)
|
|
77
|
+
|
|
78
|
+
const em = {
|
|
79
|
+
transactions,
|
|
80
|
+
usages,
|
|
81
|
+
balances,
|
|
82
|
+
persisted,
|
|
83
|
+
} as MockEm
|
|
84
|
+
|
|
85
|
+
em.create = jest.fn((entity: unknown, data: Row) => {
|
|
86
|
+
const ctor = (entity as { name?: string }).name ?? ''
|
|
87
|
+
idCounter += 1
|
|
88
|
+
const created: Row = { ...data, id: (data.id as string) ?? `gen-${ctor}-${idCounter}` }
|
|
89
|
+
if (ctor === 'BillingAccountTransaction') transactions.push(created)
|
|
90
|
+
else if (ctor === 'BillingAccountUsage') usages.push(created)
|
|
91
|
+
else if (ctor === 'BillingAccountBalance') {
|
|
92
|
+
balances.set(key(created.tenantId, created.billAccountId), created)
|
|
93
|
+
}
|
|
94
|
+
return created
|
|
95
|
+
}) as never
|
|
96
|
+
|
|
97
|
+
em.persist = jest.fn((entity: Row) => {
|
|
98
|
+
persisted.push(entity)
|
|
99
|
+
}) as never
|
|
100
|
+
|
|
101
|
+
em.flush = jest.fn(async () => undefined) as never
|
|
102
|
+
|
|
103
|
+
em.findOne = jest.fn(async (entity: unknown, where: Row, opts?: { orderBy?: Row }) => {
|
|
104
|
+
const ctor = (entity as { name?: string }).name ?? ''
|
|
105
|
+
if (ctor === 'BillingAccount') return options.account ?? null
|
|
106
|
+
if (ctor === 'BillingAccountBalance') {
|
|
107
|
+
return balances.get(key(where.tenantId, where.billAccountId)) ?? null
|
|
108
|
+
}
|
|
109
|
+
if (ctor === 'BillingAccountUsage') {
|
|
110
|
+
return (
|
|
111
|
+
usages.find(
|
|
112
|
+
(u) =>
|
|
113
|
+
(where.sourceRef === undefined || u.sourceRef === where.sourceRef) &&
|
|
114
|
+
(where.id === undefined || u.id === where.id) &&
|
|
115
|
+
(where.billAccountId === undefined || u.billAccountId === where.billAccountId),
|
|
116
|
+
) ?? null
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
if (ctor === 'BillingAccountTransaction') {
|
|
120
|
+
const matches = transactions.filter(
|
|
121
|
+
(t) =>
|
|
122
|
+
(where.sourceRef === undefined || t.sourceRef === where.sourceRef) &&
|
|
123
|
+
(where.usageId === undefined || t.usageId === where.usageId) &&
|
|
124
|
+
(where.billAccountId === undefined || t.billAccountId === where.billAccountId),
|
|
125
|
+
)
|
|
126
|
+
// Insertion order IS chronological in this mock, so the last inserted
|
|
127
|
+
// matching row is the most recent — correct for both a key lookup and
|
|
128
|
+
// an `orderBy: { createdAt: 'DESC' }` "latest transaction" query (real
|
|
129
|
+
// millisecond timestamps can collide and break a createdAt sort).
|
|
130
|
+
void opts
|
|
131
|
+
return matches[matches.length - 1] ?? null
|
|
132
|
+
}
|
|
133
|
+
if (ctor === 'BillingItem') {
|
|
134
|
+
const item = options.usageItem
|
|
135
|
+
if (!item) return null
|
|
136
|
+
if (where.uomCode !== undefined && item.uomCode !== where.uomCode) return null
|
|
137
|
+
return item
|
|
138
|
+
}
|
|
139
|
+
if (ctor === 'BillingTopup') return options.topup ?? null
|
|
140
|
+
if (ctor === 'BillingStatement') {
|
|
141
|
+
// A `periodEnd: { $lt }` range query is the prior-statement (opening
|
|
142
|
+
// chaining) lookup; an exact period match is the anti-duplicate check.
|
|
143
|
+
const pe = where.periodEnd as { $lt?: unknown } | undefined
|
|
144
|
+
if (pe && typeof pe === 'object' && '$lt' in pe) {
|
|
145
|
+
return options.priorStatement ?? null
|
|
146
|
+
}
|
|
147
|
+
return options.existingStatement ?? null
|
|
148
|
+
}
|
|
149
|
+
// resolveInvoiceStatusEntryId looks up Dictionary then DictionaryEntry.
|
|
150
|
+
if (ctor === 'Dictionary') return { id: 'dict-invoice-status' }
|
|
151
|
+
if (ctor === 'DictionaryEntry') return { id: `status-${(where.value as string) ?? 'x'}` }
|
|
152
|
+
if (ctor === 'ModuleConfig') {
|
|
153
|
+
const name = where.name as string
|
|
154
|
+
const configs = options.configs ?? {}
|
|
155
|
+
if (name in configs) return { moduleId: 'billing', name, valueJson: configs[name] }
|
|
156
|
+
return null
|
|
157
|
+
}
|
|
158
|
+
return null
|
|
159
|
+
}) as never
|
|
160
|
+
|
|
161
|
+
em.find = jest.fn(async (entity: unknown) => {
|
|
162
|
+
const ctor = (entity as { name?: string }).name ?? ''
|
|
163
|
+
if (ctor === 'BillingItem') return options.items ?? []
|
|
164
|
+
return []
|
|
165
|
+
}) as never
|
|
166
|
+
|
|
167
|
+
em.execute = jest.fn(async (sql: string, params: unknown[] = []) => {
|
|
168
|
+
if (sql.includes('INSERT INTO billing_account_balances')) {
|
|
169
|
+
// params: [org, tenant, acct, ccy, now, now]
|
|
170
|
+
const [organizationId, tenantId, billAccountId, currencyCode] = params as string[]
|
|
171
|
+
const k = key(tenantId, billAccountId)
|
|
172
|
+
if (!balances.has(k)) {
|
|
173
|
+
balances.set(k, {
|
|
174
|
+
organizationId,
|
|
175
|
+
tenantId,
|
|
176
|
+
billAccountId,
|
|
177
|
+
currencyCode,
|
|
178
|
+
balance: '0.0000',
|
|
179
|
+
lowBalanceThreshold: null,
|
|
180
|
+
lastMovementAt: null,
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
return []
|
|
184
|
+
}
|
|
185
|
+
if (sql.includes('UPDATE billing_account_balances')) {
|
|
186
|
+
// params: [amount, now, now, tenant, acct]
|
|
187
|
+
const amount = params[0] as string
|
|
188
|
+
const tenantId = params[3]
|
|
189
|
+
const billAccountId = params[4]
|
|
190
|
+
const k = key(tenantId, billAccountId)
|
|
191
|
+
const row = balances.get(k)
|
|
192
|
+
if (!row) return []
|
|
193
|
+
row.balance = addMoney((row.balance as string) ?? '0', amount)
|
|
194
|
+
row.lastMovementAt = params[1] as Date
|
|
195
|
+
return [{ balance: row.balance }]
|
|
196
|
+
}
|
|
197
|
+
if (sql.includes('SUM(amount)') && sql.includes('billing_account_transactions')) {
|
|
198
|
+
// sumByTypeInWindow: params [tenant, org, acct, type, from, to]
|
|
199
|
+
if (sql.includes('type = ?') && sql.includes('created_at >= ?')) {
|
|
200
|
+
const type = params[3] as string
|
|
201
|
+
const from = new Date(params[4] as string).getTime()
|
|
202
|
+
const to = new Date(params[5] as string).getTime()
|
|
203
|
+
const total = transactions
|
|
204
|
+
.filter((t) => {
|
|
205
|
+
if (t.type !== type || !t.createdAt) return false
|
|
206
|
+
const ts = new Date(t.createdAt as Date).getTime()
|
|
207
|
+
return ts >= from && ts < to
|
|
208
|
+
})
|
|
209
|
+
.reduce((acc, t) => addMoney(acc, (t.amount as string) ?? '0'), '0')
|
|
210
|
+
return [{ total }]
|
|
211
|
+
}
|
|
212
|
+
// sumAmountsBefore: params [tenant, org, acct, before]
|
|
213
|
+
if (sql.includes('created_at < ?')) {
|
|
214
|
+
const before = new Date(params[3] as string).getTime()
|
|
215
|
+
const total = transactions
|
|
216
|
+
.filter((t) => t.createdAt && new Date(t.createdAt as Date).getTime() < before)
|
|
217
|
+
.reduce((acc, t) => addMoney(acc, (t.amount as string) ?? '0'), '0')
|
|
218
|
+
return [{ total }]
|
|
219
|
+
}
|
|
220
|
+
// reconcile (whole ledger)
|
|
221
|
+
const sum = transactions.reduce((acc, t) => addMoney(acc, (t.amount as string) ?? '0'), '0')
|
|
222
|
+
return [{ sum_amount: sum }]
|
|
223
|
+
}
|
|
224
|
+
if (sql.includes('SUM(outstanding_amount)')) {
|
|
225
|
+
return [{ total: String(options.postpaidOutstanding ?? '0') }]
|
|
226
|
+
}
|
|
227
|
+
return []
|
|
228
|
+
}) as never
|
|
229
|
+
|
|
230
|
+
em.nativeUpdate = jest.fn(async () => 0) as never
|
|
231
|
+
em.transactional = jest.fn(async (cb: (tem: MockEm) => unknown) => cb(em)) as never
|
|
232
|
+
em.fork = () => em
|
|
233
|
+
|
|
234
|
+
return em
|
|
235
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildPostpaidCreditCrossingEvents,
|
|
3
|
+
getBalanceSnapshot,
|
|
4
|
+
getCreditSnapshot,
|
|
5
|
+
} from '../../lib/accountStatus'
|
|
6
|
+
import { createPrepaidMockEm } from '../helpers/prepaidMockEm'
|
|
7
|
+
|
|
8
|
+
const TENANT = 'ten-1'
|
|
9
|
+
const ORG = 'org-1'
|
|
10
|
+
const ACCT = 'acct-1'
|
|
11
|
+
|
|
12
|
+
function account(overrides: Record<string, unknown> = {}) {
|
|
13
|
+
return {
|
|
14
|
+
id: ACCT,
|
|
15
|
+
tenantId: TENANT,
|
|
16
|
+
organizationId: ORG,
|
|
17
|
+
currencyCode: 'EUR',
|
|
18
|
+
billingMode: 'prepaid',
|
|
19
|
+
creditLimit: '0',
|
|
20
|
+
...overrides,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('getBalanceSnapshot', () => {
|
|
25
|
+
it('reports balance + resolved threshold + status', async () => {
|
|
26
|
+
const em = createPrepaidMockEm({
|
|
27
|
+
balance: { tenantId: TENANT, billAccountId: ACCT, balance: '7.0000', lowBalanceThreshold: '10.0000' },
|
|
28
|
+
})
|
|
29
|
+
const snap = await getBalanceSnapshot(em as never, account() as never)
|
|
30
|
+
expect(snap.balance).toBe('7.0000')
|
|
31
|
+
expect(snap.lowBalanceThreshold).toBe('10.0000')
|
|
32
|
+
expect(snap.balanceStatus).toBe('low')
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('getCreditSnapshot — both modes', () => {
|
|
37
|
+
it('prepaid: credit_used = max(0,−balance)', async () => {
|
|
38
|
+
const em = createPrepaidMockEm({
|
|
39
|
+
balance: { tenantId: TENANT, billAccountId: ACCT, balance: '-30.0000' },
|
|
40
|
+
})
|
|
41
|
+
const snap = await getCreditSnapshot(em as never, account({ creditLimit: '50.0000' }) as never)
|
|
42
|
+
expect(snap.mode).toBe('prepaid')
|
|
43
|
+
expect(snap.creditUsed).toBe('30.0000')
|
|
44
|
+
expect(snap.availableCredit).toBe('20.0000')
|
|
45
|
+
expect(snap.creditStatus).toBe('within_limit')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('postpaid: credit_used = Σ posted-unpaid outstanding', async () => {
|
|
49
|
+
const em = createPrepaidMockEm({ postpaidOutstanding: '4200.0000' })
|
|
50
|
+
const snap = await getCreditSnapshot(
|
|
51
|
+
em as never,
|
|
52
|
+
account({ billingMode: 'postpaid', creditLimit: '5000.0000' }) as never,
|
|
53
|
+
)
|
|
54
|
+
expect(snap.mode).toBe('postpaid')
|
|
55
|
+
expect(snap.creditUsed).toBe('4200.0000')
|
|
56
|
+
expect(snap.availableCredit).toBe('800.0000')
|
|
57
|
+
expect(snap.creditStatus).toBe('within_limit')
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('buildPostpaidCreditCrossingEvents', () => {
|
|
62
|
+
it('emits over_limit when a posted invoice pushes outstanding past the limit', async () => {
|
|
63
|
+
// After posting, total outstanding 5200; this invoice contributed 300 →
|
|
64
|
+
// before 4900 (under 5000), after 5200 (over) → crossing.
|
|
65
|
+
const em = createPrepaidMockEm({ postpaidOutstanding: '5200.0000' })
|
|
66
|
+
const events = await buildPostpaidCreditCrossingEvents(
|
|
67
|
+
em as never,
|
|
68
|
+
account({ billingMode: 'postpaid', creditLimit: '5000.0000' }) as never,
|
|
69
|
+
'300.0000',
|
|
70
|
+
)
|
|
71
|
+
expect(events.map((e) => e.id)).toContain('billing.credit.over_limit')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('no event when still under the limit', async () => {
|
|
75
|
+
const em = createPrepaidMockEm({ postpaidOutstanding: '4800.0000' })
|
|
76
|
+
const events = await buildPostpaidCreditCrossingEvents(
|
|
77
|
+
em as never,
|
|
78
|
+
account({ billingMode: 'postpaid', creditLimit: '5000.0000' }) as never,
|
|
79
|
+
'300.0000',
|
|
80
|
+
)
|
|
81
|
+
expect(events).toHaveLength(0)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('no event for a prepaid account (handled on the consume path)', async () => {
|
|
85
|
+
const em = createPrepaidMockEm({ postpaidOutstanding: '9999.0000' })
|
|
86
|
+
const events = await buildPostpaidCreditCrossingEvents(
|
|
87
|
+
em as never,
|
|
88
|
+
account({ billingMode: 'prepaid', creditLimit: '5000.0000' }) as never,
|
|
89
|
+
'300.0000',
|
|
90
|
+
)
|
|
91
|
+
expect(events).toHaveLength(0)
|
|
92
|
+
})
|
|
93
|
+
})
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyBalanceMovement,
|
|
3
|
+
reconcileAccountBalance,
|
|
4
|
+
} from '../../lib/balanceLedger'
|
|
5
|
+
import { createPrepaidMockEm } from '../helpers/prepaidMockEm'
|
|
6
|
+
|
|
7
|
+
const SCOPE = {
|
|
8
|
+
organizationId: '22222222-2222-4222-8222-222222222222',
|
|
9
|
+
tenantId: '11111111-1111-4111-8111-111111111111',
|
|
10
|
+
billAccountId: '33333333-3333-4333-8333-333333333333',
|
|
11
|
+
currencyCode: 'EUR',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function newEm() {
|
|
15
|
+
return createPrepaidMockEm({
|
|
16
|
+
balance: { tenantId: SCOPE.tenantId, billAccountId: SCOPE.billAccountId, balance: '0.0000' },
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('balanceLedger — applyBalanceMovement', () => {
|
|
21
|
+
it('credits and debits, snapshotting balance_after', async () => {
|
|
22
|
+
const em = newEm()
|
|
23
|
+
const credit = await applyBalanceMovement(em as never, {
|
|
24
|
+
...SCOPE,
|
|
25
|
+
type: 'topup',
|
|
26
|
+
amount: '100.0000',
|
|
27
|
+
description: 'Top-up',
|
|
28
|
+
})
|
|
29
|
+
expect(credit.balanceBefore).toBe('0.0000')
|
|
30
|
+
expect(credit.balanceAfter).toBe('100.0000')
|
|
31
|
+
expect(credit.transaction.balanceAfter).toBe('100.0000')
|
|
32
|
+
|
|
33
|
+
const debit = await applyBalanceMovement(em as never, {
|
|
34
|
+
...SCOPE,
|
|
35
|
+
type: 'usage',
|
|
36
|
+
amount: '-12.3400',
|
|
37
|
+
description: 'API requests',
|
|
38
|
+
})
|
|
39
|
+
expect(debit.balanceBefore).toBe('100.0000')
|
|
40
|
+
expect(debit.balanceAfter).toBe('87.6600')
|
|
41
|
+
expect(em.transactions).toHaveLength(2)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('allows the balance to go negative (usage is never rejected)', async () => {
|
|
45
|
+
const em = newEm()
|
|
46
|
+
await applyBalanceMovement(em as never, {
|
|
47
|
+
...SCOPE,
|
|
48
|
+
type: 'topup',
|
|
49
|
+
amount: '5.0000',
|
|
50
|
+
description: 'Top-up',
|
|
51
|
+
})
|
|
52
|
+
const debit = await applyBalanceMovement(em as never, {
|
|
53
|
+
...SCOPE,
|
|
54
|
+
type: 'usage',
|
|
55
|
+
amount: '-8.0000',
|
|
56
|
+
description: 'Overspend',
|
|
57
|
+
})
|
|
58
|
+
expect(debit.balanceAfter).toBe('-3.0000')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('is idempotent on sourceRef — no double-move', async () => {
|
|
62
|
+
const em = newEm()
|
|
63
|
+
const first = await applyBalanceMovement(em as never, {
|
|
64
|
+
...SCOPE,
|
|
65
|
+
type: 'recurring',
|
|
66
|
+
amount: '-20.0000',
|
|
67
|
+
description: 'Platform fee',
|
|
68
|
+
sourceRef: 'recurring-item-2026-05-31',
|
|
69
|
+
})
|
|
70
|
+
expect(first.deduplicated).toBe(false)
|
|
71
|
+
expect(first.balanceAfter).toBe('-20.0000')
|
|
72
|
+
|
|
73
|
+
const retry = await applyBalanceMovement(em as never, {
|
|
74
|
+
...SCOPE,
|
|
75
|
+
type: 'recurring',
|
|
76
|
+
amount: '-20.0000',
|
|
77
|
+
description: 'Platform fee',
|
|
78
|
+
sourceRef: 'recurring-item-2026-05-31',
|
|
79
|
+
})
|
|
80
|
+
expect(retry.deduplicated).toBe(true)
|
|
81
|
+
expect(retry.balanceAfter).toBe('-20.0000') // unchanged
|
|
82
|
+
expect(em.transactions).toHaveLength(1) // no second transaction
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('balanceLedger — reconciliation invariant', () => {
|
|
87
|
+
it('balance == SUM(amount) == latest.balance_after after a sequence', async () => {
|
|
88
|
+
const em = newEm()
|
|
89
|
+
const moves: Array<{ type: 'topup' | 'usage' | 'recurring'; amount: string }> = [
|
|
90
|
+
{ type: 'topup', amount: '100.0000' },
|
|
91
|
+
{ type: 'usage', amount: '-5.0000' },
|
|
92
|
+
{ type: 'usage', amount: '-3.0000' },
|
|
93
|
+
{ type: 'recurring', amount: '-20.0000' },
|
|
94
|
+
]
|
|
95
|
+
for (const m of moves) {
|
|
96
|
+
await applyBalanceMovement(em as never, { ...SCOPE, ...m, description: m.type })
|
|
97
|
+
}
|
|
98
|
+
const recon = await reconcileAccountBalance(em as never, SCOPE)
|
|
99
|
+
expect(recon.cachedBalance).toBe('72.0000')
|
|
100
|
+
expect(recon.sumOfAmounts).toBe('72.0000')
|
|
101
|
+
expect(recon.latestBalanceAfter).toBe('72.0000')
|
|
102
|
+
expect(recon.reconciled).toBe(true)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('holds across many small sub-cent debits (no FP drift)', async () => {
|
|
106
|
+
const em = newEm()
|
|
107
|
+
await applyBalanceMovement(em as never, {
|
|
108
|
+
...SCOPE,
|
|
109
|
+
type: 'topup',
|
|
110
|
+
amount: '1.0000',
|
|
111
|
+
description: 'Top-up',
|
|
112
|
+
})
|
|
113
|
+
for (let i = 0; i < 100; i++) {
|
|
114
|
+
await applyBalanceMovement(em as never, {
|
|
115
|
+
...SCOPE,
|
|
116
|
+
type: 'usage',
|
|
117
|
+
amount: '-0.0010',
|
|
118
|
+
description: 'micro',
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
const recon = await reconcileAccountBalance(em as never, SCOPE)
|
|
122
|
+
expect(recon.cachedBalance).toBe('0.9000')
|
|
123
|
+
expect(recon.reconciled).toBe(true)
|
|
124
|
+
})
|
|
125
|
+
})
|