@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,250 @@
1
+ /**
2
+ * Pure functions to rate usage quantity against a `rate_json` payload.
3
+ *
4
+ * Three tier models per the spec (`volume` / `graduated` / `flat`) plus
5
+ * the "simple" flat-rate shape (`{ unit_price }` without a `model`).
6
+ * The validator at `data/validators.ts` ensures the payload structure
7
+ * before it reaches this rater — this module trusts the shape but
8
+ * defends against numeric edge cases (NaN, negative quantity).
9
+ *
10
+ * Output:
11
+ * - `amount` — numeric string, rounded 2dp half-up, formatted to 4
12
+ * decimals to match the `core/sales` numeric(18,4) column shape.
13
+ * - `breakdown` — per-tier rated quantity / amount, persisted into
14
+ * `SalesInvoiceLine.metadata.usage_tier_breakdown` so operators
15
+ * can see how the line was built without re-running the math.
16
+ */
17
+
18
+ import type { BillingItem } from '../data/entities'
19
+
20
+ export type SimpleFlatRate = { unit_price: number }
21
+ export type VolumeTier = { up_to: number | null; unit_price: number }
22
+ export type GraduatedTier = { up_to: number | null; unit_price: number }
23
+ export type FlatTier = { up_to: number | null; flat_amount: number }
24
+
25
+ export type VolumeRate = { model: 'volume'; tiers: VolumeTier[] }
26
+ export type GraduatedRate = { model: 'graduated'; tiers: GraduatedTier[] }
27
+ export type FlatRate = { model: 'flat'; tiers: FlatTier[] }
28
+ export type TieredRate = VolumeRate | GraduatedRate | FlatRate
29
+ export type UsageRate = SimpleFlatRate | TieredRate
30
+
31
+ export type TierBreakdownEntry = {
32
+ tierIndex: number
33
+ upTo: number | null
34
+ /** Quantity rated at this tier — 0 for tiers above the consumed range. */
35
+ rangeQuantity: number
36
+ /** `unit_price` for volume/graduated; `flat_amount` for flat. */
37
+ rate: number
38
+ /** Per-tier amount contribution, rounded 4dp. */
39
+ amount: string
40
+ }
41
+
42
+ export type UsageRateResult = {
43
+ /** Total rated amount, numeric(18,4) string. */
44
+ amount: string
45
+ /** Per-tier or single-rate breakdown for invoice-line metadata. */
46
+ breakdown: TierBreakdownEntry[]
47
+ }
48
+
49
+ /**
50
+ * Half-up rounding to 2dp survives the JS floating-point edge case
51
+ * (`1.005 → 1.01`, `100 × 0.001 + 100 × 0.001 + … → exactly N×0.001`).
52
+ * Match `invoiceWriter`'s rounding policy so totals are consistent
53
+ * across line types.
54
+ */
55
+ function roundHalfUp2dp(value: number): number {
56
+ return Math.round((value + Number.EPSILON) * 100) / 100
57
+ }
58
+
59
+ function format4dp(value: number): string {
60
+ return roundHalfUp2dp(value).toFixed(4)
61
+ }
62
+
63
+ /**
64
+ * Format a unit price to 4dp WITHOUT 2dp rounding — sub-cent rates
65
+ * like `0.001` (per-API-request pricing) are legitimate. The spec's
66
+ * 2dp half-up rule applies only to line *amounts*, not per-unit
67
+ * rates.
68
+ */
69
+ function formatUnitPrice(value: number): string {
70
+ if (!Number.isFinite(value)) return '0.0000'
71
+ return value.toFixed(4)
72
+ }
73
+
74
+ function isTieredRate(rate: UsageRate): rate is TieredRate {
75
+ return typeof (rate as { model?: unknown }).model === 'string'
76
+ }
77
+
78
+ function safeQuantity(quantity: number): number {
79
+ if (!Number.isFinite(quantity) || quantity < 0) return 0
80
+ return quantity
81
+ }
82
+
83
+ // ─── Simple flat-rate ────────────────────────────────────────────
84
+
85
+ function rateSimpleFlat(quantity: number, rate: SimpleFlatRate): UsageRateResult {
86
+ const q = safeQuantity(quantity)
87
+ const total = q * rate.unit_price
88
+ return {
89
+ amount: format4dp(total),
90
+ breakdown: [
91
+ {
92
+ tierIndex: 0,
93
+ upTo: null,
94
+ rangeQuantity: q,
95
+ rate: rate.unit_price,
96
+ amount: format4dp(total),
97
+ },
98
+ ],
99
+ }
100
+ }
101
+
102
+ // ─── Volume tier ─────────────────────────────────────────────────
103
+ //
104
+ // "Entire quantity priced at the unit_price of the tier its total falls
105
+ // into." So we walk tiers in order, find the first tier whose `up_to`
106
+ // is `null` (open-ended) or `>= quantity`, and charge `quantity ×
107
+ // that tier's unit_price`. The unconsumed tiers report 0.
108
+
109
+ function rateVolume(quantity: number, rate: VolumeRate): UsageRateResult {
110
+ const q = safeQuantity(quantity)
111
+ const breakdown: TierBreakdownEntry[] = rate.tiers.map((tier, idx) => ({
112
+ tierIndex: idx,
113
+ upTo: tier.up_to,
114
+ rangeQuantity: 0,
115
+ rate: tier.unit_price,
116
+ amount: format4dp(0),
117
+ }))
118
+ for (let i = 0; i < rate.tiers.length; i++) {
119
+ const tier = rate.tiers[i]
120
+ if (tier.up_to === null || q <= tier.up_to) {
121
+ const total = q * tier.unit_price
122
+ breakdown[i] = {
123
+ tierIndex: i,
124
+ upTo: tier.up_to,
125
+ rangeQuantity: q,
126
+ rate: tier.unit_price,
127
+ amount: format4dp(total),
128
+ }
129
+ return { amount: format4dp(total), breakdown }
130
+ }
131
+ }
132
+ // Should be unreachable: validator enforces an open-ended top tier.
133
+ return { amount: format4dp(0), breakdown }
134
+ }
135
+
136
+ // ─── Graduated tier ──────────────────────────────────────────────
137
+ //
138
+ // "Each unit priced at its tier." Walk tiers in order, charging
139
+ // `min(q_remaining, tier_capacity) × unit_price` for each tier the
140
+ // quantity touches.
141
+
142
+ function rateGraduated(quantity: number, rate: GraduatedRate): UsageRateResult {
143
+ const q = safeQuantity(quantity)
144
+ let remaining = q
145
+ let previousUpTo = 0
146
+ let total = 0
147
+ const breakdown: TierBreakdownEntry[] = []
148
+ for (let i = 0; i < rate.tiers.length; i++) {
149
+ const tier = rate.tiers[i]
150
+ const tierCapacity =
151
+ tier.up_to === null ? Infinity : Math.max(0, tier.up_to - previousUpTo)
152
+ const consumed = Math.min(remaining, tierCapacity)
153
+ const tierAmount = consumed * tier.unit_price
154
+ total += tierAmount
155
+ breakdown.push({
156
+ tierIndex: i,
157
+ upTo: tier.up_to,
158
+ rangeQuantity: consumed,
159
+ rate: tier.unit_price,
160
+ amount: format4dp(tierAmount),
161
+ })
162
+ remaining -= consumed
163
+ previousUpTo = tier.up_to ?? previousUpTo
164
+ if (remaining <= 0) {
165
+ // Pad remaining tiers with zeros for breakdown completeness.
166
+ for (let j = i + 1; j < rate.tiers.length; j++) {
167
+ breakdown.push({
168
+ tierIndex: j,
169
+ upTo: rate.tiers[j].up_to,
170
+ rangeQuantity: 0,
171
+ rate: rate.tiers[j].unit_price,
172
+ amount: format4dp(0),
173
+ })
174
+ }
175
+ break
176
+ }
177
+ }
178
+ return { amount: format4dp(total), breakdown }
179
+ }
180
+
181
+ // ─── Flat tier ───────────────────────────────────────────────────
182
+ //
183
+ // "One flat fee for the highest tier reached." Find the first tier
184
+ // whose `up_to` is `null` or `>= quantity`. Charge that tier's
185
+ // `flat_amount`. Other tiers report 0.
186
+
187
+ function rateFlat(quantity: number, rate: FlatRate): UsageRateResult {
188
+ const q = safeQuantity(quantity)
189
+ const breakdown: TierBreakdownEntry[] = rate.tiers.map((tier, idx) => ({
190
+ tierIndex: idx,
191
+ upTo: tier.up_to,
192
+ rangeQuantity: 0,
193
+ rate: tier.flat_amount,
194
+ amount: format4dp(0),
195
+ }))
196
+ for (let i = 0; i < rate.tiers.length; i++) {
197
+ const tier = rate.tiers[i]
198
+ if (tier.up_to === null || q <= tier.up_to) {
199
+ breakdown[i] = {
200
+ tierIndex: i,
201
+ upTo: tier.up_to,
202
+ rangeQuantity: q,
203
+ rate: tier.flat_amount,
204
+ amount: format4dp(tier.flat_amount),
205
+ }
206
+ return { amount: format4dp(tier.flat_amount), breakdown }
207
+ }
208
+ }
209
+ return { amount: format4dp(0), breakdown }
210
+ }
211
+
212
+ // ─── Public dispatcher ───────────────────────────────────────────
213
+
214
+ export function rateUsageQuantity(rate: UsageRate, totalQuantity: number): UsageRateResult {
215
+ if (!isTieredRate(rate)) {
216
+ return rateSimpleFlat(totalQuantity, rate)
217
+ }
218
+ switch (rate.model) {
219
+ case 'volume':
220
+ return rateVolume(totalQuantity, rate)
221
+ case 'graduated':
222
+ return rateGraduated(totalQuantity, rate)
223
+ case 'flat':
224
+ return rateFlat(totalQuantity, rate)
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Convenience wrapper: takes a BillingItem's `rate_json` (typed as
230
+ * `unknown` from JSONB) and the total quantity, returns the rated
231
+ * amount + breakdown. Returns `null` when the rate shape is invalid —
232
+ * caller falls back to "no charge" + warns instead of crashing the
233
+ * whole account.
234
+ */
235
+ export function rateUsageForItem(
236
+ item: Pick<BillingItem, 'rateJson' | 'type'>,
237
+ totalQuantity: number,
238
+ ): UsageRateResult | null {
239
+ if (item.type !== 'usage') return null
240
+ const rate = item.rateJson as UsageRate | null | undefined
241
+ if (!rate || typeof rate !== 'object') return null
242
+ if (isTieredRate(rate)) {
243
+ if (!Array.isArray(rate.tiers) || rate.tiers.length === 0) return null
244
+ } else if (typeof (rate as SimpleFlatRate).unit_price !== 'number') {
245
+ return null
246
+ }
247
+ return rateUsageQuantity(rate, totalQuantity)
248
+ }
249
+
250
+ export { roundHalfUp2dp, format4dp, formatUnitPrice }
@@ -0,0 +1,189 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { type BillingAccount, type BillingItem } from '../data/entities'
3
+ import type { BillPeriod } from './billPeriod'
4
+ import type { InvoiceLineDescriptor } from './invoiceWriter'
5
+ import { format4dp, formatUnitPrice, rateUsageForItem, type UsageRate } from './usageRater'
6
+
7
+ /**
8
+ * Per-account usage processing — Phase 3.
9
+ *
10
+ * ## Scale design
11
+ *
12
+ * This module's hot path runs once per account per Bill Run. For
13
+ * telco / infra customers the spec's "Shape B" scenario projects
14
+ * **millions of usage records per account per cycle**. A naive
15
+ * `em.find(BillingAccountUsage, …)` materialises every row in JS
16
+ * memory before we can sum, which OOMs on those tenants.
17
+ *
18
+ * Phase 4f refactor: replace the load-all-then-aggregate path with a
19
+ * single Postgres `GROUP BY uom_code` aggregate. Memory is now bounded
20
+ * by `O(distinct uom_codes per account)` (a small number, typically
21
+ * 1-20) rather than `O(records)`. The corresponding "mark as rated"
22
+ * UPDATE happens engine-side via a `uom_code IN (…)` predicate so we
23
+ * never enumerate ids into memory either.
24
+ *
25
+ * ## Spec behaviour
26
+ *
27
+ * Selects all unrated usage records whose `period_end <= bill_period_end`,
28
+ * groups them by `uom_code`, matches each group against the account's
29
+ * `type=usage` Billing Items (exact `uom_code` match per spec — NO
30
+ * conversion), rates the aggregated quantity, and emits ONE invoice
31
+ * line per matched item.
32
+ *
33
+ * Per-record `line_description` itemization is deferred to a follow-up
34
+ * (would require a non-aggregate path; the engine keeps the invoice
35
+ * clean with one aggregated line per item for now).
36
+ *
37
+ * Unmatched UoMs:
38
+ * - When usage records exist for a `uom_code` that does NOT match
39
+ * any active `type=usage` Billing Item, those records are LEFT
40
+ * un-rated (`rated_in_bill_run_id` stays NULL) and the uom is
41
+ * surfaced in `unmatchedUoms` so the account outcome carries it
42
+ * as `success_with_warnings`. Operator's fix: add the missing
43
+ * Billing Item, then trigger retry-failed.
44
+ *
45
+ * Mode handling:
46
+ * - real: the engine marks `rated_in_bill_run_id` on records matching
47
+ * the returned `matchedUoms` via a single bulk UPDATE.
48
+ * - test / dry: the engine skips the UPDATE; same usage stays
49
+ * consumable by a follow-up run.
50
+ */
51
+
52
+ export type UsageRunResult = {
53
+ /** Invoice line descriptors ready for `writeDraftInvoice`. */
54
+ lines: InvoiceLineDescriptor[]
55
+ /**
56
+ * `uom_code`s the engine should mark as rated. The engine issues a
57
+ * single `UPDATE ... WHERE uom_code IN (matchedUoms) AND <scope>`
58
+ * after this returns — no id list materialisation.
59
+ */
60
+ matchedUoms: string[]
61
+ /** UoMs present in usage records but missing from `type=usage` items. */
62
+ unmatchedUoms: string[]
63
+ /** Count of usage records rated this cycle (for the run summary). */
64
+ ratedCount: number
65
+ }
66
+
67
+ const EMPTY_RESULT: UsageRunResult = {
68
+ lines: [],
69
+ matchedUoms: [],
70
+ unmatchedUoms: [],
71
+ ratedCount: 0,
72
+ }
73
+
74
+ function nonNullUomItems(items: readonly BillingItem[]): Map<string, BillingItem> {
75
+ const out = new Map<string, BillingItem>()
76
+ for (const item of items) {
77
+ if (item.type !== 'usage') continue
78
+ if (!item.isActive || item.deletedAt) continue
79
+ if (!item.uomCode) continue
80
+ out.set(item.uomCode, item)
81
+ }
82
+ return out
83
+ }
84
+
85
+ type UsageAggregateRow = {
86
+ uom_code: string
87
+ total_quantity: string
88
+ record_count: string
89
+ }
90
+
91
+ /**
92
+ * Single Postgres aggregate: bucket unrated usage records by `uom_code`,
93
+ * sum the quantities, count the records. Returns one row per uom — a
94
+ * small, bounded result set regardless of how many millions of records
95
+ * the account actually has.
96
+ *
97
+ * The matching index is the partial index on
98
+ * `(bill_account_id, uom_code, rated_in_bill_run_id, period_end)` shipped
99
+ * in Phase 0's migration — both the filter and the GROUP BY land on
100
+ * the same index.
101
+ */
102
+ async function fetchUsageAggregate(
103
+ em: EntityManager,
104
+ account: BillingAccount,
105
+ period: BillPeriod,
106
+ ): Promise<UsageAggregateRow[]> {
107
+ const rows = (await em.execute(
108
+ `SELECT uom_code,
109
+ SUM(quantity)::text AS total_quantity,
110
+ COUNT(*)::text AS record_count
111
+ FROM billing_account_usage
112
+ WHERE tenant_id = ?
113
+ AND organization_id = ?
114
+ AND bill_account_id = ?
115
+ AND deleted_at IS NULL
116
+ AND rated_in_bill_run_id IS NULL
117
+ AND period_end <= ?
118
+ GROUP BY uom_code`,
119
+ [account.tenantId, account.organizationId, account.id, period.periodEnd],
120
+ )) as unknown as UsageAggregateRow[] | undefined
121
+ return rows ?? []
122
+ }
123
+
124
+ export async function processUsageForAccount(
125
+ em: EntityManager,
126
+ account: BillingAccount,
127
+ period: BillPeriod,
128
+ usageItems: readonly BillingItem[],
129
+ ): Promise<UsageRunResult> {
130
+ const aggregateRows = await fetchUsageAggregate(em, account, period)
131
+ if (aggregateRows.length === 0) return EMPTY_RESULT
132
+
133
+ const itemByUom = nonNullUomItems(usageItems)
134
+
135
+ const lines: InvoiceLineDescriptor[] = []
136
+ const matchedUoms: string[] = []
137
+ const unmatchedUoms: string[] = []
138
+ let ratedCount = 0
139
+
140
+ for (const row of aggregateRows) {
141
+ const uom = row.uom_code
142
+ const totalQuantity = Number.parseFloat(row.total_quantity)
143
+ const recordCount = Number.parseInt(row.record_count, 10)
144
+
145
+ const item = itemByUom.get(uom)
146
+ if (!item) {
147
+ unmatchedUoms.push(uom)
148
+ continue
149
+ }
150
+ if (!Number.isFinite(totalQuantity) || totalQuantity === 0) continue
151
+
152
+ const rated = rateUsageForItem(item, totalQuantity)
153
+ if (!rated) {
154
+ // Malformed rate_json on a passing-validation item is rare but
155
+ // possible if an operator hand-edited it. Surface as unmatched
156
+ // so the operator knows to look rather than silently dropping
157
+ // revenue.
158
+ unmatchedUoms.push(uom)
159
+ continue
160
+ }
161
+
162
+ const isSimpleFlat = !('model' in (item.rateJson as Record<string, unknown>))
163
+ const unitPriceNet = isSimpleFlat
164
+ ? formatUnitPrice((item.rateJson as { unit_price: number }).unit_price)
165
+ : '0'
166
+ lines.push({
167
+ billingItemId: item.id,
168
+ billingType: 'usage',
169
+ description: item.description,
170
+ unitPriceNet,
171
+ quantity: format4dp(totalQuantity),
172
+ totalNetAmount: rated.amount,
173
+ metadataExtra: {
174
+ uom_code: uom,
175
+ usage_record_count: recordCount,
176
+ usage_tier_breakdown: rated.breakdown,
177
+ },
178
+ })
179
+ matchedUoms.push(uom)
180
+ ratedCount += Number.isFinite(recordCount) ? recordCount : 0
181
+ }
182
+
183
+ return { lines, matchedUoms, unmatchedUoms, ratedCount }
184
+ }
185
+
186
+ export { EMPTY_RESULT as __EMPTY_USAGE_RESULT }
187
+
188
+ // Suppress unused-export warning for the type alias.
189
+ export type _UsageRateAlias = UsageRate
@@ -0,0 +1,175 @@
1
+ import { Migration } from '@mikro-orm/migrations'
2
+
3
+ /**
4
+ * Initial Billing module schema (XD-249 Phase 0).
5
+ *
6
+ * Frozen as hand-written. Why this is OK despite the "no hand-written
7
+ * migrations" rule in AGENTS.md:
8
+ *
9
+ * - At Phase 0 (May 2026) the standalone-app build of
10
+ * `@open-mercato/cli@0.6.0` did not auto-discover entities from
11
+ * `@dainamite/*` workspaces, so `yarn mercato db generate` produced
12
+ * an empty diff. The CLI patch shipped in this PR fixes the
13
+ * resolver going forward (see `packages/cpq/scripts/cli-resolver.patch.mjs`
14
+ * usage), but **regenerating THIS initial migration would mean
15
+ * wiping every existing billing_* table on every tenant** to let
16
+ * the CLI re-emit the same `create table` statements. That's
17
+ * never safe on a live system.
18
+ * - The 1:1 mapping against `packages/billing/src/modules/billing/data/entities.ts`
19
+ * has been validated against production-shape data (CPQ → billing
20
+ * end-to-end flow). Any subsequent schema change MUST go through
21
+ * `yarn mercato db generate` — the CLI now correctly walks
22
+ * workspace `@dainamite/*` packages, so future migration files
23
+ * in this folder will be generated, not hand-written.
24
+ */
25
+ export class Migration20260519000000_billing extends Migration {
26
+ async up(): Promise<void> {
27
+ // ─── billing_accounts ────────────────────────────────────
28
+ this.addSql(`create table "billing_accounts" (
29
+ "id" uuid not null default gen_random_uuid(),
30
+ "organization_id" uuid not null,
31
+ "tenant_id" uuid not null,
32
+ "customer_id" text not null,
33
+ "name" text not null,
34
+ "currency_code" text not null,
35
+ "bill_cycle" text not null,
36
+ "bill_cycle_anchor" text not null,
37
+ "invoice_email" text not null,
38
+ "invoice_language" text not null,
39
+ "tax_id" text null,
40
+ "invoice_address" jsonb not null,
41
+ "next_bill_date" date not null,
42
+ "last_bill_date" date null,
43
+ "is_active" boolean not null default true,
44
+ "created_at" timestamptz not null,
45
+ "updated_at" timestamptz not null,
46
+ "deleted_at" timestamptz null,
47
+ constraint "billing_accounts_pkey" primary key ("id")
48
+ );`)
49
+ this.addSql(`create index "billing_accounts_next_bill_date_idx" on "billing_accounts" ("organization_id", "tenant_id", "next_bill_date") where "deleted_at" is null;`)
50
+ this.addSql(`create index "billing_accounts_customer_idx" on "billing_accounts" ("organization_id", "tenant_id", "customer_id");`)
51
+ this.addSql(`create index "billing_accounts_sort_idx" on "billing_accounts" ("organization_id", "tenant_id", "deleted_at", "created_at");`)
52
+
53
+ // ─── billing_items ───────────────────────────────────────
54
+ this.addSql(`create table "billing_items" (
55
+ "id" uuid not null default gen_random_uuid(),
56
+ "organization_id" uuid not null,
57
+ "tenant_id" uuid not null,
58
+ "bill_account_id" uuid not null,
59
+ "type" text not null,
60
+ "bill_start_date" date not null,
61
+ "bill_end_date" date null,
62
+ "description" text not null,
63
+ "rate_json" jsonb not null,
64
+ "uom_code" text null,
65
+ "subscription_id" text null,
66
+ "subscription_item_id" text null,
67
+ "source_ref" text null,
68
+ "currency_mismatch" boolean not null default false,
69
+ "billed_to_date" date null,
70
+ "is_active" boolean not null default true,
71
+ "created_at" timestamptz not null,
72
+ "updated_at" timestamptz not null,
73
+ "deleted_at" timestamptz null,
74
+ constraint "billing_items_pkey" primary key ("id")
75
+ );`)
76
+ this.addSql(`alter table "billing_items" add constraint "billing_items_source_ref_unique" unique ("tenant_id", "bill_account_id", "source_ref");`)
77
+ this.addSql(`create index "billing_items_account_type_idx" on "billing_items" ("bill_account_id", "type");`)
78
+ this.addSql(`create index "billing_items_account_active_idx" on "billing_items" ("bill_account_id", "type", "is_active") where "deleted_at" is null;`)
79
+ this.addSql(`create index "billing_items_subscription_idx" on "billing_items" ("organization_id", "tenant_id", "subscription_id");`)
80
+
81
+ // ─── billing_account_usage ───────────────────────────────
82
+ this.addSql(`create table "billing_account_usage" (
83
+ "id" uuid not null default gen_random_uuid(),
84
+ "organization_id" uuid not null,
85
+ "tenant_id" uuid not null,
86
+ "bill_account_id" uuid not null,
87
+ "uom_code" text not null,
88
+ "quantity" numeric(18, 4) not null,
89
+ "period_start" timestamptz not null,
90
+ "period_end" timestamptz not null,
91
+ "line_description" text null,
92
+ "source_ref" text null,
93
+ "rated_in_bill_run_id" uuid null,
94
+ "is_active" boolean not null default true,
95
+ "created_at" timestamptz not null,
96
+ "updated_at" timestamptz not null,
97
+ "deleted_at" timestamptz null,
98
+ constraint "billing_account_usage_pkey" primary key ("id")
99
+ );`)
100
+ this.addSql(`alter table "billing_account_usage" add constraint "billing_account_usage_source_ref_unique" unique ("tenant_id", "bill_account_id", "source_ref");`)
101
+ this.addSql(`create index "billing_account_usage_rating_idx" on "billing_account_usage" ("bill_account_id", "uom_code", "rated_in_bill_run_id", "period_end");`)
102
+
103
+ // ─── billing_runs ────────────────────────────────────────
104
+ this.addSql(`create table "billing_runs" (
105
+ "id" uuid not null default gen_random_uuid(),
106
+ "organization_id" uuid not null,
107
+ "tenant_id" uuid not null,
108
+ "triggered_by" text not null,
109
+ "parent_run_id" uuid null,
110
+ "dry_run" boolean not null default false,
111
+ "test_mode" boolean not null default false,
112
+ "catch_up" boolean not null default false,
113
+ "scoped_account_ids" jsonb null,
114
+ "as_of_date" date not null,
115
+ "started_at" timestamptz null,
116
+ "finished_at" timestamptz null,
117
+ "status" text not null,
118
+ "summary" jsonb null,
119
+ "is_active" boolean not null default true,
120
+ "created_at" timestamptz not null,
121
+ "updated_at" timestamptz not null,
122
+ "deleted_at" timestamptz null,
123
+ constraint "billing_runs_pkey" primary key ("id")
124
+ );`)
125
+ this.addSql(`create index "billing_runs_tenant_status_idx" on "billing_runs" ("tenant_id", "status", "started_at");`)
126
+ this.addSql(`create index "billing_runs_parent_idx" on "billing_runs" ("parent_run_id");`)
127
+
128
+ // ─── billing_run_outcomes ────────────────────────────────
129
+ this.addSql(`create table "billing_run_outcomes" (
130
+ "id" uuid not null default gen_random_uuid(),
131
+ "organization_id" uuid not null,
132
+ "tenant_id" uuid not null,
133
+ "bill_run_id" uuid not null,
134
+ "bill_account_id" uuid not null,
135
+ "status" text not null,
136
+ "error_message" text null,
137
+ "warnings" jsonb null,
138
+ "draft_invoice_id" uuid null,
139
+ "is_active" boolean not null default true,
140
+ "created_at" timestamptz not null,
141
+ "updated_at" timestamptz not null,
142
+ "deleted_at" timestamptz null,
143
+ constraint "billing_run_outcomes_pkey" primary key ("id")
144
+ );`)
145
+ this.addSql(`create index "billing_run_outcomes_run_status_idx" on "billing_run_outcomes" ("bill_run_id", "status");`)
146
+ this.addSql(`create index "billing_run_outcomes_account_history_idx" on "billing_run_outcomes" ("organization_id", "tenant_id", "bill_account_id", "created_at");`)
147
+
148
+ // ─── billing_draft_invoice_edits ─────────────────────────
149
+ this.addSql(`create table "billing_draft_invoice_edits" (
150
+ "id" uuid not null default gen_random_uuid(),
151
+ "organization_id" uuid not null,
152
+ "tenant_id" uuid not null,
153
+ "invoice_id" uuid not null,
154
+ "invoice_line_id" uuid null,
155
+ "user_id" uuid not null,
156
+ "action" text not null,
157
+ "before_json" jsonb null,
158
+ "after_json" jsonb null,
159
+ "created_at" timestamptz not null,
160
+ "deleted_at" timestamptz null,
161
+ constraint "billing_draft_invoice_edits_pkey" primary key ("id")
162
+ );`)
163
+ this.addSql(`create index "billing_draft_invoice_edits_invoice_idx" on "billing_draft_invoice_edits" ("organization_id", "tenant_id", "invoice_id", "created_at");`)
164
+ this.addSql(`create index "billing_draft_invoice_edits_user_idx" on "billing_draft_invoice_edits" ("organization_id", "tenant_id", "user_id", "created_at");`)
165
+ }
166
+
167
+ async down(): Promise<void> {
168
+ this.addSql(`drop table if exists "billing_draft_invoice_edits" cascade;`)
169
+ this.addSql(`drop table if exists "billing_run_outcomes" cascade;`)
170
+ this.addSql(`drop table if exists "billing_runs" cascade;`)
171
+ this.addSql(`drop table if exists "billing_account_usage" cascade;`)
172
+ this.addSql(`drop table if exists "billing_items" cascade;`)
173
+ this.addSql(`drop table if exists "billing_accounts" cascade;`)
174
+ }
175
+ }