@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,561 @@
1
+ import { runBillRun, type RunBillRunParams } from '../../lib/billRunEngine'
2
+ import { __resetInvoiceWriterCacheForTests } from '../../lib/invoiceWriter'
3
+ import {
4
+ BillRun,
5
+ BillRunOutcome,
6
+ BillingAccount,
7
+ BillingAccountUsage,
8
+ BillingItem,
9
+ } from '../../data/entities'
10
+
11
+ /**
12
+ * End-to-end engine orchestration tests using a thin EM mock.
13
+ *
14
+ * These exercise the *control flow* — lock acquisition, account
15
+ * iteration, savepoint isolation per account, dry/test/real branching,
16
+ * final summary aggregation. The per-account work (period derivation,
17
+ * item selection, invoice writing) is covered by the focused tests in
18
+ * `billPeriod` / `itemSelector` / `invoiceWriter` and is intentionally
19
+ * collapsed in the mock here (`em.execute` no-ops the open-draft check,
20
+ * `em.find(BillingItem, ...)` returns whatever the test stages).
21
+ */
22
+
23
+ type Captured = {
24
+ persisted: unknown[]
25
+ flushes: number
26
+ findCalls: Array<{ entity: unknown; where: unknown }>
27
+ }
28
+
29
+ function createEnvironment(
30
+ options: {
31
+ accounts: BillingAccount[]
32
+ itemsByAccount: Map<string, BillingItem[]>
33
+ usageByAccount?: Map<string, BillingAccountUsage[]>
34
+ openDraftIds?: Map<string, string> // accountId → existing-draft id
35
+ lockAcquired?: boolean
36
+ accountFails?: Set<string>
37
+ },
38
+ ): {
39
+ em: unknown
40
+ container: unknown
41
+ captured: Captured
42
+ numberGenerator: { generate: jest.MockedFunction<(...args: unknown[]) => unknown> }
43
+ usageMarkedAs: jest.MockedFunction<(entity: unknown, where: unknown, data: unknown) => Promise<number>>
44
+ } {
45
+ const captured: Captured = { persisted: [], flushes: 0, findCalls: [] }
46
+ const numberGenerator = {
47
+ generate: jest.fn(async () => ({
48
+ number: 'INV-2026-0000001',
49
+ format: 'INV-{YYYY}-{0000001}',
50
+ sequence: 1,
51
+ })),
52
+ }
53
+
54
+ type EmShape = {
55
+ transactional: jest.MockedFunction<(fn: (tem: EmShape) => Promise<unknown>) => Promise<unknown>>
56
+ execute: jest.MockedFunction<(sql: string, params?: unknown[]) => Promise<unknown>>
57
+ create: jest.MockedFunction<(ctor: unknown, data: Record<string, unknown>) => unknown>
58
+ persist: jest.MockedFunction<(entity: unknown) => void>
59
+ flush: jest.MockedFunction<() => Promise<void>>
60
+ find: jest.MockedFunction<(entity: unknown, where: unknown) => Promise<unknown[]>>
61
+ findOne: jest.MockedFunction<(entity: unknown, where: unknown) => Promise<unknown>>
62
+ nativeUpdate: jest.MockedFunction<(entity: unknown, where: unknown, data: unknown) => Promise<number>>
63
+ }
64
+
65
+ const em: EmShape = {
66
+ transactional: jest.fn(async function (fn: (tem: EmShape) => Promise<unknown>) {
67
+ const failedAccountId = (this as { __failingAccount?: string })?.__failingAccount
68
+ if (failedAccountId) {
69
+ throw new Error(`mock per-account failure: ${failedAccountId}`)
70
+ }
71
+ return fn(em)
72
+ }),
73
+ execute: jest.fn(async (sql: string, params?: unknown[]) => {
74
+ // Usage aggregate: SELECT uom_code, SUM(quantity), COUNT(*) FROM
75
+ // billing_account_usage WHERE ... GROUP BY uom_code. Buckets the
76
+ // test fixture records by uom for the runner.
77
+ if (sql.includes('billing_account_usage') && sql.includes('GROUP BY uom_code')) {
78
+ const accountId = (params?.[2] ?? '') as string
79
+ const recs = (options.usageByAccount?.get(accountId) ?? []).filter(
80
+ (r) => r.ratedInBillRunId === null,
81
+ )
82
+ const grouped = new Map<string, { sum: number; count: number }>()
83
+ for (const r of recs) {
84
+ const prev = grouped.get(r.uomCode) ?? { sum: 0, count: 0 }
85
+ prev.sum += Number.parseFloat(r.quantity)
86
+ prev.count += 1
87
+ grouped.set(r.uomCode, prev)
88
+ }
89
+ return Array.from(grouped.entries()).map(([uom, agg]) => ({
90
+ uom_code: uom,
91
+ total_quantity: String(agg.sum),
92
+ record_count: String(agg.count),
93
+ })) as unknown
94
+ }
95
+ // Open-draft check: SELECT id FROM sales_invoices ... bill_account_id = ?
96
+ if (sql.includes('sales_invoices') && params && Array.isArray(params) && params.length >= 3) {
97
+ const accountId = params[2] as string
98
+ const existing = options.openDraftIds?.get(accountId)
99
+ if (existing) return [{ id: existing }] as unknown
100
+ }
101
+ // Advisory-lock acquire query for the outer transaction
102
+ return [{ acquired: options.lockAcquired ?? true }] as unknown
103
+ }),
104
+ create: jest.fn((_ctor: unknown, data: Record<string, unknown>) => ({
105
+ ...data,
106
+ id: (data.id as string | undefined) ?? `gen-${captured.persisted.length + 1}`,
107
+ })),
108
+ persist: jest.fn((entity: unknown) => {
109
+ captured.persisted.push(entity)
110
+ }),
111
+ flush: jest.fn(async () => {
112
+ captured.flushes += 1
113
+ }),
114
+ find: jest.fn(async (entity: unknown, where: unknown) => {
115
+ captured.findCalls.push({ entity, where })
116
+ if (entity === BillingAccount) return options.accounts as unknown[]
117
+ if (entity === BillingItem) {
118
+ const accountId = (where as { billAccountId?: string }).billAccountId
119
+ if (!accountId) return []
120
+ return (options.itemsByAccount.get(accountId) ?? []) as unknown[]
121
+ }
122
+ if (entity === BillingAccountUsage) {
123
+ const accountId = (where as { billAccountId?: string }).billAccountId
124
+ if (!accountId) return []
125
+ const records = options.usageByAccount?.get(accountId) ?? []
126
+ // Apply mock filtering for rated_in_bill_run_id IS NULL
127
+ return records.filter((r) => r.ratedInBillRunId === null) as unknown[]
128
+ }
129
+ return []
130
+ }),
131
+ findOne: jest.fn(async (_entity: unknown, _where: unknown) => null),
132
+ nativeUpdate: jest.fn(async (entity: unknown, where: unknown, data: unknown) => {
133
+ if (entity === BillingAccountUsage) {
134
+ // Predicate-based bulk update (post-Phase-4f refactor): the
135
+ // engine now passes `{ billAccountId, uomCode: { $in: [...] },
136
+ // ratedInBillRunId: null, periodEnd: { $lte: <date> }, ... }`
137
+ // and we never see an explicit id list.
138
+ const w = where as {
139
+ billAccountId?: string
140
+ uomCode?: { $in?: string[] }
141
+ ratedInBillRunId?: null
142
+ }
143
+ const accountId = w.billAccountId
144
+ const uoms = new Set(w.uomCode?.$in ?? [])
145
+ if (!accountId || uoms.size === 0) return 0
146
+ const records = options.usageByAccount?.get(accountId) ?? []
147
+ let touched = 0
148
+ for (const record of records) {
149
+ if (record.ratedInBillRunId !== null) continue
150
+ if (!uoms.has(record.uomCode)) continue
151
+ record.ratedInBillRunId =
152
+ (data as { ratedInBillRunId?: string }).ratedInBillRunId ?? null
153
+ touched += 1
154
+ }
155
+ return touched
156
+ }
157
+ return 0
158
+ }),
159
+ }
160
+
161
+ // For failure injection — wire the per-account hook
162
+ if (options.accountFails && options.accountFails.size > 0) {
163
+ let accountIdx = 0
164
+ em.transactional = jest.fn(async function (fn: (tem: EmShape) => Promise<unknown>) {
165
+ // The FIRST transactional call is the outer (lock) wrap — pass
166
+ // through. Subsequent calls are per-account savepoints — fail
167
+ // the ones in `accountFails`.
168
+ if (accountIdx === 0) {
169
+ accountIdx += 1
170
+ return fn(em)
171
+ }
172
+ const accountId = options.accounts[accountIdx - 1]?.id
173
+ accountIdx += 1
174
+ if (accountId && options.accountFails!.has(accountId)) {
175
+ throw new Error(`Synthetic per-account failure: ${accountId}`)
176
+ }
177
+ return fn(em)
178
+ })
179
+ }
180
+
181
+ const container = { resolve: jest.fn((token: string) => {
182
+ if (token === 'salesDocumentNumberGenerator') return numberGenerator
183
+ return null
184
+ }) }
185
+
186
+ return { em, container, captured, numberGenerator, usageMarkedAs: em.nativeUpdate }
187
+ }
188
+
189
+ function makeAccount(overrides: Partial<BillingAccount>): BillingAccount {
190
+ return {
191
+ id: overrides.id ?? 'acc-1',
192
+ organizationId: overrides.organizationId ?? 'org-1',
193
+ tenantId: overrides.tenantId ?? 'tenant-1',
194
+ customerId: overrides.customerId ?? 'cust-1',
195
+ name: overrides.name ?? 'Acme',
196
+ currencyCode: overrides.currencyCode ?? 'EUR',
197
+ billCycle: overrides.billCycle ?? 'monthly',
198
+ billCycleAnchor: overrides.billCycleAnchor ?? '1',
199
+ invoiceEmail: overrides.invoiceEmail ?? 'ar@acme.test',
200
+ invoiceLanguage: overrides.invoiceLanguage ?? 'en',
201
+ taxId: overrides.taxId ?? null,
202
+ invoiceAddress: overrides.invoiceAddress ?? {
203
+ line1: 'X',
204
+ city: 'Y',
205
+ postal_code: 'Z',
206
+ country: 'PL',
207
+ },
208
+ nextBillDate: overrides.nextBillDate ?? new Date('2026-06-01T00:00:00Z'),
209
+ lastBillDate: overrides.lastBillDate ?? null,
210
+ isActive: overrides.isActive ?? true,
211
+ createdAt: overrides.createdAt ?? new Date(),
212
+ updatedAt: overrides.updatedAt ?? new Date(),
213
+ deletedAt: overrides.deletedAt ?? null,
214
+ } as BillingAccount
215
+ }
216
+
217
+ function makeRecurringItem(billAccountId: string, id = 'item-mrc'): BillingItem {
218
+ return {
219
+ id,
220
+ organizationId: 'org-1',
221
+ tenantId: 'tenant-1',
222
+ billAccountId,
223
+ type: 'recurring',
224
+ billStartDate: new Date('2026-01-01T00:00:00Z'),
225
+ billEndDate: null,
226
+ description: 'MRC',
227
+ rateJson: { unit_price: 49.99 },
228
+ uomCode: null,
229
+ subscriptionId: null,
230
+ subscriptionItemId: null,
231
+ sourceRef: null,
232
+ currencyMismatch: false,
233
+ billedToDate: null,
234
+ isActive: true,
235
+ createdAt: new Date(),
236
+ updatedAt: new Date(),
237
+ deletedAt: null,
238
+ } as BillingItem
239
+ }
240
+
241
+ beforeEach(() => {
242
+ __resetInvoiceWriterCacheForTests()
243
+ })
244
+
245
+ const params = (overrides: Partial<RunBillRunParams>): RunBillRunParams => ({
246
+ tenantId: 'tenant-1',
247
+ organizationId: 'org-1',
248
+ asOfDate: new Date('2026-06-01T00:00:00Z'),
249
+ triggeredBy: 'manual',
250
+ mode: 'real',
251
+ ...overrides,
252
+ })
253
+
254
+ describe('runBillRun — orchestration', () => {
255
+ it('happy path: one account, one recurring item → one draft + success outcome', async () => {
256
+ const account = makeAccount({})
257
+ const item = makeRecurringItem(account.id)
258
+ const { em, container, captured } = createEnvironment({
259
+ accounts: [account],
260
+ itemsByAccount: new Map([[account.id, [item]]]),
261
+ })
262
+
263
+ const { billRun, outcomes } = await runBillRun(em as never, container as never, params({}))
264
+
265
+ expect(billRun.status).toBe('completed')
266
+ expect(outcomes).toHaveLength(1)
267
+ expect(outcomes[0].status).toBe('success')
268
+ expect(outcomes[0].draftInvoiceId).toMatch(/^gen-/)
269
+ // Real-mode advances next_bill_date
270
+ expect(account.nextBillDate.toISOString().slice(0, 10)).toBe('2026-07-01')
271
+ expect(item.billedToDate?.toISOString().slice(0, 10)).toBe('2026-05-31')
272
+ // Persisted entities: BillRun + SalesInvoice + 1 line + BillingAccountUsage advance + BillRunOutcome
273
+ expect(captured.persisted.length).toBeGreaterThan(0)
274
+ })
275
+
276
+ it('zero accounts → completed run with summary={accounts_processed: 0}', async () => {
277
+ const { em, container } = createEnvironment({
278
+ accounts: [],
279
+ itemsByAccount: new Map(),
280
+ })
281
+ const { billRun, outcomes } = await runBillRun(em as never, container as never, params({}))
282
+ expect(billRun.status).toBe('completed')
283
+ expect(billRun.summary).toMatchObject({ accounts_processed: 0 })
284
+ expect(outcomes).toHaveLength(0)
285
+ })
286
+
287
+ it('open-draft check: account with existing draft is skipped, no advance', async () => {
288
+ const account = makeAccount({})
289
+ const item = makeRecurringItem(account.id)
290
+ const { em, container } = createEnvironment({
291
+ accounts: [account],
292
+ itemsByAccount: new Map([[account.id, [item]]]),
293
+ openDraftIds: new Map([[account.id, 'existing-draft-1']]),
294
+ })
295
+
296
+ const { billRun, outcomes } = await runBillRun(em as never, container as never, params({}))
297
+
298
+ expect(outcomes[0].status).toBe('skipped_existing_draft')
299
+ expect(outcomes[0].draftInvoiceId).toBe('existing-draft-1')
300
+ // next_bill_date does NOT advance when we skip
301
+ expect(account.nextBillDate.toISOString().slice(0, 10)).toBe('2026-06-01')
302
+ // Item not marked billed
303
+ expect(item.billedToDate).toBeNull()
304
+ expect(billRun.summary).toMatchObject({ drafts_skipped_existing: 1 })
305
+ })
306
+
307
+ it('dry-run mode: persists nothing, advances nothing, computes outcomes', async () => {
308
+ const account = makeAccount({})
309
+ const item = makeRecurringItem(account.id)
310
+ const { em, container } = createEnvironment({
311
+ accounts: [account],
312
+ itemsByAccount: new Map([[account.id, [item]]]),
313
+ })
314
+
315
+ const { billRun, outcomes } = await runBillRun(em as never, container as never, params({ mode: 'dry' }))
316
+
317
+ expect(billRun.dryRun).toBe(true)
318
+ expect(outcomes[0].status).toBe('success')
319
+ // Dry-run: no draft invoice id stored on the outcome
320
+ expect(outcomes[0].draftInvoiceId).toBeNull()
321
+ // No advancement
322
+ expect(account.nextBillDate.toISOString().slice(0, 10)).toBe('2026-06-01')
323
+ expect(item.billedToDate).toBeNull()
324
+ })
325
+
326
+ it('test mode: persists draft with test_run flag, does NOT advance state', async () => {
327
+ const account = makeAccount({})
328
+ const item = makeRecurringItem(account.id)
329
+ const { em, container } = createEnvironment({
330
+ accounts: [account],
331
+ itemsByAccount: new Map([[account.id, [item]]]),
332
+ })
333
+
334
+ const { billRun, outcomes } = await runBillRun(em as never, container as never, params({ mode: 'test' }))
335
+
336
+ expect(billRun.testMode).toBe(true)
337
+ expect(outcomes[0].status).toBe('success')
338
+ expect(outcomes[0].draftInvoiceId).toMatch(/^gen-/)
339
+ // No state advance
340
+ expect(account.nextBillDate.toISOString().slice(0, 10)).toBe('2026-06-01')
341
+ expect(item.billedToDate).toBeNull()
342
+ })
343
+
344
+ it('currency-mismatch item flags the outcome with success_with_warnings', async () => {
345
+ const account = makeAccount({})
346
+ const item = makeRecurringItem(account.id)
347
+ item.currencyMismatch = true
348
+ const { em, container } = createEnvironment({
349
+ accounts: [account],
350
+ itemsByAccount: new Map([[account.id, [item]]]),
351
+ })
352
+
353
+ const { outcomes } = await runBillRun(em as never, container as never, params({}))
354
+ expect(outcomes[0].status).toBe('success_with_warnings')
355
+ expect((outcomes[0].warnings as { currency_mismatch_items?: string[] } | null)
356
+ ?.currency_mismatch_items).toEqual([item.id])
357
+ })
358
+
359
+ it('account with no eligible items still advances next_bill_date in real mode', async () => {
360
+ const account = makeAccount({})
361
+ const { em, container } = createEnvironment({
362
+ accounts: [account],
363
+ itemsByAccount: new Map([[account.id, []]]),
364
+ })
365
+
366
+ const { outcomes } = await runBillRun(em as never, container as never, params({}))
367
+ expect(outcomes[0].status).toBe('success')
368
+ expect(account.nextBillDate.toISOString().slice(0, 10)).toBe('2026-07-01')
369
+ })
370
+
371
+ // ─── Phase 3 — usage flow ────────────────────────────────────
372
+
373
+ function makeUsageItem(
374
+ billAccountId: string,
375
+ uomCode: string,
376
+ rate: Record<string, unknown>,
377
+ ): BillingItem {
378
+ return {
379
+ id: `usage-item-${uomCode}`,
380
+ organizationId: 'org-1',
381
+ tenantId: 'tenant-1',
382
+ billAccountId,
383
+ type: 'usage',
384
+ billStartDate: new Date('2026-01-01T00:00:00Z'),
385
+ billEndDate: null,
386
+ description: `Usage — ${uomCode}`,
387
+ rateJson: rate,
388
+ uomCode,
389
+ subscriptionId: null,
390
+ subscriptionItemId: null,
391
+ sourceRef: null,
392
+ currencyMismatch: false,
393
+ billedToDate: null,
394
+ isActive: true,
395
+ createdAt: new Date(),
396
+ updatedAt: new Date(),
397
+ deletedAt: null,
398
+ } as BillingItem
399
+ }
400
+
401
+ function makeUsageRecord(
402
+ id: string,
403
+ billAccountId: string,
404
+ uomCode: string,
405
+ quantity: number,
406
+ ): BillingAccountUsage {
407
+ return {
408
+ id,
409
+ organizationId: 'org-1',
410
+ tenantId: 'tenant-1',
411
+ billAccountId,
412
+ uomCode,
413
+ quantity: quantity.toFixed(4),
414
+ periodStart: new Date('2026-05-01T00:00:00Z'),
415
+ periodEnd: new Date('2026-05-31T23:59:59Z'),
416
+ lineDescription: null,
417
+ sourceRef: null,
418
+ ratedInBillRunId: null,
419
+ isActive: true,
420
+ createdAt: new Date(),
421
+ updatedAt: new Date(),
422
+ deletedAt: null,
423
+ } as BillingAccountUsage
424
+ }
425
+
426
+ it('aggregates usage records: graduated 25k → 15 EUR + marks rated_in_bill_run_id', async () => {
427
+ const account = makeAccount({})
428
+ const usageItem = makeUsageItem(account.id, 'api_request', {
429
+ model: 'graduated',
430
+ tiers: [
431
+ { up_to: 10000, unit_price: 0 },
432
+ { up_to: null, unit_price: 0.001 },
433
+ ],
434
+ })
435
+ const u1 = makeUsageRecord('u1', account.id, 'api_request', 10000)
436
+ const u2 = makeUsageRecord('u2', account.id, 'api_request', 15000)
437
+ const { em, container, captured } = createEnvironment({
438
+ accounts: [account],
439
+ itemsByAccount: new Map([[account.id, [usageItem]]]),
440
+ usageByAccount: new Map([[account.id, [u1, u2]]]),
441
+ })
442
+
443
+ const { billRun, outcomes } = await runBillRun(em as never, container as never, params({}))
444
+
445
+ expect(outcomes[0].status).toBe('success')
446
+ // Persisted SalesInvoiceLine for the usage item with the right total
447
+ const usageLine = captured.persisted.find((p) => {
448
+ const m = (p as { metadata?: { billing_type?: string } }).metadata
449
+ return m?.billing_type === 'usage'
450
+ })
451
+ expect(usageLine).toBeDefined()
452
+ expect((usageLine as { totalNetAmount?: string }).totalNetAmount).toBe('15.0000')
453
+
454
+ // Real-mode marks records as rated
455
+ expect(u1.ratedInBillRunId).toBe(billRun.id)
456
+ expect(u2.ratedInBillRunId).toBe(billRun.id)
457
+ expect(billRun.summary).toMatchObject({ usage_records_rated: 2 })
458
+ })
459
+
460
+ it('unmatched usage uom → success_with_warnings, records left un-rated', async () => {
461
+ const account = makeAccount({})
462
+ const orphan = makeUsageRecord('u-orphan', account.id, 'mystery_meter', 999)
463
+ const { em, container } = createEnvironment({
464
+ accounts: [account],
465
+ itemsByAccount: new Map([[account.id, []]]),
466
+ usageByAccount: new Map([[account.id, [orphan]]]),
467
+ })
468
+
469
+ const { outcomes } = await runBillRun(em as never, container as never, params({}))
470
+
471
+ expect(outcomes[0].status).toBe('success_with_warnings')
472
+ expect((outcomes[0].warnings as { unmatched_usage_uoms?: string[] } | null)
473
+ ?.unmatched_usage_uoms).toEqual(['mystery_meter'])
474
+ // Critically: orphan is NOT marked rated
475
+ expect(orphan.ratedInBillRunId).toBeNull()
476
+ })
477
+
478
+ it('simple flat-rate usage emits unit_price on the invoice line', async () => {
479
+ const account = makeAccount({})
480
+ const usageItem = makeUsageItem(account.id, 'api_request', { unit_price: 0.001 })
481
+ const u1 = makeUsageRecord('u1', account.id, 'api_request', 12000)
482
+ const { em, container, captured } = createEnvironment({
483
+ accounts: [account],
484
+ itemsByAccount: new Map([[account.id, [usageItem]]]),
485
+ usageByAccount: new Map([[account.id, [u1]]]),
486
+ })
487
+
488
+ await runBillRun(em as never, container as never, params({}))
489
+
490
+ const usageLine = captured.persisted.find((p) => {
491
+ const m = (p as { metadata?: { billing_type?: string } }).metadata
492
+ return m?.billing_type === 'usage'
493
+ }) as { unitPriceNet?: string; totalNetAmount?: string; quantity?: string } | undefined
494
+ expect(usageLine?.unitPriceNet).toBe('0.0010')
495
+ expect(usageLine?.quantity).toBe('12000.0000')
496
+ expect(usageLine?.totalNetAmount).toBe('12.0000')
497
+ })
498
+
499
+ it('test mode: rates usage on draft but does NOT mark records as rated', async () => {
500
+ const account = makeAccount({})
501
+ const usageItem = makeUsageItem(account.id, 'api_request', { unit_price: 0.001 })
502
+ const u1 = makeUsageRecord('u1', account.id, 'api_request', 1000)
503
+ const { em, container } = createEnvironment({
504
+ accounts: [account],
505
+ itemsByAccount: new Map([[account.id, [usageItem]]]),
506
+ usageByAccount: new Map([[account.id, [u1]]]),
507
+ })
508
+
509
+ await runBillRun(em as never, container as never, params({ mode: 'test' }))
510
+
511
+ expect(u1.ratedInBillRunId).toBeNull()
512
+ })
513
+
514
+ it('mixed recurring + usage: invoice has BOTH lines, total = sum', async () => {
515
+ const account = makeAccount({})
516
+ const mrc = makeRecurringItem(account.id, 'mrc')
517
+ const usageItem = makeUsageItem(account.id, 'api_request', { unit_price: 0.001 })
518
+ const u1 = makeUsageRecord('u1', account.id, 'api_request', 12000)
519
+ const { em, container, captured } = createEnvironment({
520
+ accounts: [account],
521
+ itemsByAccount: new Map([[account.id, [mrc, usageItem]]]),
522
+ usageByAccount: new Map([[account.id, [u1]]]),
523
+ })
524
+
525
+ await runBillRun(em as never, container as never, params({}))
526
+
527
+ const lines = captured.persisted.filter((p) => {
528
+ const m = (p as { metadata?: { billing_type?: string } }).metadata
529
+ return m?.billing_type === 'recurring' || m?.billing_type === 'usage'
530
+ })
531
+ expect(lines).toHaveLength(2)
532
+ // Invoice header totalNet should be 49.99 (MRC) + 12.00 (usage) = 61.99
533
+ const invoice = captured.persisted.find(
534
+ (p) => (p as { invoiceNumber?: string }).invoiceNumber,
535
+ ) as { grandTotalNetAmount?: string } | undefined
536
+ expect(invoice?.grandTotalNetAmount).toBe('61.9900')
537
+ })
538
+
539
+ it('summary aggregates drafts_created / accounts_failed / accounts_with_warnings', async () => {
540
+ const a1 = makeAccount({ id: 'a1' })
541
+ const a2 = makeAccount({ id: 'a2' })
542
+ const i1 = makeRecurringItem('a1')
543
+ const i2 = makeRecurringItem('a2')
544
+ i2.currencyMismatch = true
545
+ const { em, container } = createEnvironment({
546
+ accounts: [a1, a2],
547
+ itemsByAccount: new Map([
548
+ ['a1', [i1]],
549
+ ['a2', [i2]],
550
+ ]),
551
+ })
552
+
553
+ const { billRun } = await runBillRun(em as never, container as never, params({}))
554
+ expect(billRun.summary).toMatchObject({
555
+ accounts_processed: 2,
556
+ drafts_created: 2,
557
+ accounts_with_warnings: 1,
558
+ accounts_failed: 0,
559
+ })
560
+ })
561
+ })
@@ -0,0 +1,81 @@
1
+ import { findBySourceRef } from '../../lib/idempotency'
2
+
3
+ class FakeEntity {
4
+ id = 'fake-id'
5
+ sourceRef?: string | null
6
+ }
7
+
8
+ type MockEm = {
9
+ findOne: jest.MockedFunction<(entity: unknown, where: Record<string, unknown>) => Promise<unknown>>
10
+ }
11
+
12
+ function createMockEm(): MockEm {
13
+ return { findOne: jest.fn() }
14
+ }
15
+
16
+ const TENANT_ID = '00000000-0000-0000-0000-0000000000aa'
17
+ const ACCOUNT_ID = '00000000-0000-0000-0000-0000000000bb'
18
+
19
+ describe('findBySourceRef', () => {
20
+ it('queries with the full idempotency scope (tenant, account, sourceRef, not deleted)', async () => {
21
+ const em = createMockEm()
22
+ em.findOne.mockResolvedValueOnce(null)
23
+
24
+ await findBySourceRef(em as never, FakeEntity, {
25
+ tenantId: TENANT_ID,
26
+ billAccountId: ACCOUNT_ID,
27
+ sourceRef: 'cpq-sub-1-mrc',
28
+ })
29
+
30
+ expect(em.findOne).toHaveBeenCalledTimes(1)
31
+ const [entity, where] = em.findOne.mock.calls[0]
32
+ expect(entity).toBe(FakeEntity)
33
+ expect(where).toEqual({
34
+ tenantId: TENANT_ID,
35
+ billAccountId: ACCOUNT_ID,
36
+ sourceRef: 'cpq-sub-1-mrc',
37
+ deletedAt: null,
38
+ })
39
+ })
40
+
41
+ it('returns the existing row when one matches', async () => {
42
+ const em = createMockEm()
43
+ const existing = Object.assign(new FakeEntity(), { id: 'existing-uuid' })
44
+ em.findOne.mockResolvedValueOnce(existing)
45
+
46
+ const result = await findBySourceRef(em as never, FakeEntity, {
47
+ tenantId: TENANT_ID,
48
+ billAccountId: ACCOUNT_ID,
49
+ sourceRef: 'cpq-sub-1-mrc',
50
+ })
51
+
52
+ expect(result).toBe(existing)
53
+ })
54
+
55
+ it('returns null when no row matches (caller proceeds with insert)', async () => {
56
+ const em = createMockEm()
57
+ em.findOne.mockResolvedValueOnce(null)
58
+
59
+ const result = await findBySourceRef(em as never, FakeEntity, {
60
+ tenantId: TENANT_ID,
61
+ billAccountId: ACCOUNT_ID,
62
+ sourceRef: 'never-seen',
63
+ })
64
+
65
+ expect(result).toBeNull()
66
+ })
67
+
68
+ it('excludes soft-deleted rows (deletedAt IS NULL)', async () => {
69
+ const em = createMockEm()
70
+ em.findOne.mockResolvedValueOnce(null)
71
+
72
+ await findBySourceRef(em as never, FakeEntity, {
73
+ tenantId: TENANT_ID,
74
+ billAccountId: ACCOUNT_ID,
75
+ sourceRef: 'src',
76
+ })
77
+
78
+ const [, where] = em.findOne.mock.calls[0]
79
+ expect((where as { deletedAt: unknown }).deletedAt).toBeNull()
80
+ })
81
+ })