@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.
Files changed (206) hide show
  1. package/CHANGELOG.md +99 -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 +9 -9
  129. package/src/modules/billing/__tests__/commands/invoices.test.ts +3 -3
  130. package/src/modules/billing/__tests__/commands/topups.test.ts +104 -0
  131. package/src/modules/billing/__tests__/commands/transactions.test.ts +107 -0
  132. package/src/modules/billing/__tests__/helpers/prepaidMockEm.ts +235 -0
  133. package/src/modules/billing/__tests__/lib/accountStatus.test.ts +93 -0
  134. package/src/modules/billing/__tests__/lib/balanceLedger.test.ts +125 -0
  135. package/src/modules/billing/__tests__/lib/balanceStatus.test.ts +65 -0
  136. package/src/modules/billing/__tests__/lib/creditStatus.test.ts +111 -0
  137. package/src/modules/billing/__tests__/lib/money.test.ts +56 -0
  138. package/src/modules/billing/__tests__/lib/prepaidConsume.test.ts +179 -0
  139. package/src/modules/billing/__tests__/lib/prepaidPeriodClose.test.ts +232 -0
  140. package/src/modules/billing/__tests__/lib/topupCapture.test.ts +123 -0
  141. package/src/modules/billing/__tests__/lib/topupReceipt.test.ts +84 -0
  142. package/src/modules/billing/__tests__/setup.test.ts +28 -2
  143. package/src/modules/billing/acl.ts +8 -0
  144. package/src/modules/billing/api/accounts/[id]/balance/route.ts +74 -0
  145. package/src/modules/billing/api/accounts/[id]/credit-status/route.ts +71 -0
  146. package/src/modules/billing/api/accounts/[id]/topups/route.ts +49 -0
  147. package/src/modules/billing/api/accounts/route.ts +7 -0
  148. package/src/modules/billing/api/export/account/[id]/route.ts +47 -2
  149. package/src/modules/billing/api/statements/generate/route.ts +78 -0
  150. package/src/modules/billing/api/statements/route.ts +117 -0
  151. package/src/modules/billing/api/topups/route.ts +154 -0
  152. package/src/modules/billing/api/transactions/adjust/route.ts +80 -0
  153. package/src/modules/billing/api/transactions/route.ts +116 -0
  154. package/src/modules/billing/api/usage/route.ts +36 -5
  155. package/src/modules/billing/backend/billing/accounts/[id]/page.tsx +28 -0
  156. package/src/modules/billing/backend/billing/accounts/create/page.tsx +7 -0
  157. package/src/modules/billing/backend/billing/accounts/page.tsx +32 -1
  158. package/src/modules/billing/backend/billing/statements/[id]/page.meta.ts +19 -0
  159. package/src/modules/billing/backend/billing/statements/[id]/page.tsx +198 -0
  160. package/src/modules/billing/backend/billing/statements/page.meta.ts +16 -0
  161. package/src/modules/billing/backend/billing/statements/page.tsx +190 -0
  162. package/src/modules/billing/backend/billing/topups/[id]/page.meta.ts +19 -0
  163. package/src/modules/billing/backend/billing/topups/[id]/page.tsx +151 -0
  164. package/src/modules/billing/backend/billing/topups/page.meta.ts +16 -0
  165. package/src/modules/billing/backend/billing/topups/page.tsx +182 -0
  166. package/src/modules/billing/backend/billing/transactions/[id]/page.meta.ts +19 -0
  167. package/src/modules/billing/backend/billing/transactions/[id]/page.tsx +179 -0
  168. package/src/modules/billing/backend/billing/transactions/page.meta.ts +16 -0
  169. package/src/modules/billing/backend/billing/transactions/page.tsx +187 -0
  170. package/src/modules/billing/commands/accounts.ts +42 -1
  171. package/src/modules/billing/commands/index.ts +3 -0
  172. package/src/modules/billing/commands/invoices.ts +26 -2
  173. package/src/modules/billing/commands/statements.ts +101 -0
  174. package/src/modules/billing/commands/topups.ts +181 -0
  175. package/src/modules/billing/commands/transactions.ts +152 -0
  176. package/src/modules/billing/commands/usage.ts +114 -4
  177. package/src/modules/billing/components/AccountForm.tsx +66 -0
  178. package/src/modules/billing/components/PrepaidPanel.tsx +382 -0
  179. package/src/modules/billing/components/accountNames.ts +23 -0
  180. package/src/modules/billing/data/entities.ts +383 -0
  181. package/src/modules/billing/data/entityIds.ts +5 -0
  182. package/src/modules/billing/data/validators.ts +144 -0
  183. package/src/modules/billing/events.ts +52 -0
  184. package/src/modules/billing/i18n/en.json +112 -4
  185. package/src/modules/billing/i18n/pl.json +112 -4
  186. package/src/modules/billing/index.ts +1 -1
  187. package/src/modules/billing/lib/accountStatus.ts +155 -0
  188. package/src/modules/billing/lib/balanceEvents.ts +74 -0
  189. package/src/modules/billing/lib/balanceLedger.ts +258 -0
  190. package/src/modules/billing/lib/balanceStatus.ts +78 -0
  191. package/src/modules/billing/lib/billRunEngine.ts +66 -5
  192. package/src/modules/billing/lib/creditStatus.ts +153 -0
  193. package/src/modules/billing/lib/money.ts +73 -0
  194. package/src/modules/billing/lib/prepaidConfig.ts +58 -0
  195. package/src/modules/billing/lib/prepaidConsume.ts +299 -0
  196. package/src/modules/billing/lib/prepaidPeriodClose.ts +453 -0
  197. package/src/modules/billing/lib/topupCapture.ts +117 -0
  198. package/src/modules/billing/lib/topupReceipt.ts +153 -0
  199. package/src/modules/billing/lib/topupTransition.ts +31 -0
  200. package/src/modules/billing/migrations/Migration20260619000000_billing_prepaid.ts +131 -0
  201. package/src/modules/billing/setup.ts +45 -0
  202. package/src/modules/billing/subscribers/prepaid-topup-cancelled.ts +31 -0
  203. package/src/modules/billing/subscribers/prepaid-topup-captured.ts +120 -0
  204. package/src/modules/billing/subscribers/prepaid-topup-expired.ts +31 -0
  205. package/src/modules/billing/subscribers/prepaid-topup-failed.ts +32 -0
  206. package/src/modules/billing/workers/reconcile-prepaid-balances.ts +68 -0
