@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,258 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import {
3
+ BillingAccountBalance,
4
+ BillingAccountTransaction,
5
+ } from '../data/entities'
6
+ import type { TransactionType } from '../data/validators'
7
+ import { fromUnits, subMoney, toUnits } from './money'
8
+
9
+ /**
10
+ * Prepaid balance ledger (SPEC-002).
11
+ *
12
+ * Two append-only primitives back every balance change:
13
+ * - `BillingAccountBalance` — the O(1) running-balance cache, one row per
14
+ * prepaid account; the row the atomic `UPDATE … RETURNING` locks.
15
+ * - `BillingAccountTransaction` — the source of truth; `balance == SUM(amount)`.
16
+ *
17
+ * Concurrency correctness: the single-statement `UPDATE … RETURNING`
18
+ * serializes via the Postgres row lock on the balance row, so N concurrent
19
+ * uploads apply N debits with no lost update and each gets a distinct
20
+ * `balance_after`. The transaction insert in the SAME DB transaction carries
21
+ * that exact snapshot.
22
+ *
23
+ * **All functions here MUST be called inside an `em.transactional(...)`** so
24
+ * the balance move and the transaction insert commit (or roll back) together.
25
+ */
26
+
27
+ // Sentinel `rated_in_bill_run_id` stamped on a usage record the moment it is
28
+ // rated in real time, so a postpaid-style Bill Run never re-rates it. It is a
29
+ // fixed, recognizable UUID (not a real run id) — its presence simply means
30
+ // "already rated at ingest, paired with a BillingAccountTransaction".
31
+ export const PREPAID_USAGE_SENTINEL_RUN_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'
32
+
33
+ export type BalanceRowInit = {
34
+ organizationId: string
35
+ tenantId: string
36
+ billAccountId: string
37
+ currencyCode: string
38
+ lowBalanceThreshold?: string | null
39
+ }
40
+
41
+ /**
42
+ * Find-or-create the 1:1 balance row for an account. Idempotent — safe to
43
+ * call on every prepaid account create. Uses the ORM (this runs at account
44
+ * create, not on the hot consume path).
45
+ */
46
+ export async function ensureBalanceRow(
47
+ em: EntityManager,
48
+ init: BalanceRowInit,
49
+ ): Promise<BillingAccountBalance> {
50
+ const existing = await em.findOne(BillingAccountBalance, {
51
+ tenantId: init.tenantId,
52
+ billAccountId: init.billAccountId,
53
+ })
54
+ if (existing) {
55
+ // Allow create-time threshold to land even if a prior (defensive) row
56
+ // already exists with no threshold set.
57
+ if (
58
+ init.lowBalanceThreshold !== undefined &&
59
+ init.lowBalanceThreshold !== null &&
60
+ (existing.lowBalanceThreshold === null || existing.lowBalanceThreshold === undefined)
61
+ ) {
62
+ existing.lowBalanceThreshold = init.lowBalanceThreshold
63
+ existing.updatedAt = new Date()
64
+ }
65
+ return existing
66
+ }
67
+ const now = new Date()
68
+ const row = em.create(BillingAccountBalance, {
69
+ organizationId: init.organizationId,
70
+ tenantId: init.tenantId,
71
+ billAccountId: init.billAccountId,
72
+ currencyCode: init.currencyCode,
73
+ balance: '0',
74
+ lowBalanceThreshold: init.lowBalanceThreshold ?? null,
75
+ lastMovementAt: null,
76
+ createdAt: now,
77
+ updatedAt: now,
78
+ })
79
+ em.persist(row)
80
+ await em.flush()
81
+ return row
82
+ }
83
+
84
+ export type ApplyMovementParams = {
85
+ organizationId: string
86
+ tenantId: string
87
+ billAccountId: string
88
+ currencyCode: string
89
+ /** Transaction type. */
90
+ type: TransactionType
91
+ /** SIGNED amount: > 0 credit, < 0 debit. */
92
+ amount: string
93
+ description: string
94
+ usageId?: string | null
95
+ billingItemId?: string | null
96
+ topupId?: string | null
97
+ statementId?: string | null
98
+ /** Idempotency key — when set, a duplicate is a no-op (returns the prior row). */
99
+ sourceRef?: string | null
100
+ metadata?: Record<string, unknown> | null
101
+ userId?: string | null
102
+ }
103
+
104
+ export type ApplyMovementResult = {
105
+ /** Running balance BEFORE this movement (4dp string). */
106
+ balanceBefore: string
107
+ /** Running balance AFTER this movement — authoritative (4dp string). */
108
+ balanceAfter: string
109
+ transaction: BillingAccountTransaction
110
+ /** True when `sourceRef` already existed — no balance was moved. */
111
+ deduplicated: boolean
112
+ }
113
+
114
+ /**
115
+ * Atomically move the balance and append the paired transaction.
116
+ *
117
+ * Idempotent on `sourceRef`: if a transaction with the same
118
+ * `(tenant_id, bill_account_id, source_ref)` already exists, NO balance move
119
+ * happens and the existing row is returned (`deduplicated: true`). The unique
120
+ * constraint is the correctness backstop if a race slips past the pre-check —
121
+ * the duplicate insert fails and rolls back the whole (caller's) transaction,
122
+ * so the balance move is undone too. Never double-moves.
123
+ */
124
+ export async function applyBalanceMovement(
125
+ em: EntityManager,
126
+ params: ApplyMovementParams,
127
+ ): Promise<ApplyMovementResult> {
128
+ if (params.sourceRef) {
129
+ const existing = await em.findOne(BillingAccountTransaction, {
130
+ tenantId: params.tenantId,
131
+ billAccountId: params.billAccountId,
132
+ sourceRef: params.sourceRef,
133
+ deletedAt: null,
134
+ })
135
+ if (existing) {
136
+ return {
137
+ balanceBefore: subMoney(existing.balanceAfter, existing.amount),
138
+ balanceAfter: existing.balanceAfter,
139
+ transaction: existing,
140
+ deduplicated: true,
141
+ }
142
+ }
143
+ }
144
+
145
+ const now = new Date()
146
+ // Self-healing: ensure the balance row exists (race-safe via the unique
147
+ // constraint), then the atomic single-statement move. `amount` is signed
148
+ // so one statement covers both credit and debit.
149
+ await em.execute(
150
+ `INSERT INTO billing_account_balances
151
+ (id, organization_id, tenant_id, bill_account_id, currency_code, balance, created_at, updated_at)
152
+ VALUES (gen_random_uuid(), ?, ?, ?, ?, 0, ?, ?)
153
+ ON CONFLICT (tenant_id, bill_account_id) DO NOTHING`,
154
+ [
155
+ params.organizationId,
156
+ params.tenantId,
157
+ params.billAccountId,
158
+ params.currencyCode,
159
+ now,
160
+ now,
161
+ ],
162
+ )
163
+ const rows = (await em.execute(
164
+ `UPDATE billing_account_balances
165
+ SET balance = balance + ?, last_movement_at = ?, updated_at = ?
166
+ WHERE tenant_id = ? AND bill_account_id = ?
167
+ RETURNING balance`,
168
+ [params.amount, now, now, params.tenantId, params.billAccountId],
169
+ )) as unknown as Array<{ balance: string }>
170
+
171
+ if (!rows || rows.length === 0) {
172
+ // Should be unreachable — the upsert above guarantees a row exists.
173
+ throw new Error(
174
+ `balanceLedger: balance row missing for account ${params.billAccountId} after upsert`,
175
+ )
176
+ }
177
+ // Normalize whatever the driver hands back (string or number) to 4dp.
178
+ const balanceAfter = fromUnits(toUnits(rows[0].balance))
179
+ const balanceBefore = subMoney(balanceAfter, params.amount)
180
+
181
+ const transaction = em.create(BillingAccountTransaction, {
182
+ organizationId: params.organizationId,
183
+ tenantId: params.tenantId,
184
+ billAccountId: params.billAccountId,
185
+ type: params.type,
186
+ amount: params.amount,
187
+ currencyCode: params.currencyCode,
188
+ balanceAfter,
189
+ usageId: params.usageId ?? null,
190
+ billingItemId: params.billingItemId ?? null,
191
+ topupId: params.topupId ?? null,
192
+ statementId: params.statementId ?? null,
193
+ description: params.description,
194
+ sourceRef: params.sourceRef ?? null,
195
+ metadata: params.metadata ?? null,
196
+ userId: params.userId ?? null,
197
+ createdAt: now,
198
+ })
199
+ em.persist(transaction)
200
+ await em.flush()
201
+
202
+ return { balanceBefore, balanceAfter, transaction, deduplicated: false }
203
+ }
204
+
205
+ export type ReconcileResult = {
206
+ /** The denormalized running balance (cache). */
207
+ cachedBalance: string
208
+ /** SUM(amount) over all (non-deleted) transactions. */
209
+ sumOfAmounts: string
210
+ /** The most recent transaction's balance_after, or '0.0000' if none. */
211
+ latestBalanceAfter: string
212
+ /** True when all three agree. */
213
+ reconciled: boolean
214
+ }
215
+
216
+ /**
217
+ * Assert the ledger invariant for one account:
218
+ * `cachedBalance == SUM(amount) == latest.balance_after`.
219
+ *
220
+ * The optional reconciliation worker (P5) runs this per account and alerts on
221
+ * drift — a cheap guard against any future non-atomic write slipping in.
222
+ */
223
+ export async function reconcileAccountBalance(
224
+ em: EntityManager,
225
+ scope: { tenantId: string; organizationId: string; billAccountId: string },
226
+ ): Promise<ReconcileResult> {
227
+ const balanceRow = await em.findOne(BillingAccountBalance, {
228
+ tenantId: scope.tenantId,
229
+ billAccountId: scope.billAccountId,
230
+ })
231
+ const cachedBalance = balanceRow ? fromUnits(toUnits(balanceRow.balance)) : '0.0000'
232
+
233
+ const sumRows = (await em.execute(
234
+ `SELECT COALESCE(SUM(amount), 0) AS sum_amount
235
+ FROM billing_account_transactions
236
+ WHERE tenant_id = ? AND organization_id = ? AND bill_account_id = ? AND deleted_at IS NULL`,
237
+ [scope.tenantId, scope.organizationId, scope.billAccountId],
238
+ )) as unknown as Array<{ sum_amount: string }>
239
+ const sumOfAmounts = fromUnits(toUnits(sumRows?.[0]?.sum_amount ?? 0))
240
+
241
+ const latest = await em.findOne(
242
+ BillingAccountTransaction,
243
+ {
244
+ tenantId: scope.tenantId,
245
+ organizationId: scope.organizationId,
246
+ billAccountId: scope.billAccountId,
247
+ deletedAt: null,
248
+ },
249
+ { orderBy: { createdAt: 'DESC' } },
250
+ )
251
+ const latestBalanceAfter = latest ? fromUnits(toUnits(latest.balanceAfter)) : '0.0000'
252
+
253
+ const reconciled =
254
+ toUnits(cachedBalance) === toUnits(sumOfAmounts) &&
255
+ (latest === null || toUnits(cachedBalance) === toUnits(latestBalanceAfter))
256
+
257
+ return { cachedBalance, sumOfAmounts, latestBalanceAfter, reconciled }
258
+ }
@@ -0,0 +1,78 @@
1
+ import type { BalanceStatus } from '../data/validators'
2
+ import { compareMoney, toUnits } from './money'
3
+
4
+ /**
5
+ * Prepaid funds status & threshold-crossing detection (SPEC-002).
6
+ *
7
+ * Usage is NEVER rejected — we only classify and report. Events fire ONLY on
8
+ * a downward crossing (compare `balance_before` vs `balance_after`), so a
9
+ * client sitting at `exhausted` and still pushing usage is not spammed.
10
+ */
11
+
12
+ /**
13
+ * Resolve the effective low-balance threshold:
14
+ * account row's own threshold → tenant config default → 0 (off).
15
+ */
16
+ export function resolveLowBalanceThreshold(
17
+ accountThreshold: string | null | undefined,
18
+ configDefault: string | number | null | undefined,
19
+ ): string {
20
+ if (accountThreshold !== null && accountThreshold !== undefined) {
21
+ return accountThreshold
22
+ }
23
+ if (configDefault !== null && configDefault !== undefined) {
24
+ return String(configDefault)
25
+ }
26
+ return '0'
27
+ }
28
+
29
+ /**
30
+ * | status | condition |
31
+ * |------------|------------------------------------|
32
+ * | exhausted | balance <= 0 (incl. negative) |
33
+ * | low | 0 < balance <= threshold |
34
+ * | ok | balance > threshold |
35
+ */
36
+ export function computeBalanceStatus(
37
+ balance: string | number,
38
+ threshold: string | number,
39
+ ): BalanceStatus {
40
+ if (compareMoney(balance, 0) <= 0) return 'exhausted'
41
+ if (compareMoney(balance, threshold) <= 0) return 'low'
42
+ return 'ok'
43
+ }
44
+
45
+ export type BalanceCrossing = {
46
+ /** Crossed downward from > threshold into the (0, threshold] low band. */
47
+ lowCrossedDown: boolean
48
+ /** Crossed downward from > 0 to <= 0. */
49
+ exhaustedCrossedDown: boolean
50
+ }
51
+
52
+ /**
53
+ * Detect downward threshold crossings between two balances.
54
+ *
55
+ * A single large debit that jumps straight past the low band into exhausted
56
+ * fires ONLY `exhausted` (the customer is already negative — "you're low" is
57
+ * moot). `low` fires only when the balance lands in the (0, threshold] band.
58
+ * When `threshold <= 0` the low band is empty, so `low` never fires (the
59
+ * config default 0 = "off"); `exhausted` still works.
60
+ */
61
+ export function detectBalanceCrossing(
62
+ balanceBefore: string | number,
63
+ balanceAfter: string | number,
64
+ threshold: string | number,
65
+ ): BalanceCrossing {
66
+ const beforeUnits = toUnits(balanceBefore)
67
+ const afterUnits = toUnits(balanceAfter)
68
+ const thresholdUnits = toUnits(threshold)
69
+
70
+ const exhaustedCrossedDown = beforeUnits > 0 && afterUnits <= 0
71
+ const lowCrossedDown =
72
+ thresholdUnits > 0 &&
73
+ beforeUnits > thresholdUnits &&
74
+ afterUnits <= thresholdUnits &&
75
+ afterUnits > 0
76
+
77
+ return { lowCrossedDown, exhaustedCrossedDown }
78
+ }
@@ -20,6 +20,9 @@ import {
20
20
  } from './invoiceWriter'
