@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,562 @@
1
+ import { z } from 'zod'
2
+
3
+ // ─── Enums ───────────────────────────────────────────────────────
4
+
5
+ export const BILLING_ITEM_TYPES = ['one_time', 'recurring', 'usage'] as const
6
+ export type BillingItemType = (typeof BILLING_ITEM_TYPES)[number]
7
+
8
+ export const BILL_CYCLES = ['weekly', 'monthly', 'quarterly', 'annually'] as const
9
+ export type BillCycle = (typeof BILL_CYCLES)[number]
10
+
11
+ export const BILL_RUN_TRIGGERS = ['schedule', 'manual'] as const
12
+ export type BillRunTrigger = (typeof BILL_RUN_TRIGGERS)[number]
13
+
14
+ export const BILL_RUN_STATUSES = ['running', 'completed', 'partial_failure', 'failed'] as const
15
+ export type BillRunStatus = (typeof BILL_RUN_STATUSES)[number]
16
+
17
+ export const BILL_RUN_OUTCOME_STATUSES = [
18
+ 'success',
19
+ 'success_with_warnings',
20
+ 'skipped_existing_draft',
21
+ 'failed',
22
+ ] as const
23
+ export type BillRunOutcomeStatus = (typeof BILL_RUN_OUTCOME_STATUSES)[number]
24
+
25
+ export const DRAFT_INVOICE_EDIT_ACTIONS = ['line_added', 'line_removed', 'line_edited'] as const
26
+ export type DraftInvoiceEditAction = (typeof DRAFT_INVOICE_EDIT_ACTIONS)[number]
27
+
28
+ export const INVOICE_STATUSES = ['draft', 'posted', 'paid', 'void'] as const
29
+ export type InvoiceStatus = (typeof INVOICE_STATUSES)[number]
30
+
31
+ // `mon`–`sun` for weekly cycles; 1–28 (as text) for the others.
32
+ // Why text for numeric anchors: the column type stays uniform across cycles
33
+ // and we never do arithmetic on the raw value (the engine parses it on use).
34
+ const WEEKLY_ANCHORS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] as const
35
+
36
+ export const billCycleAnchorSchema = z
37
+ .string()
38
+ .min(1)
39
+ .refine(
40
+ (val) => {
41
+ if ((WEEKLY_ANCHORS as readonly string[]).includes(val.toLowerCase())) return true
42
+ const n = Number.parseInt(val, 10)
43
+ return Number.isInteger(n) && n >= 1 && n <= 28
44
+ },
45
+ {
46
+ message:
47
+ 'bill_cycle_anchor must be 1–28 (monthly/quarterly/annually) or mon–sun (weekly)',
48
+ },
49
+ )
50
+
51
+ // ─── rate_json — pricing definitions ─────────────────────────────
52
+ //
53
+ // Validated at API ingress so the engine can trust the shape downstream.
54
+ // Negative amounts are intentionally allowed only on `one_time` (refunds /
55
+ // credits / proration credits). Recurring and usage unit prices MUST be
56
+ // non-negative — discounts on those are modeled as separate `one_time`
57
+ // credit items.
58
+
59
+ const numericNonNegative = z.number().finite().nonnegative()
60
+ const numericAny = z.number().finite()
61
+
62
+ export const rateJsonOneTime = z
63
+ .object({ amount: numericAny })
64
+ .strict()
65
+
66
+ export const rateJsonRecurring = z
67
+ .object({ unit_price: numericNonNegative })
68
+ .strict()
69
+
70
+ export const rateJsonUsageSimple = z
71
+ .object({ unit_price: numericNonNegative })
72
+ .strict()
73
+
74
+ const tierWithUnitPrice = z
75
+ .object({
76
+ up_to: z.union([z.number().int().positive(), z.null()]),
77
+ unit_price: numericNonNegative,
78
+ })
79
+ .strict()
80
+
81
+ const tierWithFlatAmount = z
82
+ .object({
83
+ up_to: z.union([z.number().int().positive(), z.null()]),
84
+ flat_amount: numericNonNegative,
85
+ })
86
+ .strict()
87
+
88
+ // All tiers must use the same key shape (consistent with the model). Mixing
89
+ // `unit_price` and `flat_amount` across tiers is rejected — see spec
90
+ // validation rules.
91
+ const tiersUnitPrice = z
92
+ .array(tierWithUnitPrice)
93
+ .min(1, 'tiers must be a non-empty array')
94
+ const tiersFlatAmount = z
95
+ .array(tierWithFlatAmount)
96
+ .min(1, 'tiers must be a non-empty array')
97
+
98
+ function refineTierProgression<T extends { up_to: number | null }>(
99
+ tiers: T[],
100
+ ctx: z.RefinementCtx,
101
+ ): void {
102
+ const openTopCount = tiers.filter((t) => t.up_to === null).length
103
+ if (openTopCount !== 1) {
104
+ ctx.addIssue({
105
+ code: z.ZodIssueCode.custom,
106
+ message: 'tiers must contain exactly one open-ended top tier (up_to: null)',
107
+ })
108
+ return
109
+ }
110
+ if (tiers[tiers.length - 1].up_to !== null) {
111
+ ctx.addIssue({
112
+ code: z.ZodIssueCode.custom,
113
+ message: 'the open-ended top tier (up_to: null) must be the last entry',
114
+ })
115
+ return
116
+ }
117
+ for (let i = 1; i < tiers.length - 1; i++) {
118
+ const prev = tiers[i - 1].up_to
119
+ const curr = tiers[i].up_to
120
+ if (prev !== null && curr !== null && curr <= prev) {
121
+ ctx.addIssue({
122
+ code: z.ZodIssueCode.custom,
123
+ message: `up_to values must be strictly increasing across tiers (failed at index ${i})`,
124
+ })
125
+ return
126
+ }
127
+ }
128
+ }
129
+
130
+ export const rateJsonUsageVolume = z
131
+ .object({
132
+ model: z.literal('volume'),
133
+ tiers: tiersUnitPrice,
134
+ })
135
+ .strict()
136
+ .superRefine((val, ctx) => refineTierProgression(val.tiers, ctx))
137
+
138
+ export const rateJsonUsageGraduated = z
139
+ .object({
140
+ model: z.literal('graduated'),
141
+ tiers: tiersUnitPrice,
142
+ })
143
+ .strict()
144
+ .superRefine((val, ctx) => refineTierProgression(val.tiers, ctx))
145
+
146
+ export const rateJsonUsageFlat = z
147
+ .object({
148
+ model: z.literal('flat'),
149
+ tiers: tiersFlatAmount,
150
+ })
151
+ .strict()
152
+ .superRefine((val, ctx) => refineTierProgression(val.tiers, ctx))
153
+
154
+ export const rateJsonUsageTiered = z.discriminatedUnion('model', [
155
+ rateJsonUsageVolume,
156
+ rateJsonUsageGraduated,
157
+ rateJsonUsageFlat,
158
+ ])
159
+
160
+ export const rateJsonUsage = z.union([rateJsonUsageSimple, rateJsonUsageTiered])
161
+
162
+ /**
163
+ * Validate a `rate_json` payload against the rules for a given Billing Item
164
+ * `type`. Returns the parsed value or throws a `ZodError` whose message
165
+ * surfaces the specific shape violation.
166
+ */
167
+ export function validateRateJson(
168
+ type: BillingItemType,
169
+ rateJson: unknown,
170
+ ):
171
+ | z.infer<typeof rateJsonOneTime>
172
+ | z.infer<typeof rateJsonRecurring>
173
+ | z.infer<typeof rateJsonUsage> {
174
+ switch (type) {
175
+ case 'one_time':
176
+ return rateJsonOneTime.parse(rateJson)
177
+ case 'recurring':
178
+ return rateJsonRecurring.parse(rateJson)
179
+ case 'usage':
180
+ return rateJsonUsage.parse(rateJson)
181
+ }
182
+ }
183
+
184
+ // ─── Invoice address (BillingAccount.invoice_address) ────────────
185
+
186
+ export const invoiceAddressSchema = z
187
+ .object({
188
+ line1: z.string().min(1),
189
+ line2: z.string().optional(),
190
+ city: z.string().min(1),
191
+ postal_code: z.string().min(1),
192
+ country: z.string().min(2).max(3), // ISO 3166-1 alpha-2 or alpha-3
193
+ })
194
+ .strict()
195
+
196
+ export type InvoiceAddress = z.infer<typeof invoiceAddressSchema>
197
+
198
+ // ─── Scope (injected by withScopedPayload at API ingress) ────────
199
+
200
+ const scopedSchema = z.object({
201
+ tenantId: z.string().uuid(),
202
+ organizationId: z.string().uuid(),
203
+ })
204
+
205
+ // ─── List pagination (shared shape) ──────────────────────────────
206
+
207
+ const listPaginationBase = {
208
+ page: z.coerce.number().int().min(1).default(1),
209
+ pageSize: z.coerce.number().int().min(1).max(100).default(50),
210
+ sortField: z.string().optional(),
211
+ sortDir: z.enum(['asc', 'desc']).optional(),
212
+ }
213
+
214
+ // ─── BillingAccount payloads ─────────────────────────────────────
215
+
216
+ export const billingAccountCreateSchema = scopedSchema.extend({
217
+ customerId: z.string().min(1).max(255),
218
+ name: z.string().trim().min(1).max(255),
219
+ currencyCode: z.string().trim().toUpperCase().length(3),
220
+ billCycle: z.enum(BILL_CYCLES),
221
+ billCycleAnchor: billCycleAnchorSchema,
222
+ invoiceEmail: z.string().trim().email().max(320),
223
+ invoiceLanguage: z.string().trim().toLowerCase().min(2).max(5),
224
+ // Nullable + optional — consistent with the update schema, and the
225
+ // cpq-billing-connector's shell-account seed sends `taxId: null`
226
+ // for customers with no tax id.
227
+ taxId: z.string().trim().max(64).nullable().optional(),
228
+ invoiceAddress: invoiceAddressSchema,
229
+ nextBillDate: z.coerce.date(),
230
+ lastBillDate: z.coerce.date().optional(),
231
+ isActive: z.boolean().optional(),
232
+ })
233
+
234
+ export type BillingAccountCreateInput = z.infer<typeof billingAccountCreateSchema>
235
+
236
+ export const billingAccountUpdateSchema = scopedSchema.extend({
237
+ id: z.string().uuid(),
238
+ name: z.string().trim().min(1).max(255).optional(),
239
+ billCycle: z.enum(BILL_CYCLES).optional(),
240
+ billCycleAnchor: billCycleAnchorSchema.optional(),
241
+ invoiceEmail: z.string().trim().email().max(320).optional(),
242
+ invoiceLanguage: z.string().trim().toLowerCase().min(2).max(5).optional(),
243
+ taxId: z.string().trim().max(64).nullable().optional(),
244
+ invoiceAddress: invoiceAddressSchema.optional(),
245
+ nextBillDate: z.coerce.date().optional(),
246
+ lastBillDate: z.coerce.date().nullable().optional(),
247
+ isActive: z.boolean().optional(),
248
+ // `currencyCode` is intentionally NOT updatable — currency is frozen at
249
+ // account create time so historical invoices keep their currency context.
250
+ // `customerId` is also immutable; create a new account if the customer
251
+ // FK changes.
252
+ })
253
+
254
+ export type BillingAccountUpdateInput = z.infer<typeof billingAccountUpdateSchema>
255
+
256
+ export const billingAccountDeleteSchema = scopedSchema.extend({
257
+ id: z.string().uuid(),
258
+ })
259
+
260
+ export type BillingAccountDeleteInput = z.infer<typeof billingAccountDeleteSchema>
261
+
262
+ export const billingAccountListQuerySchema = z
263
+ .object({
264
+ ...listPaginationBase,
265
+ id: z.string().uuid().optional(),
266
+ customerId: z.string().optional(),
267
+ currencyCode: z.string().optional(),
268
+ billCycle: z.enum(BILL_CYCLES).optional(),
269
+ isActive: z.coerce.boolean().optional(),
270
+ search: z.string().optional(),
271
+ })
272
+ .passthrough()
273
+
274
+ export type BillingAccountListQuery = z.infer<typeof billingAccountListQuerySchema>
275
+
276
+ // ─── BillingItem payloads ────────────────────────────────────────
277
+
278
+ const ratePayloadByType = z
279
+ .object({
280
+ type: z.enum(BILLING_ITEM_TYPES),
281
+ rateJson: z.unknown(),
282
+ })
283
+ .superRefine((val, ctx) => {
284
+ try {
285
+ validateRateJson(val.type, val.rateJson)
286
+ } catch (err) {
287
+ ctx.addIssue({
288
+ code: z.ZodIssueCode.custom,
289
+ path: ['rateJson'],
290
+ message: err instanceof Error ? err.message : 'rate_json is invalid for this type',
291
+ })
292
+ }
293
+ })
294
+
295
+ export const billingItemCreateSchema = scopedSchema
296
+ .extend({
297
+ billAccountId: z.string().uuid(),
298
+ type: z.enum(BILLING_ITEM_TYPES),
299
+ billStartDate: z.coerce.date(),
300
+ billEndDate: z.coerce.date().nullable().optional(),
301
+ description: z.string().trim().min(1).max(500),
302
+ rateJson: z.unknown(), // shape validated via the refine below
303
+ uomCode: z.string().trim().max(64).optional(),
304
+ subscriptionId: z.string().trim().max(255).optional(),
305
+ subscriptionItemId: z.string().trim().max(255).optional(),
306
+ // `source_ref` is the idempotency key. NULL → no dedup, always create.
307
+ sourceRef: z.string().trim().min(1).max(255).optional(),
308
+ // currency_mismatch and billed_to_date are system-managed, never set by
309
+ // clients on create.
310
+ })
311
+ .superRefine((val, ctx) => {
312
+ try {
313
+ validateRateJson(val.type, val.rateJson)
314
+ } catch (err) {
315
+ const message = err instanceof Error ? err.message : 'rate_json is invalid for this type'
316
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['rateJson'], message })
317
+ }
318
+ if (val.type === 'usage' && !val.uomCode) {
319
+ ctx.addIssue({
320
+ code: z.ZodIssueCode.custom,
321
+ path: ['uomCode'],
322
+ message: 'uomCode is required for type=usage',
323
+ })
324
+ }
325
+ })
326
+
327
+ export type BillingItemCreateInput = z.infer<typeof billingItemCreateSchema>
328
+
329
+ // ─── Bulk item create (Phase 4g) ─────────────────────────────────
330
+ //
331
+ // One scope envelope, N item payloads. The CPQ connector activating
332
+ // a large subscription used to fire one `billing.items.create` per
333
+ // charge — bulk-create collapses that into a single command with a
334
+ // batched `source_ref` idempotency check + single flush.
335
+
336
+ const billingItemBulkEntrySchema = z
337
+ .object({
338
+ billAccountId: z.string().uuid(),
339
+ type: z.enum(BILLING_ITEM_TYPES),
340
+ billStartDate: z.coerce.date(),
341
+ billEndDate: z.coerce.date().nullable().optional(),
342
+ description: z.string().trim().min(1).max(500),
343
+ rateJson: z.unknown(),
344
+ uomCode: z.string().trim().max(64).optional(),
345
+ subscriptionId: z.string().trim().max(255).optional(),
346
+ subscriptionItemId: z.string().trim().max(255).optional(),
347
+ sourceRef: z.string().trim().min(1).max(255).optional(),
348
+ })
349
+ .superRefine((val, ctx) => {
350
+ try {
351
+ validateRateJson(val.type, val.rateJson)
352
+ } catch (err) {
353
+ const message = err instanceof Error ? err.message : 'rate_json is invalid for this type'
354
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['rateJson'], message })
355
+ }
356
+ if (val.type === 'usage' && !val.uomCode) {
357
+ ctx.addIssue({
358
+ code: z.ZodIssueCode.custom,
359
+ path: ['uomCode'],
360
+ message: 'uomCode is required for type=usage',
361
+ })
362
+ }
363
+ })
364
+
365
+ export type BillingItemBulkEntry = z.infer<typeof billingItemBulkEntrySchema>
366
+
367
+ export const billingItemBulkCreateSchema = scopedSchema.extend({
368
+ // Cap at 500 per call — keeps a single bulk transaction bounded.
369
+ // A CPQ subscription with >500 charges should be split by the
370
+ // caller (the connector); 500 is already far past any realistic
371
+ // single-subscription charge count.
372
+ items: z.array(billingItemBulkEntrySchema).min(1).max(500),
373
+ })
374
+
375
+ export type BillingItemBulkCreateInput = z.infer<typeof billingItemBulkCreateSchema>
376
+
377
+ export const billingItemUpdateSchema = scopedSchema.extend({
378
+ id: z.string().uuid(),
379
+ // `type` is immutable — changing one_time → recurring would silently
380
+ // change billing semantics. Cancel the old item, create a new one.
381
+ billStartDate: z.coerce.date().optional(),
382
+ billEndDate: z.coerce.date().nullable().optional(),
383
+ description: z.string().trim().min(1).max(500).optional(),
384
+ rateJson: z.unknown().optional(),
385
+ uomCode: z.string().trim().max(64).nullable().optional(),
386
+ subscriptionId: z.string().trim().max(255).nullable().optional(),
387
+ subscriptionItemId: z.string().trim().max(255).nullable().optional(),
388
+ // `source_ref` immutable — it's the idempotency identity of the item.
389
+ isActive: z.boolean().optional(),
390
+ })
391
+
392
+ export type BillingItemUpdateInput = z.infer<typeof billingItemUpdateSchema>
393
+
394
+ export const billingItemDeleteSchema = scopedSchema.extend({
395
+ id: z.string().uuid(),
396
+ })
397
+
398
+ export type BillingItemDeleteInput = z.infer<typeof billingItemDeleteSchema>
399
+
400
+ export const billingItemListQuerySchema = z
401
+ .object({
402
+ ...listPaginationBase,
403
+ id: z.string().uuid().optional(),
404
+ search: z.string().optional(),
405
+ billAccountId: z.string().uuid().optional(),
406
+ type: z.enum(BILLING_ITEM_TYPES).optional(),
407
+ subscriptionId: z.string().optional(),
408
+ isActive: z.coerce.boolean().optional(),
409
+ activeAt: z.coerce.date().optional(), // bill_start_date <= X AND (bill_end_date IS NULL OR bill_end_date >= X)
410
+ })
411
+ .passthrough()
412
+
413
+ export type BillingItemListQuery = z.infer<typeof billingItemListQuerySchema>
414
+
415
+ // ─── BillingAccountUsage payloads ────────────────────────────────
416
+
417
+ export const billingUsageCreateSchema = scopedSchema.extend({
418
+ billAccountId: z.string().uuid(),
419
+ uomCode: z.string().trim().min(1).max(64),
420
+ // `quantity` arrives as a number from JSON; MikroORM stores it as
421
+ // numeric(18,4). The service coerces to a string at persist time.
422
+ quantity: z.number().finite().nonnegative(),
423
+ periodStart: z.coerce.date(),
424
+ periodEnd: z.coerce.date(),
425
+ lineDescription: z.string().trim().min(1).max(500).optional(),
426
+ sourceRef: z.string().trim().min(1).max(255).optional(),
427
+ }).superRefine((val, ctx) => {
428
+ if (val.periodEnd < val.periodStart) {
429
+ ctx.addIssue({
430
+ code: z.ZodIssueCode.custom,
431
+ path: ['periodEnd'],
432
+ message: 'periodEnd must be on or after periodStart',
433
+ })
434
+ }
435
+ })
436
+
437
+ export type BillingUsageCreateInput = z.infer<typeof billingUsageCreateSchema>
438
+
439
+ export const billingUsageListQuerySchema = z
440
+ .object({
441
+ ...listPaginationBase,
442
+ billAccountId: z.string().uuid().optional(),
443
+ uomCode: z.string().optional(),
444
+ rated: z.coerce.boolean().optional(), // true → already rated; false → unrated
445
+ periodEndFrom: z.coerce.date().optional(),
446
+ periodEndTo: z.coerce.date().optional(),
447
+ })
448
+ .passthrough()
449
+
450
+ export type BillingUsageListQuery = z.infer<typeof billingUsageListQuerySchema>
451
+
452
+ // Suppress unused-export TS warning — `ratePayloadByType` is reserved for
453
+ // reuse by a future per-tenant rate-validation CLI tool.
454
+ void ratePayloadByType
455
+
456
+ // ─── Bill Run trigger payloads (Phase 2) ─────────────────────────
457
+
458
+ export const BILL_RUN_MODES = ['real', 'test', 'dry'] as const
459
+ export type BillRunMode = (typeof BILL_RUN_MODES)[number]
460
+
461
+ export const billingRunTriggerSchema = scopedSchema.extend({
462
+ // `triggeredBy` is filled in by the route layer (always 'manual' for
463
+ // API triggers; the cron worker sets 'schedule'). The client cannot
464
+ // forge a 'schedule' trigger — the field is intentionally omitted
465
+ // from the input shape.
466
+ mode: z.enum(BILL_RUN_MODES).default('real'),
467
+ asOfDate: z.coerce.date().optional(),
468
+ scopedAccountIds: z.array(z.string().uuid()).optional(),
469
+ catchUp: z.boolean().optional(),
470
+ })
471
+
472
+ export type BillingRunTriggerInput = z.infer<typeof billingRunTriggerSchema>
473
+
474
+ export const billingRunRetryFailedSchema = scopedSchema.extend({
475
+ billRunId: z.string().uuid(),
476
+ mode: z.enum(BILL_RUN_MODES).default('real'),
477
+ })
478
+
479
+ export type BillingRunRetryFailedInput = z.infer<typeof billingRunRetryFailedSchema>
480
+
481
+ export const billingRunListQuerySchema = z
482
+ .object({
483
+ ...listPaginationBase,
484
+ id: z.string().uuid().optional(),
485
+ search: z.string().optional(),
486
+ status: z.enum(BILL_RUN_STATUSES).optional(),
487
+ triggeredBy: z.enum(BILL_RUN_TRIGGERS).optional(),
488
+ parentRunId: z.string().uuid().optional(),
489
+ asOfDateFrom: z.coerce.date().optional(),
490
+ asOfDateTo: z.coerce.date().optional(),
491
+ })
492
+ .passthrough()
493
+
494
+ export type BillingRunListQuery = z.infer<typeof billingRunListQuerySchema>
495
+
496
+ // ─── Invoice post (Phase 4) ──────────────────────────────────────
497
+
498
+ export const billingInvoicePostSchema = scopedSchema.extend({
499
+ invoiceId: z.string().uuid(),
500
+ })
501
+
502
+ export type BillingInvoicePostInput = z.infer<typeof billingInvoicePostSchema>
503
+
504
+ // ─── Draft invoice edits (Phase 4b) ──────────────────────────────
505
+
506
+ /**
507
+ * Fields the operator may change on a draft invoice line. Per spec:
508
+ * "operator CAN: add lines, remove lines, edit line description /
509
+ * quantity / unit_price / amount". `taxRate` is owned by core/sales
510
+ * tax service and `currencyCode` is frozen at account create — both
511
+ * intentionally absent from this schema.
512
+ */
513
+ const lineEditableFields = z.object({
514
+ description: z.string().trim().min(1).max(500).optional(),
515
+ quantity: z.coerce.number().finite().nonnegative().optional(),
516
+ unitPriceNet: z.coerce.number().finite().optional(),
517
+ // `totalNetAmount` is operator-overridable for the "I know better
518
+ // than `unit_price × quantity`" cases (rare; mostly for negative
519
+ // credits or rounding fix-ups). When provided, it short-circuits
520
+ // the auto-compute path.
521
+ totalNetAmount: z.coerce.number().finite().optional(),
522
+ })
523
+
524
+ export const billingInvoiceEditLineSchema = scopedSchema.extend({
525
+ invoiceId: z.string().uuid(),
526
+ invoiceLineId: z.string().uuid(),
527
+ changes: lineEditableFields,
528
+ })
529
+
530
+ export type BillingInvoiceEditLineInput = z.infer<typeof billingInvoiceEditLineSchema>
531
+
532
+ export const billingInvoiceAddLineSchema = scopedSchema.extend({
533
+ invoiceId: z.string().uuid(),
534
+ description: z.string().trim().min(1).max(500),
535
+ quantity: z.coerce.number().finite().nonnegative(),
536
+ unitPriceNet: z.coerce.number().finite(),
537
+ // Optional metadata for traceability — typically operators leave
538
+ // `billing_item_id` blank because ad-hoc lines aren't tied to a
539
+ // Billing Item.
540
+ billingItemId: z.string().uuid().optional(),
541
+ billingType: z.enum(BILLING_ITEM_TYPES).optional(),
542
+ })
543
+
544
+ export type BillingInvoiceAddLineInput = z.infer<typeof billingInvoiceAddLineSchema>
545
+
546
+ export const billingInvoiceRemoveLineSchema = scopedSchema.extend({
547
+ invoiceId: z.string().uuid(),
548
+ invoiceLineId: z.string().uuid(),
549
+ })
550
+
551
+ export type BillingInvoiceRemoveLineInput = z.infer<typeof billingInvoiceRemoveLineSchema>
552
+
553
+ // ─── Test-invoices wipe (Phase 4b) ───────────────────────────────
554
+
555
+ export const billingWipeTestInvoicesSchema = scopedSchema.extend({
556
+ /** Optional — when set, wipes only test invoices from this run. */
557
+ billRunId: z.string().uuid().optional(),
558
+ })
559
+
560
+ export type BillingWipeTestInvoicesInput = z.infer<typeof billingWipeTestInvoicesSchema>
561
+
562
+
@@ -0,0 +1,14 @@
1
+ import type { AppContainer } from '@open-mercato/shared/lib/di/container'
2
+
3
+ /**
4
+ * Billing module DI registrar.
5
+ *
6
+ * Phase 0 ships no services — entities, ACL, and tenant setup only. The
7
+ * REST API surface (Phase 1) and Bill Run engine (Phase 2) bring services
8
+ * here. Keeping the file in place so the auto-discovery generator picks
9
+ * up `billing` as a DI-aware module without surprise on the first new
10
+ * registration.
11
+ */
12
+ export function register(_container: AppContainer): void {
13
+ // intentionally empty — see file header
14
+ }
@@ -0,0 +1,47 @@
1
+ import { createModuleEvents } from '@open-mercato/shared/modules/events'
2
+
3
+ /**
4
+ * Billing module events.
5
+ *
6
+ * Emission contract:
7
+ * - `billing.invoice.posted` fires once per posted draft AFTER the post
8
+ * transaction commits (sequence number assigned, status set to `posted`
9
+ * in core/sales). Consumed by mailers / accounting bridges / analytics.
10
+ * - `billing.invoice.line_posted` fires once per line on a posted invoice
11
+ * so analytics can aggregate without scanning core/sales lines directly.
12
+ * - `billing.run.completed` fires once per Bill Run after all per-account
13
+ * transactions finished (status is one of `completed` / `partial_failure`
14
+ * / `failed`). Useful for "did the nightly run go OK?" alerts.
15
+ */
16
+ const events = [
17
+ {
18
+ id: 'billing.invoice.posted',
19
+ label: 'Billing Invoice Posted',
20
+ entity: 'invoice',
21
+ category: 'lifecycle',
22
+ clientBroadcast: true,
23
+ },
24
+ {
25
+ id: 'billing.invoice.line_posted',
26
+ label: 'Billing Invoice Line Posted',
27
+ entity: 'invoice_line',
28
+ category: 'lifecycle',
29
+ },
30
+ {
31
+ id: 'billing.run.completed',
32
+ label: 'Bill Run Completed',
33
+ entity: 'bill_run',
34
+ category: 'lifecycle',
35
+ clientBroadcast: true,
36
+ },
37
+ ] as const
38
+
39
+ export const eventsConfig = createModuleEvents({
40
+ moduleId: 'billing',
41
+ events,
42
+ })
43
+
44
+ export const emitBillingEvent = eventsConfig.emit
45
+ export type BillingEventId = (typeof events)[number]['id']
46
+
47
+ export default eventsConfig