@dainamite/billing 0.17.0 → 0.18.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.
Files changed (205) hide show
  1. package/CHANGELOG.md +81 -19
  2. package/dist/modules/billing/acl.js +9 -1
  3. package/dist/modules/billing/acl.js.map +2 -2
  4. package/dist/modules/billing/api/accounts/[id]/balance/route.js +62 -0
  5. package/dist/modules/billing/api/accounts/[id]/balance/route.js.map +7 -0
  6. package/dist/modules/billing/api/accounts/[id]/credit-status/route.js +56 -0
  7. package/dist/modules/billing/api/accounts/[id]/credit-status/route.js.map +7 -0
  8. package/dist/modules/billing/api/accounts/[id]/topups/route.js +36 -0
  9. package/dist/modules/billing/api/accounts/[id]/topups/route.js.map +7 -0
  10. package/dist/modules/billing/api/accounts/route.js +7 -0
  11. package/dist/modules/billing/api/accounts/route.js.map +2 -2
  12. package/dist/modules/billing/api/export/account/[id]/route.js +48 -3
  13. package/dist/modules/billing/api/export/account/[id]/route.js.map +2 -2
  14. package/dist/modules/billing/api/statements/generate/route.js +68 -0
  15. package/dist/modules/billing/api/statements/generate/route.js.map +7 -0
  16. package/dist/modules/billing/api/statements/route.js +110 -0
  17. package/dist/modules/billing/api/statements/route.js.map +7 -0
  18. package/dist/modules/billing/api/topups/route.js +139 -0
  19. package/dist/modules/billing/api/topups/route.js.map +7 -0
  20. package/dist/modules/billing/api/transactions/adjust/route.js +71 -0
  21. package/dist/modules/billing/api/transactions/adjust/route.js.map +7 -0
  22. package/dist/modules/billing/api/transactions/route.js +109 -0
  23. package/dist/modules/billing/api/transactions/route.js.map +7 -0
  24. package/dist/modules/billing/api/usage/route.js +30 -6
  25. package/dist/modules/billing/api/usage/route.js.map +2 -2
  26. package/dist/modules/billing/backend/billing/accounts/[id]/page.js +16 -1
  27. package/dist/modules/billing/backend/billing/accounts/[id]/page.js.map +2 -2
  28. package/dist/modules/billing/backend/billing/accounts/create/page.js +8 -1
  29. package/dist/modules/billing/backend/billing/accounts/create/page.js.map +2 -2
  30. package/dist/modules/billing/backend/billing/accounts/page.js +26 -1
  31. package/dist/modules/billing/backend/billing/accounts/page.js.map +2 -2
  32. package/dist/modules/billing/backend/billing/statements/[id]/page.js +129 -0
  33. package/dist/modules/billing/backend/billing/statements/[id]/page.js.map +7 -0
  34. package/dist/modules/billing/backend/billing/statements/[id]/page.meta.js +21 -0
  35. package/dist/modules/billing/backend/billing/statements/[id]/page.meta.js.map +7 -0
  36. package/dist/modules/billing/backend/billing/statements/page.js +152 -0
  37. package/dist/modules/billing/backend/billing/statements/page.js.map +7 -0
  38. package/dist/modules/billing/backend/billing/statements/page.meta.js +18 -0
  39. package/dist/modules/billing/backend/billing/statements/page.meta.js.map +7 -0
  40. package/dist/modules/billing/backend/billing/topups/[id]/page.js +90 -0
  41. package/dist/modules/billing/backend/billing/topups/[id]/page.js.map +7 -0
  42. package/dist/modules/billing/backend/billing/topups/[id]/page.meta.js +21 -0
  43. package/dist/modules/billing/backend/billing/topups/[id]/page.meta.js.map +7 -0
  44. package/dist/modules/billing/backend/billing/topups/page.js +147 -0
  45. package/dist/modules/billing/backend/billing/topups/page.js.map +7 -0
  46. package/dist/modules/billing/backend/billing/topups/page.meta.js +18 -0
  47. package/dist/modules/billing/backend/billing/topups/page.meta.js.map +7 -0
  48. package/dist/modules/billing/backend/billing/transactions/[id]/page.js +114 -0
  49. package/dist/modules/billing/backend/billing/transactions/[id]/page.js.map +7 -0
  50. package/dist/modules/billing/backend/billing/transactions/[id]/page.meta.js +21 -0
  51. package/dist/modules/billing/backend/billing/transactions/[id]/page.meta.js.map +7 -0
  52. package/dist/modules/billing/backend/billing/transactions/page.js +149 -0
  53. package/dist/modules/billing/backend/billing/transactions/page.js.map +7 -0
  54. package/dist/modules/billing/backend/billing/transactions/page.meta.js +18 -0
  55. package/dist/modules/billing/backend/billing/transactions/page.meta.js.map +7 -0
  56. package/dist/modules/billing/commands/accounts.js +26 -1
  57. package/dist/modules/billing/commands/accounts.js.map +2 -2
  58. package/dist/modules/billing/commands/index.js +3 -0
  59. package/dist/modules/billing/commands/index.js.map +2 -2
  60. package/dist/modules/billing/commands/invoices.js +21 -1
  61. package/dist/modules/billing/commands/invoices.js.map +2 -2
  62. package/dist/modules/billing/commands/statements.js +74 -0
  63. package/dist/modules/billing/commands/statements.js.map +7 -0
  64. package/dist/modules/billing/commands/topups.js +139 -0
  65. package/dist/modules/billing/commands/topups.js.map +7 -0
  66. package/dist/modules/billing/commands/transactions.js +119 -0
  67. package/dist/modules/billing/commands/transactions.js.map +7 -0
  68. package/dist/modules/billing/commands/usage.js +72 -0
  69. package/dist/modules/billing/commands/usage.js.map +2 -2
  70. package/dist/modules/billing/components/AccountForm.js +53 -1
  71. package/dist/modules/billing/components/AccountForm.js.map +2 -2
  72. package/dist/modules/billing/components/PrepaidPanel.js +314 -0
  73. package/dist/modules/billing/components/PrepaidPanel.js.map +7 -0
  74. package/dist/modules/billing/components/accountNames.js +20 -0
  75. package/dist/modules/billing/components/accountNames.js.map +7 -0
  76. package/dist/modules/billing/data/entities.js +290 -0
  77. package/dist/modules/billing/data/entities.js.map +2 -2
  78. package/dist/modules/billing/data/entityIds.js +6 -1
  79. package/dist/modules/billing/data/entityIds.js.map +2 -2
  80. package/dist/modules/billing/data/validators.js +107 -2
  81. package/dist/modules/billing/data/validators.js.map +2 -2
  82. package/dist/modules/billing/events.js +52 -0
  83. package/dist/modules/billing/events.js.map +2 -2
  84. package/dist/modules/billing/i18n/en.json +112 -4
  85. package/dist/modules/billing/i18n/pl.json +112 -4
  86. package/dist/modules/billing/index.js +1 -1
  87. package/dist/modules/billing/index.js.map +2 -2
  88. package/dist/modules/billing/lib/accountStatus.js +106 -0
  89. package/dist/modules/billing/lib/accountStatus.js.map +7 -0
  90. package/dist/modules/billing/lib/balanceEvents.js +48 -0
  91. package/dist/modules/billing/lib/balanceEvents.js.map +7 -0
  92. package/dist/modules/billing/lib/balanceLedger.js +136 -0
  93. package/dist/modules/billing/lib/balanceLedger.js.map +7 -0
  94. package/dist/modules/billing/lib/balanceStatus.js +29 -0
  95. package/dist/modules/billing/lib/balanceStatus.js.map +7 -0
  96. package/dist/modules/billing/lib/billRunEngine.js +40 -2
  97. package/dist/modules/billing/lib/billRunEngine.js.map +2 -2
  98. package/dist/modules/billing/lib/creditStatus.js +65 -0
  99. package/dist/modules/billing/lib/creditStatus.js.map +7 -0
  100. package/dist/modules/billing/lib/money.js +47 -0
  101. package/dist/modules/billing/lib/money.js.map +7 -0
  102. package/dist/modules/billing/lib/prepaidConfig.js +45 -0
  103. package/dist/modules/billing/lib/prepaidConfig.js.map +7 -0
  104. package/dist/modules/billing/lib/prepaidConsume.js +214 -0
  105. package/dist/modules/billing/lib/prepaidConsume.js.map +7 -0
  106. package/dist/modules/billing/lib/prepaidPeriodClose.js +285 -0
  107. package/dist/modules/billing/lib/prepaidPeriodClose.js.map +7 -0
  108. package/dist/modules/billing/lib/topupCapture.js +62 -0
  109. package/dist/modules/billing/lib/topupCapture.js.map +7 -0
  110. package/dist/modules/billing/lib/topupReceipt.js +97 -0
  111. package/dist/modules/billing/lib/topupReceipt.js.map +7 -0
  112. package/dist/modules/billing/lib/topupTransition.js +19 -0
  113. package/dist/modules/billing/lib/topupTransition.js.map +7 -0
  114. package/dist/modules/billing/migrations/Migration20260619000000_billing_prepaid.js +110 -0
  115. package/dist/modules/billing/migrations/Migration20260619000000_billing_prepaid.js.map +7 -0
  116. package/dist/modules/billing/setup.js +32 -1
  117. package/dist/modules/billing/setup.js.map +2 -2
  118. package/dist/modules/billing/subscribers/prepaid-topup-cancelled.js +26 -0
  119. package/dist/modules/billing/subscribers/prepaid-topup-cancelled.js.map +7 -0
  120. package/dist/modules/billing/subscribers/prepaid-topup-captured.js +87 -0
  121. package/dist/modules/billing/subscribers/prepaid-topup-captured.js.map +7 -0
  122. package/dist/modules/billing/subscribers/prepaid-topup-expired.js +26 -0
  123. package/dist/modules/billing/subscribers/prepaid-topup-expired.js.map +7 -0
  124. package/dist/modules/billing/subscribers/prepaid-topup-failed.js +26 -0
  125. package/dist/modules/billing/subscribers/prepaid-topup-failed.js.map +7 -0
  126. package/dist/modules/billing/workers/reconcile-prepaid-balances.js +50 -0
  127. package/dist/modules/billing/workers/reconcile-prepaid-balances.js.map +7 -0
  128. package/package.json +1 -1
  129. package/src/modules/billing/__tests__/commands/topups.test.ts +104 -0
  130. package/src/modules/billing/__tests__/commands/transactions.test.ts +84 -0
  131. package/src/modules/billing/__tests__/helpers/prepaidMockEm.ts +235 -0
  132. package/src/modules/billing/__tests__/lib/accountStatus.test.ts +93 -0
  133. package/src/modules/billing/__tests__/lib/balanceLedger.test.ts +125 -0
  134. package/src/modules/billing/__tests__/lib/balanceStatus.test.ts +65 -0
  135. package/src/modules/billing/__tests__/lib/creditStatus.test.ts +111 -0
  136. package/src/modules/billing/__tests__/lib/money.test.ts +56 -0
  137. package/src/modules/billing/__tests__/lib/prepaidConsume.test.ts +179 -0
  138. package/src/modules/billing/__tests__/lib/prepaidPeriodClose.test.ts +232 -0
  139. package/src/modules/billing/__tests__/lib/topupCapture.test.ts +123 -0
  140. package/src/modules/billing/__tests__/lib/topupReceipt.test.ts +84 -0
  141. package/src/modules/billing/__tests__/setup.test.ts +28 -2
  142. package/src/modules/billing/acl.ts +8 -0
  143. package/src/modules/billing/api/accounts/[id]/balance/route.ts +74 -0
  144. package/src/modules/billing/api/accounts/[id]/credit-status/route.ts +71 -0
  145. package/src/modules/billing/api/accounts/[id]/topups/route.ts +49 -0
  146. package/src/modules/billing/api/accounts/route.ts +7 -0
  147. package/src/modules/billing/api/export/account/[id]/route.ts +47 -2
  148. package/src/modules/billing/api/statements/generate/route.ts +78 -0
  149. package/src/modules/billing/api/statements/route.ts +117 -0
  150. package/src/modules/billing/api/topups/route.ts +154 -0
  151. package/src/modules/billing/api/transactions/adjust/route.ts +80 -0
  152. package/src/modules/billing/api/transactions/route.ts +116 -0
  153. package/src/modules/billing/api/usage/route.ts +36 -5
  154. package/src/modules/billing/backend/billing/accounts/[id]/page.tsx +28 -0
  155. package/src/modules/billing/backend/billing/accounts/create/page.tsx +7 -0
  156. package/src/modules/billing/backend/billing/accounts/page.tsx +32 -1
  157. package/src/modules/billing/backend/billing/statements/[id]/page.meta.ts +19 -0
  158. package/src/modules/billing/backend/billing/statements/[id]/page.tsx +198 -0
  159. package/src/modules/billing/backend/billing/statements/page.meta.ts +16 -0
  160. package/src/modules/billing/backend/billing/statements/page.tsx +190 -0
  161. package/src/modules/billing/backend/billing/topups/[id]/page.meta.ts +19 -0
  162. package/src/modules/billing/backend/billing/topups/[id]/page.tsx +151 -0
  163. package/src/modules/billing/backend/billing/topups/page.meta.ts +16 -0
  164. package/src/modules/billing/backend/billing/topups/page.tsx +182 -0
  165. package/src/modules/billing/backend/billing/transactions/[id]/page.meta.ts +19 -0
  166. package/src/modules/billing/backend/billing/transactions/[id]/page.tsx +179 -0
  167. package/src/modules/billing/backend/billing/transactions/page.meta.ts +16 -0
  168. package/src/modules/billing/backend/billing/transactions/page.tsx +187 -0
  169. package/src/modules/billing/commands/accounts.ts +42 -1
  170. package/src/modules/billing/commands/index.ts +3 -0
  171. package/src/modules/billing/commands/invoices.ts +26 -2
  172. package/src/modules/billing/commands/statements.ts +101 -0
  173. package/src/modules/billing/commands/topups.ts +181 -0
  174. package/src/modules/billing/commands/transactions.ts +152 -0
  175. package/src/modules/billing/commands/usage.ts +114 -4
  176. package/src/modules/billing/components/AccountForm.tsx +66 -0
  177. package/src/modules/billing/components/PrepaidPanel.tsx +382 -0
  178. package/src/modules/billing/components/accountNames.ts +23 -0
  179. package/src/modules/billing/data/entities.ts +383 -0
  180. package/src/modules/billing/data/entityIds.ts +5 -0
  181. package/src/modules/billing/data/validators.ts +144 -0
  182. package/src/modules/billing/events.ts +52 -0
  183. package/src/modules/billing/i18n/en.json +112 -4
  184. package/src/modules/billing/i18n/pl.json +112 -4
  185. package/src/modules/billing/index.ts +1 -1
  186. package/src/modules/billing/lib/accountStatus.ts +155 -0
  187. package/src/modules/billing/lib/balanceEvents.ts +74 -0
  188. package/src/modules/billing/lib/balanceLedger.ts +258 -0
  189. package/src/modules/billing/lib/balanceStatus.ts +78 -0
  190. package/src/modules/billing/lib/billRunEngine.ts +66 -5
  191. package/src/modules/billing/lib/creditStatus.ts +153 -0
  192. package/src/modules/billing/lib/money.ts +73 -0
  193. package/src/modules/billing/lib/prepaidConfig.ts +58 -0
  194. package/src/modules/billing/lib/prepaidConsume.ts +299 -0
  195. package/src/modules/billing/lib/prepaidPeriodClose.ts +453 -0
  196. package/src/modules/billing/lib/topupCapture.ts +117 -0
  197. package/src/modules/billing/lib/topupReceipt.ts +153 -0
  198. package/src/modules/billing/lib/topupTransition.ts +31 -0
  199. package/src/modules/billing/migrations/Migration20260619000000_billing_prepaid.ts +131 -0
  200. package/src/modules/billing/setup.ts +45 -0
  201. package/src/modules/billing/subscribers/prepaid-topup-cancelled.ts +31 -0
  202. package/src/modules/billing/subscribers/prepaid-topup-captured.ts +120 -0
  203. package/src/modules/billing/subscribers/prepaid-topup-expired.ts +31 -0
  204. package/src/modules/billing/subscribers/prepaid-topup-failed.ts +32 -0
  205. 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' },
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,84 @@
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
+ function makeCtx(em: MockEm) {
21
+ const dataEngine = {
22
+ markOrmEntityChange: jest.fn(),
23
+ flushOrmEntityChanges: jest.fn(async () => undefined),
24
+ }
25
+ return {
26
+ auth: { sub: '99999999-9999-4999-8999-999999999999' },
27
+ container: {
28
+ resolve: jest.fn((name: string) => {
29
+ if (name === 'em') return em
30
+ if (name === 'dataEngine') return dataEngine
31
+ return null
32
+ }),
33
+ },
34
+ }
35
+ }
36
+
37
+ describe('adjustTransactionCommand', () => {
38
+ it('debits the balance and returns the new balance + status', async () => {
39
+ const em = createPrepaidMockEm({
40
+ account: prepaidAccount(),
41
+ balance: { tenantId: TENANT, billAccountId: ACCT, balance: '100.0000' },
42
+ })
43
+ const ctx = makeCtx(em)
44
+ const result = await adjustTransactionCommand.execute(
45
+ { tenantId: TENANT, organizationId: ORG, billAccountId: ACCT, amount: -30, description: 'Goodwill debit' } as never,
46
+ ctx as never,
47
+ )
48
+ expect(result.balance).toBe('70.0000')
49
+ expect(result.balanceStatus).toBe('ok')
50
+ expect(result.transactionId).toBeTruthy()
51
+ expect(em.transactions).toHaveLength(1)
52
+ expect(em.transactions[0].type).toBe('adjustment')
53
+ expect(em.transactions[0].amount).toBe('-30.0000')
54
+ expect(em.transactions[0].userId).toBe('99999999-9999-4999-8999-999999999999')
55
+ })
56
+
57
+ it('credits the balance', async () => {
58
+ const em = createPrepaidMockEm({
59
+ account: prepaidAccount(),
60
+ balance: { tenantId: TENANT, billAccountId: ACCT, balance: '-5.0000' },
61
+ })
62
+ const ctx = makeCtx(em)
63
+ const result = await adjustTransactionCommand.execute(
64
+ { tenantId: TENANT, organizationId: ORG, billAccountId: ACCT, amount: 25, description: 'Refund credit' } as never,
65
+ ctx as never,
66
+ )
67
+ expect(result.balance).toBe('20.0000')
68
+ expect(em.transactions[0].amount).toBe('25.0000')
69
+ })
70
+
71
+ it('rejects a non-zero amount on a postpaid account with 409', async () => {
72
+ const em = createPrepaidMockEm({ account: prepaidAccount({ billingMode: 'postpaid' }) })
73
+ const ctx = makeCtx(em)
74
+ await expect(
75
+ adjustTransactionCommand.execute(
76
+ { tenantId: TENANT, organizationId: ORG, billAccountId: ACCT, amount: 10, description: 'x' } as never,
77
+ ctx as never,
78
+ ),
79
+ ).rejects.toMatchObject({
80
+ status: 409,
81
+ body: expect.objectContaining({ code: 'billing.account.not_prepaid' }),
82
+ })
83
+ })
84
+ })
@@ -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
+ })