21
21
  import { withTenantLock } from './tenantLock'
22
22
  import { processUsageForAccount } from './usageRunner'
23
+ import { processPrepaidAccount } from './prepaidPeriodClose'
24
+ import type { DomainEvent } from './balanceEvents'
25
+ import { emitBillingEvent, type BillingEventId } from '../events'
23
26
  import type { BillCycle } from '../data/validators'
24
27
 
25
28
  /**
@@ -73,15 +76,27 @@ export type RunBillRunResult = {
73
76
  outcomes: BillRunOutcome[]
74
77
  }
75
78
 
79
+ type RunOutput = RunBillRunResult & {
80
+ /** Prepaid statement / crossing events to emit AFTER the outer tx commits. */
81
+ pendingEvents: DomainEvent[]
82
+ }
83
+
76
84
  type AccountWarnings = {
77
85
  currency_mismatch_items?: string[]
78
86
  unmatched_usage_uoms?: string[]
79
87
  }
80
88
 
81
89
  type AccountProcessSuccess = {
82
- status: 'success' | 'success_with_warnings' | 'skipped_existing_draft'
83
- warnings: AccountWarnings | null
90
+ status:
91
+ | 'success'
92
+ | 'success_with_warnings'
93
+ | 'skipped_existing_draft'
94
+ | 'skipped_existing_statement'
95
+ warnings: AccountWarnings | Record<string, unknown> | null
84
96
  draftInvoiceId: string | null
97
+ // SPEC-002: prepaid accounts produce a statement instead of a draft invoice.
98
+ statementId?: string | null
99
+ statementEvents?: DomainEvent[]
85
100
  cyclesEmitted: number
86
101
  usageRecordsRated: number
87
102
  }
