@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,153 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import {
3
+ SalesInvoice,
4
+ SalesInvoiceLine,
5
+ } from '@open-mercato/core/modules/sales/data/entities'
6
+ import type { SalesDocumentNumberGenerator } from '@open-mercato/core/modules/sales/services/salesDocumentNumberGenerator'
7
+ import type { TaxCalculationService } from '@open-mercato/core/modules/sales/services/taxCalculationService'
8
+ import { resolveInvoiceStatusEntryId } from './invoiceStatus'
9
+
10
+ /**
11
+ * Creates the fiscal VAT receipt for a captured prepaid top-up (SPEC-002 P2).
12
+ *
13
+ * Deliberate divergence from the postpaid draft→post gate: a captured top-up
14
+ * is a completed, already-paid transaction with nothing to review, so the
15
+ * receipt is created **posted + paid** in one shot (the gate exists for
16
+ * outbound bills, not money already received — mirrors the xd-249 "deviations"
17
+ * convention).
18
+ *
19
+ * **VAT at top-up.** The top-up `amount` is the GROSS the customer paid (= the
20
+ * balance credited). The core tax service splits it into net + VAT at the
21
+ * configured rate. Gap-free numbering reuses `salesDocumentNumberGenerator`.
22
+ */
23
+
24
+ export type TopupReceiptParams = {
25
+ account: {
26
+ id: string
27
+ organizationId: string
28
+ tenantId: string
29
+ currencyCode: string
30
+ }
31
+ topupId: string
32
+ paymentId: string
33
+ /** Gross amount paid (numeric(18,4) string). */
34
+ amount: string
35
+ /** VAT percent (e.g. 23). 0 = no VAT. */
36
+ taxRate: number
37
+ }
38
+
39
+ export type TopupReceiptDeps = {
40
+ numberGenerator: Pick<SalesDocumentNumberGenerator, 'generate'>
41
+ taxService: TaxCalculationService
42
+ }
43
+
44
+ export type TopupReceipt = {
45
+ invoiceId: string
46
+ invoiceNumber: string
47
+ netAmount: string
48
+ taxAmount: string
49
+ grossAmount: string
50
+ taxRate: number
51
+ }
52
+
53
+ function fmt4(value: number): string {
54
+ return (Number.isFinite(value) ? value : 0).toFixed(4)
55
+ }
56
+
57
+ export async function createTopupReceipt(
58
+ em: EntityManager,
59
+ deps: TopupReceiptDeps,
60
+ params: TopupReceiptParams,
61
+ ): Promise<TopupReceipt> {
62
+ const { account } = params
63
+
64
+ // VAT split — the top-up amount is GROSS (what was paid = balance credited).
65
+ const tax = await deps.taxService.calculateUnitAmounts({
66
+ amount: Number.parseFloat(params.amount),
67
+ mode: 'gross',
68
+ organizationId: account.organizationId,
69
+ tenantId: account.tenantId,
70
+ taxRate: params.taxRate,
71
+ })
72
+ const net = fmt4(tax.netAmount)
73
+ const gross = fmt4(tax.grossAmount)
74
+ const taxAmount = fmt4(tax.taxAmount)
75
+ const effectiveRate = tax.taxRate ?? 0
76
+
77
+ const generated = await deps.numberGenerator.generate({
78
+ kind: 'invoice',
79
+ organizationId: account.organizationId,
80
+ tenantId: account.tenantId,
81
+ })
82
+
83
+ // Posted+paid in one shot — resolve the `paid` status entry directly.
84
+ const paidStatusEntryId = await resolveInvoiceStatusEntryId(
85
+ em,
86
+ account.tenantId,
87
+ account.organizationId,
88
+ 'paid',
89
+ )
90
+
91
+ const now = new Date()
92
+ const invoice = em.create(SalesInvoice, {
93
+ organizationId: account.organizationId,
94
+ tenantId: account.tenantId,
95
+ invoiceNumber: generated.number,
96
+ statusEntryId: paidStatusEntryId,
97
+ status: 'paid',
98
+ issueDate: now,
99
+ currencyCode: account.currencyCode,
100
+ subtotalNetAmount: net,
101
+ subtotalGrossAmount: gross,
102
+ discountTotalAmount: '0',
103
+ taxTotalAmount: taxAmount,
104
+ grandTotalNetAmount: net,
105
+ grandTotalGrossAmount: gross,
106
+ paidTotalAmount: gross,
107
+ outstandingAmount: '0',
108
+ metadata: {
109
+ kind: 'prepaid_topup',
110
+ bill_account_id: account.id,
111
+ topup_id: params.topupId,
112
+ payment_id: params.paymentId,
113
+ },
114
+ createdAt: now,
115
+ updatedAt: now,
116
+ })
117
+ em.persist(invoice)
118
+
119
+ const line = em.create(SalesInvoiceLine, {
120
+ invoice,
121
+ organizationId: account.organizationId,
122
+ tenantId: account.tenantId,
123
+ lineNumber: 1,
124
+ kind: 'service',
125
+ description: 'Account top-up',
126
+ quantity: '1',
127
+ normalizedQuantity: '1',
128
+ currencyCode: account.currencyCode,
129
+ unitPriceNet: net,
130
+ unitPriceGross: gross,
131
+ discountAmount: '0',
132
+ discountPercent: '0',
133
+ taxRate: fmt4(effectiveRate),
134
+ taxAmount,
135
+ totalNetAmount: net,
136
+ totalGrossAmount: gross,
137
+ metadata: {
138
+ kind: 'prepaid_topup',
139
+ topup_id: params.topupId,
140
+ },
141
+ })
142
+ em.persist(line)
143
+ await em.flush()
144
+
145
+ return {
146
+ invoiceId: invoice.id,
147
+ invoiceNumber: generated.number,
148
+ netAmount: net,
149
+ taxAmount,
150
+ grossAmount: gross,
151
+ taxRate: effectiveRate,
152
+ }
153
+ }
@@ -0,0 +1,31 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { BillingTopup } from '../data/entities'
3
+ import type { TopupStatus } from '../data/validators'
4
+
5
+ /**
6
+ * Transition a registered top-up to a terminal non-captured status
7
+ * (failed / cancelled / expired) in response to the sibling gateway events.
8
+ *
9
+ * Finding a BillingTopup by `payment_id` IS the guard: a non-top-up payment
10
+ * (e.g. a postpaid invoice, whose paymentId is the invoice id) matches no
11
+ * BillingTopup, so this is a silent no-op for everyone else. A captured
12
+ * top-up is never downgraded.
13
+ */
14
+ export async function transitionTopupByPaymentId(
15
+ em: EntityManager,
16
+ scope: { paymentId: string; tenantId: string; organizationId: string },
17
+ toStatus: Extract<TopupStatus, 'failed' | 'cancelled' | 'expired'>,
18
+ ): Promise<BillingTopup | null> {
19
+ const topup = await em.findOne(BillingTopup, {
20
+ paymentId: scope.paymentId,
21
+ tenantId: scope.tenantId,
22
+ organizationId: scope.organizationId,
23
+ deletedAt: null,
24
+ })
25
+ if (!topup) return null
26
+ if (topup.status === 'captured' || topup.status === toStatus) return topup
27
+ topup.status = toStatus
28
+ topup.updatedAt = new Date()
29
+ await em.flush()
30
+ return topup
31
+ }
@@ -0,0 +1,131 @@
1
+ import { Migration } from '@mikro-orm/migrations'
2
+
3
+ /**
4
+ * Prepaid balance billing schema (SPEC-002).
5
+ *
6
+ * Additive only — extends `billing_accounts` + `billing_run_outcomes` and
7
+ * adds the four new prepaid tables. Postpaid accounts are unaffected
8
+ * (`billing_mode` defaults to 'postpaid', `credit_limit` to 0).
9
+ *
10
+ * Hand-written to match `data/entities.ts` 1:1 (index/constraint names mirror
11
+ * the decorator names so a later `yarn mercato db generate` diffs clean). The
12
+ * statements here are pure `create table` / `alter table … add column` —
13
+ * never destructive to existing billing data.
14
+ */
15
+ export class Migration20260619000000_billing_prepaid extends Migration {
16
+ async up(): Promise<void> {
17
+ // ─── billing_accounts: billing_mode + credit_limit ───────
18
+ this.addSql(
19
+ `alter table "billing_accounts" add column "billing_mode" text not null default 'postpaid';`,
20
+ )
21
+ this.addSql(
22
+ `alter table "billing_accounts" add column "credit_limit" numeric(18, 4) not null default 0;`,
23
+ )
24
+
25
+ // ─── billing_run_outcomes: statement_id (prepaid analogue) ─
26
+ this.addSql(`alter table "billing_run_outcomes" add column "statement_id" uuid null;`)
27
+
28
+ // ─── billing_account_balances ────────────────────────────
29
+ this.addSql(`create table "billing_account_balances" (
30
+ "id" uuid not null default gen_random_uuid(),
31
+ "organization_id" uuid not null,
32
+ "tenant_id" uuid not null,
33
+ "bill_account_id" uuid not null,
34
+ "currency_code" text not null,
35
+ "balance" numeric(18, 4) not null default 0,
36
+ "low_balance_threshold" numeric(18, 4) null,
37
+ "last_movement_at" timestamptz null,
38
+ "created_at" timestamptz not null,
39
+ "updated_at" timestamptz not null,
40
+ constraint "billing_account_balances_pkey" primary key ("id")
41
+ );`)
42
+ this.addSql(`alter table "billing_account_balances" add constraint "billing_account_balances_account_unique" unique ("tenant_id", "bill_account_id");`)
43
+ this.addSql(`create index "billing_account_balances_balance_idx" on "billing_account_balances" ("organization_id", "tenant_id", "balance");`)
44
+
45
+ // ─── billing_account_transactions ────────────────────────
46
+ this.addSql(`create table "billing_account_transactions" (
47
+ "id" uuid not null default gen_random_uuid(),
48
+ "organization_id" uuid not null,
49
+ "tenant_id" uuid not null,
50
+ "bill_account_id" uuid not null,
51
+ "type" text not null,
52
+ "amount" numeric(18, 4) not null,
53
+ "currency_code" text not null,
54
+ "balance_after" numeric(18, 4) not null,
55
+ "usage_id" uuid null,
56
+ "billing_item_id" uuid null,
57
+ "topup_id" uuid null,
58
+ "statement_id" uuid null,
59
+ "description" text not null,
60
+ "source_ref" text null,
61
+ "metadata" jsonb null,
62
+ "user_id" uuid null,
63
+ "created_at" timestamptz not null,
64
+ "deleted_at" timestamptz null,
65
+ constraint "billing_account_transactions_pkey" primary key ("id")
66
+ );`)
67
+ this.addSql(`alter table "billing_account_transactions" add constraint "billing_account_transactions_source_ref_unique" unique ("tenant_id", "bill_account_id", "source_ref");`)
68
+ this.addSql(`create index "billing_account_transactions_history_idx" on "billing_account_transactions" ("organization_id", "tenant_id", "bill_account_id", "created_at");`)
69
+ this.addSql(`create index "billing_account_transactions_account_type_idx" on "billing_account_transactions" ("bill_account_id", "type", "created_at");`)
70
+
71
+ // ─── billing_topups ──────────────────────────────────────
72
+ this.addSql(`create table "billing_topups" (
73
+ "id" uuid not null default gen_random_uuid(),
74
+ "organization_id" uuid not null,
75
+ "tenant_id" uuid not null,
76
+ "bill_account_id" uuid not null,
77
+ "status" text not null,
78
+ "amount" numeric(18, 4) not null,
79
+ "currency_code" text not null,
80
+ "provider_key" text not null,
81
+ "payment_id" uuid not null,
82
+ "gateway_transaction_id" uuid null,
83
+ "transaction_id" uuid null,
84
+ "receipt_invoice_id" uuid null,
85
+ "source_ref" text null,
86
+ "metadata" jsonb null,
87
+ "created_at" timestamptz not null,
88
+ "updated_at" timestamptz not null,
89
+ "deleted_at" timestamptz null,
90
+ constraint "billing_topups_pkey" primary key ("id")
91
+ );`)
92
+ this.addSql(`alter table "billing_topups" add constraint "billing_topups_payment_id_unique" unique ("tenant_id", "payment_id");`)
93
+ this.addSql(`create index "billing_topups_account_history_idx" on "billing_topups" ("organization_id", "tenant_id", "bill_account_id", "created_at");`)
94
+ this.addSql(`create index "billing_topups_status_idx" on "billing_topups" ("tenant_id", "status");`)
95
+
96
+ // ─── billing_statements ──────────────────────────────────
97
+ this.addSql(`create table "billing_statements" (
98
+ "id" uuid not null default gen_random_uuid(),
99
+ "organization_id" uuid not null,
100
+ "tenant_id" uuid not null,
101
+ "bill_account_id" uuid not null,
102
+ "bill_run_id" uuid null,
103
+ "period_start" date not null,
104
+ "period_end" date not null,
105
+ "currency_code" text not null,
106
+ "opening_balance" numeric(18, 4) not null,
107
+ "total_topups" numeric(18, 4) not null,
108
+ "total_usage" numeric(18, 4) not null,
109
+ "total_recurring" numeric(18, 4) not null,
110
+ "total_adjustments" numeric(18, 4) not null default 0,
111
+ "closing_balance" numeric(18, 4) not null,
112
+ "debit_breakdown" jsonb not null,
113
+ "status" text not null,
114
+ "created_at" timestamptz not null,
115
+ "updated_at" timestamptz not null,
116
+ constraint "billing_statements_pkey" primary key ("id")
117
+ );`)
118
+ this.addSql(`alter table "billing_statements" add constraint "billing_statements_period_unique" unique ("tenant_id", "bill_account_id", "period_start", "period_end");`)
119
+ this.addSql(`create index "billing_statements_account_period_idx" on "billing_statements" ("organization_id", "tenant_id", "bill_account_id", "period_end");`)
120
+ }
121
+
122
+ async down(): Promise<void> {
123
+ this.addSql(`drop table if exists "billing_statements" cascade;`)
124
+ this.addSql(`drop table if exists "billing_topups" cascade;`)
125
+ this.addSql(`drop table if exists "billing_account_transactions" cascade;`)
126
+ this.addSql(`drop table if exists "billing_account_balances" cascade;`)
127
+ this.addSql(`alter table "billing_run_outcomes" drop column if exists "statement_id";`)
128
+ this.addSql(`alter table "billing_accounts" drop column if exists "credit_limit";`)
129
+ this.addSql(`alter table "billing_accounts" drop column if exists "billing_mode";`)
130
+ }
131
+ }
@@ -200,6 +200,45 @@ async function seedBillingConfigs(em: EntityManager): Promise<void> {
200
200
  name: 'invoice_number.reset_cycle',
201
201
  value: 'yearly',
202
202
  })
