@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,260 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { AwilixContainer } from 'awilix'
3
+ import {
4
+ SalesInvoice,
5
+ SalesInvoiceLine,
6
+ } from '@open-mercato/core/modules/sales/data/entities'
7
+ import type { SalesDocumentNumberGenerator } from '@open-mercato/core/modules/sales/services/salesDocumentNumberGenerator'
8
+ import type { BillingAccount, BillingItem } from '../data/entities'
9
+ import type { BillPeriod } from './billPeriod'
10
+ import {
11
+ __resetInvoiceStatusCacheForTests,
12
+ resolveInvoiceStatusEntryId,
13
+ } from './invoiceStatus'
14
+
15
+ /**
16
+ * Writes a `core/sales` draft invoice for a billing account's bill period.
17
+ *
18
+ * Two notable deviations from the spec, documented in CHANGELOG / spec:
19
+ *
20
+ * 1. `SalesInvoice.invoice_number` is `NOT NULL` in the upstream
21
+ * schema, but the spec says "sequence number assigned at Post."
22
+ * Real-mode invoices get a real number via
23
+ * `salesDocumentNumberGenerator.generate({ kind: 'invoice' })` at
24
+ * *draft create time* — the spec's intent (gap-free numbering via
25
+ * atomic SQL upsert) is preserved; the timing differs because the
26
+ * upstream column is `NOT NULL`. "Post" then just transitions
27
+ * status `draft → posted`.
28
+ *
29
+ * 2. Test-mode invoices use a `TEST-<uuid>` placeholder number (not
30
+ * the real sequence) so test runs do not burn production numbers
31
+ * and can be repeated idempotently.
32
+ *
33
+ * VAT is NOT computed here — `core/sales`'s tax service owns the
34
+ * per-line `tax_rate` / `tax_amount` calculation, and runs on POST.
35
+ * Drafts therefore carry zeroes in the tax columns; the totals reflect
36
+ * net amounts only.
37
+ */
38
+
39
+ export type WriteMode = 'real' | 'test' | 'dry'
40
+
41
+ export type InvoiceLineDescriptor = {
42
+ /** Maps to billing_item_id on the resulting SalesInvoiceLine.metadata. */
43
+ billingItemId: string
44
+ /** From the spec's `metadata.billing_type` for traceability. */
45
+ billingType: 'one_time' | 'recurring' | 'usage'
46
+ description: string
47
+ /** Net unit price (numeric string for SalesInvoiceLine.unit_price_net). */
48
+ unitPriceNet: string
49
+ /** Quantity (string for the numeric(18,4) column). */
50
+ quantity: string
51
+ /** Total net (string). */
52
+ totalNetAmount: string
53
+ /** Optional per-line metadata extension — e.g. usage tier breakdown. */
54
+ metadataExtra?: Record<string, unknown>
55
+ }
56
+
57
+ export type WriteInvoiceParams = {
58
+ mode: WriteMode
59
+ account: BillingAccount
60
+ period: BillPeriod
61
+ billRunId: string
62
+ lines: InvoiceLineDescriptor[]
63
+ }
64
+
65
+ export type WrittenInvoice = {
66
+ invoiceId: string | null
67
+ invoiceNumber: string | null
68
+ totalNet: string
69
+ }
70
+
71
+ const DRAFT_INVOICE_STATUS_VALUE = 'draft'
72
+
73
+ /**
74
+ * Test hook — clears the shared resolver's status cache so the engine
75
+ * test suite stays deterministic across mocked-EM environments.
76
+ */
77
+ export function __resetInvoiceWriterCacheForTests(): void {
78
+ __resetInvoiceStatusCacheForTests()
79
+ }
80
+
81
+ function generateTestInvoiceNumber(): string {
82
+ // Match the pattern operators search by: `TEST-` prefix + random
83
+ // suffix so the test wipe-out endpoint can filter via `LIKE 'TEST-%'`
84
+ // (defensive — primary filter is `metadata.test_run=true`).
85
+ const random = Math.floor(Math.random() * 1e9)
86
+ .toString(16)
87
+ .padStart(8, '0')
88
+ return `TEST-${Date.now().toString(16)}-${random}`
89
+ }
90
+
91
+ /**
92
+ * 2dp half-up rounding that survives binary floating-point edge cases
93
+ * (e.g. `1.005 * 100 === 100.49999...` would otherwise round DOWN to
94
+ * `1.00` under naive `Math.round`). The `+ EPSILON` nudge pushes
95
+ * representations like 100.4999...8 up to the true half-up boundary.
96
+ * Matches the spec's "2 decimal places, half-up" rounding policy.
97
+ */
98
+ function roundHalfUp2dp(value: number): number {
99
+ return Math.round((value + Number.EPSILON) * 100) / 100
100
+ }
101
+
102
+ function format4dp(value: number): string {
103
+ return roundHalfUp2dp(value).toFixed(4)
104
+ }
105
+
106
+ function sumNetTotal(lines: InvoiceLineDescriptor[]): string {
107
+ let total = 0
108
+ for (const line of lines) {
109
+ total += Number.parseFloat(line.totalNetAmount)
110
+ }
111
+ return format4dp(total)
112
+ }
113
+
114
+ export async function writeDraftInvoice(
115
+ em: EntityManager,
116
+ container: AwilixContainer,
117
+ params: WriteInvoiceParams,
118
+ ): Promise<WrittenInvoice> {
119
+ const totalNet = sumNetTotal(params.lines)
120
+
121
+ if (params.mode === 'dry') {
122
+ // Compute totals; persist nothing.
123
+ return { invoiceId: null, invoiceNumber: null, totalNet }
124
+ }
125
+
126
+ const { account, period, billRunId, lines, mode } = params
127
+
128
+ let invoiceNumber: string
129
+ if (mode === 'test') {
130
+ invoiceNumber = generateTestInvoiceNumber()
131
+ } else {
132
+ const numberGenerator = container.resolve(
133
+ 'salesDocumentNumberGenerator',
134
+ ) as SalesDocumentNumberGenerator
135
+ const generated = await numberGenerator.generate({
136
+ organizationId: account.organizationId,
137
+ tenantId: account.tenantId,
138
+ kind: 'invoice',
139
+ })
140
+ invoiceNumber = generated.number
141
+ }
142
+
143
+ const statusEntryId = await resolveInvoiceStatusEntryId(
144
+ em,
145
+ account.tenantId,
146
+ account.organizationId,
147
+ 'draft',
148
+ )
149
+
150
+ const now = new Date()
151
+ const invoice = em.create(SalesInvoice, {
152
+ organizationId: account.organizationId,
153
+ tenantId: account.tenantId,
154
+ invoiceNumber,
155
+ statusEntryId,
156
+ status: DRAFT_INVOICE_STATUS_VALUE,
157
+ issueDate: now,
158
+ currencyCode: account.currencyCode,
159
+ subtotalNetAmount: totalNet,
160
+ subtotalGrossAmount: totalNet, // gross == net for drafts (tax computed on post)
161
+ discountTotalAmount: '0',
162
+ taxTotalAmount: '0',
163
+ grandTotalNetAmount: totalNet,
164
+ grandTotalGrossAmount: totalNet,
165
+ paidTotalAmount: '0',
166
+ outstandingAmount: totalNet,
167
+ metadata: {
168
+ bill_run_id: billRunId,
169
+ bill_account_id: account.id,
170
+ bill_period_start: period.periodStart.toISOString().slice(0, 10),
171
+ bill_period_end: period.periodEnd.toISOString().slice(0, 10),
172
+ test_run: mode === 'test' ? true : undefined,
173
+ },
174
+ createdAt: now,
175
+ updatedAt: now,
176
+ })
177
+ em.persist(invoice)
178
+
179
+ let lineNumber = 1
180
+ for (const line of lines) {
181
+ const invoiceLine = em.create(SalesInvoiceLine, {
182
+ invoice,
183
+ organizationId: account.organizationId,
184
+ tenantId: account.tenantId,
185
+ lineNumber: lineNumber++,
186
+ kind: 'product',
187
+ description: line.description,
188
+ quantity: line.quantity,
189
+ normalizedQuantity: line.quantity,
190
+ currencyCode: account.currencyCode,
191
+ unitPriceNet: line.unitPriceNet,
192
+ unitPriceGross: line.unitPriceNet, // gross == net until tax computed on post
193
+ discountAmount: '0',
194
+ discountPercent: '0',
195
+ taxRate: '0',
196
+ taxAmount: '0',
197
+ totalNetAmount: line.totalNetAmount,
198
+ totalGrossAmount: line.totalNetAmount,
199
+ metadata: {
200
+ billing_item_id: line.billingItemId,
201
+ billing_type: line.billingType,
202
+ ...(line.metadataExtra ?? {}),
203
+ },
204
+ })
205
+ em.persist(invoiceLine)
206
+ }
207
+
208
+ await em.flush()
209
+
210
+ return { invoiceId: invoice.id, invoiceNumber, totalNet }
211
+ }
212
+
213
+ /**
214
+ * Build the InvoiceLineDescriptor list for a single account's eligible
215
+ * items (the result of `itemSelector.selectItemsForPeriod`). One line
216
+ * per item; `usage` items are excluded earlier by the selector.
217
+ *
218
+ * Per-type pricing per the spec's `BillingItem → InvoiceLine mapping`:
219
+ * - `one_time`: unit_price = amount, quantity = 1
220
+ * - `recurring`: unit_price = unit_price, quantity = 1
221
+ * (one full cycle per bill period; catch-up emits separate drafts
222
+ * per missed cycle, never combined)
223
+ */
224
+ export function buildInvoiceLinesFromItems(
225
+ items: readonly BillingItem[],
226
+ ): InvoiceLineDescriptor[] {
227
+ const lines: InvoiceLineDescriptor[] = []
228
+ for (const item of items) {
229
+ if (item.type === 'one_time') {
230
+ const rate = item.rateJson as { amount?: number } | null
231
+ if (!rate || typeof rate.amount !== 'number') continue
232
+ // For one_time the per-unit price IS the total — quantity is 1.
233
+ // Both fields therefore carry the same value, with the 2dp
234
+ // half-up policy applied (unit price + line total are the same
235
+ // numeric column at numeric(18,4)).
236
+ const amount = format4dp(rate.amount)
237
+ lines.push({
238
+ billingItemId: item.id,
239
+ billingType: 'one_time',
240
+ description: item.description,
241
+ unitPriceNet: amount,
242
+ quantity: '1',
243
+ totalNetAmount: amount,
244
+ })
245
+ } else if (item.type === 'recurring') {
246
+ const rate = item.rateJson as { unit_price?: number } | null
247
+ if (!rate || typeof rate.unit_price !== 'number') continue
248
+ const unitPrice = format4dp(rate.unit_price)
249
+ lines.push({
250
+ billingItemId: item.id,
251
+ billingType: 'recurring',
252
+ description: item.description,
253
+ unitPriceNet: unitPrice,
254
+ quantity: '1',
255
+ totalNetAmount: unitPrice,
256
+ })
257
+ }
258
+ }
259
+ return lines
260
+ }
@@ -0,0 +1,154 @@
1
+ import type { BillingItem } from '../data/entities'
2
+ import type { BillPeriod } from './billPeriod'
3
+
4
+ /**
5
+ * Decide whether a Billing Item contributes to a given bill period.
6
+ *
7
+ * Two anti-duplicate guards working together (spec "Why two anti-duplicate
8
+ * guards"):
9
+ *
10
+ * 1. `billed_to_date` — per-item watermark. Set after a successful
11
+ * real-mode run that emitted this item; prevents re-billing the
12
+ * same item even if the period boundaries shift.
13
+ * 2. Open-draft check — per-account, handled by the engine before it
14
+ * even invokes this selector (see `billRunEngine`). Not this
15
+ * module's concern.
16
+ *
17
+ * Per-type semantics from the spec:
18
+ *
19
+ * `one_time` — one shot per item, ever. Eligible only if
20
+ * `billed_to_date IS NULL` AND the item's `[bill_start_date,
21
+ * bill_end_date]` window overlaps the bill period.
22
+ *
23
+ * `recurring` — one line per full cycle. Eligible only when:
24
+ * - `bill_start_date <= bill_period_start`
25
+ * - AND (`bill_end_date IS NULL` OR `bill_end_date >= bill_period_end`)
26
+ * - AND (`billed_to_date IS NULL` OR `billed_to_date < bill_period_end`)
27
+ *
28
+ * Mid-cycle starts / cancels are SKIPPED — the integrator is
29
+ * responsible for posting a `one_time` proration line if the
30
+ * partial period should be charged (spec "Mid-cycle recurring
31
+ * items").
32
+ *
33
+ * `usage` — not handled here. Phase 3 owns usage rating end-to-end.
34
+ *
35
+ * All comparisons treat dates as midnight (consistent with `billPeriod`).
36
+ */
37
+
38
+ export type SelectableItem = Pick<
39
+ BillingItem,
40
+ | 'id'
41
+ | 'type'
42
+ | 'billStartDate'
43
+ | 'billEndDate'
44
+ | 'billedToDate'
45
+ | 'description'
46
+ | 'rateJson'
47
+ | 'isActive'
48
+ | 'deletedAt'
49
+ >
50
+
51
+ export type SelectionDecision =
52
+ | { include: true }
53
+ | { include: false; reason: string }
54
+
55
+ function asTime(date: Date | null | undefined): number | null {
56
+ if (!date) return null
57
+ return new Date(date).getTime()
58
+ }
59
+
60
+ function isWithin(
61
+ windowStart: Date,
62
+ windowEnd: Date | null | undefined,
63
+ point: Date,
64
+ ): boolean {
65
+ const pointTime = asTime(point)!
66
+ const startTime = asTime(windowStart)!
67
+ if (pointTime < startTime) return false
68
+ const endTime = asTime(windowEnd)
69
+ if (endTime === null) return true
70
+ return pointTime <= endTime
71
+ }
72
+
73
+ function overlapsPeriod(
74
+ itemStart: Date,
75
+ itemEnd: Date | null | undefined,
76
+ period: BillPeriod,
77
+ ): boolean {
78
+ const itemStartTime = asTime(itemStart)!
79
+ const itemEndTime = asTime(itemEnd)
80
+ const periodStartTime = asTime(period.periodStart)!
81
+ const periodEndTime = asTime(period.periodEnd)!
82
+ if (itemStartTime > periodEndTime) return false
83
+ if (itemEndTime !== null && itemEndTime < periodStartTime) return false
84
+ return true
85
+ }
86
+
87
+ export function shouldIncludeItem(
88
+ item: SelectableItem,
89
+ period: BillPeriod,
90
+ ): SelectionDecision {
91
+ if (!item.isActive) return { include: false, reason: 'item-inactive' }
92
+ if (item.deletedAt) return { include: false, reason: 'item-soft-deleted' }
93
+
94
+ switch (item.type) {
95
+ case 'one_time': {
96
+ if (item.billedToDate) {
97
+ return { include: false, reason: 'one_time-already-billed' }
98
+ }
99
+ if (!overlapsPeriod(item.billStartDate, item.billEndDate, period)) {
100
+ return { include: false, reason: 'one_time-window-outside-period' }
101
+ }
102
+ return { include: true }
103
+ }
104
+ case 'recurring': {
105
+ const billedToTime = asTime(item.billedToDate)
106
+ const periodEndTime = asTime(period.periodEnd)!
107
+ if (billedToTime !== null && billedToTime >= periodEndTime) {
108
+ return { include: false, reason: 'recurring-already-billed-through-period' }
109
+ }
110
+ const periodStartTime = asTime(period.periodStart)!
111
+ const itemStartTime = asTime(item.billStartDate)!
112
+ if (itemStartTime > periodStartTime) {
113
+ return { include: false, reason: 'recurring-starts-mid-cycle' }
114
+ }
115
+ const itemEndTime = asTime(item.billEndDate)
116
+ if (itemEndTime !== null && itemEndTime < periodEndTime) {
117
+ return { include: false, reason: 'recurring-ends-mid-cycle' }
118
+ }
119
+ return { include: true }
120
+ }
121
+ case 'usage':
122
+ // Phase 3 routes usage items through the dedicated usage rater.
123
+ // The selector returns "skip" so the engine's recurring/one_time
124
+ // path is unaffected.
125
+ return { include: false, reason: 'usage-handled-by-rater' }
126
+ default:
127
+ return { include: false, reason: `unknown-type:${item.type}` }
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Pure filter — given the items already loaded for an account, returns
133
+ * those eligible for the bill period together with the decision reason
134
+ * for diagnostics (the engine logs skipped items in the run outcome's
135
+ * warnings on demand).
136
+ */
137
+ export function selectItemsForPeriod<T extends SelectableItem>(
138
+ items: readonly T[],
139
+ period: BillPeriod,
140
+ ): { included: T[]; skipped: Array<{ item: T; reason: string }> } {
141
+ const included: T[] = []
142
+ const skipped: Array<{ item: T; reason: string }> = []
143
+ for (const item of items) {
144
+ const decision = shouldIncludeItem(item, period)
145
+ if (decision.include) {
146
+ included.push(item)
147
+ } else {
148
+ skipped.push({ item, reason: decision.reason })
149
+ }
150
+ }
151
+ return { included, skipped }
152
+ }
153
+
154
+ export { isWithin, overlapsPeriod }
@@ -0,0 +1,199 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { BillingAccount, BillingItem, BillingAccountUsage } from '../../data/entities'
3
+
4
+ /**
5
+ * Self-contained billing test-data seed.
6
+ *
7
+ * Creates a couple of demo billing accounts with recurring + usage items
8
+ * and one unrated usage record, scoped to a single tenant. Everything is
9
+ * idempotent: accounts are matched by name, items/usage by `source_ref`,
10
+ * so re-running is a no-op rather than a duplicate.
11
+ *
12
+ * The accounts are dated so they are immediately billable: `next_bill_date`
13
+ * is the 1st of the current month, which makes a Bill Run with the default
14
+ * (today's) as-of date produce an invoice for the previous month — the usage
15
+ * record is seeded into exactly that period.
16
+ *
17
+ * Drives `mercato billing seed`. Kept free of any customer-specific data so
18
+ * it ships with `@dainamite/billing`.
19
+ */
20
+
21
+ export type BillingDemoScope = { tenantId: string; organizationId: string }
22
+ export type BillingDemoResult = {
23
+ accountsCreated: number
24
+ itemsCreated: number
25
+ usageCreated: number
26
+ }
27
+
28
+ // Shared prefix for every row this seed owns — the reset path keys off it.
29
+ const SRC = 'demo-billing'
30
+ const ACCOUNT_NAME_PREFIX = 'Demo — '
31
+
32
+ type DemoAccountSpec = {
33
+ name: string
34
+ customerId: string
35
+ currencyCode: string
36
+ invoiceLanguage: string
37
+ }
38
+
39
+ type DemoItemSpec = {
40
+ ref: string
41
+ type: 'recurring' | 'usage'
42
+ description: string
43
+ unitPrice: number
44
+ uomCode?: string
45
+ }
46
+
47
+ type DemoUsageSpec = {
48
+ ref: string
49
+ uomCode: string
50
+ quantity: number
51
+ lineDescription: string
52
+ }
53
+
54
+ const ACCOUNTS: Array<{
55
+ account: DemoAccountSpec
56
+ items: DemoItemSpec[]
57
+ usage: DemoUsageSpec[]
58
+ }> = [
59
+ {
60
+ account: {
61
+ name: `${ACCOUNT_NAME_PREFIX}Northwind Telecom`,
62
+ customerId: 'demo-cust-northwind',
63
+ currencyCode: 'PLN',
64
+ invoiceLanguage: 'en',
65
+ },
66
+ items: [
67
+ { ref: `${SRC}-nw-rec`, type: 'recurring', description: 'Platform subscription (monthly)', unitPrice: 199 },
68
+ { ref: `${SRC}-nw-usg`, type: 'usage', description: 'Data transfer (per GB)', unitPrice: 0.5, uomCode: 'gb' },
69
+ ],
70
+ usage: [
71
+ { ref: `${SRC}-nw-usage`, uomCode: 'gb', quantity: 120, lineDescription: 'Data transfer (prev. month)' },
72
+ ],
73
+ },
74
+ {
75
+ account: {
76
+ name: `${ACCOUNT_NAME_PREFIX}Globex Cloud`,
77
+ customerId: 'demo-cust-globex',
78
+ currencyCode: 'EUR',
79
+ invoiceLanguage: 'en',
80
+ },
81
+ items: [
82
+ { ref: `${SRC}-gx-rec`, type: 'recurring', description: 'Cloud plan (monthly)', unitPrice: 49 },
83
+ ],
84
+ usage: [],
85
+ },
86
+ ]
87
+
88
+ /** Delete every row this seed owns within the scope (reset before re-seed). */
89
+ export async function resetBillingDemo(em: EntityManager, scope: BillingDemoScope): Promise<void> {
90
+ await em.nativeDelete(BillingAccountUsage, {
91
+ ...scope,
92
+ sourceRef: { $like: `${SRC}-%` },
93
+ } as never)
94
+ await em.nativeDelete(BillingItem, {
95
+ ...scope,
96
+ sourceRef: { $like: `${SRC}-%` },
97
+ } as never)
98
+ await em.nativeDelete(BillingAccount, {
99
+ ...scope,
100
+ name: { $like: `${ACCOUNT_NAME_PREFIX}%` },
101
+ } as never)
102
+ }
103
+
104
+ export async function seedBillingDemo(
105
+ em: EntityManager,
106
+ scope: BillingDemoScope,
107
+ options: { reset?: boolean; now?: Date } = {},
108
+ ): Promise<BillingDemoResult> {
109
+ if (options.reset) await resetBillingDemo(em, scope)
110
+
111
+ const now = options.now ?? new Date()
112
+ // Bill period the next run will cover, given next_bill = 1st of this month:
113
+ // periodStart = 1st of previous month, periodEnd = last day of previous month.
114
+ // Build at UTC midnight so `date` columns don't drift a day under a positive
115
+ // local UTC offset (local `new Date(y, m, 1)` would persist as the prev day).
116
+ const nextBillDate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1))
117
+ const prevMonthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1))
118
+ const prevMonthEnd = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 0))
119
+
120
+ const result: BillingDemoResult = { accountsCreated: 0, itemsCreated: 0, usageCreated: 0 }
121
+
122
+ for (const spec of ACCOUNTS) {
123
+ // ── Account (idempotent by name within scope) ──
124
+ let account = await em.findOne(BillingAccount, {
125
+ ...scope,
126
+ name: spec.account.name,
127
+ deletedAt: null,
128
+ } as never)
129
+ if (!account) {
130
+ account = em.create(BillingAccount, {
131
+ ...scope,
132
+ customerId: spec.account.customerId,
133
+ name: spec.account.name,
134
+ currencyCode: spec.account.currencyCode,
135
+ billCycle: 'monthly',
136
+ billCycleAnchor: '1',
137
+ invoiceEmail: `billing@${spec.account.customerId}.demo`,
138
+ invoiceLanguage: spec.account.invoiceLanguage,
139
+ taxId: null,
140
+ invoiceAddress: { line1: 'ul. Demo 1', city: 'Warszawa', postal_code: '00-001', country: 'PL' },
141
+ nextBillDate,
142
+ lastBillDate: null,
143
+ isActive: true,
144
+ })
145
+ em.persist(account)
146
+ await em.flush() // need the id for child rows
147
+ result.accountsCreated += 1
148
+ }
149
+
150
+ // ── Items (idempotent by source_ref) ──
151
+ for (const item of spec.items) {
152
+ const existing = await em.findOne(BillingItem, { ...scope, sourceRef: item.ref } as never)
153
+ if (existing) continue
154
+ em.persist(
155
+ em.create(BillingItem, {
156
+ ...scope,
157
+ billAccountId: account.id,
158
+ type: item.type,
159
+ billStartDate: prevMonthStart,
160
+ billEndDate: null,
161
+ description: item.description,
162
+ rateJson: { unit_price: item.unitPrice },
163
+ uomCode: item.uomCode ?? null,
164
+ subscriptionId: null,
165
+ subscriptionItemId: null,
166
+ sourceRef: item.ref,
167
+ currencyMismatch: false,
168
+ billedToDate: null,
169
+ isActive: true,
170
+ }),
171
+ )
172
+ result.itemsCreated += 1
173
+ }
174
+
175
+ // ── Usage records (idempotent by source_ref) ──
176
+ for (const u of spec.usage) {
177
+ const existing = await em.findOne(BillingAccountUsage, { ...scope, sourceRef: u.ref } as never)
178
+ if (existing) continue
179
+ em.persist(
180
+ em.create(BillingAccountUsage, {
181
+ ...scope,
182
+ billAccountId: account.id,
183
+ uomCode: u.uomCode,
184
+ quantity: u.quantity.toFixed(4),
185
+ periodStart: prevMonthStart,
186
+ periodEnd: prevMonthEnd,
187
+ lineDescription: u.lineDescription,
188
+ sourceRef: u.ref,
189
+ ratedInBillRunId: null,
190
+ isActive: true,
191
+ }),
192
+ )
193
+ result.usageCreated += 1
194
+ }
195
+ }
196
+
197
+ await em.flush()
198
+ return result
199
+ }
@@ -0,0 +1,87 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+
3
+ /**
4
+ * Per-tenant Postgres transaction-scoped advisory lock primitive.
5
+ *
6
+ * The Bill Run engine (Phase 2) uses this to guarantee at most one
7
+ * concurrently-running run per tenant — the spec requires this so two
8
+ * cron triggers or a "schedule" + "manual" overlap cannot produce
9
+ * duplicate drafts.
10
+ *
11
+ * Why advisory locks (not row locks):
12
+ * - `pg_try_advisory_xact_lock` is non-blocking — we get a clean "busy"
13
+ * signal instead of waiting on a row.
14
+ * - It's released automatically when the transaction ends (commit,
15
+ * rollback, or process death) — so there is no lock-leak path even
16
+ * on `kill -9`. The `BillRun` row's `status='running'` may be left
17
+ * hanging, but the lock itself vanishes; the reaper worker
18
+ * (workers/reap-stale-bill-runs.ts) handles the orphaned row.
19
+ * - It works without a sentinel table — we hash a string key into the
20
+ * bigint key space the advisory-lock API requires.
21
+ *
22
+ * Usage:
23
+ *
24
+ * ```ts
25
+ * import { withTenantLock, TenantLockBusyError } from '../lib/tenantLock'
26
+ *
27
+ * try {
28
+ * await withTenantLock(em, { tenantId, lockName: 'billing-run' }, async (tem) => {
29
+ * // tem is the transactional EntityManager; lock is held here.
30
+ * // Returns to caller after function resolves and tx commits.
31
+ * })
32
+ * } catch (err) {
33
+ * if (err instanceof TenantLockBusyError) {
34
+ * // HTTP 409 — another run is in progress.
35
+ * }
36
+ * throw err
37
+ * }
38
+ * ```
39
+ *
40
+ * Keep work inside the callback short. The advisory lock is released only
41
+ * when the transaction ends; long-running work blocks other triggers for
42
+ * the whole tenant.
43
+ */
44
+
45
+ export class TenantLockBusyError extends Error {
46
+ readonly tenantId: string
47
+ readonly lockName: string
48
+
49
+ constructor(tenantId: string, lockName: string) {
50
+ super(`Tenant ${tenantId} already holds the billing lock "${lockName}"`)
51
+ this.name = 'TenantLockBusyError'
52
+ this.tenantId = tenantId
53
+ this.lockName = lockName
54
+ }
55
+ }
56
+
57
+ const LOCK_KEY_PREFIX = 'dainamite-billing'
58
+
59
+ /**
60
+ * Build the deterministic string key fed into `hashtext()`. Exposed for
61
+ * tests so they can assert lock-key independence across tenants /
62
+ * lockNames without scraping SQL strings.
63
+ */
64
+ export function buildLockKey(tenantId: string, lockName: string): string {
65
+ return `${LOCK_KEY_PREFIX}:${tenantId}:${lockName}`
66
+ }
67
+
68
+ type AdvisoryLockRow = { acquired: boolean }
69
+
70
+ export async function withTenantLock<T>(
71
+ em: EntityManager,
72
+ params: { tenantId: string; lockName: string },
73
+ fn: (tem: EntityManager) => Promise<T>,
74
+ ): Promise<T> {
75
+ return em.transactional(async (tem) => {
76
+ const key = buildLockKey(params.tenantId, params.lockName)
77
+ const rows = (await tem.execute(
78
+ 'SELECT pg_try_advisory_xact_lock(hashtext(?)::bigint) AS acquired',
79
+ [key],
80
+ )) as unknown as AdvisoryLockRow[] | AdvisoryLockRow | undefined
81
+ const first: AdvisoryLockRow | undefined = Array.isArray(rows) ? rows[0] : rows
82
+ if (!first?.acquired) {
83
+ throw new TenantLockBusyError(params.tenantId, params.lockName)
84
+ }
85
+ return fn(tem)
86
+ })
87
+ }