@@ -0,0 +1,123 @@
1
+ import { captureTopup } from '../../lib/topupCapture'
2
+ import { __resetInvoiceStatusCacheForTests } from '../../lib/invoiceStatus'
3
+ import { createPrepaidMockEm } from '../helpers/prepaidMockEm'
4
+
5
+ const ACCOUNT = {
6
+ id: 'acct-1',
7
+ organizationId: 'org-1',
8
+ tenantId: 'ten-1',
9
+ currencyCode: 'EUR',
10
+ }
11
+
12
+ function makeDeps() {
13
+ return {
14
+ numberGenerator: {
15
+ generate: jest.fn(async () => ({ number: 'FV/2026/06/0042', format: 'x', sequence: 42 })),
16
+ },
17
+ taxService: {
18
+ calculateUnitAmounts: jest.fn(async (input: { amount: number }) => ({
19
+ netAmount: input.amount,
20
+ grossAmount: input.amount,
21
+ taxAmount: 0,
22
+ taxRate: 0,
23
+ })),
24
+ },
25
+ }
26
+ }
27
+
28
+ function makeTopup(overrides: Record<string, unknown> = {}) {
29
+ return {
30
+ id: 'topup-1',
31
+ status: 'pending',
32
+ amount: '100.0000',
33
+ paymentId: 'pay-1',
34
+ providerKey: 'stripe',
35
+ billAccountId: 'acct-1',
36
+ tenantId: 'ten-1',
37
+ organizationId: 'org-1',
38
+ ...overrides,
39
+ }
40
+ }
41
+
42
+ beforeEach(() => {
43
+ __resetInvoiceStatusCacheForTests()
44
+ })
45
+
46
+ describe('captureTopup', () => {
47
+ it('credits the balance, issues a receipt, and flips the top-up to captured', async () => {
48
+ const topup = makeTopup()
49
+ const em = createPrepaidMockEm({
50
+ topup,
51
+ balance: { tenantId: 'ten-1', billAccountId: 'acct-1', balance: '0.0000' },
52
+ })
53
+ const result = await captureTopup(em as never, makeDeps(), {
54
+ topup: topup as never,
55
+ account: ACCOUNT,
56
+ taxRate: 0,
57
+ gatewayTransactionId: 'gw-1',
58
+ })
59
+
60
+ expect(result.status).toBe('captured')
61
+ expect(result.balanceAfter).toBe('100.0000')
62
+ expect(result.transactionId).toBeTruthy()
63
+ expect(result.receiptInvoiceId).toBeTruthy()
64
+ expect(result.invoiceNumber).toBe('FV/2026/06/0042')
65
+
66
+ // The credit transaction carries the deterministic source_ref.
67
+ expect(em.transactions).toHaveLength(1)
68
+ expect(em.transactions[0].type).toBe('topup')
69
+ expect(em.transactions[0].amount).toBe('100.0000')
70
+ expect(em.transactions[0].sourceRef).toBe('topup-pay-1')
71
+
72
+ // The top-up itself transitioned + backfilled.
73
+ expect(topup.status).toBe('captured')
74
+ expect((topup as Record<string, unknown>).transactionId).toBe(result.transactionId)
75
+ expect((topup as Record<string, unknown>).receiptInvoiceId).toBe(result.receiptInvoiceId)
76
+ })
77
+
78
+ it('is a no-op on an already-captured top-up (idempotent re-fire)', async () => {
79
+ const topup = makeTopup({ status: 'captured' })
80
+ const em = createPrepaidMockEm({
81
+ topup,
82
+ balance: { tenantId: 'ten-1', billAccountId: 'acct-1', balance: '100.0000' },
83
+ })
84
+ const result = await captureTopup(em as never, makeDeps(), {
85
+ topup: topup as never,
86
+ account: ACCOUNT,
87
+ taxRate: 0,
88
+ })
89
+ expect(result.status).toBe('already_captured')
90
+ expect(em.transactions).toHaveLength(0) // no second credit
91
+ })
92
+
93
+ it('does not double-credit OR issue a second receipt when the source_ref exists', async () => {
94
+ // Simulates an at-least-once / concurrent re-fire that raced past the stale
95
+ // status check: the credit dedups AND no second fiscal receipt is minted.
96
+ const topup = makeTopup({ transactionId: 'txn-prior', receiptInvoiceId: 'inv-prior' })
97
+ const em = createPrepaidMockEm({
98
+ topup,
99
+ balance: { tenantId: 'ten-1', billAccountId: 'acct-1', balance: '100.0000' },
100
+ })
101
+ em.transactions.push({
102
+ id: 'txn-prior',
103
+ type: 'topup',
104
+ amount: '100.0000',
105
+ balanceAfter: '100.0000',
106
+ sourceRef: 'topup-pay-1',
107
+ billAccountId: 'acct-1',
108
+ createdAt: new Date('2026-06-10'),
109
+ })
110
+ const deps = makeDeps()
111
+ const result = await captureTopup(em as never, deps, {
112
+ topup: topup as never,
113
+ account: ACCOUNT,
114
+ taxRate: 0,
115
+ })
116
+ expect(result.status).toBe('already_captured')
117
+ expect(result.balanceAfter).toBe('100.0000') // unchanged — dedup hit
118
+ expect(result.receiptInvoiceId).toBe('inv-prior') // returns the existing receipt
119
+ expect(em.transactions).toHaveLength(1) // no new credit transaction
120
+ // No second fiscal document: the number generator was never called.
121
+ expect(deps.numberGenerator.generate).not.toHaveBeenCalled()
122
+ })
123
+ })
@@ -0,0 +1,84 @@
1
+ import { createTopupReceipt, type TopupReceiptDeps } from '../../lib/topupReceipt'
2
+ import { __resetInvoiceStatusCacheForTests } from '../../lib/invoiceStatus'
3
+ import { createPrepaidMockEm } from '../helpers/prepaidMockEm'
4
+
5
+ const ACCOUNT = {
6
+ id: 'acct-1',
7
+ organizationId: 'org-1',
8
+ tenantId: 'ten-1',
9
+ currencyCode: 'EUR',
10
+ }
11
+
12
+ function makeDeps(taxRateOut: number | null): TopupReceiptDeps {
13
+ return {
14
+ numberGenerator: {
15
+ generate: jest.fn(async () => ({
16
+ number: 'FV/2026/06/0042',
17
+ format: 'FV/{yyyy}/{mm}/{seq:4}',
18
+ sequence: 42,
19
+ })),
20
+ },
21
+ taxService: {
22
+ calculateUnitAmounts: jest.fn(async (input: { amount: number; taxRate?: number | string | null }) => {
23
+ const rate = typeof input.taxRate === 'number' ? input.taxRate : Number(input.taxRate ?? 0)
24
+ const gross = input.amount
25
+ const net = rate > 0 ? gross / (1 + rate / 100) : gross
26
+ return {
27
+ netAmount: Math.round(net * 10000) / 10000,
28
+ grossAmount: Math.round(gross * 10000) / 10000,
29
+ taxAmount: Math.round((gross - net) * 10000) / 10000,
30
+ taxRate: taxRateOut,
31
+ }
32
+ }),
33
+ },
34
+ }
35
+ }
36
+
37
+ beforeEach(() => {
38
+ __resetInvoiceStatusCacheForTests()
39
+ })
40
+
41
+ describe('createTopupReceipt — VAT at top-up (amount is gross)', () => {
42
+ it('splits 100 gross @ 23% into net + VAT and posts paid', async () => {
43
+ const em = createPrepaidMockEm()
44
+ const deps = makeDeps(23)
45
+ const receipt = await createTopupReceipt(em as never, deps, {
46
+ account: ACCOUNT,
47
+ topupId: 'topup-1',
48
+ paymentId: 'pay-1',
49
+ amount: '100.0000',
50
+ taxRate: 23,
51
+ })
52
+
53
+ expect(receipt.invoiceNumber).toBe('FV/2026/06/0042')
54
+ expect(receipt.grossAmount).toBe('100.0000')
55
+ expect(receipt.netAmount).toBe('81.3008')
56
+ expect(receipt.taxAmount).toBe('18.6992')
57
+ expect(receipt.taxRate).toBe(23)
58
+
59
+ // The invoice was persisted as paid with a single top-up line.
60
+ const invoice = em.persisted.find((e) => (e as { status?: string }).status === 'paid') as Record<
61
+ string,
62
+ unknown
63
+ >
64
+ expect(invoice).toBeTruthy()
65
+ expect(invoice.paidTotalAmount).toBe('100.0000')
66
+ expect(invoice.outstandingAmount).toBe('0')
67
+ expect((invoice.metadata as Record<string, unknown>).kind).toBe('prepaid_topup')
68
+ })
69
+
70
+ it('0% VAT → net == gross, tax 0', async () => {
71
+ const em = createPrepaidMockEm()
72
+ const deps = makeDeps(null)
73
+ const receipt = await createTopupReceipt(em as never, deps, {
74
+ account: ACCOUNT,
75
+ topupId: 'topup-2',
76
+ paymentId: 'pay-2',
77
+ amount: '50.0000',
78
+ taxRate: 0,
79
+ })
80
+ expect(receipt.netAmount).toBe('50.0000')
81
+ expect(receipt.grossAmount).toBe('50.0000')
82
+ expect(receipt.taxAmount).toBe('0.0000')
83
+ })
84
+ })
@@ -77,8 +77,8 @@ describe('billing setup — contract', () => {
77
77
  })
