@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,464 @@
1
+ /**
2
+ * Unit tests for the Phase 4a + 4b invoice commands.
3
+ *
4
+ * These exercise the *command contract* — refusal on non-draft,
5
+ * audit-row writing, totals recompute — via a thin mocked EM. Real
6
+ * end-to-end persistence is covered by the integration suite once
7
+ * Phase 4c admin UI lands and Playwright can drive the flow.
8
+ *
9
+ * The mock EM models the subset of operations the commands touch:
10
+ * findOne / find / create / persist / flush / remove / execute.
11
+ * `create` returns a shallow copy with a synthetic id so the
12
+ * command's `emit / return` path can read `result.id` immediately.
13
+ */
14
+
15
+ import {
16
+ __resetInvoiceStatusCacheForTests,
17
+ } from '../../lib/invoiceStatus'
18
+ import {
19
+ addDraftLineCommand,
20
+ editDraftLineCommand,
21
+ postInvoiceCommand,
22
+ removeDraftLineCommand,
23
+ wipeTestInvoicesCommand,
24
+ } from '../../commands/invoices'
25
+ import {
26
+ DraftInvoiceEdit,
27
+ } from '../../data/entities'
28
+
29
+ type FakeInvoice = {
30
+ id: string
31
+ status: string
32
+ statusEntryId: string | null
33
+ tenantId: string
34
+ organizationId: string
35
+ invoiceNumber: string
36
+ currencyCode: string
37
+ metadata: Record<string, unknown> | null
38
+ subtotalNetAmount: string
39
+ subtotalGrossAmount: string
40
+ grandTotalNetAmount: string
41
+ grandTotalGrossAmount: string
42
+ outstandingAmount: string
43
+ paidTotalAmount: string
44
+ updatedAt: Date
45
+ deletedAt: Date | null
46
+ }
47
+
48
+ type FakeLine = {
49
+ id: string
50
+ invoice: FakeInvoice
51
+ tenantId: string
52
+ organizationId: string
53
+ description: string | null
54
+ quantity: string
55
+ normalizedQuantity: string
56
+ unitPriceNet: string
57
+ unitPriceGross: string
58
+ totalNetAmount: string
59
+ totalGrossAmount: string
60
+ metadata: Record<string, unknown> | null
61
+ lineNumber: number
62
+ }
63
+
64
+ const TENANT = '11111111-1111-4111-8111-111111111111'
65
+ const ORG = '22222222-2222-4222-8222-222222222222'
66
+ const INVOICE_ID = '33333333-3333-4333-8333-333333333333'
67
+ const LINE_ID = '44444444-4444-4444-8444-444444444444'
68
+
69
+ function makeInvoice(overrides: Partial<FakeInvoice> = {}): FakeInvoice {
70
+ return {
71
+ id: overrides.id ?? INVOICE_ID,
72
+ status: overrides.status ?? 'draft',
73
+ statusEntryId: overrides.statusEntryId ?? 'status-draft',
74
+ tenantId: overrides.tenantId ?? TENANT,
75
+ organizationId: overrides.organizationId ?? ORG,
76
+ invoiceNumber: overrides.invoiceNumber ?? 'INV-2026-0000001',
77
+ currencyCode: overrides.currencyCode ?? 'EUR',
78
+ metadata: overrides.metadata ?? {},
79
+ subtotalNetAmount: overrides.subtotalNetAmount ?? '100.0000',
80
+ subtotalGrossAmount: overrides.subtotalGrossAmount ?? '100.0000',
81
+ grandTotalNetAmount: overrides.grandTotalNetAmount ?? '100.0000',
82
+ grandTotalGrossAmount: overrides.grandTotalGrossAmount ?? '100.0000',
83
+ outstandingAmount: overrides.outstandingAmount ?? '100.0000',
84
+ paidTotalAmount: overrides.paidTotalAmount ?? '0',
85
+ updatedAt: overrides.updatedAt ?? new Date(),
86
+ deletedAt: overrides.deletedAt ?? null,
87
+ }
88
+ }
89
+
90
+ function makeLine(invoice: FakeInvoice, overrides: Partial<FakeLine> = {}): FakeLine {
91
+ return {
92
+ id: overrides.id ?? LINE_ID,
93
+ invoice,
94
+ tenantId: invoice.tenantId,
95
+ organizationId: invoice.organizationId,
96
+ description: overrides.description ?? 'MRC',
97
+ quantity: overrides.quantity ?? '1.0000',
98
+ normalizedQuantity: overrides.normalizedQuantity ?? '1.0000',
99
+ unitPriceNet: overrides.unitPriceNet ?? '100.0000',
100
+ unitPriceGross: overrides.unitPriceGross ?? '100.0000',
101
+ totalNetAmount: overrides.totalNetAmount ?? '100.0000',
102
+ totalGrossAmount: overrides.totalGrossAmount ?? '100.0000',
103
+ metadata: overrides.metadata ?? { billing_type: 'recurring' },
104
+ lineNumber: overrides.lineNumber ?? 1,
105
+ }
106
+ }
107
+
108
+ type Env = {
109
+ ctx: { container: { resolve: jest.MockedFunction<(name: string) => unknown> }; auth: { sub?: string } | null }
110
+ em: {
111
+ findOne: jest.MockedFunction<(entity: unknown, where: unknown) => Promise<unknown>>
112
+ find: jest.MockedFunction<(entity: unknown, where: unknown) => Promise<unknown[]>>
113
+ create: jest.MockedFunction<(entity: unknown, data: Record<string, unknown>) => unknown>
114
+ persist: jest.MockedFunction<(entity: unknown) => unknown>
115
+ flush: jest.MockedFunction<() => Promise<void>>
116
+ remove: jest.MockedFunction<(entity: unknown) => unknown>
117
+ execute: jest.MockedFunction<(sql: string, params?: unknown[]) => Promise<unknown>>
118
+ fork: jest.MockedFunction<() => Env['em']>
119
+ }
120
+ persistedEntities: unknown[]
121
+ removedEntities: unknown[]
122
+ }
123
+
124
+ let auditIdCounter = 0
125
+
126
+ function createEnv(
127
+ options: {
128
+ invoice?: FakeInvoice | null
129
+ line?: FakeLine | null
130
+ statusEntryDraft?: string | null
131
+ statusEntryPosted?: string | null
132
+ initialLines?: FakeLine[]
133
+ invoiceFinalLines?: FakeLine[]
134
+ auth?: { sub?: string } | null
135
+ testInvoiceRows?: Array<{ id: string }>
136
+ } = {},
137
+ ): Env {
138
+ const persistedEntities: unknown[] = []
139
+ const removedEntities: unknown[] = []
140
+ let lineSequence = (options.initialLines ?? []).length
141
+
142
+ const em: Env['em'] = {
143
+ findOne: jest.fn(async (entity: unknown, _where: unknown) => {
144
+ // Distinguish by entity reference. Order of priority:
145
+ // SalesInvoice → Dictionary / DictionaryEntry (via invoiceStatus
146
+ // resolver — returns dict then entry) → SalesInvoiceLine.
147
+ const constructorName = (entity as { name?: string }).name ?? ''
148
+ if (constructorName === 'SalesInvoice') return options.invoice ?? null
149
+ if (constructorName === 'SalesInvoiceLine') return options.line ?? null
150
+ if (constructorName === 'Dictionary') return { id: 'dict-1' }
151
+ if (constructorName === 'DictionaryEntry') {
152
+ // The status resolver looks up by value; assume posted for the
153
+ // post test (the only one that exercises this path).
154
+ return { id: options.statusEntryPosted ?? 'entry-posted' }
155
+ }
156
+ return null
157
+ }) as never,
158
+ find: jest.fn(async (entity: unknown, _where: unknown) => {
159
+ const constructorName = (entity as { name?: string }).name ?? ''
160
+ if (constructorName === 'SalesInvoiceLine') {
161
+ // For add-line: return initialLines so lineNumber is computed.
162
+ // For recomputeInvoiceTotals: return invoiceFinalLines after edit.
163
+ if (options.invoiceFinalLines) return options.invoiceFinalLines
164
+ return options.initialLines ?? []
165
+ }
166
+ return []
167
+ }) as never,
168
+ create: jest.fn((entity: unknown, data: Record<string, unknown>) => {
169
+ const ctor = (entity as { name?: string }).name ?? ''
170
+ if (ctor === 'DraftInvoiceEdit') {
171
+ auditIdCounter += 1
172
+ return { ...data, id: `audit-${auditIdCounter}` }
173
+ }
174
+ if (ctor === 'SalesInvoiceLine') {
175
+ lineSequence += 1
176
+ return { ...data, id: `new-line-${lineSequence}` }
177
+ }
178
+ return { ...data, id: 'generated-id' }
179
+ }) as never,
180
+ persist: jest.fn((entity: unknown) => {
181
+ persistedEntities.push(entity)
182
+ }) as never,
183
+ flush: jest.fn(async () => undefined) as never,
184
+ remove: jest.fn((entity: unknown) => {
185
+ removedEntities.push(entity)
186
+ }) as never,
187
+ execute: jest.fn(async () => options.testInvoiceRows ?? []) as never,
188
+ fork: jest.fn() as never,
189
+ }
190
+ em.fork = jest.fn(() => em) as never
191
+
192
+ const container = {
193
+ resolve: jest.fn((name: string) => {
194
+ if (name === 'em') return em
195
+ return null
196
+ }) as never,
197
+ }
198
+
199
+ return {
200
+ ctx: {
201
+ container,
202
+ auth: options.auth ?? { sub: '99999999-9999-4999-8999-999999999999' },
203
+ },
204
+ em,
205
+ persistedEntities,
206
+ removedEntities,
207
+ }
208
+ }
209
+
210
+ beforeEach(() => {
211
+ __resetInvoiceStatusCacheForTests()
212
+ auditIdCounter = 0
213
+ })
214
+
215
+ const baseInput = {
216
+ tenantId: TENANT,
217
+ organizationId: ORG,
218
+ invoiceId: INVOICE_ID,
219
+ }
220
+
221
+ describe('postInvoiceCommand', () => {
222
+ it('refuses non-draft invoices with 409 + not_draft code', async () => {
223
+ const env = createEnv({ invoice: makeInvoice({ status: 'posted' }) })
224
+ await expect(
225
+ postInvoiceCommand.execute(baseInput, env.ctx as never),
226
+ ).rejects.toMatchObject({
227
+ status: 409,
228
+ body: expect.objectContaining({ code: 'billing.invoice.not_draft' }),
229
+ })
230
+ })
231
+
232
+ it('refuses test_run=true invoices with 409 + test_run code', async () => {
233
+ const env = createEnv({
234
+ invoice: makeInvoice({ status: 'draft', metadata: { test_run: true } }),
235
+ })
236
+ await expect(
237
+ postInvoiceCommand.execute(baseInput, env.ctx as never),
238
+ ).rejects.toMatchObject({
239
+ status: 409,
240
+ body: expect.objectContaining({ code: 'billing.invoice.test_run' }),
241
+ })
242
+ })
243
+
244
+ it('happy path: draft → posted, status_entry_id resolved', async () => {
245
+ const invoice = makeInvoice({ status: 'draft' })
246
+ const env = createEnv({ invoice, initialLines: [makeLine(invoice)] })
247
+ const result = await postInvoiceCommand.execute(baseInput, env.ctx as never)
248
+ expect(result.status).toBe('posted')
249
+ expect(invoice.status).toBe('posted')
250
+ expect(result.lineCount).toBe(1)
251
+ })
252
+
253
+ it('throws 404 when the invoice does not exist for this scope', async () => {
254
+ const env = createEnv({ invoice: null })
255
+ await expect(
256
+ postInvoiceCommand.execute(baseInput, env.ctx as never),
257
+ ).rejects.toMatchObject({ status: 404 })
258
+ })
259
+ })
260
+
261
+ describe('editDraftLineCommand', () => {
262
+ it('refuses non-draft invoices', async () => {
263
+ const invoice = makeInvoice({ status: 'posted' })
264
+ const env = createEnv({ invoice })
265
+ await expect(
266
+ editDraftLineCommand.execute(
267
+ { ...baseInput, invoiceLineId: LINE_ID, changes: { description: 'x' } },
268
+ env.ctx as never,
269
+ ),
270
+ ).rejects.toMatchObject({
271
+ status: 409,
272
+ body: expect.objectContaining({ code: 'billing.invoice.not_draft' }),
273
+ })
274
+ })
275
+
276
+ it('updates description + recomputes line total from unit_price × quantity', async () => {
277
+ const invoice = makeInvoice({ status: 'draft' })
278
+ const line = makeLine(invoice, {
279
+ quantity: '1.0000',
280
+ unitPriceNet: '50.0000',
281
+ totalNetAmount: '50.0000',
282
+ })
283
+ const env = createEnv({
284
+ invoice,
285
+ line,
286
+ invoiceFinalLines: [line],
287
+ })
288
+
289
+ await editDraftLineCommand.execute(
290
+ {
291
+ ...baseInput,
292
+ invoiceLineId: LINE_ID,
293
+ changes: { quantity: 3, unitPriceNet: 60 },
294
+ },
295
+ env.ctx as never,
296
+ )
297
+
298
+ expect(line.quantity).toBe('3.0000')
299
+ expect(line.unitPriceNet).toBe('60.0000')
300
+ // 3 × 60 = 180
301
+ expect(line.totalNetAmount).toBe('180.0000')
302
+ // Invoice totals match the new line sum.
303
+ expect(invoice.grandTotalNetAmount).toBe('180.0000')
304
+ })
305
+
306
+ it('honours explicit totalNetAmount override (operator manual fix)', async () => {
307
+ const invoice = makeInvoice({ status: 'draft' })
308
+ const line = makeLine(invoice, { quantity: '1.0000', unitPriceNet: '50.0000' })
309
+ const env = createEnv({ invoice, line, invoiceFinalLines: [line] })
310
+ await editDraftLineCommand.execute(
311
+ {
312
+ ...baseInput,
313
+ invoiceLineId: LINE_ID,
314
+ changes: { totalNetAmount: 999.99 },
315
+ },
316
+ env.ctx as never,
317
+ )
318
+ expect(line.totalNetAmount).toBe('999.9900')
319
+ })
320
+
321
+ it('persists a DraftInvoiceEdit audit row with before+after snapshots', async () => {
322
+ const invoice = makeInvoice({ status: 'draft' })
323
+ const line = makeLine(invoice, {
324
+ description: 'Old',
325
+ totalNetAmount: '50.0000',
326
+ })
327
+ const env = createEnv({ invoice, line, invoiceFinalLines: [line] })
328
+
329
+ await editDraftLineCommand.execute(
330
+ {
331
+ ...baseInput,
332
+ invoiceLineId: LINE_ID,
333
+ changes: { description: 'New' },
334
+ },
335
+ env.ctx as never,
336
+ )
337
+
338
+ const audit = env.persistedEntities.find(
339
+ (e) => (e as { action?: string }).action === 'line_edited',
340
+ ) as Record<string, unknown> | undefined
341
+ expect(audit).toBeDefined()
342
+ expect((audit?.beforeJson as { description: string }).description).toBe('Old')
343
+ expect((audit?.afterJson as { description: string }).description).toBe('New')
344
+ })
345
+
346
+ it('returns 404 when the line is not on the invoice', async () => {
347
+ const invoice = makeInvoice({ status: 'draft' })
348
+ const env = createEnv({ invoice, line: null })
349
+ await expect(
350
+ editDraftLineCommand.execute(
351
+ { ...baseInput, invoiceLineId: LINE_ID, changes: { description: 'x' } },
352
+ env.ctx as never,
353
+ ),
354
+ ).rejects.toMatchObject({ status: 404 })
355
+ })
356
+ })
357
+
358
+ describe('addDraftLineCommand', () => {
359
+ it('appends a line with the next lineNumber and writes a line_added audit', async () => {
360
+ const invoice = makeInvoice({ status: 'draft' })
361
+ const existing = [makeLine(invoice), makeLine(invoice, { id: 'l2' })]
362
+ const env = createEnv({
363
+ invoice,
364
+ initialLines: existing,
365
+ invoiceFinalLines: [...existing],
366
+ })
367
+
368
+ const result = await addDraftLineCommand.execute(
369
+ {
370
+ ...baseInput,
371
+ description: 'Manual adjustment',
372
+ quantity: 2,
373
+ unitPriceNet: 25,
374
+ },
375
+ env.ctx as never,
376
+ )
377
+ expect(result.invoiceLineId).toBe('new-line-3')
378
+ const audit = env.persistedEntities.find(
379
+ (e) => (e as { action?: string }).action === 'line_added',
380
+ ) as Record<string, unknown> | undefined
381
+ expect(audit).toBeDefined()
382
+ expect(audit?.beforeJson).toBeNull()
383
+ expect(audit?.afterJson).toBeDefined()
384
+ })
385
+
386
+ it('refuses non-draft invoices', async () => {
387
+ const env = createEnv({ invoice: makeInvoice({ status: 'paid' }) })
388
+ await expect(
389
+ addDraftLineCommand.execute(
390
+ {
391
+ ...baseInput,
392
+ description: 'x',
393
+ quantity: 1,
394
+ unitPriceNet: 1,
395
+ },
396
+ env.ctx as never,
397
+ ),
398
+ ).rejects.toMatchObject({ status: 409 })
399
+ })
400
+ })
401
+
402
+ describe('removeDraftLineCommand', () => {
403
+ it('removes the line and writes a line_removed audit with before snapshot', async () => {
404
+ const invoice = makeInvoice({ status: 'draft' })
405
+ const line = makeLine(invoice, { description: 'Going away' })
406
+ const env = createEnv({ invoice, line, invoiceFinalLines: [] })
407
+
408
+ await removeDraftLineCommand.execute(
409
+ { ...baseInput, invoiceLineId: LINE_ID },
410
+ env.ctx as never,
411
+ )
412
+
413
+ expect(env.removedEntities).toContain(line)
414
+ const audit = env.persistedEntities.find(
415
+ (e) => (e as { action?: string }).action === 'line_removed',
416
+ ) as Record<string, unknown> | undefined
417
+ expect(audit).toBeDefined()
418
+ expect((audit?.beforeJson as { description: string }).description).toBe('Going away')
419
+ expect(audit?.afterJson).toBeNull()
420
+ expect(audit?.invoiceLineId).toBeNull()
421
+ // Invoice total reset to zero (no lines left).
422
+ expect(invoice.grandTotalNetAmount).toBe('0.0000')
423
+ })
424
+ })
425
+
426
+ describe('wipeTestInvoicesCommand', () => {
427
+ it('returns 0 when no test invoices match', async () => {
428
+ const env = createEnv({ testInvoiceRows: [] })
429
+ const result = await wipeTestInvoicesCommand.execute(
430
+ { tenantId: TENANT, organizationId: ORG },
431
+ env.ctx as never,
432
+ )
433
+ expect(result.invoicesRemoved).toBe(0)
434
+ })
435
+
436
+ it('deletes lines then invoices when matches exist', async () => {
437
+ const env = createEnv({
438
+ testInvoiceRows: [{ id: 'test-inv-1' }, { id: 'test-inv-2' }],
439
+ })
440
+ const result = await wipeTestInvoicesCommand.execute(
441
+ { tenantId: TENANT, organizationId: ORG },
442
+ env.ctx as never,
443
+ )
444
+ expect(result.invoicesRemoved).toBe(2)
445
+ // 1 SELECT + 1 DELETE lines + 1 DELETE invoices
446
+ expect(env.em.execute).toHaveBeenCalledTimes(3)
447
+ })
448
+
449
+ it('scopes by billRunId when provided', async () => {
450
+ const env = createEnv({ testInvoiceRows: [{ id: 'test-1' }] })
451
+ await wipeTestInvoicesCommand.execute(
452
+ { tenantId: TENANT, organizationId: ORG, billRunId: INVOICE_ID },
453
+ env.ctx as never,
454
+ )
455
+ const selectCall = env.em.execute.mock.calls[0]
456
+ expect(selectCall[0]).toContain("metadata->>'bill_run_id' = ?")
457
+ expect(selectCall[1]).toContain(INVOICE_ID)
458
+ })
459
+ })
460
+
461
+ // Suppress unused-import warning — DraftInvoiceEdit is needed by the
462
+ // mock's discriminator path but not directly referenced by name in
463
+ // individual assertions.
464
+ void DraftInvoiceEdit