@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,542 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands/types'
3
+ import { registerCommand } from '@open-mercato/shared/lib/commands'
4
+ import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
5
+ import {
6
+ ensureOrganizationScope,
7
+ ensureTenantScope,
8
+ } from '@open-mercato/shared/lib/commands/scope'
9
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
10
+ import { SalesInvoice, SalesInvoiceLine } from '@open-mercato/core/modules/sales/data/entities'
11
+ import { DraftInvoiceEdit } from '../data/entities'
12
+ import { emitBillingEvent } from '../events'
13
+ import {
14
+ billingInvoiceAddLineSchema,
15
+ billingInvoiceEditLineSchema,
16
+ billingInvoicePostSchema,
17
+ billingInvoiceRemoveLineSchema,
18
+ billingWipeTestInvoicesSchema,
19
+ type BillingInvoiceAddLineInput,
20
+ type BillingInvoiceEditLineInput,
21
+ type BillingInvoicePostInput,
22
+ type BillingInvoiceRemoveLineInput,
23
+ type BillingWipeTestInvoicesInput,
24
+ } from '../data/validators'
25
+ import { resolveInvoiceStatusEntryId } from '../lib/invoiceStatus'
26
+
27
+ /**
28
+ * Posts a `core/sales` draft invoice that the Bill Run engine
29
+ * created.
30
+ *
31
+ * Phase 2 deviation note: the invoice number is already set at
32
+ * draft-create time (because `SalesInvoice.invoice_number` is NOT
33
+ * NULL upstream). "Post" therefore only:
34
+ * 1. Asserts the invoice is currently a draft (refuse otherwise).
35
+ * 2. Refuses test-mode invoices (`metadata.test_run=true`) — they
36
+ * should never reach production posting.
37
+ * 3. Flips `status` (and `status_entry_id`) to `posted`.
38
+ * 4. Fires `billing.invoice.posted` (one) + `billing.invoice.line_posted`
39
+ * per line (for downstream consumers — accounting, analytics).
40
+ *
41
+ * Once posted, the invoice is frozen at the billing layer; corrections
42
+ * go through `core/sales`'s manual edit flow.
43
+ */
44
+
45
+ function getEm(ctx: CommandRuntimeContext): EntityManager {
46
+ return (ctx.container.resolve('em') as EntityManager).fork()
47
+ }
48
+
49
+ type PostResult = {
50
+ invoiceId: string
51
+ invoiceNumber: string
52
+ status: 'posted'
53
+ lineCount: number
54
+ }
55
+
56
+ const postInvoiceCommand: CommandHandler<BillingInvoicePostInput, PostResult> = {
57
+ id: 'billing.invoices.post',
58
+
59
+ async execute(rawInput, ctx) {
60
+ const parsed = billingInvoicePostSchema.parse(rawInput)
61
+ ensureTenantScope(ctx, parsed.tenantId)
62
+ ensureOrganizationScope(ctx, parsed.organizationId)
63
+
64
+ const em = getEm(ctx)
65
+ const invoice = await em.findOne(SalesInvoice, {
66
+ id: parsed.invoiceId,
67
+ tenantId: parsed.tenantId,
68
+ organizationId: parsed.organizationId,
69
+ deletedAt: null,
70
+ })
71
+ if (!invoice) {
72
+ throw new CrudHttpError(404, { error: 'Invoice not found' })
73
+ }
74
+ if (invoice.status !== 'draft') {
75
+ throw new CrudHttpError(409, {
76
+ error: 'Only draft invoices can be posted',
77
+ code: 'billing.invoice.not_draft',
78
+ currentStatus: invoice.status,
79
+ })
80
+ }
81
+ const meta = (invoice.metadata as Record<string, unknown> | null) ?? {}
82
+ if (meta.test_run === true) {
83
+ // Test invoices live for inspection only — posting them would
84
+ // leak test data into accounting. Force-delete the test invoice
85
+ // instead (Phase 4b admin UI exposes a "Wipe test drafts"
86
+ // button against `DELETE /api/billing/test-invoices`).
87
+ throw new CrudHttpError(409, {
88
+ error: 'Test-mode invoices cannot be posted',
89
+ code: 'billing.invoice.test_run',
90
+ })
91
+ }
92
+
93
+ const postedStatusEntryId = await resolveInvoiceStatusEntryId(
94
+ em,
95
+ parsed.tenantId,
96
+ parsed.organizationId,
97
+ 'posted',
98
+ )
99
+
100
+ await withAtomicFlush(em, [
101
+ () => {
102
+ invoice.status = 'posted'
103
+ invoice.statusEntryId = postedStatusEntryId
104
+ invoice.updatedAt = new Date()
105
+ },
106
+ ])
107
+
108
+ const lines = await em.find(SalesInvoiceLine, {
109
+ invoice,
110
+ tenantId: parsed.tenantId,
111
+ organizationId: parsed.organizationId,
112
+ } as never)
113
+
114
+ // Side effects fire AFTER the DB commit so downstream subscribers
115
+ // (mailer, accounting bridge, analytics) never observe a "posted"
116
+ // event for an invoice the DB doesn't reflect.
117
+ await emitBillingEvent('billing.invoice.posted', {
118
+ invoiceId: invoice.id,
119
+ invoiceNumber: invoice.invoiceNumber,
120
+ tenantId: invoice.tenantId,
121
+ organizationId: invoice.organizationId,
122
+ grandTotalNet: invoice.grandTotalNetAmount,
123
+ grandTotalGross: invoice.grandTotalGrossAmount,
124
+ currencyCode: invoice.currencyCode,
125
+ billRunId: (meta as { bill_run_id?: string }).bill_run_id ?? null,
126
+ billAccountId: (meta as { bill_account_id?: string }).bill_account_id ?? null,
127
+ billPeriodStart: (meta as { bill_period_start?: string }).bill_period_start ?? null,
128
+ billPeriodEnd: (meta as { bill_period_end?: string }).bill_period_end ?? null,
129
+ lineCount: lines.length,
130
+ })
131
+ for (const line of lines) {
132
+ const lineMeta = (line.metadata as Record<string, unknown> | null) ?? {}
133
+ await emitBillingEvent('billing.invoice.line_posted', {
134
+ invoiceId: invoice.id,
135
+ invoiceLineId: line.id,
136
+ billingItemId: lineMeta.billing_item_id ?? null,
137
+ billingType: lineMeta.billing_type ?? null,
138
+ description: line.description ?? null,
139
+ unitPriceNet: line.unitPriceNet,
140
+ quantity: line.quantity,
141
+ totalNetAmount: line.totalNetAmount,
142
+ tenantId: invoice.tenantId,
143
+ organizationId: invoice.organizationId,
144
+ })
145
+ }
146
+
147
+ return {
148
+ invoiceId: invoice.id,
149
+ invoiceNumber: invoice.invoiceNumber,
150
+ status: 'posted',
151
+ lineCount: lines.length,
152
+ }
153
+ },
154
+ }
155
+
156
+ // ─── Draft edit / add / remove (Phase 4b) ────────────────────────
157
+ //
158
+ // Three commands sharing the same shape:
159
+ // 1. Assert the invoice is still a draft (refuse otherwise).
160
+ // 2. Mutate the line / collection.
161
+ // 3. Write a `DraftInvoiceEdit` audit row carrying before+after
162
+ // snapshots (`null` on the "no prior state" / "no resulting state"
163
+ // side per the spec — see entity column docs).
164
+ // 4. Recompute the invoice totals from the surviving lines.
165
+ //
166
+ // The audit row is append-only; once written, never updated. Soft-
167
+ // deleting the invoice does NOT delete the audit rows (compliance).
168
+
169
+ function roundHalfUp2dp(value: number): number {
170
+ return Math.round((value + Number.EPSILON) * 100) / 100
171
+ }
172
+
173
+ function format4dp(value: number): string {
174
+ return roundHalfUp2dp(value).toFixed(4)
175
+ }
176
+
177
+ async function assertDraftInvoice(
178
+ em: EntityManager,
179
+ params: { invoiceId: string; tenantId: string; organizationId: string },
180
+ ): Promise<SalesInvoice> {
181
+ const invoice = await em.findOne(SalesInvoice, {
182
+ id: params.invoiceId,
183
+ tenantId: params.tenantId,
184
+ organizationId: params.organizationId,
185
+ deletedAt: null,
186
+ })
187
+ if (!invoice) {
188
+ throw new CrudHttpError(404, { error: 'Invoice not found' })
189
+ }
190
+ if (invoice.status !== 'draft') {
191
+ throw new CrudHttpError(409, {
192
+ error: 'Only draft invoices can be edited',
193
+ code: 'billing.invoice.not_draft',
194
+ currentStatus: invoice.status,
195
+ })
196
+ }
197
+ return invoice
198
+ }
199
+
200
+ type LineSnapshot = {
201
+ id: string
202
+ description: string | null
203
+ quantity: string
204
+ unitPriceNet: string
205
+ totalNetAmount: string
206
+ totalGrossAmount: string
207
+ metadata: Record<string, unknown> | null
208
+ }
209
+
210
+ function snapshotLine(line: SalesInvoiceLine): LineSnapshot {
211
+ return {
212
+ id: line.id,
213
+ description: line.description ?? null,
214
+ quantity: line.quantity,
215
+ unitPriceNet: line.unitPriceNet,
216
+ totalNetAmount: line.totalNetAmount,
217
+ totalGrossAmount: line.totalGrossAmount,
218
+ metadata: (line.metadata as Record<string, unknown> | null) ?? null,
219
+ }
220
+ }
221
+
222
+ async function recomputeInvoiceTotals(
223
+ em: EntityManager,
224
+ invoice: SalesInvoice,
225
+ ): Promise<void> {
226
+ const lines = await em.find(SalesInvoiceLine, {
227
+ invoice,
228
+ tenantId: invoice.tenantId,
229
+ organizationId: invoice.organizationId,
230
+ } as never)
231
+ let totalNet = 0
232
+ for (const line of lines) {
233
+ totalNet += Number.parseFloat(line.totalNetAmount)
234
+ }
235
+ const formatted = format4dp(totalNet)
236
+ invoice.subtotalNetAmount = formatted
237
+ invoice.subtotalGrossAmount = formatted
238
+ invoice.grandTotalNetAmount = formatted
239
+ invoice.grandTotalGrossAmount = formatted
240
+ invoice.outstandingAmount = formatted
241
+ invoice.updatedAt = new Date()
242
+ }
243
+
244
+ function getUserIdFromCtx(ctx: CommandRuntimeContext): string {
245
+ const userId = (ctx.auth?.sub as string | undefined) ?? null
246
+ // The DraftInvoiceEdit.user_id column is NOT NULL. For system-driven
247
+ // edits (e.g. catch-up flow that revises a value), we fall back to
248
+ // a sentinel UUID. Operator-driven edits always have a real
249
+ // ctx.auth.sub.
250
+ return userId ?? '00000000-0000-0000-0000-000000000000'
251
+ }
252
+
253
+ const editDraftLineCommand: CommandHandler<
254
+ BillingInvoiceEditLineInput,
255
+ { invoiceId: string; invoiceLineId: string; auditId: string }
256
+ > = {
257
+ id: 'billing.invoices.edit_draft_line',
258
+
259
+ async execute(rawInput, ctx) {
260
+ const parsed = billingInvoiceEditLineSchema.parse(rawInput)
261
+ ensureTenantScope(ctx, parsed.tenantId)
262
+ ensureOrganizationScope(ctx, parsed.organizationId)
263
+
264
+ const em = getEm(ctx)
265
+ const invoice = await assertDraftInvoice(em, {
266
+ invoiceId: parsed.invoiceId,
267
+ tenantId: parsed.tenantId,
268
+ organizationId: parsed.organizationId,
269
+ })
270
+
271
+ const line = await em.findOne(SalesInvoiceLine, {
272
+ id: parsed.invoiceLineId,
273
+ invoice,
274
+ tenantId: parsed.tenantId,
275
+ organizationId: parsed.organizationId,
276
+ })
277
+ if (!line) {
278
+ throw new CrudHttpError(404, { error: 'Invoice line not found' })
279
+ }
280
+
281
+ const before = snapshotLine(line)
282
+ let audit: DraftInvoiceEdit | null = null
283
+
284
+ await withAtomicFlush(em, [
285
+ () => {
286
+ if (parsed.changes.description !== undefined) {
287
+ line.description = parsed.changes.description
288
+ }
289
+ if (parsed.changes.quantity !== undefined) {
290
+ line.quantity = format4dp(parsed.changes.quantity)
291
+ line.normalizedQuantity = line.quantity
292
+ }
293
+ if (parsed.changes.unitPriceNet !== undefined) {
294
+ line.unitPriceNet = format4dp(parsed.changes.unitPriceNet)
295
+ line.unitPriceGross = line.unitPriceNet
296
+ }
297
+
298
+ // Recompute the line total. If the operator supplied an explicit
299
+ // `totalNetAmount` override, honour it; otherwise compute from
300
+ // unit_price × quantity.
301
+ if (parsed.changes.totalNetAmount !== undefined) {
302
+ const total = format4dp(parsed.changes.totalNetAmount)
303
+ line.totalNetAmount = total
304
+ line.totalGrossAmount = total
305
+ } else {
306
+ const total = format4dp(
307
+ Number.parseFloat(line.unitPriceNet) * Number.parseFloat(line.quantity),
308
+ )
309
+ line.totalNetAmount = total
310
+ line.totalGrossAmount = total
311
+ }
312
+ },
313
+ async () => {
314
+ await recomputeInvoiceTotals(em, invoice)
315
+ },
316
+ () => {
317
+ const after = snapshotLine(line)
318
+ audit = em.create(DraftInvoiceEdit, {
319
+ tenantId: parsed.tenantId,
320
+ organizationId: parsed.organizationId,
321
+ invoiceId: invoice.id,
322
+ invoiceLineId: line.id,
323
+ userId: getUserIdFromCtx(ctx),
324
+ action: 'line_edited',
325
+ beforeJson: before as Record<string, unknown>,
326
+ afterJson: after as Record<string, unknown>,
327
+ createdAt: new Date(),
328
+ })
329
+ em.persist(audit)
330
+ },
331
+ ])
332
+
333
+ return { invoiceId: invoice.id, invoiceLineId: line.id, auditId: audit!.id }
334
+ },
335
+ }
336
+
337
+ const addDraftLineCommand: CommandHandler<
338
+ BillingInvoiceAddLineInput,
339
+ { invoiceId: string; invoiceLineId: string; auditId: string }
340
+ > = {
341
+ id: 'billing.invoices.add_draft_line',
342
+
343
+ async execute(rawInput, ctx) {
344
+ const parsed = billingInvoiceAddLineSchema.parse(rawInput)
345
+ ensureTenantScope(ctx, parsed.tenantId)
346
+ ensureOrganizationScope(ctx, parsed.organizationId)
347
+
348
+ const em = getEm(ctx)
349
+ const invoice = await assertDraftInvoice(em, {
350
+ invoiceId: parsed.invoiceId,
351
+ tenantId: parsed.tenantId,
352
+ organizationId: parsed.organizationId,
353
+ })
354
+
355
+ const existing = await em.find(SalesInvoiceLine, {
356
+ invoice,
357
+ tenantId: parsed.tenantId,
358
+ organizationId: parsed.organizationId,
359
+ } as never)
360
+ const nextLineNumber = existing.length + 1
361
+
362
+ const total = format4dp(parsed.unitPriceNet * parsed.quantity)
363
+ const line = em.create(SalesInvoiceLine, {
364
+ invoice,
365
+ organizationId: parsed.organizationId,
366
+ tenantId: parsed.tenantId,
367
+ lineNumber: nextLineNumber,
368
+ kind: 'product',
369
+ description: parsed.description,
370
+ quantity: format4dp(parsed.quantity),
371
+ normalizedQuantity: format4dp(parsed.quantity),
372
+ currencyCode: invoice.currencyCode,
373
+ unitPriceNet: format4dp(parsed.unitPriceNet),
374
+ unitPriceGross: format4dp(parsed.unitPriceNet),
375
+ discountAmount: '0',
376
+ discountPercent: '0',
377
+ taxRate: '0',
378
+ taxAmount: '0',
379
+ totalNetAmount: total,
380
+ totalGrossAmount: total,
381
+ metadata: {
382
+ billing_item_id: parsed.billingItemId ?? null,
383
+ billing_type: parsed.billingType ?? 'manual',
384
+ operator_added: true,
385
+ },
386
+ })
387
+ let audit: DraftInvoiceEdit | null = null
388
+
389
+ await withAtomicFlush(em, [
390
+ () => {
391
+ em.persist(line)
392
+ },
393
+ async () => {
394
+ await recomputeInvoiceTotals(em, invoice)
395
+ },
396
+ () => {
397
+ const after = snapshotLine(line)
398
+ audit = em.create(DraftInvoiceEdit, {
399
+ tenantId: parsed.tenantId,
400
+ organizationId: parsed.organizationId,
401
+ invoiceId: invoice.id,
402
+ invoiceLineId: line.id,
403
+ userId: getUserIdFromCtx(ctx),
404
+ action: 'line_added',
405
+ beforeJson: null,
406
+ afterJson: after as Record<string, unknown>,
407
+ createdAt: new Date(),
408
+ })
409
+ em.persist(audit)
410
+ },
411
+ ])
412
+
413
+ return { invoiceId: invoice.id, invoiceLineId: line.id, auditId: audit!.id }
414
+ },
415
+ }
416
+
417
+ const removeDraftLineCommand: CommandHandler<
418
+ BillingInvoiceRemoveLineInput,
419
+ { invoiceId: string; invoiceLineId: string; auditId: string }
420
+ > = {
421
+ id: 'billing.invoices.remove_draft_line',
422
+
423
+ async execute(rawInput, ctx) {
424
+ const parsed = billingInvoiceRemoveLineSchema.parse(rawInput)
425
+ ensureTenantScope(ctx, parsed.tenantId)
426
+ ensureOrganizationScope(ctx, parsed.organizationId)
427
+
428
+ const em = getEm(ctx)
429
+ const invoice = await assertDraftInvoice(em, {
430
+ invoiceId: parsed.invoiceId,
431
+ tenantId: parsed.tenantId,
432
+ organizationId: parsed.organizationId,
433
+ })
434
+ const line = await em.findOne(SalesInvoiceLine, {
435
+ id: parsed.invoiceLineId,
436
+ invoice,
437
+ tenantId: parsed.tenantId,
438
+ organizationId: parsed.organizationId,
439
+ })
440
+ if (!line) {
441
+ throw new CrudHttpError(404, { error: 'Invoice line not found' })
442
+ }
443
+
444
+ const before = snapshotLine(line)
445
+ let audit: DraftInvoiceEdit | null = null
446
+
447
+ await withAtomicFlush(em, [
448
+ () => {
449
+ em.remove(line)
450
+ },
451
+ async () => {
452
+ await recomputeInvoiceTotals(em, invoice)
453
+ },
454
+ () => {
455
+ audit = em.create(DraftInvoiceEdit, {
456
+ tenantId: parsed.tenantId,
457
+ organizationId: parsed.organizationId,
458
+ invoiceId: invoice.id,
459
+ invoiceLineId: null,
460
+ userId: getUserIdFromCtx(ctx),
461
+ action: 'line_removed',
462
+ beforeJson: before as Record<string, unknown>,
463
+ afterJson: null,
464
+ createdAt: new Date(),
465
+ })
466
+ em.persist(audit)
467
+ },
468
+ ])
469
+
470
+ return { invoiceId: invoice.id, invoiceLineId: parsed.invoiceLineId, auditId: audit!.id }
471
+ },
472
+ }
473
+
474
+ // ─── Wipe test invoices (Phase 4b) ───────────────────────────────
475
+ //
476
+ // Hard-deletes invoices flagged `metadata.test_run=true` (and their
477
+ // lines via cascade — the invoice's lines collection points at it
478
+ // with ON DELETE CASCADE). Optionally scoped to a single `bill_run_id`
479
+ // via the param so operators can wipe a specific test run without
480
+ // risking other test data.
481
+
482
+ const wipeTestInvoicesCommand: CommandHandler<
483
+ BillingWipeTestInvoicesInput,
484
+ { invoicesRemoved: number }
485
+ > = {
486
+ id: 'billing.invoices.wipe_test',
487
+
488
+ async execute(rawInput, ctx) {
489
+ const parsed = billingWipeTestInvoicesSchema.parse(rawInput)
490
+ ensureTenantScope(ctx, parsed.tenantId)
491
+ ensureOrganizationScope(ctx, parsed.organizationId)
492
+
493
+ const em = getEm(ctx)
494
+ // The metadata->>'test_run' = 'true' predicate uses Postgres'
495
+ // JSONB ->> operator + the literal string 'true' (jsonb true is
496
+ // serialized as the JSON literal `true`, which ->> returns as the
497
+ // text "true").
498
+ const params: unknown[] = [parsed.tenantId, parsed.organizationId]
499
+ let billRunClause = ''
500
+ if (parsed.billRunId) {
501
+ billRunClause = ` AND metadata->>'bill_run_id' = ?`
502
+ params.push(parsed.billRunId)
503
+ }
504
+ const rows = (await em.execute(
505
+ `SELECT id FROM sales_invoices
506
+ WHERE tenant_id = ?
507
+ AND organization_id = ?
508
+ AND deleted_at IS NULL
509
+ AND metadata->>'test_run' = 'true'${billRunClause}`,
510
+ params,
511
+ )) as unknown as Array<{ id: string }> | undefined
512
+ if (!rows || rows.length === 0) {
513
+ return { invoicesRemoved: 0 }
514
+ }
515
+ const ids = rows.map((r) => r.id)
516
+ // Hard-delete lines first then invoices to avoid relying on
517
+ // cascade semantics that the migration may or may not encode.
518
+ await em.execute(
519
+ `DELETE FROM sales_invoice_lines WHERE invoice_id IN (${ids.map(() => '?').join(',')})`,
520
+ ids,
521
+ )
522
+ await em.execute(
523
+ `DELETE FROM sales_invoices WHERE id IN (${ids.map(() => '?').join(',')})`,
524
+ ids,
525
+ )
526
+ return { invoicesRemoved: ids.length }
527
+ },
528
+ }
529
+
530
+ registerCommand(postInvoiceCommand)
531
+ registerCommand(editDraftLineCommand)
532
+ registerCommand(addDraftLineCommand)
533
+ registerCommand(removeDraftLineCommand)
534
+ registerCommand(wipeTestInvoicesCommand)
535
+
536
+ export {
537
+ postInvoiceCommand,
538
+ editDraftLineCommand,
539
+ addDraftLineCommand,
540
+ removeDraftLineCommand,
541
+ wipeTestInvoicesCommand,
542
+ }