@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,453 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import {
3
+ BillingAccount,
4
+ BillingAccountBalance,
5
+ BillingAccountTransaction,
6
+ BillingItem,
7
+ BillingStatement,
8
+ BillRun,
9
+ } from '../data/entities'
10
+ import type { BillCycle } from '../data/validators'
11
+ import { advanceNextBillDate, addDays, deriveBillPeriod, isCycleDue, type BillPeriod } from './billPeriod'
12
+ import { applyBalanceMovement } from './balanceLedger'
13
+ import { resolveLowBalanceThreshold } from './balanceStatus'
14
+ import { buildInvoiceLinesFromItems } from './invoiceWriter'
15
+ import { selectItemsForPeriod, type SelectableItem } from './itemSelector'
16
+ import { absMoney, addMoney, fromUnits, subMoney, toUnits } from './money'
17
+ import {
18
+ getLowBalanceThresholdDefault,
19
+ getNearLimitBufferDefault,
20
+ getStatementEnabled,
21
+ } from './prepaidConfig'
22
+ import { buildBalanceCrossingEvents, type DomainEvent } from './balanceEvents'
23
+
24
+ /**
25
+ * Prepaid period close (SPEC-002 P3).
26
+ *
27
+ * A prepaid account never gets a payable draft invoice. Instead the Bill Run,
28
+ * for a due prepaid account:
29
+ * 1. charges its `recurring` / `one_time` items to the **balance** (atomic
30
+ * debit + transaction, deterministic `source_ref =
31
+ * recurring-{itemId}-{periodEndISO}`, then `billed_to_date = period_end`),
32
+ * 2. builds a non-fiscal **BillingStatement** (opening, topups, usage,
33
+ * recurring, closing + per-item breakdown).
34
+ *
35
+ * Metered usage was already rated + debited in real time during the period —
36
+ * it is NOT re-rated here; the statement only summarizes it.
37
+ *
38
+ * Three anti-duplicate guards: the statement unique
39
+ * `(bill_account_id, period_start, period_end)`, the per-item `billed_to_date`,
40
+ * and the deterministic charge `source_ref`.
41
+ */
42
+
43
+ export type PrepaidCloseMode = 'real' | 'test' | 'dry'
44
+
45
+ export type PrepaidAccountWarnings = {
46
+ // Reserved for future per-close warnings (e.g. negative-balance close).
47
+ closed_negative?: boolean
48
+ }
49
+
50
+ export type PrepaidAccountResult =
51
+ | {
52
+ status: 'success' | 'success_with_warnings' | 'skipped_existing_statement'
53
+ warnings: PrepaidAccountWarnings | null
54
+ statementId: string | null
55
+ cyclesEmitted: number
56
+ statementEvents: DomainEvent[]
57
+ }
58
+ | { status: 'failed'; errorMessage: string }
59
+
60
+ const SCHEDULED_CYCLE_LIMIT = 1
61
+ const CATCH_UP_HARD_CAP = 60
62
+
63
+ export type ProcessPrepaidParams = {
64
+ asOfDate: Date
65
+ mode: PrepaidCloseMode
66
+ triggeredBy: 'schedule' | 'manual'
67
+ catchUp?: boolean
68
+ }
69
+
70
+ /**
71
+ * Multi-cycle prepaid account processing (mirrors the postpaid engine's
72
+ * catch-up loop). One statement + one set of recurring debits per missed
73
+ * period — never combined.
74
+ */
75
+ export async function processPrepaidAccount(
76
+ em: EntityManager,
77
+ account: BillingAccount,
78
+ billRun: BillRun,
79
+ params: ProcessPrepaidParams,
80
+ ): Promise<PrepaidAccountResult> {
81
+ const maxCycles =
82
+ params.triggeredBy === 'schedule' || !params.catchUp
83
+ ? SCHEDULED_CYCLE_LIMIT
84
+ : CATCH_UP_HARD_CAP
85
+
86
+ const statementEnabled = await getStatementEnabled(em)
87
+ // Resolve the crossing thresholds once — they don't change during the run.
88
+ const balanceRow = await em.findOne(BillingAccountBalance, {
89
+ tenantId: account.tenantId,
90
+ billAccountId: account.id,
91
+ })
92
+ const threshold = resolveLowBalanceThreshold(
93
+ balanceRow?.lowBalanceThreshold ?? null,
94
+ await getLowBalanceThresholdDefault(em),
95
+ )
96
+ const nearLimitBuffer = await getNearLimitBufferDefault(em)
97
+ const creditLimit = account.creditLimit ?? '0'
98
+
99
+ let cyclesEmitted = 0
100
+ let lastStatementId: string | null = null
101
+ const statementEvents: DomainEvent[] = []
102
+ // Chain opening → closing across cycles in a catch-up run. Null on the first
103
+ // cycle → closePrepaidPeriod resolves it from the prior statement / ledger.
104
+ let previousClosing: string | null = null
105
+
106
+ while (cyclesEmitted < maxCycles && isCycleDue(account.nextBillDate, params.asOfDate)) {
107
+ const period = deriveBillPeriod(account.nextBillDate, account.billCycle as BillCycle)
108
+
109
+ // Statement anti-duplicate (only when we would actually persist).
110
+ if (params.mode !== 'dry') {
111
+ const existing = await em.findOne(BillingStatement, {
112
+ tenantId: account.tenantId,
113
+ billAccountId: account.id,
114
+ periodStart: period.periodStart,
115
+ periodEnd: period.periodEnd,
116
+ })
117
+ if (existing) {
118
+ return {
119
+ status: 'skipped_existing_statement',
120
+ warnings: null,
121
+ statementId: existing.id,
122
+ cyclesEmitted,
123
+ statementEvents,
124
+ }
125
+ }
126
+ }
127
+
128
+ const cycle = await closePrepaidPeriod(em, account, period, billRun, {
129
+ mode: params.mode,
130
+ statementEnabled,
131
+ previousClosing,
132
+ threshold,
133
+ nearLimitBuffer,
134
+ creditLimit,
135
+ })
136
+ previousClosing = cycle.closingBalance
137
+ // Recurring charges move the balance too, so they can trip the same
138
+ // low/exhausted/over_limit crossings as a usage debit (SPEC-002). These
139
+ // are accumulated and emitted by the engine AFTER the run commits.
140
+ statementEvents.push(...cycle.crossingEvents)
141
+
142
+ // A negative closing balance is NORMAL for prepaid (the customer overspent;
143
+ // it's cleared by the next top-up), so it is not flagged as a warning.
144
+ if (cycle.statementId) {
145
+ lastStatementId = cycle.statementId
146
+ statementEvents.push({
147
+ id: 'billing.statement.generated',
148
+ payload: {
149
+ statementId: cycle.statementId,
150
+ billAccountId: account.id,
151
+ tenantId: account.tenantId,
152
+ organizationId: account.organizationId,
153
+ billRunId: billRun.id,
154
+ periodStart: period.periodStart.toISOString().slice(0, 10),
155
+ periodEnd: period.periodEnd.toISOString().slice(0, 10),
156
+ currencyCode: account.currencyCode,
157
+ openingBalance: cycle.openingBalance,
158
+ closingBalance: cycle.closingBalance,
159
+ totalTopups: cycle.totalTopups,
160
+ totalUsage: cycle.totalUsage,
161
+ totalRecurring: cycle.totalRecurring,
162
+ totalAdjustments: cycle.totalAdjustments,
163
+ },
164
+ })
165
+ }
166
+
167
+ if (params.mode === 'real') {
168
+ account.nextBillDate = advanceNextBillDate(
169
+ account.nextBillDate,
170
+ account.billCycle as BillCycle,
171
+ )
172
+ account.lastBillDate = period.periodEnd
173
+ account.updatedAt = new Date()
174
+ await em.flush()
175
+ } else {
176
+ // test / dry: do not advance state; one iteration only.
177
+ cyclesEmitted += 1
178
+ break
179
+ }
180
+ cyclesEmitted += 1
181
+ }
182
+
183
+ return {
184
+ status: 'success',
185
+ warnings: null,
186
+ statementId: lastStatementId,
187
+ cyclesEmitted,
188
+ statementEvents,
189
+ }
190
+ }
191
+
192
+ type ClosePeriodResult = {
193
+ statementId: string | null
194
+ openingBalance: string
195
+ totalTopups: string
196
+ totalUsage: string
197
+ totalRecurring: string
198
+ totalAdjustments: string
199
+ closingBalance: string
200
+ crossingEvents: DomainEvent[]
201
+ }
202
+
203
+ type DebitBreakdownEntry = {
204
+ billing_item_id: string
205
+ billing_type: string
206
+ uom_code?: string
207
+ quantity: string
208
+ amount: string
209
+ }
210
+
211
+ async function closePrepaidPeriod(
212
+ em: EntityManager,
213
+ account: BillingAccount,
214
+ period: BillPeriod,
215
+ billRun: BillRun,
216
+ opts: {
217
+ mode: PrepaidCloseMode
218
+ statementEnabled: boolean
219
+ previousClosing: string | null
220
+ threshold: string
221
+ nearLimitBuffer: string
222
+ creditLimit: string
223
+ },
224
+ ): Promise<ClosePeriodResult> {
225
+ const periodEndISO = period.periodEnd.toISOString().slice(0, 10)
226
+
227
+ // Recurring + one_time items eligible for this period (usage excluded by the
228
+ // selector — it was rated in real time).
229
+ const items = await em.find(BillingItem, {
230
+ tenantId: account.tenantId,
231
+ organizationId: account.organizationId,
232
+ billAccountId: account.id,
233
+ deletedAt: null,
234
+ } as never)
235
+ const { included } = selectItemsForPeriod(items as unknown as SelectableItem[], period)
236
+ const billableItems = included as unknown as BillingItem[]
237
+ const lines = buildInvoiceLinesFromItems(billableItems)
238
+
239
+ // Window bounds for categorized sums (topups / usage already in the ledger).
240
+ const windowStart = period.periodStart
241
+ const windowEnd = addDays(period.periodEnd, 1) // exclusive upper bound
242
+
243
+ // Opening chains from the prior statement's closing — NOT a created_at sum.
244
+ // Recurring debits are stamped at run time (after period_end), so a
245
+ // `SUM(amount WHERE created_at < period_start)` would drop the prior period's
246
+ // recurring charge and break the chain. Resolution order:
247
+ // 1. previous cycle's closing (catch-up, in this run),
248
+ // 2. the prior persisted statement's closing (month-over-month),
249
+ // 3. the ledger balance before the first-ever statement window.
250
+ const openingBalance = await resolveOpeningBalance(em, account, period, opts.previousClosing)
251
+ const totalTopups = await sumByTypeInWindow(em, account, 'topup', windowStart, windowEnd)
252
+ const totalUsage = absMoney(
253
+ await sumByTypeInWindow(em, account, 'usage', windowStart, windowEnd),
254
+ )
255
+ // Manual adjustments / reversals that landed in the window — SIGNED (a credit
256
+ // is +, a debit −). Kept as a first-class term so the statement is
257
+ // self-consistent and the opening→closing chain never drifts.
258
+ const totalAdjustments = addMoney(
259
+ await sumByTypeInWindow(em, account, 'adjustment', windowStart, windowEnd),
260
+ await sumByTypeInWindow(em, account, 'reversal', windowStart, windowEnd),
261
+ )
262
+
263
+ let totalRecurring = '0.0000'
264
+ const breakdown: DebitBreakdownEntry[] = []
265
+ const chargeTransactionIds: string[] = []
266
+ // The balance just before / after the recurring batch — for crossing events.
267
+ let recurringBalanceBefore: string | null = null
268
+ let recurringBalanceAfter: string | null = null
269
+
270
+ if (opts.mode === 'real') {
271
+ const now = new Date()
272
+ for (const line of lines) {
273
+ const movement = await applyBalanceMovement(em, {
274
+ organizationId: account.organizationId,
275
+ tenantId: account.tenantId,
276
+ billAccountId: account.id,
277
+ currencyCode: account.currencyCode,
278
+ type: line.billingType === 'one_time' ? 'one_time' : 'recurring',
279
+ amount: fromUnits(-toUnits(line.totalNetAmount)),
280
+ description: line.description,
281
+ billingItemId: line.billingItemId,
282
+ sourceRef: `recurring-${line.billingItemId}-${periodEndISO}`,
283
+ metadata: {
284
+ bill_period_start: period.periodStart.toISOString().slice(0, 10),
285
+ bill_period_end: periodEndISO,
286
+ bill_run_id: billRun.id,
287
+ },
288
+ })
289
+ if (recurringBalanceBefore === null) recurringBalanceBefore = movement.balanceBefore
290
+ recurringBalanceAfter = movement.balanceAfter
291
+ totalRecurring = addMoney(totalRecurring, line.totalNetAmount)
292
+ if (!movement.deduplicated) chargeTransactionIds.push(movement.transaction.id)
293
+ breakdown.push({
294
+ billing_item_id: line.billingItemId,
295
+ billing_type: line.billingType,
296
+ quantity: line.quantity,
297
+ amount: line.totalNetAmount,
298
+ })
299
+ const item = billableItems.find((it) => it.id === line.billingItemId)
300
+ if (item) {
301
+ item.billedToDate = period.periodEnd
302
+ item.updatedAt = now
303
+ }
304
+ }
305
+ await em.flush()
306
+ } else {
307
+ // test / dry: compute the would-be charges, persist nothing.
308
+ for (const line of lines) {
309
+ totalRecurring = addMoney(totalRecurring, line.totalNetAmount)
310
+ breakdown.push({
311
+ billing_item_id: line.billingItemId,
312
+ billing_type: line.billingType,
313
+ quantity: line.quantity,
314
+ amount: line.totalNetAmount,
315
+ })
316
+ }
317
+ }
318
+
319
+ // closing = opening + topups + adjustments − usage − recurring.
320
+ const closingBalance = subMoney(
321
+ subMoney(addMoney(addMoney(openingBalance, totalTopups), totalAdjustments), totalUsage),
322
+ totalRecurring,
323
+ )
324
+
325
+ // The recurring batch can push the balance low / exhausted / over-limit —
326
+ // emit those crossings (real mode only; dry/test never move the balance).
327
+ const crossingEvents =
328
+ recurringBalanceBefore !== null && recurringBalanceAfter !== null
329
+ ? buildBalanceCrossingEvents({
330
+ scope: {
331
+ billAccountId: account.id,
332
+ tenantId: account.tenantId,
333
+ organizationId: account.organizationId,
334
+ currencyCode: account.currencyCode,
335
+ },
336
+ balanceBefore: recurringBalanceBefore,
337
+ balanceAfter: recurringBalanceAfter,
338
+ threshold: opts.threshold,
339
+ creditLimit: opts.creditLimit,
340
+ nearLimitBuffer: opts.nearLimitBuffer,
341
+ })
342
+ : []
343
+
344
+ let statementId: string | null = null
345
+ if (opts.mode === 'real' && opts.statementEnabled) {
346
+ const now = new Date()
347
+ const statement = em.create(BillingStatement, {
348
+ organizationId: account.organizationId,
349
+ tenantId: account.tenantId,
350
+ billAccountId: account.id,
351
+ billRunId: billRun.id,
352
+ periodStart: period.periodStart,
353
+ periodEnd: period.periodEnd,
354
+ currencyCode: account.currencyCode,
355
+ openingBalance,
356
+ totalTopups,
357
+ totalUsage,
358
+ totalRecurring,
359
+ totalAdjustments,
360
+ closingBalance,
361
+ debitBreakdown: { items: breakdown },
362
+ status: 'generated',
363
+ createdAt: now,
364
+ updatedAt: now,
365
+ })
366
+ em.persist(statement)
367
+ await em.flush()
368
+ statementId = statement.id
369
+
370
+ // Backfill statement_id on the charge transactions for attribution.
371
+ if (chargeTransactionIds.length > 0) {
372
+ await em.nativeUpdate(
373
+ BillingAccountTransaction,
374
+ { id: { $in: chargeTransactionIds } } as never,
375
+ { statementId } as never,
376
+ )
377
+ }
378
+ }
379
+
380
+ return {
381
+ statementId,
382
+ openingBalance,
383
+ totalTopups,
384
+ totalUsage,
385
+ totalRecurring,
386
+ totalAdjustments,
387
+ closingBalance,
388
+ crossingEvents,
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Resolve a period's opening balance by chaining from the prior statement's
394
+ * closing (the spec's "chains cleanly"), with a ledger fallback for the very
395
+ * first statement. See the call site for why a created_at sum alone is wrong.
396
+ */
397
+ async function resolveOpeningBalance(
398
+ em: EntityManager,
399
+ account: BillingAccount,
400
+ period: BillPeriod,
401
+ previousClosing: string | null,
402
+ ): Promise<string> {
403
+ if (previousClosing !== null) return previousClosing
404
+ const prior = await em.findOne(
405
+ BillingStatement,
406
+ {
407
+ tenantId: account.tenantId,
408
+ organizationId: account.organizationId,
409
+ billAccountId: account.id,
410
+ periodEnd: { $lt: period.periodStart },
411
+ } as never,
412
+ { orderBy: { periodEnd: 'DESC' } },
413
+ )
414
+ if (prior) return fromUnits(toUnits(prior.closingBalance))
415
+ // First-ever statement: the ledger balance just before the period window
416
+ // (only top-ups/usage exist before the first close, so created_at is safe).
417
+ return sumAmountsBefore(em, account, period.periodStart)
418
+ }
419
+
420
+ /** Balance at `before` = SUM(amount) of transactions created strictly before it. */
421
+ async function sumAmountsBefore(
422
+ em: EntityManager,
423
+ account: BillingAccount,
424
+ before: Date,
425
+ ): Promise<string> {
426
+ const rows = (await em.execute(
427
+ `SELECT COALESCE(SUM(amount), 0) AS total
428
+ FROM billing_account_transactions
429
+ WHERE tenant_id = ? AND organization_id = ? AND bill_account_id = ?
430
+ AND deleted_at IS NULL AND created_at < ?`,
431
+ [account.tenantId, account.organizationId, account.id, before],
432
+ )) as unknown as Array<{ total: string }>
433
+ return fromUnits(toUnits(rows?.[0]?.total ?? 0))
434
+ }
435
+
436
+ /** SUM(amount) for one transaction type within [from, to). */
437
+ async function sumByTypeInWindow(
438
+ em: EntityManager,
439
+ account: BillingAccount,
440
+ type: string,
441
+ from: Date,
442
+ to: Date,
443
+ ): Promise<string> {
444
+ const rows = (await em.execute(
445
+ `SELECT COALESCE(SUM(amount), 0) AS total
446
+ FROM billing_account_transactions
447
+ WHERE tenant_id = ? AND organization_id = ? AND bill_account_id = ?
448
+ AND deleted_at IS NULL AND type = ?
449
+ AND created_at >= ? AND created_at < ?`,
450
+ [account.tenantId, account.organizationId, account.id, type, from, to],
451
+ )) as unknown as Array<{ total: string }>
452
+ return fromUnits(toUnits(rows?.[0]?.total ?? 0))
453
+ }
@@ -0,0 +1,117 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { BillingTopup } from '../data/entities'
3
+ import { applyBalanceMovement } from './balanceLedger'
4
+ import { createTopupReceipt, type TopupReceiptDeps } from './topupReceipt'
5
+
6
+ /**
7
+ * Idempotent top-up capture (SPEC-002 P2).
8
+ *
9
+ * On `payment_gateways.payment.captured` for a top-up, in ONE DB transaction:
10
+ * - credit the balance (atomic) + append a `type='topup'` transaction with a
11
+ * deterministic `source_ref = topup-{paymentId}` (so a re-fired capture can
12
+ * never double-credit),
13
+ * - create the posted+paid VAT receipt in core/sales,
14
+ * - transition the BillingTopup → captured, backfilling `transaction_id` +
15
+ * `receipt_invoice_id`.
16
+ *
17
+ * Re-firing on an already-captured top-up is a no-op (the gateway delivers
18
+ * webhooks at-least-once).
19
+ */
20
+
21
+ export type CaptureTopupAccount = {
22
+ id: string
23
+ organizationId: string
24
+ tenantId: string
25
+ currencyCode: string
26
+ }
27
+
28
+ export type CaptureTopupParams = {
29
+ topup: BillingTopup
30
+ account: CaptureTopupAccount
31
+ /** VAT percent applied to the receipt. */
32
+ taxRate: number
33
+ /** Provider transaction id for the transaction metadata, if known. */
34
+ gatewayTransactionId?: string | null
35
+ }
36
+
37
+ export type CaptureTopupResult = {
38
+ status: 'captured' | 'already_captured'
39
+ balanceAfter?: string
40
+ transactionId?: string
41
+ receiptInvoiceId?: string
42
+ invoiceNumber?: string
43
+ grossAmount?: string
44
+ }
45
+
46
+ export async function captureTopup(
47
+ em: EntityManager,
48
+ deps: TopupReceiptDeps,
49
+ params: CaptureTopupParams,
50
+ ): Promise<CaptureTopupResult> {
51
+ const { topup, account } = params
52
+
53
+ if (topup.status === 'captured') {
54
+ return { status: 'already_captured' }
55
+ }
56
+
57
+ return em.transactional(async (tem) => {
58
+ // 1. Atomic credit + paired transaction (idempotent on source_ref).
59
+ const movement = await applyBalanceMovement(tem as EntityManager, {
60
+ organizationId: account.organizationId,
61
+ tenantId: account.tenantId,
62
+ billAccountId: account.id,
63
+ currencyCode: account.currencyCode,
64
+ type: 'topup',
65
+ amount: topup.amount,
66
+ description: `Top-up — ${topup.providerKey}`,
67
+ topupId: topup.id,
68
+ sourceRef: `topup-${topup.paymentId}`,
69
+ metadata: {
70
+ provider_key: topup.providerKey,
71
+ payment_id: topup.paymentId,
72
+ gateway_transaction_id: params.gatewayTransactionId ?? topup.gatewayTransactionId ?? null,
73
+ },
74
+ })
75
+
76
+ // A deduplicated credit means a PRIOR capture already committed (the
77
+ // gateway delivers `payment.captured` at-least-once, and the stale
78
+ // `status` check above can race a concurrent capture). Do NOT issue a
79
+ // second fiscal receipt — return the existing capture's links.
80
+ if (movement.deduplicated) {
81
+ const prior = await tem.findOne(BillingTopup, { id: topup.id })
82
+ return {
83
+ status: 'already_captured' as const,
84
+ balanceAfter: movement.balanceAfter,
85
+ transactionId: prior?.transactionId ?? movement.transaction.id,
86
+ receiptInvoiceId: prior?.receiptInvoiceId ?? undefined,
87
+ }
88
+ }
89
+
90
+ // 2. Fiscal VAT receipt (posted+paid).
91
+ const receipt = await createTopupReceipt(tem as EntityManager, deps, {
92
+ account,
93
+ topupId: topup.id,
94
+ paymentId: topup.paymentId,
95
+ amount: topup.amount,
96
+ taxRate: params.taxRate,
97
+ })
98
+
99
+ // 3. Transition the top-up.
100
+ const managed = await tem.findOne(BillingTopup, { id: topup.id })
101
+ const target = managed ?? topup
102
+ target.status = 'captured'
103
+ target.transactionId = movement.transaction.id
104
+ target.receiptInvoiceId = receipt.invoiceId
105
+ target.updatedAt = new Date()
106
+ await tem.flush()
107
+
108
+ return {
109
+ status: 'captured' as const,
110
+ balanceAfter: movement.balanceAfter,
111
+ transactionId: movement.transaction.id,
112
+ receiptInvoiceId: receipt.invoiceId,
113
+ invoiceNumber: receipt.invoiceNumber,
114
+ grossAmount: receipt.grossAmount,
115
+ }
116
+ })
117
+ }