78
78
 
79
79
  describe('billing acl — surface', () => {
80
- it('exports exactly 12 features per Phase 0 spec', () => {
81
- expect(features).toHaveLength(12)
80
+ it('exports exactly 19 features (12 Phase 0 + 7 SPEC-002 prepaid)', () => {
81
+ expect(features).toHaveLength(19)
82
82
  })
83
83
 
84
84
  it('all feature IDs are prefixed `billing.` (no leaking module boundary)', () => {
@@ -101,7 +101,33 @@ describe('billing acl — surface', () => {
101
101
  'billing.invoice.post',
102
102
  'billing.invoice.edit_draft',
103
103
  'billing.invoice.view',
104
+ // SPEC-002 prepaid
105
+ 'billing.balance.view',
106
+ 'billing.balance.adjust',
107
+ 'billing.topup.create',
108
+ 'billing.topup.view',
109
+ 'billing.statement.view',
110
+ 'billing.statement.generate',
111
+ 'billing.credit.view',
104
112
  ])('declares feature %s', (id) => {
105
113
  expect(featureIds.has(id)).toBe(true)
106
114
  })
107
115
  })
116
+
117
+ describe('billing setup — prepaid read grants (SPEC-002)', () => {
118
+ it('billing_finance_user can read prepaid balances, top-ups, statements, credit', () => {
119
+ const grants = setup.defaultRoleFeatures?.billing_finance_user ?? []
120
+ expect(grants).toEqual(
121
+ expect.arrayContaining([
122
+ 'billing.balance.view',
123
+ 'billing.credit.view',
124
+ 'billing.topup.view',
125
+ 'billing.statement.view',
126
+ ]),
127
+ )
128
+ // …but cannot adjust balances or initiate top-ups (read-only persona)
129
+ expect(grants).not.toContain('billing.balance.adjust')
130
+ expect(grants).not.toContain('billing.topup.create')
131
+ expect(grants).not.toContain('billing.statement.generate')
132
+ })
133
+ })
@@ -11,6 +11,14 @@ export const features = [
11
11
  { id: 'billing.invoice.post', title: 'Post Draft Invoice', module: 'billing' },
12
12
  { id: 'billing.invoice.edit_draft', title: 'Edit Draft Invoice', module: 'billing' },
13
13
  { id: 'billing.invoice.view', title: 'View Billing Invoices', module: 'billing' },
14
+ // ─── Prepaid balance billing (SPEC-002) ──────────────────────
15
+ { id: 'billing.balance.view', title: 'View Balance & Transactions', module: 'billing' },
16
+ { id: 'billing.balance.adjust', title: 'Adjust Balance (manual credit/debit)', module: 'billing' },
17
+ { id: 'billing.topup.create', title: 'Initiate Account Top-up', module: 'billing' },
18
+ { id: 'billing.topup.view', title: 'View Account Top-ups', module: 'billing' },
19
+ { id: 'billing.statement.view', title: 'View Consumption Statements', module: 'billing' },
20
+ { id: 'billing.statement.generate', title: 'Generate Consumption Statement', module: 'billing' },
21
+ { id: 'billing.credit.view', title: 'View Account Credit Status', module: 'billing' },
14
22
  ] as const
15
23
 
16
24
  export default features
@@ -0,0 +1,74 @@
1
+ import { NextResponse } from 'next/server'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
5
+ import { BillingAccount } from '../../../../data/entities'
6
+ import { getBalanceSnapshot } from '../../../../lib/accountStatus'
7
+
8
+ /**
9
+ * `GET /api/billing/accounts/[id]/balance` — current prepaid balance, status,
10
+ * and resolved low-balance threshold (SPEC-002). Read-only.
11
+ */
12
+
13
+ export const metadata = {
14
+ GET: { requireAuth: true, requireFeatures: ['billing.balance.view'] },
15
+ }
16
+
17
+ export async function GET(
18
+ req: Request,
19
+ { params }: { params: Promise<{ id: string }> },
20
+ ) {
21
+ let container: Awaited<ReturnType<typeof createRequestContainer>> | null = null
22
+ try {
23
+ container = await createRequestContainer()
24
+ const auth = await getAuthFromRequest(req)
25
+ if (!auth || !auth.tenantId) {
26
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
27
+ }
28
+ const organizationId = (auth as { orgId?: string }).orgId ?? null
29
+ if (!organizationId) {
30
+ return NextResponse.json({ error: 'Organization scope required' }, { status: 403 })
31
+ }
32
+
33
+ const { id: accountId } = await params
34
+ const em = (container.resolve('em') as EntityManager).fork()
35
+ const account = await em.findOne(BillingAccount, {
36
+ id: accountId,
37
+ tenantId: auth.tenantId,
38
+ organizationId,
39
+ deletedAt: null,
40
+ })
41
+ if (!account) {
42
+ return NextResponse.json({ error: 'Account not found' }, { status: 404 })
43
+ }
44
+ if (account.billingMode !== 'prepaid') {
45
+ return NextResponse.json(
46
+ { error: 'Account is not prepaid', code: 'billing.account.not_prepaid' },
47
+ { status: 409 },
48
+ )
49
+ }
50
+
51
+ const snapshot = await getBalanceSnapshot(em, account)
52
+ return NextResponse.json(snapshot)
53
+ } catch (err) {
54
+ console.error('[billing/accounts/balance.GET]', err)
55
+ return NextResponse.json({ error: 'Internal error' }, { status: 500 })
56
+ }
57
+ }
58
+
59
+ export const openApi = {
60
+ methods: {
61
+ GET: {
62
+ tags: ['Billing'],
63
+ summary: 'Get a prepaid account balance + status',
64
+ parameters: [
65
+ { name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } },
66
+ ],
67
+ description:
68
+ 'Returns the current running balance, `balanceStatus` (ok/low/exhausted), ' +
69
+ 'the resolved low-balance threshold, the currency, and the last-movement ' +
70
+ 'timestamp for a prepaid account. 409 (`billing.account.not_prepaid`) for ' +
71
+ 'postpaid accounts.',
72
+ },
73
+ },
74
+ }
@@ -0,0 +1,71 @@
1
+ import { NextResponse } from 'next/server'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
5
+ import { BillingAccount } from '../../../../data/entities'
6
+ import { getCreditSnapshot } from '../../../../lib/accountStatus'
7
+
8
+ /**
9
+ * `GET /api/billing/accounts/[id]/credit-status` — credit limit / used /
10
+ * available / status for BOTH modes (SPEC-002 P6). Report-only — billing never
11
+ * blocks; the quote→order flow (e.g. a CPQ credit guard) decides what to do.
12
+ *
13
+ * - prepaid: credit_used = max(0, −balance)
14
+ * - postpaid: credit_used = Σ posted-unpaid invoice outstanding (core/sales)
15
+ */
16
+
17
+ export const metadata = {
18
+ GET: { requireAuth: true, requireFeatures: ['billing.credit.view'] },
19
+ }
20
+
21
+ export async function GET(
22
+ req: Request,
23
+ { params }: { params: Promise<{ id: string }> },
24
+ ) {
25
+ let container: Awaited<ReturnType<typeof createRequestContainer>> | null = null
26
+ try {
27
+ container = await createRequestContainer()
28
+ const auth = await getAuthFromRequest(req)
29
+ if (!auth || !auth.tenantId) {
30
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
31
+ }
32
+ const organizationId = (auth as { orgId?: string }).orgId ?? null
33
+ if (!organizationId) {
34
+ return NextResponse.json({ error: 'Organization scope required' }, { status: 403 })
35
+ }
36
+
37
+ const { id: accountId } = await params
38
+ const em = (container.resolve('em') as EntityManager).fork()
39
+ const account = await em.findOne(BillingAccount, {
40
+ id: accountId,
41
+ tenantId: auth.tenantId,
42
+ organizationId,
43
+ deletedAt: null,
44
+ })
45
+ if (!account) {
46
+ return NextResponse.json({ error: 'Account not found' }, { status: 404 })
47
+ }
48
+
49
+ const snapshot = await getCreditSnapshot(em, account)
50
+ return NextResponse.json(snapshot)
51
+ } catch (err) {
52
+ console.error('[billing/accounts/credit-status.GET]', err)
53
+ return NextResponse.json({ error: 'Internal error' }, { status: 500 })
54
+ }
55
+ }
56
+
57
+ export const openApi = {
58
+ methods: {
59
+ GET: {
60
+ tags: ['Billing'],
61
+ summary: 'Get an account credit status (both modes)',
62
+ parameters: [
63
+ { name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } },
64
+ ],
65
+ description:
66
+ 'Returns `{ mode, creditLimit, creditUsed, availableCredit, creditStatus, ' +
67
+ 'currencyCode }`. Prepaid creditUsed = max(0,−balance); postpaid = Σ ' +
68
+ 'posted-unpaid invoice outstanding. Report-only — billing never blocks.',
69
+ },
70
+ },
71
+ }
@@ -0,0 +1,49 @@
1
+ import { POST as topupsPost } from '../../../topups/route'
2
+
3
+ /**
4
+ * `POST /api/billing/accounts/{id}/topups` (SPEC-002 P2).
5
+ *
6
+ * Spec-faithful nested path. Injects the path `id` as `billAccountId` into the
7
+ * body and delegates to the flat `POST /api/billing/topups` handler so all the
8
+ * RBAC / command-bus / side-effect plumbing lives in exactly one place.
9
+ */
10
+
11
+ export const metadata = {
12
+ POST: { requireAuth: true, requireFeatures: ['billing.topup.create'] },
13
+ }
14
+
15
+ export async function POST(
16
+ req: Request,
17
+ { params }: { params: Promise<{ id: string }> },
18
+ ): Promise<Response> {
19
+ const { id } = await params
20
+ const body = (await req.json().catch(() => ({}))) as Record<string, unknown>
21
+ const merged = { ...body, billAccountId: id }
22
+ // Rebuild headers WITHOUT the stale content-length (the body changed); keep
23
+ // auth (cookies / Authorization) intact and force JSON content-type.
24
+ const headers = new Headers(req.headers)
25
+ headers.delete('content-length')
26
+ headers.set('content-type', 'application/json')
27
+ const forwarded = new Request(req.url, {
28
+ method: 'POST',
29
+ headers,
30
+ body: JSON.stringify(merged),
31
+ })
32
+ return topupsPost(forwarded)
33
+ }
34
+
35
+ export const openApi = {
36
+ methods: {
37
+ POST: {
38
+ tags: ['Billing'],
39
+ summary: 'Initiate a prepaid top-up for an account',
40
+ parameters: [
41
+ { name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } },
42
+ ],
43
+ description:
44
+ 'Registers a BillingTopup (pending) and opens a payment session. The ' +
45
+ 'path `id` is the prepaid account; the balance is credited on capture. ' +
46
+ 'Body: `{ amount, providerKey?, successUrl?, cancelUrl?, sourceRef? }`.',
47
+ },
48
+ },
49
+ }
@@ -43,6 +43,8 @@ const billingAccountListItemSchema = z.object({
43
43
  invoice_address: z.record(z.string(), z.unknown()),
44
44
  next_bill_date: z.string(),
45
45
  last_bill_date: z.string().nullable(),
46
+ billing_mode: z.string(),
47
+ credit_limit: z.string(),
46
48
  is_active: z.boolean(),
47
49
  organization_id: z.string().uuid(),
48
50
  tenant_id: z.string().uuid(),
@@ -79,6 +81,8 @@ const crud = makeCrudRoute({
79
81
  'invoice_address',
80
82
  'next_bill_date',
81
83
  'last_bill_date',
84
+ 'billing_mode',
85
+ 'credit_limit',
82
86
  'is_active',
83
87
  'organization_id',
84
88
  'tenant_id',
@@ -107,6 +111,9 @@ const crud = makeCrudRoute({
107
111
  if (typeof query.billCycle === 'string' && query.billCycle) {
108
112
  filters.bill_cycle = { $eq: query.billCycle }
109
113
  }
114
+ if (typeof query.billingMode === 'string' && query.billingMode) {
115
+ filters.billing_mode = { $eq: query.billingMode }
116
+ }
110
117
  if (typeof query.isActive === 'boolean') {
111
118
  filters.is_active = { $eq: query.isActive }
112
119
  }
@@ -4,8 +4,12 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
4
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
5
5
  import {
6
6
  BillingAccount,
7
+ BillingAccountBalance,
8
+ BillingAccountTransaction,
7
9
  BillingAccountUsage,
8
10
  BillingItem,
11
+ BillingStatement,
12
+ BillingTopup,
9
13
  BillRunOutcome,
10
14
  DraftInvoiceEdit,
11
15
  } from '../../../../data/entities'
@@ -83,7 +87,7 @@ export async function GET(
83
87
  ?? defaultSince()
84
88
  const until = parseDate(url.searchParams.get('until')) ?? new Date()
85
89
 
86
- const [items, usage, outcomes] = await Promise.all([
90
+ const [items, usage, outcomes, balance, transactions, topups, statements] = await Promise.all([
87
91
  // Items are bounded by integration volume (typically <100 per
88
92
  // account); no time-window needed — they often outlive a single
89
93
  // export window and the operator needs to see active items
@@ -113,6 +117,41 @@ export async function GET(
113
117
  } as never,
114
118
  { limit: EXPORT_HARD_LIMIT, orderBy: { createdAt: 'DESC' } },
115
119
  ),
120
+ // SPEC-002 prepaid data — the balance row + the windowed money trail.
121
+ em.findOne(BillingAccountBalance, {
122
+ tenantId,
123
+ billAccountId: account.id,
124
+ } as never),
125
+ em.find(
126
+ BillingAccountTransaction,
127
+ {
128
+ tenantId,
129
+ organizationId,
130
+ billAccountId: account.id,
131
+ createdAt: { $gte: since, $lte: until },
132
+ } as never,
133
+ { limit: EXPORT_HARD_LIMIT, orderBy: { createdAt: 'DESC' } },
134
+ ),
135
+ em.find(
136
+ BillingTopup,
137
+ {
138
+ tenantId,
139
+ organizationId,
140
+ billAccountId: account.id,
141
+ createdAt: { $gte: since, $lte: until },
142
+ } as never,
143
+ { limit: EXPORT_HARD_LIMIT, orderBy: { createdAt: 'DESC' } },
144
+ ),
145
+ em.find(
146
+ BillingStatement,
147
+ {
148
+ tenantId,
149
+ organizationId,
150
+ billAccountId: account.id,
151
+ periodEnd: { $gte: since, $lte: until },
152
+ } as never,
153
+ { limit: EXPORT_HARD_LIMIT, orderBy: { periodEnd: 'DESC' } },
154
+ ),
116
155
  ])
117
156
 
118
157
  // Invoices created in the window. The `metadata->>'bill_account_id'`
@@ -150,7 +189,8 @@ export async function GET(
150
189
 
151
190
  return NextResponse.json({
152
191
  exportedAt: new Date().toISOString(),
153
- schemaVersion: 1,
192
+ // v2 adds prepaid balance / transactions / top-ups / statements (SPEC-002).
193
+ schemaVersion: 2,
154
194
  tenantId,
155
195
  organizationId,
156
196
  window: {
@@ -164,6 +204,11 @@ export async function GET(
164
204
  runOutcomes: outcomes,
165
205
  invoices: invoiceRows,
166
206
  draftInvoiceEdits: draftEdits,
207
+ // SPEC-002 prepaid section.
208
+ balance,
209
+ transactions,
210
+ topups,
211
+ statements,
167
212
  })
168
213
  } catch (err) {
169
214
  console.error('[billing/export/account.GET]', err)
@@ -0,0 +1,78 @@
1
+ import { z } from 'zod'
2
+ import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
3
+ import { withScopedPayload } from '@open-mercato/shared/lib/api/scoped'
4
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
5
+ import { BillingStatement } from '../../../data/entities'
6
+ import { billingEntityIds } from '../../../data/entityIds'
7
+ import { billingStatementGenerateSchema } from '../../../data/validators'
8
+ import { createBillingCrudOpenApi } from '../../openapi'
9
+
10
+ /**
11
+ * `POST /api/billing/statements/generate` (SPEC-002 P3) — manual statement
12
+ * generation for a prepaid account. Reuses the Bill Run engine scoped to the
13
+ * one account (same recurring-charge + anti-duplicate machinery).
14
+ */
15
+
16
+ const routeMetadata = {
17
+ POST: { requireAuth: true, requireFeatures: ['billing.statement.generate'] },
18
+ }
19
+
20
+ export const metadata = routeMetadata
21
+
22
+ const generateResponseSchema = z.object({
23
+ statementId: z.string().uuid().nullable(),
24
+ billRunId: z.string().uuid(),
25
+ status: z.string(),
26
+ })
27
+
28
+ const rawBodySchema = z.object({}).passthrough()
29
+
30
+ const crud = makeCrudRoute({
31
+ metadata: routeMetadata,
32
+ orm: {
33
+ entity: BillingStatement,
34
+ idField: 'id',
35
+ orgField: 'organizationId',
36
+ tenantField: 'tenantId',
37
+ },
38
+ list: {
39
+ schema: z.object({}).passthrough(),
40
+ entityId: billingEntityIds.statement,
41
+ fields: ['id'],
42
+ buildFilters: async () => ({}),
43
+ },
44
+ actions: {
45
+ create: {
46
+ commandId: 'billing.statements.generate',
47
+ schema: rawBodySchema,
48
+ mapInput: async ({ raw, ctx }) => {
49
+ const { translate } = await resolveTranslations()
50
+ const scoped = withScopedPayload(raw ?? {}, ctx, translate)
51
+ return billingStatementGenerateSchema.parse(scoped)
52
+ },
53
+ response: ({ result }) => ({
54
+ statementId: result?.statementId ?? null,
55
+ billRunId: result?.billRunId ?? null,
56
+ status: result?.status ?? null,
57
+ }),
58
+ status: 201,
59
+ },
60
+ },
61
+ })
62
+
63
+ export const POST = crud.POST
64
+
65
+ export const openApi = createBillingCrudOpenApi({
66
+ resourceName: 'BillingStatementGenerate',
67
+ pluralName: 'BillingStatementGenerations',
68
+ querySchema: z.object({}).passthrough(),
69
+ listResponseSchema: z.object({ items: z.array(z.unknown()) }),
70
+ create: {
71
+ schema: billingStatementGenerateSchema,
72
+ responseSchema: generateResponseSchema,
73
+ description:
74
+ 'Generates a consumption statement for a prepaid account by running the ' +
75
+ 'period close (recurring charges → balance + statement) scoped to that ' +
76
+ 'account. Returns the statement id (null when the account is not due).',
77
+ },
78
+ })