@dainamite/billing 0.17.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 (248) hide show
  1. package/CHANGELOG.md +658 -0
  2. package/LICENSE +21 -0
  3. package/README.md +77 -0
  4. package/dist/modules/billing/acl.js +20 -0
  5. package/dist/modules/billing/acl.js.map +7 -0
  6. package/dist/modules/billing/api/accounts/route.js +182 -0
  7. package/dist/modules/billing/api/accounts/route.js.map +7 -0
  8. package/dist/modules/billing/api/export/account/[id]/route.js +170 -0
  9. package/dist/modules/billing/api/export/account/[id]/route.js.map +7 -0
  10. package/dist/modules/billing/api/invoices/[id]/route.js +85 -0
  11. package/dist/modules/billing/api/invoices/[id]/route.js.map +7 -0
  12. package/dist/modules/billing/api/invoices/add-line/route.js +69 -0
  13. package/dist/modules/billing/api/invoices/add-line/route.js.map +7 -0
  14. package/dist/modules/billing/api/invoices/edit-line/route.js +78 -0
  15. package/dist/modules/billing/api/invoices/edit-line/route.js.map +7 -0
  16. package/dist/modules/billing/api/invoices/post/route.js +74 -0
  17. package/dist/modules/billing/api/invoices/post/route.js.map +7 -0
  18. package/dist/modules/billing/api/invoices/remove-line/route.js +69 -0
  19. package/dist/modules/billing/api/invoices/remove-line/route.js.map +7 -0
  20. package/dist/modules/billing/api/invoices/route.js +109 -0
  21. package/dist/modules/billing/api/invoices/route.js.map +7 -0
  22. package/dist/modules/billing/api/items/bulk/route.js +74 -0
  23. package/dist/modules/billing/api/items/bulk/route.js.map +7 -0
  24. package/dist/modules/billing/api/items/route.js +224 -0
  25. package/dist/modules/billing/api/items/route.js.map +7 -0
  26. package/dist/modules/billing/api/openapi.js +25 -0
  27. package/dist/modules/billing/api/openapi.js.map +7 -0
  28. package/dist/modules/billing/api/run-outcomes/route.js +122 -0
  29. package/dist/modules/billing/api/run-outcomes/route.js.map +7 -0
  30. package/dist/modules/billing/api/runs/retry-failed/route.js +70 -0
  31. package/dist/modules/billing/api/runs/retry-failed/route.js.map +7 -0
  32. package/dist/modules/billing/api/runs/route.js +143 -0
  33. package/dist/modules/billing/api/runs/route.js.map +7 -0
  34. package/dist/modules/billing/api/test-invoices/wipe/route.js +65 -0
  35. package/dist/modules/billing/api/test-invoices/wipe/route.js.map +7 -0
  36. package/dist/modules/billing/api/usage/route.js +138 -0
  37. package/dist/modules/billing/api/usage/route.js.map +7 -0
  38. package/dist/modules/billing/backend/billing/accounts/[id]/page.js +402 -0
  39. package/dist/modules/billing/backend/billing/accounts/[id]/page.js.map +7 -0
  40. package/dist/modules/billing/backend/billing/accounts/[id]/page.meta.js +25 -0
  41. package/dist/modules/billing/backend/billing/accounts/[id]/page.meta.js.map +7 -0
  42. package/dist/modules/billing/backend/billing/accounts/create/page.js +82 -0
  43. package/dist/modules/billing/backend/billing/accounts/create/page.js.map +7 -0
  44. package/dist/modules/billing/backend/billing/accounts/create/page.meta.js +25 -0
  45. package/dist/modules/billing/backend/billing/accounts/create/page.meta.js.map +7 -0
  46. package/dist/modules/billing/backend/billing/accounts/page.js +173 -0
  47. package/dist/modules/billing/backend/billing/accounts/page.js.map +7 -0
  48. package/dist/modules/billing/backend/billing/accounts/page.meta.js +18 -0
  49. package/dist/modules/billing/backend/billing/accounts/page.meta.js.map +7 -0
  50. package/dist/modules/billing/backend/billing/invoices/[id]/page.js +500 -0
  51. package/dist/modules/billing/backend/billing/invoices/[id]/page.js.map +7 -0
  52. package/dist/modules/billing/backend/billing/invoices/[id]/page.meta.js +25 -0
  53. package/dist/modules/billing/backend/billing/invoices/[id]/page.meta.js.map +7 -0
  54. package/dist/modules/billing/backend/billing/invoices/page.js +187 -0
  55. package/dist/modules/billing/backend/billing/invoices/page.js.map +7 -0
  56. package/dist/modules/billing/backend/billing/invoices/page.meta.js +18 -0
  57. package/dist/modules/billing/backend/billing/invoices/page.meta.js.map +7 -0
  58. package/dist/modules/billing/backend/billing/items/[id]/page.js +265 -0
  59. package/dist/modules/billing/backend/billing/items/[id]/page.js.map +7 -0
  60. package/dist/modules/billing/backend/billing/items/[id]/page.meta.js +25 -0
  61. package/dist/modules/billing/backend/billing/items/[id]/page.meta.js.map +7 -0
  62. package/dist/modules/billing/backend/billing/items/create/page.js +90 -0
  63. package/dist/modules/billing/backend/billing/items/create/page.js.map +7 -0
  64. package/dist/modules/billing/backend/billing/items/create/page.meta.js +25 -0
  65. package/dist/modules/billing/backend/billing/items/create/page.meta.js.map +7 -0
  66. package/dist/modules/billing/backend/billing/items/page.js +208 -0
  67. package/dist/modules/billing/backend/billing/items/page.js.map +7 -0
  68. package/dist/modules/billing/backend/billing/items/page.meta.js +18 -0
  69. package/dist/modules/billing/backend/billing/items/page.meta.js.map +7 -0
  70. package/dist/modules/billing/backend/billing/runs/[id]/page.js +293 -0
  71. package/dist/modules/billing/backend/billing/runs/[id]/page.js.map +7 -0
  72. package/dist/modules/billing/backend/billing/runs/[id]/page.meta.js +21 -0
  73. package/dist/modules/billing/backend/billing/runs/[id]/page.meta.js.map +7 -0
  74. package/dist/modules/billing/backend/billing/runs/page.js +341 -0
  75. package/dist/modules/billing/backend/billing/runs/page.js.map +7 -0
  76. package/dist/modules/billing/backend/billing/runs/page.meta.js +18 -0
  77. package/dist/modules/billing/backend/billing/runs/page.meta.js.map +7 -0
  78. package/dist/modules/billing/backend/billing/usage/create/page.js +100 -0
  79. package/dist/modules/billing/backend/billing/usage/create/page.js.map +7 -0
  80. package/dist/modules/billing/backend/billing/usage/create/page.meta.js +25 -0
  81. package/dist/modules/billing/backend/billing/usage/create/page.meta.js.map +7 -0
  82. package/dist/modules/billing/ce.js +56 -0
  83. package/dist/modules/billing/ce.js.map +7 -0
  84. package/dist/modules/billing/cli.js +67 -0
  85. package/dist/modules/billing/cli.js.map +7 -0
  86. package/dist/modules/billing/commands/accounts.js +157 -0
  87. package/dist/modules/billing/commands/accounts.js.map +7 -0
  88. package/dist/modules/billing/commands/index.js +6 -0
  89. package/dist/modules/billing/commands/index.js.map +7 -0
  90. package/dist/modules/billing/commands/invoices.js +401 -0
  91. package/dist/modules/billing/commands/invoices.js.map +7 -0
  92. package/dist/modules/billing/commands/items.js +317 -0
  93. package/dist/modules/billing/commands/items.js.map +7 -0
  94. package/dist/modules/billing/commands/runs.js +123 -0
  95. package/dist/modules/billing/commands/runs.js.map +7 -0
  96. package/dist/modules/billing/commands/usage.js +88 -0
  97. package/dist/modules/billing/commands/usage.js.map +7 -0
  98. package/dist/modules/billing/components/AccountForm.js +420 -0
  99. package/dist/modules/billing/components/AccountForm.js.map +7 -0
  100. package/dist/modules/billing/components/DetailFields.js +37 -0
  101. package/dist/modules/billing/components/DetailFields.js.map +7 -0
  102. package/dist/modules/billing/components/ItemForm.js +440 -0
  103. package/dist/modules/billing/components/ItemForm.js.map +7 -0
  104. package/dist/modules/billing/components/LineFormDialog.js +195 -0
  105. package/dist/modules/billing/components/LineFormDialog.js.map +7 -0
  106. package/dist/modules/billing/components/UsageForm.js +262 -0
  107. package/dist/modules/billing/components/UsageForm.js.map +7 -0
  108. package/dist/modules/billing/data/entities.js +433 -0
  109. package/dist/modules/billing/data/entities.js.map +7 -0
  110. package/dist/modules/billing/data/entityIds.js +12 -0
  111. package/dist/modules/billing/data/entityIds.js.map +7 -0
  112. package/dist/modules/billing/data/validators.js +396 -0
  113. package/dist/modules/billing/data/validators.js.map +7 -0
  114. package/dist/modules/billing/di.js +6 -0
  115. package/dist/modules/billing/di.js.map +7 -0
  116. package/dist/modules/billing/events.js +35 -0
  117. package/dist/modules/billing/events.js.map +7 -0
  118. package/dist/modules/billing/i18n/en.json +336 -0
  119. package/dist/modules/billing/i18n/pl.json +336 -0
  120. package/dist/modules/billing/index.js +17 -0
  121. package/dist/modules/billing/index.js.map +7 -0
  122. package/dist/modules/billing/lib/billPeriod.js +101 -0
  123. package/dist/modules/billing/lib/billPeriod.js.map +7 -0
  124. package/dist/modules/billing/lib/billRunEngine.js +256 -0
  125. package/dist/modules/billing/lib/billRunEngine.js.map +7 -0
  126. package/dist/modules/billing/lib/billRunLabel.js +8 -0
  127. package/dist/modules/billing/lib/billRunLabel.js.map +7 -0
  128. package/dist/modules/billing/lib/idempotency.js +13 -0
  129. package/dist/modules/billing/lib/idempotency.js.map +7 -0
  130. package/dist/modules/billing/lib/invoiceStatus.js +38 -0
  131. package/dist/modules/billing/lib/invoiceStatus.js.map +7 -0
  132. package/dist/modules/billing/lib/invoiceWriter.js +153 -0
  133. package/dist/modules/billing/lib/invoiceWriter.js.map +7 -0
  134. package/dist/modules/billing/lib/itemSelector.js +77 -0
  135. package/dist/modules/billing/lib/itemSelector.js.map +7 -0
  136. package/dist/modules/billing/lib/seeds/demo.js +130 -0
  137. package/dist/modules/billing/lib/seeds/demo.js.map +7 -0
  138. package/dist/modules/billing/lib/tenantLock.js +32 -0
  139. package/dist/modules/billing/lib/tenantLock.js.map +7 -0
  140. package/dist/modules/billing/lib/usageRater.js +150 -0
  141. package/dist/modules/billing/lib/usageRater.js.map +7 -0
  142. package/dist/modules/billing/lib/usageRunner.js +82 -0
  143. package/dist/modules/billing/lib/usageRunner.js.map +7 -0
  144. package/dist/modules/billing/migrations/Migration20260519000000_billing.js +144 -0
  145. package/dist/modules/billing/migrations/Migration20260519000000_billing.js.map +7 -0
  146. package/dist/modules/billing/setup.js +190 -0
  147. package/dist/modules/billing/setup.js.map +7 -0
  148. package/dist/modules/billing/subscribers/payment-captured-to-paid.js +47 -0
  149. package/dist/modules/billing/subscribers/payment-captured-to-paid.js.map +7 -0
  150. package/dist/modules/billing/translations.js +5 -0
  151. package/dist/modules/billing/translations.js.map +7 -0
  152. package/dist/modules/billing/workers/reap-stale-bill-runs.js +31 -0
  153. package/dist/modules/billing/workers/reap-stale-bill-runs.js.map +7 -0
  154. package/package.json +148 -0
  155. package/src/modules/billing/__integration__/TC-BILL-001.spec.ts +116 -0
  156. package/src/modules/billing/__integration__/TC-BILL-002.spec.ts +169 -0
  157. package/src/modules/billing/__integration__/TC-BILL-003.spec.ts +28 -0
  158. package/src/modules/billing/__integration__/meta.ts +4 -0
  159. package/src/modules/billing/__tests__/commands/invoices.test.ts +464 -0
  160. package/src/modules/billing/__tests__/commands/itemsBulk.test.ts +284 -0
  161. package/src/modules/billing/__tests__/lib/billPeriod.test.ts +131 -0
  162. package/src/modules/billing/__tests__/lib/billRunEngine.test.ts +561 -0
  163. package/src/modules/billing/__tests__/lib/idempotency.test.ts +81 -0
  164. package/src/modules/billing/__tests__/lib/invoiceStatus.test.ts +97 -0
  165. package/src/modules/billing/__tests__/lib/invoiceWriter.test.ts +119 -0
  166. package/src/modules/billing/__tests__/lib/itemSelector.test.ts +192 -0
  167. package/src/modules/billing/__tests__/lib/seedsDemo.test.ts +86 -0
  168. package/src/modules/billing/__tests__/lib/tenantLock.test.ts +126 -0
  169. package/src/modules/billing/__tests__/lib/usageRater.test.ts +190 -0
  170. package/src/modules/billing/__tests__/package-isolation.test.ts +223 -0
  171. package/src/modules/billing/__tests__/setup.test.ts +107 -0
  172. package/src/modules/billing/__tests__/validators.test.ts +594 -0
  173. package/src/modules/billing/acl.ts +16 -0
  174. package/src/modules/billing/api/accounts/route.ts +198 -0
  175. package/src/modules/billing/api/export/account/[id]/route.ts +233 -0
  176. package/src/modules/billing/api/invoices/[id]/route.ts +143 -0
  177. package/src/modules/billing/api/invoices/add-line/route.ts +74 -0
  178. package/src/modules/billing/api/invoices/edit-line/route.ts +97 -0
  179. package/src/modules/billing/api/invoices/post/route.ts +97 -0
  180. package/src/modules/billing/api/invoices/remove-line/route.ts +75 -0
  181. package/src/modules/billing/api/invoices/route.ts +167 -0
  182. package/src/modules/billing/api/items/bulk/route.ts +92 -0
  183. package/src/modules/billing/api/items/route.ts +255 -0
  184. package/src/modules/billing/api/openapi.ts +33 -0
  185. package/src/modules/billing/api/run-outcomes/route.ts +149 -0
  186. package/src/modules/billing/api/runs/retry-failed/route.ts +88 -0
  187. package/src/modules/billing/api/runs/route.ts +170 -0
  188. package/src/modules/billing/api/test-invoices/wipe/route.ts +87 -0
  189. package/src/modules/billing/api/usage/route.ts +148 -0
  190. package/src/modules/billing/backend/billing/accounts/[id]/page.meta.ts +23 -0
  191. package/src/modules/billing/backend/billing/accounts/[id]/page.tsx +574 -0
  192. package/src/modules/billing/backend/billing/accounts/create/page.meta.ts +23 -0
  193. package/src/modules/billing/backend/billing/accounts/create/page.tsx +101 -0
  194. package/src/modules/billing/backend/billing/accounts/page.meta.ts +16 -0
  195. package/src/modules/billing/backend/billing/accounts/page.tsx +222 -0
  196. package/src/modules/billing/backend/billing/invoices/[id]/page.meta.ts +23 -0
  197. package/src/modules/billing/backend/billing/invoices/[id]/page.tsx +613 -0
  198. package/src/modules/billing/backend/billing/invoices/page.meta.ts +16 -0
  199. package/src/modules/billing/backend/billing/invoices/page.tsx +245 -0
  200. package/src/modules/billing/backend/billing/items/[id]/page.meta.ts +23 -0
  201. package/src/modules/billing/backend/billing/items/[id]/page.tsx +366 -0
  202. package/src/modules/billing/backend/billing/items/create/page.meta.ts +23 -0
  203. package/src/modules/billing/backend/billing/items/create/page.tsx +110 -0
  204. package/src/modules/billing/backend/billing/items/page.meta.ts +16 -0
  205. package/src/modules/billing/backend/billing/items/page.tsx +272 -0
  206. package/src/modules/billing/backend/billing/runs/[id]/page.meta.ts +19 -0
  207. package/src/modules/billing/backend/billing/runs/[id]/page.tsx +404 -0
  208. package/src/modules/billing/backend/billing/runs/page.meta.ts +16 -0
  209. package/src/modules/billing/backend/billing/runs/page.tsx +426 -0
  210. package/src/modules/billing/backend/billing/usage/create/page.meta.ts +23 -0
  211. package/src/modules/billing/backend/billing/usage/create/page.tsx +120 -0
  212. package/src/modules/billing/ce.ts +74 -0
  213. package/src/modules/billing/cli.ts +89 -0
  214. package/src/modules/billing/commands/accounts.ts +205 -0
  215. package/src/modules/billing/commands/index.ts +13 -0
  216. package/src/modules/billing/commands/invoices.ts +542 -0
  217. package/src/modules/billing/commands/items.ts +438 -0
  218. package/src/modules/billing/commands/runs.ts +166 -0
  219. package/src/modules/billing/commands/usage.ts +118 -0
  220. package/src/modules/billing/components/AccountForm.tsx +517 -0
  221. package/src/modules/billing/components/DetailFields.tsx +60 -0
  222. package/src/modules/billing/components/ItemForm.tsx +558 -0
  223. package/src/modules/billing/components/LineFormDialog.tsx +253 -0
  224. package/src/modules/billing/components/UsageForm.tsx +326 -0
  225. package/src/modules/billing/data/entities.ts +533 -0
  226. package/src/modules/billing/data/entityIds.ts +25 -0
  227. package/src/modules/billing/data/validators.ts +562 -0
  228. package/src/modules/billing/di.ts +14 -0
  229. package/src/modules/billing/events.ts +47 -0
  230. package/src/modules/billing/i18n/en.json +336 -0
  231. package/src/modules/billing/i18n/pl.json +336 -0
  232. package/src/modules/billing/index.ts +22 -0
  233. package/src/modules/billing/lib/billPeriod.ts +181 -0
  234. package/src/modules/billing/lib/billRunEngine.ts +416 -0
  235. package/src/modules/billing/lib/billRunLabel.ts +14 -0
  236. package/src/modules/billing/lib/idempotency.ts +46 -0
  237. package/src/modules/billing/lib/invoiceStatus.ts +61 -0
  238. package/src/modules/billing/lib/invoiceWriter.ts +260 -0
  239. package/src/modules/billing/lib/itemSelector.ts +154 -0
  240. package/src/modules/billing/lib/seeds/demo.ts +199 -0
  241. package/src/modules/billing/lib/tenantLock.ts +87 -0
  242. package/src/modules/billing/lib/usageRater.ts +250 -0
  243. package/src/modules/billing/lib/usageRunner.ts +189 -0
  244. package/src/modules/billing/migrations/Migration20260519000000_billing.ts +175 -0
  245. package/src/modules/billing/setup.ts +252 -0
  246. package/src/modules/billing/subscribers/payment-captured-to-paid.ts +89 -0
  247. package/src/modules/billing/translations.ts +9 -0
  248. package/src/modules/billing/workers/reap-stale-bill-runs.ts +57 -0