203
+
204
+ // ─── Prepaid balance billing (SPEC-002) ──────────────────────
205
+ // Fallback low-balance threshold when a prepaid account leaves its own
206
+ // `low_balance_threshold` null. 0 = off (status never flips to `low`
207
+ // until an operator sets a per-account threshold).
208
+ await ensureModuleConfig(em, {
209
+ moduleId: 'billing',
210
+ name: 'prepaid.low_balance_threshold_default',
211
+ value: 0,
212
+ })
213
+ // Default gateway provider key for top-up sessions.
214
+ await ensureModuleConfig(em, {
215
+ moduleId: 'billing',
216
+ name: 'prepaid.topup_provider',
217
+ value: 'stripe',
218
+ })
219
+ // Master switch for period statements.
220
+ await ensureModuleConfig(em, {
221
+ moduleId: 'billing',
222
+ name: 'prepaid.statement_enabled',
223
+ value: true,
224
+ })
225
+ // VAT rate (percent) applied to top-up receipts. The top-up `amount` is
226
+ // treated as GROSS (what the customer pays = what the balance is credited);
227
+ // the core tax service computes net + VAT from this rate. 0 = no VAT until
228
+ // an operator sets the jurisdiction's rate. (Deviation from xd-249, which
229
+ // never resolves a rate — see CHANGELOG.)
230
+ await ensureModuleConfig(em, {
231
+ moduleId: 'billing',
232
+ name: 'prepaid.topup_tax_rate',
233
+ value: 0,
234
+ })
235
+ // Fallback buffer for the `near_limit` credit status. 0 = warn only at the
236
+ // limit itself.
237
+ await ensureModuleConfig(em, {
238
+ moduleId: 'billing',
239
+ name: 'credit.near_limit_buffer_default',
240
+ value: 0,
241
+ })
203
242
  }