@@ -101,18 +116,25 @@ export async function runBillRun(
101
116
  container: AwilixContainer,
102
117
  params: RunBillRunParams,
103
118
  ): Promise<RunBillRunResult> {
104
- return withTenantLock(
119
+ const output = await withTenantLock(
105
120
  em,
106
121
  { tenantId: params.tenantId, lockName: 'billing-run' },
107
122
  async (tem) => executeRunWithinLock(tem, container, params),
108
123
  )
124
+ // Emit prepaid statement / crossing events ONLY after the outer transaction
125
+ // (held by withTenantLock) has committed — emitting inside the lock would let
126
+ // a consumer observe an uncommitted statement or a phantom event on rollback.
127
+ for (const ev of output.pendingEvents) {
128
+ await emitBillingEvent(ev.id as BillingEventId, ev.payload, { persistent: true })
129
+ }
130
+ return { billRun: output.billRun, outcomes: output.outcomes }
109
131
  }
110
132
 
111
133
  async function executeRunWithinLock(
112
134
  em: EntityManager,
113
135
  container: AwilixContainer,
114
136
  params: RunBillRunParams,
115
- ): Promise<RunBillRunResult> {
137
+ ): Promise<RunOutput> {
116
138
  const now = new Date()
117
139
  const billRun = em.create(BillRun, {
118
140
  organizationId: params.organizationId,
@@ -136,6 +158,7 @@ async function executeRunWithinLock(
136
158
 
137
159
  const accounts = await selectTargetAccounts(em, params)
138
160
  const outcomes: BillRunOutcome[] = []
161
+ const pendingEvents: DomainEvent[] = []
139
162
  let totalUsageRecordsRated = 0
140
163
 
141
164
  for (const account of accounts) {
@@ -155,6 +178,7 @@ async function executeRunWithinLock(
155
178
  ? null
156
179
  : (processResult.warnings as Record<string, unknown> | null),
157
180
  draftInvoiceId: processResult.status === 'failed' ? null : processResult.draftInvoiceId,
181
+ statementId: processResult.status === 'failed' ? null : (processResult.statementId ?? null),
158
182
  isActive: true,
159
183
  createdAt: new Date(),
160
184
  updatedAt: new Date(),
@@ -162,6 +186,12 @@ async function executeRunWithinLock(
162
186
  em.persist(outcome)
163
187
  await em.flush()
164
188
  outcomes.push(outcome)
189
+
190
+ // Collect statement / crossing events — runBillRun emits them AFTER the
191
+ // outer tx commits (see runBillRun), never from inside the lock.
192
+ if (processResult.status !== 'failed' && processResult.statementEvents) {
193
+ pendingEvents.push(...processResult.statementEvents)
194
+ }
165
195
  }
166
196
 
167
197
  billRun.status = computeFinalRunStatus(outcomes)
@@ -170,7 +200,7 @@ async function executeRunWithinLock(
170
200
  billRun.summary = buildRunSummary(outcomes, accounts.length, totalUsageRecordsRated)
171
201
  await em.flush()
172
202
 
173
- return { billRun, outcomes }
203
+ return { billRun, outcomes, pendingEvents }
174
204
  }
175
205
 
176
206
  async function selectTargetAccounts(
@@ -215,6 +245,30 @@ async function processAccount(
215
245
  account: BillingAccount,
216
246
  params: RunBillRunParams,
217
247
  ): Promise<AccountProcessResult> {
248
+ // SPEC-002: a prepaid account never gets a payable draft invoice. It still
249
+ // runs in the Bill Run, but charges recurring / one_time items to the
250
+ // balance and produces a non-fiscal consumption statement.
251
+ if (account.billingMode === 'prepaid') {
252
+ const result = await processPrepaidAccount(em, account, billRun, {
253
+ asOfDate: params.asOfDate,
254
+ mode: params.mode,
255
+ triggeredBy: params.triggeredBy,
256
+ catchUp: params.catchUp,
257
+ })
258
+ if (result.status === 'failed') {
259
+ return { status: 'failed', errorMessage: result.errorMessage }
260
+ }
261
+ return {
262
+ status: result.status,
263
+ warnings: result.warnings,
264
+ draftInvoiceId: null,
265
+ statementId: result.statementId,
266
+ statementEvents: result.statementEvents,
267
+ cyclesEmitted: result.cyclesEmitted,
268
+ usageRecordsRated: 0,
269
+ }
270
+ }
271
+
218
272
  const maxCycles =
219
273
  params.triggeredBy === 'schedule' || !params.catchUp
220
274
  ? SCHEDULED_CYCLE_LIMIT
@@ -408,6 +462,13 @@ function buildRunSummary(
408
462
  ).length,
409
463
  drafts_skipped_existing: outcomes.filter((o) => o.status === 'skipped_existing_draft')
410
464
  .length,
465
+ // SPEC-002 prepaid period close.
466
+ statements_created: outcomes.filter(
467
+ (o) => o.statementId !== null && o.status !== 'skipped_existing_statement',
468
+ ).length,
469
+ statements_skipped_existing: outcomes.filter(
470
+ (o) => o.status === 'skipped_existing_statement',
471
+ ).length,
411
472
  accounts_failed: outcomes.filter((o) => o.status === 'failed').length,
412
473
  accounts_with_warnings: outcomes.filter((o) => o.status === 'success_with_warnings')
413
474
  .length,
@@ -0,0 +1,153 @@
1
+ import type { BillingMode, CreditStatus } from '../data/validators'
2
+ import { compareMoney, fromUnits, subMoney, toUnits } from './money'
3
+
4
+ /**
5
+ * Credit-line status (both modes, SPEC-002). Billing **computes and reports**
6
+ * where an account stands against its `credit_limit` — it NEVER blocks
7
+ * (assumption #11). A consumer (CPQ order conversion, a dunning job, an
8
+ * operator) decides what to do with an over-limit signal.
9
+ *
10
+ * `credit_used` per mode:
11
+ * - prepaid: max(0, −balance) — the portion of the balance gone negative
12
+ * (drawing on the credit line).
13
+ * - postpaid: Σ outstanding (posted, not-yet-paid) invoice `outstanding`
14
+ * amounts from core/sales.
15
+ *
16
+ * `available_credit = credit_limit − credit_used` (unified across both modes;
17
+ * for prepaid this equals `balance + credit_limit` when balance < 0, else
18
+ * `credit_limit`).
19
+ */
20
+
21
+ /** Prepaid credit drawn = max(0, −balance). */
22
+ export function prepaidCreditUsed(balance: string | number): string {
23
+ if (compareMoney(balance, 0) >= 0) return '0.0000'
24
+ return fromUnits(-toUnits(balance))
25
+ }
26
+
27
+ export function computeCreditUsed(
28
+ mode: BillingMode,
29
+ opts: { balance?: string | number | null; outstanding?: string | number | null },
30
+ ): string {
31
+ if (mode === 'prepaid') {
32
+ return prepaidCreditUsed(opts.balance ?? '0')
33
+ }
34
+ // postpaid
35
+ return fromUnits(toUnits(opts.outstanding ?? '0'))
36
+ }
37
+
38
+ export function computeAvailableCredit(
39
+ creditLimit: string | number,
40
+ creditUsed: string | number,
41
+ ): string {
42
+ return subMoney(creditLimit, creditUsed)
43
+ }
44
+
45
+ /**
46
+ * | status | condition |
47
+ * |---------------|--------------------------------------------|
48
+ * | within_limit | available_credit > near_limit_buffer |
49
+ * | near_limit | 0 < available_credit <= near_limit_buffer |
50
+ * | over_limit | available_credit <= 0 (and credit is drawn)|
51
+ *
52
+ * `creditUsed` disambiguates the default "no credit line" case: with
53
+ * `credit_limit = 0` and nothing drawn (`credit_used = 0`) the available
54
+ * credit is exactly 0 — that is NOT over-limit (the account simply has no
55
+ * credit line), so it reports `within_limit`. Once any credit is actually
56
+ * drawn (prepaid balance negative, or postpaid outstanding > 0) the formula
57
+ * applies. When `creditUsed` is omitted, a draw is assumed (pure spec
58
+ * formula) — callers that have the figure should pass it.
59
+ */
60
+ export function computeCreditStatus(
61
+ availableCredit: string | number,
62
+ nearLimitBuffer: string | number,
63
+ creditUsed?: string | number,
64
+ ): CreditStatus {
65
+ const hasDraw = creditUsed === undefined ? true : compareMoney(creditUsed, 0) > 0
66
+ if (compareMoney(availableCredit, 0) <= 0) {
67
+ return hasDraw ? 'over_limit' : 'within_limit'
68
+ }
69
+ if (compareMoney(availableCredit, nearLimitBuffer) <= 0) return 'near_limit'
70
+ return 'within_limit'
71
+ }
72
+
73
+ export function resolveNearLimitBuffer(
74
+ configDefault: string | number | null | undefined,
75
+ ): string {
76
+ if (configDefault !== null && configDefault !== undefined) return String(configDefault)
77
+ return '0'
78
+ }
79
+
80
+ export type CreditSnapshot = {
81
+ mode: BillingMode
82
+ creditLimit: string
83
+ creditUsed: string
84
+ availableCredit: string
85
+ creditStatus: CreditStatus
86
+ }
87
+
88
+ /** Assemble the full credit snapshot reported on consume responses + the API. */
89
+ export function computeCreditSnapshot(params: {
90
+ mode: BillingMode
91
+ creditLimit: string | number
92
+ nearLimitBuffer: string | number
93
+ balance?: string | number | null
94
+ outstanding?: string | number | null
95
+ }): CreditSnapshot {
96
+ const creditLimit = fromUnits(toUnits(params.creditLimit))
97
+ const creditUsed = computeCreditUsed(params.mode, {
98
+ balance: params.balance,
99
+ outstanding: params.outstanding,
100
+ })
101
+ const availableCredit = computeAvailableCredit(creditLimit, creditUsed)
102
+ return {
103
+ mode: params.mode,
104
+ creditLimit,
105
+ creditUsed,
106
+ availableCredit,
107
+ creditStatus: computeCreditStatus(availableCredit, params.nearLimitBuffer, creditUsed),
108
+ }
109
+ }
110
+
111
+ export type CreditCrossing = {
112
+ /** credit_used crossed upward past credit_limit (available ≤ 0). */
113
+ overLimitCrossedUp: boolean
114
+ /** available_credit crossed downward into (0, buffer] (optional event). */
115
+ nearLimitCrossedUp: boolean
116
+ }
117
+
118
+ /**
119
+ * Detect upward crossings of `credit_used` (i.e. downward crossings of
120
+ * `available_credit`) for the `billing.credit.over_limit` /
121
+ * `billing.credit.near_limit` events. `over_limit` fires once when usage /
122
+ * recurring pushes credit_used from below the limit to at/above it.
123
+ *
124
+ * `credit_limit == 0` (no credit) means available is never > 0 once any
125
+ * credit is "used"; to avoid firing on every prepaid debit while at 0 balance
126
+ * with no credit line, the crossing requires a real limit (> 0).
127
+ */
128
+ export function detectCreditCrossing(params: {
129
+ creditLimit: string | number
130
+ nearLimitBuffer: string | number
131
+ usedBefore: string | number
132
+ usedAfter: string | number
133
+ }): CreditCrossing {
134
+ const limitUnits = toUnits(params.creditLimit)
135
+ if (limitUnits <= 0) {
136
+ return { overLimitCrossedUp: false, nearLimitCrossedUp: false }
137
+ }
138
+ const usedBefore = toUnits(params.usedBefore)
139
+ const usedAfter = toUnits(params.usedAfter)
140
+ const bufferUnits = toUnits(params.nearLimitBuffer)
141
+
142
+ const overLimitCrossedUp = usedBefore < limitUnits && usedAfter >= limitUnits
143
+
144
+ const availBefore = limitUnits - usedBefore
145
+ const availAfter = limitUnits - usedAfter
146
+ const nearLimitCrossedUp =
147
+ bufferUnits > 0 &&
148
+ availBefore > bufferUnits &&
149
+ availAfter <= bufferUnits &&
150
+ availAfter > 0
151
+
152
+ return { overLimitCrossedUp, nearLimitCrossedUp }
153
+ }