@@ -0,0 +1,416 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { AwilixContainer } from 'awilix'
3
+ import {
4
+ BillingAccount,
5
+ BillingAccountUsage,
6
+ BillingItem,
7
+ BillRun,
8
+ BillRunOutcome,
9
+ } from '../data/entities'
10
+ import { advanceNextBillDate, deriveBillPeriod, isCycleDue } from './billPeriod'
11
+ import {
12
+ selectItemsForPeriod,
13
+ type SelectableItem,
14
+ } from './itemSelector'
15
+ import {
16
+ buildInvoiceLinesFromItems,
17
+ writeDraftInvoice,
18
+ type InvoiceLineDescriptor,
19
+ type WriteMode,
20
+ } from './invoiceWriter'
21
+ import { withTenantLock } from './tenantLock'
22
+ import { processUsageForAccount } from './usageRunner'
23
+ import type { BillCycle } from '../data/validators'
24
+
25
+ /**
26
+ * Bill Run engine — orchestrates one execution of the billing cycle for
27
+ * a single tenant.
28
+ *
29
+ * Concurrency / locking:
30
+ * - The whole run is wrapped in `withTenantLock(...)`, which holds a
31
+ * transaction-scoped Postgres advisory lock keyed by
32
+ * `hash(tenant_id || ':billing-run')`. A second trigger fired while a
33
+ * run is in flight short-circuits with `TenantLockBusyError` (the
34
+ * command layer maps that to HTTP 409).
35
+ * - Within the outer transaction, every account is processed inside a
36
+ * nested `em.transactional(...)` (PG SAVEPOINT). A per-account failure
37
+ * rolls back only that account's work, leaving siblings unaffected.
38
+ *
39
+ * Run modes:
40
+ * - `real` (default) — persist drafts, advance `next_bill_date`, mark
41
+ * items `billed_to_date`. Production path.
42
+ * - `test` — persist drafts flagged with `metadata.test_run=true`, but
43
+ * do NOT advance state. Repeatable.
44
+ * - `dry` — compute everything; persist nothing. Preview only.
45
+ *
46
+ * Catch-up:
47
+ * - Manual triggers may set `catchUp=true`. The engine then loops cycles
48
+ * per account until `next_bill_date > as_of_date`, emitting one draft
49
+ * per missed cycle (never combined).
50
+ * - Scheduled triggers always process exactly ONE cycle per account per
51
+ * run, regardless of how far behind the account is.
52
+ *
53
+ * Anti-duplicate guards (both run in every mode except `dry`):
54
+ * 1. Open-draft check on `core/sales` invoice (per period boundary).
55
+ * 2. `billed_to_date` per BillingItem.
56
+ */
57
+
58
+ export type RunMode = WriteMode
59
+
60
+ export type RunBillRunParams = {
61
+ tenantId: string
62
+ organizationId: string
63
+ asOfDate: Date
64
+ triggeredBy: 'schedule' | 'manual'
65
+ mode: RunMode
66
+ scopedAccountIds?: string[]
67
+ parentRunId?: string | null
68
+ catchUp?: boolean
69
+ }
70
+
71
+ export type RunBillRunResult = {
72
+ billRun: BillRun
73
+ outcomes: BillRunOutcome[]
74
+ }
75
+
76
+ type AccountWarnings = {
77
+ currency_mismatch_items?: string[]
78
+ unmatched_usage_uoms?: string[]
79
+ }
80
+
81
+ type AccountProcessSuccess = {
82
+ status: 'success' | 'success_with_warnings' | 'skipped_existing_draft'
83
+ warnings: AccountWarnings | null
84
+ draftInvoiceId: string | null
85
+ cyclesEmitted: number
86
+ usageRecordsRated: number
87
+ }
88
+
89
+ type AccountProcessFailed = {
90
+ status: 'failed'
91
+ errorMessage: string
92
+ }
93
+
94
+ type AccountProcessResult = AccountProcessSuccess | AccountProcessFailed
95
+
96
+ const SCHEDULED_CYCLE_LIMIT = 1
97
+ const CATCH_UP_HARD_CAP = 60 // safety net so a misconfigured account can't loop forever
98
+
99
+ export async function runBillRun(
100
+ em: EntityManager,
101
+ container: AwilixContainer,
102
+ params: RunBillRunParams,
103
+ ): Promise<RunBillRunResult> {
104
+ return withTenantLock(
105
+ em,
106
+ { tenantId: params.tenantId, lockName: 'billing-run' },
107
+ async (tem) => executeRunWithinLock(tem, container, params),
108
+ )
109
+ }
110
+
111
+ async function executeRunWithinLock(
112
+ em: EntityManager,
113
+ container: AwilixContainer,
114
+ params: RunBillRunParams,
115
+ ): Promise<RunBillRunResult> {
116
+ const now = new Date()
117
+ const billRun = em.create(BillRun, {
118
+ organizationId: params.organizationId,
119
+ tenantId: params.tenantId,
120
+ triggeredBy: params.triggeredBy,
121
+ parentRunId: params.parentRunId ?? null,
122
+ dryRun: params.mode === 'dry',
123
+ testMode: params.mode === 'test',
124
+ catchUp: Boolean(params.catchUp),
125
+ scopedAccountIds: params.scopedAccountIds ?? null,
126
+ asOfDate: params.asOfDate,
127
+ startedAt: now,
128
+ status: 'running',
129
+ summary: null,
130
+ isActive: true,
131
+ createdAt: now,
132
+ updatedAt: now,
133
+ })
134
+ em.persist(billRun)
135
+ await em.flush()
136
+
137
+ const accounts = await selectTargetAccounts(em, params)
138
+ const outcomes: BillRunOutcome[] = []
139
+ let totalUsageRecordsRated = 0
140
+
141
+ for (const account of accounts) {
142
+ const processResult = await processAccountSafely(em, container, billRun, account, params)
143
+ if (processResult.status !== 'failed') {
144
+ totalUsageRecordsRated += processResult.usageRecordsRated
145
+ }
146
+ const outcome = em.create(BillRunOutcome, {
147
+ organizationId: params.organizationId,
148
+ tenantId: params.tenantId,
149
+ billRunId: billRun.id,
150
+ billAccountId: account.id,
151
+ status: processResult.status,
152
+ errorMessage: processResult.status === 'failed' ? processResult.errorMessage : null,
153
+ warnings:
154
+ processResult.status === 'failed'
155
+ ? null
156
+ : (processResult.warnings as Record<string, unknown> | null),
157
+ draftInvoiceId: processResult.status === 'failed' ? null : processResult.draftInvoiceId,
158
+ isActive: true,
159
+ createdAt: new Date(),
160
+ updatedAt: new Date(),
161
+ })
162
+ em.persist(outcome)
163
+ await em.flush()
164
+ outcomes.push(outcome)
165
+ }
166
+
167
+ billRun.status = computeFinalRunStatus(outcomes)
168
+ billRun.finishedAt = new Date()
169
+ billRun.updatedAt = billRun.finishedAt
170
+ billRun.summary = buildRunSummary(outcomes, accounts.length, totalUsageRecordsRated)
171
+ await em.flush()
172
+
173
+ return { billRun, outcomes }
174
+ }
175
+
176
+ async function selectTargetAccounts(
177
+ em: EntityManager,
178
+ params: RunBillRunParams,
179
+ ): Promise<BillingAccount[]> {
180
+ const where: Record<string, unknown> = {
181
+ tenantId: params.tenantId,
182
+ organizationId: params.organizationId,
183
+ deletedAt: null,
184
+ nextBillDate: { $lte: params.asOfDate },
185
+ }
186
+ if (params.scopedAccountIds && params.scopedAccountIds.length > 0) {
187
+ where.id = { $in: params.scopedAccountIds }
188
+ }
189
+ return em.find(BillingAccount, where as never)
190
+ }
191
+
192
+ async function processAccountSafely(
193
+ em: EntityManager,
194
+ container: AwilixContainer,
195
+ billRun: BillRun,
196
+ account: BillingAccount,
197
+ params: RunBillRunParams,
198
+ ): Promise<AccountProcessResult> {
199
+ try {
200
+ return await em.transactional(async (subEm) => {
201
+ return processAccount(subEm, container, billRun, account, params)
202
+ })
203
+ } catch (err) {
204
+ return {
205
+ status: 'failed',
206
+ errorMessage: err instanceof Error ? err.message : String(err),
207
+ }
208
+ }
209
+ }
210
+
211
+ async function processAccount(
212
+ em: EntityManager,
213
+ container: AwilixContainer,
214
+ billRun: BillRun,
215
+ account: BillingAccount,
216
+ params: RunBillRunParams,
217
+ ): Promise<AccountProcessResult> {
218
+ const maxCycles =
219
+ params.triggeredBy === 'schedule' || !params.catchUp
220
+ ? SCHEDULED_CYCLE_LIMIT
221
+ : CATCH_UP_HARD_CAP
222
+
223
+ let cyclesEmitted = 0
224
+ let lastDraftInvoiceId: string | null = null
225
+ let totalUsageRatedThisAccount = 0
226
+ const aggregatedWarnings: AccountWarnings = {}
227
+
228
+ while (cyclesEmitted < maxCycles && isCycleDue(account.nextBillDate, params.asOfDate)) {
229
+ const period = deriveBillPeriod(account.nextBillDate, account.billCycle as BillCycle)
230
+
231
+ // Open-draft anti-duplicate — only applies when we'd actually persist.
232
+ if (params.mode !== 'dry') {
233
+ const existing = await findExistingDraftForPeriod(em, account, period)
234
+ if (existing) {
235
+ return {
236
+ status: 'skipped_existing_draft',
237
+ warnings: null,
238
+ draftInvoiceId: existing,
239
+ cyclesEmitted,
240
+ usageRecordsRated: totalUsageRatedThisAccount,
241
+ }
242
+ }
243
+ }
244
+
245
+ const items = await em.find(BillingItem, {
246
+ tenantId: account.tenantId,
247
+ organizationId: account.organizationId,
248
+ billAccountId: account.id,
249
+ deletedAt: null,
250
+ } as never)
251
+
252
+ const usageItems = (items as BillingItem[]).filter((it) => it.type === 'usage')
253
+
254
+ // Non-usage path: one_time + recurring filtered by the selector.
255
+ const { included } = selectItemsForPeriod(items as SelectableItem[], period)
256
+ const billableItems = included as unknown as BillingItem[]
257
+
258
+ const currencyMismatchIds = billableItems
259
+ .filter((it) => it.currencyMismatch)
260
+ .map((it) => it.id)
261
+ if (currencyMismatchIds.length > 0) {
262
+ aggregatedWarnings.currency_mismatch_items = [
263
+ ...(aggregatedWarnings.currency_mismatch_items ?? []),
264
+ ...currencyMismatchIds,
265
+ ]
266
+ }
267
+ const itemLines = buildInvoiceLinesFromItems(billableItems)
268
+
269
+ // Usage path: aggregate matching usage records per uom_code, rate,
270
+ // emit one line per matched usage item, warn about unmatched UoMs.
271
+ const usageResult = await processUsageForAccount(em, account, period, usageItems)
272
+ if (usageResult.unmatchedUoms.length > 0) {
273
+ aggregatedWarnings.unmatched_usage_uoms = [
274
+ ...(aggregatedWarnings.unmatched_usage_uoms ?? []),
275
+ ...usageResult.unmatchedUoms,
276
+ ]
277
+ }
278
+
279
+ const allLines: InvoiceLineDescriptor[] = [...itemLines, ...usageResult.lines]
280
+ if (allLines.length === 0) {
281
+ // Nothing to bill this cycle — still advance so the account moves
282
+ // forward in real mode. In test / dry, no advance, so we'd loop
283
+ // forever; break out.
284
+ if (params.mode !== 'real') break
285
+ account.nextBillDate = advanceNextBillDate(
286
+ account.nextBillDate,
287
+ account.billCycle as BillCycle,
288
+ )
289
+ account.lastBillDate = period.periodEnd
290
+ account.updatedAt = new Date()
291
+ cyclesEmitted += 1
292
+ continue
293
+ }
294
+
295
+ const written = await writeDraftInvoice(em, container, {
296
+ mode: params.mode,
297
+ account,
298
+ period,
299
+ billRunId: billRun.id,
300
+ lines: allLines,
301
+ })
302
+ lastDraftInvoiceId = written.invoiceId
303
+
304
+ if (params.mode === 'real') {
305
+ const now = new Date()
306
+ for (const item of billableItems) {
307
+ item.billedToDate = period.periodEnd
308
+ item.updatedAt = now
309
+ }
310
+ // Mark consumed usage records — single bulk update so we don't
311
+ // round-trip per record on accounts with thousands of usage rows.
312
+ if (usageResult.matchedUoms.length > 0) {
313
+ // Predicate-based bulk update: marks every still-unrated record
314
+ // for the matched uoms in the account's period with a single
315
+ // SQL UPDATE — no id list ever leaves Postgres. Stays
316
+ // memory-bounded even for telco accounts with millions of
317
+ // usage records per cycle (spec Performance Considerations
318
+ // "Shape B").
319
+ await em.nativeUpdate(
320
+ BillingAccountUsage,
321
+ {
322
+ tenantId: account.tenantId,
323
+ organizationId: account.organizationId,
324
+ billAccountId: account.id,
325
+ uomCode: { $in: usageResult.matchedUoms },
326
+ ratedInBillRunId: null,
327
+ periodEnd: { $lte: period.periodEnd },
328
+ deletedAt: null,
329
+ } as never,
330
+ { ratedInBillRunId: billRun.id, updatedAt: now } as never,
331
+ )
332
+ }
333
+ totalUsageRatedThisAccount += usageResult.ratedCount
334
+ account.nextBillDate = advanceNextBillDate(
335
+ account.nextBillDate,
336
+ account.billCycle as BillCycle,
337
+ )
338
+ account.lastBillDate = period.periodEnd
339
+ account.updatedAt = now
340
+ await em.flush()
341
+ } else {
342
+ // Test / dry: do not advance state. One iteration only.
343
+ break
344
+ }
345
+
346
+ cyclesEmitted += 1
347
+ }
348
+
349
+ const hasCurrencyWarnings =
350
+ (aggregatedWarnings.currency_mismatch_items ?? []).length > 0
351
+ const hasUnmatchedUsage =
352
+ (aggregatedWarnings.unmatched_usage_uoms ?? []).length > 0
353
+ const hasWarnings = hasCurrencyWarnings || hasUnmatchedUsage
354
+ return {
355
+ status: hasWarnings ? 'success_with_warnings' : 'success',
356
+ warnings: hasWarnings ? aggregatedWarnings : null,
357
+ draftInvoiceId: lastDraftInvoiceId,
358
+ cyclesEmitted,
359
+ usageRecordsRated: totalUsageRatedThisAccount,
360
+ }
361
+ }
362
+
363
+ async function findExistingDraftForPeriod(
364
+ em: EntityManager,
365
+ account: BillingAccount,
366
+ period: { periodStart: Date; periodEnd: Date },
367
+ ): Promise<string | null> {
368
+ const periodStartISO = period.periodStart.toISOString().slice(0, 10)
369
+ const periodEndISO = period.periodEnd.toISOString().slice(0, 10)
370
+ const rows = (await em.execute(
371
+ `SELECT id FROM sales_invoices
372
+ WHERE tenant_id = ?
373
+ AND organization_id = ?
374
+ AND deleted_at IS NULL
375
+ AND status = 'draft'
376
+ AND metadata->>'bill_account_id' = ?
377
+ AND metadata->>'bill_period_start' = ?
378
+ AND metadata->>'bill_period_end' = ?
379
+ LIMIT 1`,
380
+ [account.tenantId, account.organizationId, account.id, periodStartISO, periodEndISO],
381
+ )) as unknown as Array<{ id: string }> | undefined
382
+ if (!rows || rows.length === 0) return null
383
+ return rows[0].id ?? null
384
+ }
385
+
386
+ function computeFinalRunStatus(
387
+ outcomes: readonly BillRunOutcome[],
388
+ ): BillRun['status'] {
389
+ if (outcomes.length === 0) return 'completed'
390
+ const anyFailed = outcomes.some((o) => o.status === 'failed')
391
+ const anySuccess = outcomes.some(
392
+ (o) => o.status === 'success' || o.status === 'success_with_warnings',
393
+ )
394
+ if (anyFailed && anySuccess) return 'partial_failure'
395
+ if (anyFailed) return 'partial_failure'
396
+ return 'completed'
397
+ }
398
+
399
+ function buildRunSummary(
400
+ outcomes: readonly BillRunOutcome[],
401
+ totalAccounts: number,
402
+ usageRecordsRated: number,
403
+ ): Record<string, unknown> {
404
+ return {
405
+ accounts_processed: totalAccounts,
406
+ drafts_created: outcomes.filter(
407
+ (o) => o.draftInvoiceId !== null && o.status !== 'skipped_existing_draft',
408
+ ).length,
409
+ drafts_skipped_existing: outcomes.filter((o) => o.status === 'skipped_existing_draft')
410
+ .length,
411
+ accounts_failed: outcomes.filter((o) => o.status === 'failed').length,
412
+ accounts_with_warnings: outcomes.filter((o) => o.status === 'success_with_warnings')
413
+ .length,
414
+ usage_records_rated: usageRecordsRated,
415
+ }
416
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Human-readable label for a Bill Run.
3
+ *
4
+ * The BillRun entity has no `name` column — the engine keys runs by id.
5
+ * The admin UI derives a stable, scannable label from the run's as-of
6
+ * date so operators see `BILL_RUN_2026-05-20` instead of a raw UUID.
7
+ */
8
+ export function billRunName(asOfDate: string | null | undefined): string {
9
+ const date =
10
+ typeof asOfDate === 'string' && asOfDate.length >= 10
11
+ ? asOfDate.slice(0, 10)
12
+ : 'unknown'
13
+ return `BILL_RUN_${date}`
14
+ }
@@ -0,0 +1,46 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+
3
+ /**
4
+ * `source_ref` idempotency helper.
5
+ *
6
+ * The unique index on `(tenant_id, bill_account_id, source_ref)` does the
7
+ * heavy lifting at the database level — Postgres treats NULLs as distinct,
8
+ * so multiple NULL `source_ref` values coexist for the same account. This
9
+ * helper checks for an existing row *before* the insert so we can return
10
+ * it (HTTP 200 with `deduplicated: true`) instead of taking the round-trip
11
+ * to the DB and unwinding a unique-violation.
12
+ *
13
+ * Why not rely solely on the unique constraint and catch the conflict?
14
+ * Two reasons:
15
+ * 1. Catching the conflict requires a SAVEPOINT or a separate tx —
16
+ * complicates error handling and rolls back the surrounding work.
17
+ * 2. Idempotency is also a read-side guarantee: if a connector POSTs
18
+ * twice, the *second* call should observe what the first one
19
+ * persisted, not just "duplicate, sorry."
20
+ *
21
+ * The constraint stays in place as the *correctness* gate — if a race
22
+ * sneaks past this check, the DB still refuses the duplicate.
23
+ */
24
+
25
+ type SourceRefScope = {
26
+ tenantId: string
27
+ billAccountId: string
28
+ sourceRef: string
29
+ deletedAt?: null
30
+ }
31
+
32
+ type EntityCtor<T> = new (...args: never[]) => T
33
+
34
+ export async function findBySourceRef<T extends { sourceRef?: string | null }>(
35
+ em: EntityManager,
36
+ entity: EntityCtor<T>,
37
+ scope: { tenantId: string; billAccountId: string; sourceRef: string },
38
+ ): Promise<T | null> {
39
+ const where: SourceRefScope = {
40
+ tenantId: scope.tenantId,
41
+ billAccountId: scope.billAccountId,
42
+ sourceRef: scope.sourceRef,
43
+ deletedAt: null,
44
+ }
45
+ return em.findOne(entity, where as never)
46
+ }
@@ -0,0 +1,61 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import {
3
+ Dictionary,
4
+ DictionaryEntry,
5
+ } from '@open-mercato/core/modules/dictionaries/data/entities'
6
+ import type { InvoiceStatus } from '../data/validators'
7
+
8
+ /**
9
+ * Resolve the `sales.invoice_status` dictionary entry id for a given
10
+ * status value, scoped per `(tenantId, organizationId)`.
11
+ *
12
+ * Used by:
13
+ * - `invoiceWriter` to set `statusEntryId` on the draft at create time.
14
+ * - The "post" command to flip draft → posted.
15
+ * - The payment-captured subscriber to flip posted → paid.
16
+ *
17
+ * Cached because the lookup is hot (every Bill Run draft hits it
18
+ * once, every posted invoice hits it twice, every paid invoice once)
19
+ * and the dictionary entry id never changes for the lifetime of a
20
+ * tenant. Exposed `__resetInvoiceStatusCacheForTests` to keep tests
21
+ * deterministic across runs.
22
+ */
23
+
24
+ const SALES_INVOICE_STATUS_DICTIONARY_KEY = 'sales.invoice_status'
25
+
26
+ const cache = new Map<string, string>()
27
+
28
+ function key(tenantId: string, organizationId: string, status: InvoiceStatus): string {
29
+ return `${tenantId}:${organizationId}:${status}`
30
+ }
31
+
32
+ export async function resolveInvoiceStatusEntryId(
33
+ em: EntityManager,
34
+ tenantId: string,
35
+ organizationId: string,
36
+ status: InvoiceStatus,
37
+ ): Promise<string | null> {
38
+ const cacheKey = key(tenantId, organizationId, status)
39
+ const cached = cache.get(cacheKey)
40
+ if (cached) return cached
41
+ const dictionary = await em.findOne(Dictionary, {
42
+ tenantId,
43
+ organizationId,
44
+ key: SALES_INVOICE_STATUS_DICTIONARY_KEY,
45
+ deletedAt: null,
46
+ })
47
+ if (!dictionary) return null
48
+ const entry = await em.findOne(DictionaryEntry, {
49
+ dictionary,
50
+ tenantId,
51
+ organizationId,
52
+ value: status,
53
+ })
54
+ if (!entry) return null
55
+ cache.set(cacheKey, entry.id)
56
+ return entry.id
57
+ }
58
+
59
+ export function __resetInvoiceStatusCacheForTests(): void {
60
+ cache.clear()
61
+ }