204
243
 
205
244
  // Derived from `acl.ts` so a new feature added there automatically lands
@@ -224,6 +263,12 @@ export const setup: ModuleSetupConfig = {
224
263
  'billing.run.view',
225
264
  'billing.run.dry_run',
226
265
  'billing.invoice.view',
266
+ // Prepaid read-only (SPEC-002): balances, transactions, top-ups,
267
+ // statements, credit status — no adjust / no top-up initiation.
268
+ 'billing.balance.view',
269
+ 'billing.credit.view',
270
+ 'billing.topup.view',
271
+ 'billing.statement.view',
227
272
  ],
228
273
 
229
274
  // Narrow scope for external usage-ingestion API keys.
@@ -0,0 +1,31 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
3
+ import { transitionTopupByPaymentId } from '../lib/topupTransition'
4
+
5
+ /**
6
+ * Subscriber: `payment_gateways.payment.cancelled` → BillingTopup → cancelled.
7
+ * No-op for any non-top-up payment. No balance movement.
8
+ */
9
+
10
+ export const metadata = {
11
+ event: 'payment_gateways.payment.cancelled',
12
+ persistent: true,
13
+ id: 'billing-prepaid-topup-cancelled',
14
+ }
15
+
16
+ type Payload = { paymentId?: string; tenantId?: string; organizationId?: string }
17
+
18
+ export default async function handle(payload: Payload): Promise<void> {
19
+ if (!payload?.paymentId || !payload.tenantId || !payload.organizationId) return
20
+ const container = await createRequestContainer()
21
+ const em = (container.resolve('em') as EntityManager).fork()
22
+ await transitionTopupByPaymentId(
23
+ em,
24
+ {
25
+ paymentId: payload.paymentId,
26
+ tenantId: payload.tenantId,
27
+ organizationId: payload.organizationId,
28
+ },
29
+ 'cancelled',
30
+ )
31
+ }
@@ -0,0 +1,120 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
3
+ import type { SalesDocumentNumberGenerator } from '@open-mercato/core/modules/sales/services/salesDocumentNumberGenerator'
4
+ import type { TaxCalculationService } from '@open-mercato/core/modules/sales/services/taxCalculationService'
5
+ import { BillingAccount, BillingTopup } from '../data/entities'
6
+ import { emitBillingEvent } from '../events'
7
+ import { captureTopup } from '../lib/topupCapture'
8
+ import { getTopupTaxRate } from '../lib/prepaidConfig'
9
+
10
+ /**
11
+ * Subscriber: `payment_gateways.payment.captured` → prepaid top-up captured.
12
+ *
13
+ * Coexists with `payment-captured-to-paid.ts`: that subscriber matches
14
+ * `paymentId == invoiceId` (postpaid), while a top-up's `paymentId` is a
15
+ * minted UUID that only ever matches a `BillingTopup`. Finding the
16
+ * BillingTopup IS the guard — for any non-top-up payment this is a silent
17
+ * no-op.
18
+ *
19
+ * Idempotent: a re-fired capture on an already-captured top-up is a no-op
20
+ * (the gateway delivers webhooks at-least-once).
21
+ */
22
+
23
+ export const metadata = {
24
+ event: 'payment_gateways.payment.captured',
25
+ persistent: true,
26
+ id: 'billing-prepaid-topup-captured',
27
+ }
28
+
29
+ type CapturedPayload = {
30
+ paymentId?: string
31
+ transactionId?: string
32
+ organizationId?: string
33
+ tenantId?: string
34
+ providerKey?: string
35
+ }
36
+
37
+ export default async function handle(payload: CapturedPayload): Promise<void> {
38
+ if (!payload?.paymentId || !payload.tenantId || !payload.organizationId) {
39
+ return
40
+ }
41
+
42
+ const container = await createRequestContainer()
43
+ const em = (container.resolve('em') as EntityManager).fork()
44
+
45
+ const topup = await em.findOne(BillingTopup, {
46
+ paymentId: payload.paymentId,
47
+ tenantId: payload.tenantId,
48
+ organizationId: payload.organizationId,
49
+ deletedAt: null,
50
+ })
51
+ if (!topup) {
52
+ // Not a prepaid top-up — other subscribers own this captured payment.
53
+ return
54
+ }
55
+ if (topup.status === 'captured') {
56
+ return // idempotent re-fire
57
+ }
58
+
59
+ const account = await em.findOne(BillingAccount, {
60
+ id: topup.billAccountId,
61
+ tenantId: payload.tenantId,
62
+ organizationId: payload.organizationId,
63
+ deletedAt: null,
64
+ })
65
+ if (!account) {
66
+ return
67
+ }
68
+
69
+ const taxRate = await getTopupTaxRate(em)
70
+ const numberGenerator = container.resolve(
71
+ 'salesDocumentNumberGenerator',
72
+ ) as SalesDocumentNumberGenerator
73
+ const taxService = container.resolve('taxCalculationService') as TaxCalculationService
74
+
75
+ const result = await captureTopup(
76
+ em,
77
+ { numberGenerator, taxService },
78
+ {
79
+ topup,
80
+ account: {
81
+ id: account.id,
82
+ organizationId: account.organizationId,
83
+ tenantId: account.tenantId,
84
+ currencyCode: account.currencyCode,
85
+ },
86
+ taxRate,
87
+ gatewayTransactionId: payload.transactionId ?? null,
88
+ },
89
+ )
90
+
91
+ if (result.status !== 'captured') return
92
+
93
+ // Side effects fire AFTER the capture transaction commits.
94
+ await emitBillingEvent('billing.balance.topped_up', {
95
+ billAccountId: account.id,
96
+ tenantId: account.tenantId,
97
+ organizationId: account.organizationId,
98
+ topupId: topup.id,
99
+ amount: topup.amount,
100
+ currencyCode: account.currencyCode,
101
+ balance: result.balanceAfter ?? null,
102
+ transactionId: result.transactionId ?? null,
103
+ receiptInvoiceId: result.receiptInvoiceId ?? null,
104
+ })
105
+
106
+ // Reuse the postpaid receipt path so the existing mailer / accounting bridge
107
+ // sends the top-up VAT receipt.
108
+ if (result.receiptInvoiceId) {
109
+ await emitBillingEvent('billing.invoice.posted', {
110
+ invoiceId: result.receiptInvoiceId,
111
+ invoiceNumber: result.invoiceNumber ?? null,
112
+ tenantId: account.tenantId,
113
+ organizationId: account.organizationId,
114
+ grandTotalGross: result.grossAmount ?? null,
115
+ currencyCode: account.currencyCode,
116
+ billAccountId: account.id,
117
+ kind: 'prepaid_topup',
118
+ })
119
+ }
120
+ }
@@ -0,0 +1,31 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
3
+ import { transitionTopupByPaymentId } from '../lib/topupTransition'
4
+
5
+ /**
6
+ * Subscriber: `payment_gateways.session.expired` → BillingTopup → expired.
7
+ * No-op for any non-top-up session. No balance movement.
8
+ */
9
+
10
+ export const metadata = {
11
+ event: 'payment_gateways.session.expired',
12
+ persistent: true,
13
+ id: 'billing-prepaid-topup-expired',
14
+ }
15
+
16
+ type Payload = { paymentId?: string; tenantId?: string; organizationId?: string }
17
+
18
+ export default async function handle(payload: Payload): Promise<void> {
19
+ if (!payload?.paymentId || !payload.tenantId || !payload.organizationId) return
20
+ const container = await createRequestContainer()
21
+ const em = (container.resolve('em') as EntityManager).fork()
22
+ await transitionTopupByPaymentId(
23
+ em,
24
+ {
25
+ paymentId: payload.paymentId,
26
+ tenantId: payload.tenantId,
27
+ organizationId: payload.organizationId,
28
+ },
29
+ 'expired',
30
+ )
31
+ }
@@ -0,0 +1,32 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
3
+ import { transitionTopupByPaymentId } from '../lib/topupTransition'
4
+
5
+ /**
6
+ * Subscriber: `payment_gateways.payment.failed` → BillingTopup → failed.
7
+ * No-op for any non-top-up payment (no matching BillingTopup). No balance
8
+ * movement.
9
+ */
10
+
11
+ export const metadata = {
12
+ event: 'payment_gateways.payment.failed',
13
+ persistent: true,
14
+ id: 'billing-prepaid-topup-failed',
15
+ }
16
+
17
+ type Payload = { paymentId?: string; tenantId?: string; organizationId?: string }
18
+
19
+ export default async function handle(payload: Payload): Promise<void> {
20
+ if (!payload?.paymentId || !payload.tenantId || !payload.organizationId) return
21
+ const container = await createRequestContainer()
22
+ const em = (container.resolve('em') as EntityManager).fork()
23
+ await transitionTopupByPaymentId(
24
+ em,
25
+ {
26
+ paymentId: payload.paymentId,
27
+ tenantId: payload.tenantId,
28
+ organizationId: payload.organizationId,
29
+ },
30
+ 'failed',
31
+ )
32
+ }
@@ -0,0 +1,68 @@
1
+ import type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import { BillingAccountBalance } from '../data/entities'
4
+ import { reconcileAccountBalance } from '../lib/balanceLedger'
5
+
6
+ export const metadata: WorkerMeta = {
7
+ queue: 'billing-reconcile-prepaid-balances',
8
+ id: 'billing:reconcile-prepaid-balances',
9
+ concurrency: 1,
10
+ }
11
+
12
+ type HandlerContext = JobContext & {
13
+ resolve: <T = unknown>(name: string) => T
14
+ }
15
+
16
+ /**
17
+ * Reconciliation guard (SPEC-002 P5, optional).
18
+ *
19
+ * Periodically asserts the ledger invariant per prepaid account:
20
+ * `cachedBalance == SUM(transactions.amount) == latest.balance_after`.
21
+ *
22
+ * The atomic `UPDATE … RETURNING` + paired transaction insert keeps this true
23
+ * by construction, so a healthy system always reconciles. This worker is a
24
+ * cheap backstop against any future non-atomic write slipping in — it logs a
25
+ * warning per drifted account (the operator-facing alert) and never mutates
26
+ * the balance (a fix is a deliberate operator action, not an automatic one).
27
+ */
28
+ const SCAN_LIMIT = 10_000
29
+
30
+ export default async function handle(_job: QueuedJob, ctx: HandlerContext): Promise<void> {
31
+ const em = ctx.resolve<EntityManager>('em')
32
+ const rows = await em.find(
33
+ BillingAccountBalance,
34
+ {} as never,
35
+ { limit: SCAN_LIMIT, orderBy: { updatedAt: 'DESC' } },
36
+ )
37
+
38
+ let checked = 0
39
+ let drifted = 0
40
+ for (const row of rows) {
41
+ const result = await reconcileAccountBalance(em.fork(), {
42
+ tenantId: row.tenantId,
43
+ organizationId: row.organizationId,
44
+ billAccountId: row.billAccountId,
45
+ })
46
+ checked += 1
47
+ if (!result.reconciled) {
48
+ drifted += 1
49
+ console.warn('[billing/reconcile] balance drift detected', {
50
+ billAccountId: row.billAccountId,
51
+ tenantId: row.tenantId,
52
+ organizationId: row.organizationId,
53
+ cachedBalance: result.cachedBalance,
54
+ sumOfAmounts: result.sumOfAmounts,
55
+ latestBalanceAfter: result.latestBalanceAfter,
56
+ })
57
+ }
58
+ }
59
+
60
+ if (rows.length === SCAN_LIMIT) {
61
+ console.warn(
62
+ `[billing/reconcile] scan hit the ${SCAN_LIMIT}-row cap — some accounts were not checked this run`,
63
+ )
64
+ }
65
+ if (drifted > 0) {
66
+ console.warn(`[billing/reconcile] ${drifted}/${checked} prepaid accounts drifted`)
67
+ }
68
+ }