@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,558 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import { Button } from '@open-mercato/ui/primitives/button'
4
+ import { Input } from '@open-mercato/ui/primitives/input'
5
+ import { Textarea } from '@open-mercato/ui/primitives/textarea'
6
+ import {
7
+ Select,
8
+ SelectContent,
9
+ SelectItem,
10
+ SelectTrigger,
11
+ SelectValue,
12
+ } from '@open-mercato/ui/primitives/select'
13
+ import { Switch } from '@open-mercato/ui/primitives/switch'
14
+ import { Kbd, KbdShortcut } from '@open-mercato/ui/primitives/kbd'
15
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
16
+
17
+ /**
18
+ * Shared form for Billing Item create + edit.
19
+ *
20
+ * The `rate_json` field is type-discriminated per the spec's
21
+ * "Rate Formats" section:
22
+ * - `one_time`: `{ amount }` — one number.
23
+ * - `recurring`: `{ unit_price }` — one number.
24
+ * - `usage` (simple): `{ unit_price }` + required `uomCode`.
25
+ * - `usage` (tiered, advanced): operator pastes raw JSON
26
+ * `{ model, tiers: [...] }` into the textarea — for occasional
27
+ * hand-built tiered rates. The integration flow uses CPQ →
28
+ * connector to land tiered rates without UI input.
29
+ *
30
+ * `type` is immutable on edit (per `billingItemUpdateSchema`); we
31
+ * hide the `type` selector in `mode='edit'` so it can't even be
32
+ * surfaced.
33
+ */
34
+
35
+ type BillingItemType = 'one_time' | 'recurring' | 'usage'
36
+ type UsageRateMode = 'simple' | 'tiered'
37
+
38
+ export type ItemFormValues = {
39
+ billAccountId: string
40
+ type: BillingItemType
41
+ billStartDate: string
42
+ billEndDate: string
43
+ description: string
44
+ // Rate is type-dependent — we keep the inputs separate and assemble
45
+ // `rate_json` on submit.
46
+ oneTimeAmount: string
47
+ recurringUnitPrice: string
48
+ usageMode: UsageRateMode
49
+ usageUnitPrice: string
50
+ usageTieredJson: string
51
+ uomCode: string
52
+ subscriptionId: string
53
+ subscriptionItemId: string
54
+ isActive: boolean
55
+ }
56
+
57
+ // The accounts list API (`/api/billing/accounts`) returns snake_case
58
+ // rows — mirror that here so the picker reads real values. (camelCase
59
+ // resolved to `undefined`, rendering "Name · undefined · undefined".)
60
+ export type AccountOption = {
61
+ id: string
62
+ name: string
63
+ customer_id: string
64
+ currency_code: string
65
+ }
66
+
67
+ export type ItemFormProps = {
68
+ mode: 'create' | 'edit'
69
+ /** For create mode: full list of accounts the operator can attach to. */
70
+ accountOptions?: AccountOption[]
71
+ initial?: Partial<ItemFormValues>
72
+ submitting?: boolean
73
+ /** Returns the assembled payload (with rate_json built). */
74
+ onSubmit: (assembled: AssembledItemPayload) => Promise<void> | void
75
+ onCancel: () => void
76
+ }
77
+
78
+ export type AssembledItemPayload = {
79
+ billAccountId: string
80
+ type: BillingItemType
81
+ billStartDate: string
82
+ billEndDate?: string
83
+ description: string
84
+ rateJson: Record<string, unknown>
85
+ uomCode?: string
86
+ subscriptionId?: string
87
+ subscriptionItemId?: string
88
+ isActive?: boolean
89
+ }
90
+
91
+ const EMPTY_VALUES: ItemFormValues = {
92
+ billAccountId: '',
93
+ type: 'recurring',
94
+ billStartDate: '',
95
+ billEndDate: '',
96
+ description: '',
97
+ oneTimeAmount: '',
98
+ recurringUnitPrice: '',
99
+ usageMode: 'simple',
100
+ usageUnitPrice: '',
101
+ usageTieredJson: '',
102
+ uomCode: '',
103
+ subscriptionId: '',
104
+ subscriptionItemId: '',
105
+ isActive: true,
106
+ }
107
+
108
+ function todayIso(): string {
109
+ return new Date().toISOString().slice(0, 10)
110
+ }
111
+
112
+ function mergeInitial(initial: Partial<ItemFormValues> | undefined): ItemFormValues {
113
+ return {
114
+ ...EMPTY_VALUES,
115
+ billStartDate: EMPTY_VALUES.billStartDate || todayIso(),
116
+ ...initial,
117
+ }
118
+ }
119
+
120
+ function toNumber(raw: string): number | null {
121
+ if (raw.trim() === '') return null
122
+ const n = Number.parseFloat(raw)
123
+ return Number.isFinite(n) ? n : null
124
+ }
125
+
126
+ export function ItemForm({
127
+ mode,
128
+ accountOptions,
129
+ initial,
130
+ submitting = false,
131
+ onSubmit,
132
+ onCancel,
133
+ }: ItemFormProps) {
134
+ const t = useT()
135
+ const [values, setValues] = React.useState<ItemFormValues>(() => mergeInitial(initial))
136
+ const [error, setError] = React.useState<string | null>(null)
137
+ const isEdit = mode === 'edit'
138
+
139
+ const initialKey = React.useMemo(() => JSON.stringify(initial ?? null), [initial])
140
+ React.useEffect(() => {
141
+ setValues(mergeInitial(initial))
142
+ }, [initialKey, initial])
143
+
144
+ const set = React.useCallback(
145
+ <K extends keyof ItemFormValues>(key: K, value: ItemFormValues[K]) => {
146
+ setValues((prev) => ({ ...prev, [key]: value }))
147
+ },
148
+ [],
149
+ )
150
+
151
+ const buildRateJson = React.useCallback((): Record<string, unknown> | string => {
152
+ switch (values.type) {
153
+ case 'one_time': {
154
+ const amount = toNumber(values.oneTimeAmount)
155
+ if (amount == null) {
156
+ return t('billing.items.form.error.amount_invalid', 'Amount must be a number')
157
+ }
158
+ return { amount }
159
+ }
160
+ case 'recurring': {
161
+ const unit = toNumber(values.recurringUnitPrice)
162
+ if (unit == null || unit < 0) {
163
+ return t(
164
+ 'billing.items.form.error.unit_price_invalid',
165
+ 'Unit price must be a non-negative number',
166
+ )
167
+ }
168
+ return { unit_price: unit }
169
+ }
170
+ case 'usage': {
171
+ if (values.usageMode === 'simple') {
172
+ const unit = toNumber(values.usageUnitPrice)
173
+ if (unit == null || unit < 0) {
174
+ return t(
175
+ 'billing.items.form.error.unit_price_invalid',
176
+ 'Unit price must be a non-negative number',
177
+ )
178
+ }
179
+ return { unit_price: unit }
180
+ }
181
+ // Tiered: parse the textarea as JSON.
182
+ const raw = values.usageTieredJson.trim()
183
+ if (!raw) {
184
+ return t(
185
+ 'billing.items.form.error.tiered_required',
186
+ 'Tiered rate JSON is required for tiered usage',
187
+ )
188
+ }
189
+ try {
190
+ const parsed = JSON.parse(raw) as Record<string, unknown>
191
+ if (
192
+ typeof parsed !== 'object' ||
193
+ parsed === null ||
194
+ !('model' in parsed) ||
195
+ !('tiers' in parsed)
196
+ ) {
197
+ return t(
198
+ 'billing.items.form.error.tiered_shape',
199
+ 'Tiered JSON must include `model` and `tiers`',
200
+ )
201
+ }
202
+ return parsed
203
+ } catch {
204
+ return t(
205
+ 'billing.items.form.error.tiered_json',
206
+ 'Tiered rate JSON is not valid JSON',
207
+ )
208
+ }
209
+ }
210
+ }
211
+ }, [
212
+ t,
213
+ values.oneTimeAmount,
214
+ values.recurringUnitPrice,
215
+ values.type,
216
+ values.usageMode,
217
+ values.usageTieredJson,
218
+ values.usageUnitPrice,
219
+ ])
220
+
221
+ const handleSubmit = React.useCallback(async () => {
222
+ if (!isEdit && !values.billAccountId.trim()) {
223
+ setError(t('billing.items.form.error.account_required', 'Billing Account is required'))
224
+ return
225
+ }
226
+ if (!values.description.trim()) {
227
+ setError(t('billing.items.form.error.description_required', 'Description is required'))
228
+ return
229
+ }
230
+ if (!values.billStartDate) {
231
+ setError(t('billing.items.form.error.start_date_required', 'Bill start date is required'))
232
+ return
233
+ }
234
+ if (values.type === 'usage' && !values.uomCode.trim()) {
235
+ setError(t('billing.items.form.error.uom_required', 'UoM code is required for usage items'))
236
+ return
237
+ }
238
+ const rateJsonResult = buildRateJson()
239
+ if (typeof rateJsonResult === 'string') {
240
+ // builder returned an error message
241
+ setError(rateJsonResult)
242
+ return
243
+ }
244
+ setError(null)
245
+
246
+ const payload: AssembledItemPayload = {
247
+ billAccountId: values.billAccountId,
248
+ type: values.type,
249
+ billStartDate: values.billStartDate,
250
+ description: values.description.trim(),
251
+ rateJson: rateJsonResult,
252
+ }
253
+ if (values.billEndDate) payload.billEndDate = values.billEndDate
254
+ if (values.type === 'usage' && values.uomCode.trim()) {
255
+ payload.uomCode = values.uomCode.trim()
256
+ }
257
+ if (values.subscriptionId.trim()) payload.subscriptionId = values.subscriptionId.trim()
258
+ if (values.subscriptionItemId.trim()) {
259
+ payload.subscriptionItemId = values.subscriptionItemId.trim()
260
+ }
261
+ if (isEdit) payload.isActive = values.isActive
262
+ await onSubmit(payload)
263
+ }, [buildRateJson, isEdit, onSubmit, t, values])
264
+
265
+ const handleKeyDown = React.useCallback(
266
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
267
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
268
+ event.preventDefault()
269
+ void handleSubmit()
270
+ }
271
+ },
272
+ [handleSubmit],
273
+ )
274
+
275
+ return (
276
+ <div className="rounded-lg border border-border bg-card p-6" onKeyDown={handleKeyDown}>
277
+ <div className="grid grid-cols-2 gap-4">
278
+ {!isEdit ? (
279
+ <label className="flex flex-col gap-1 text-sm col-span-2">
280
+ <span className="text-muted-foreground">
281
+ {t('billing.items.form.account', 'Billing Account')}
282
+ <span className="text-destructive"> *</span>
283
+ </span>
284
+ <Select
285
+ value={values.billAccountId}
286
+ onValueChange={(next) => set('billAccountId', next)}
287
+ >
288
+ <SelectTrigger>
289
+ <SelectValue placeholder={t('billing.items.form.account.placeholder', 'Select an account')} />
290
+ </SelectTrigger>
291
+ <SelectContent>
292
+ {(accountOptions ?? []).map((opt) => (
293
+ <SelectItem key={opt.id} value={opt.id}>
294
+ {opt.name} · {opt.customer_id} · {opt.currency_code}
295
+ </SelectItem>
296
+ ))}
297
+ </SelectContent>
298
+ </Select>
299
+ </label>
300
+ ) : null}
301
+
302
+ {!isEdit ? (
303
+ <label className="flex flex-col gap-1 text-sm">
304
+ <span className="text-muted-foreground">
305
+ {t('billing.items.form.type', 'Type')}
306
+ <span className="text-destructive"> *</span>
307
+ </span>
308
+ <Select
309
+ value={values.type}
310
+ onValueChange={(next) => set('type', next as BillingItemType)}
311
+ >
312
+ <SelectTrigger>
313
+ <SelectValue />
314
+ </SelectTrigger>
315
+ <SelectContent>
316
+ <SelectItem value="one_time">
317
+ {t('billing.items.form.type.one_time', 'One-time charge')}
318
+ </SelectItem>
319
+ <SelectItem value="recurring">
320
+ {t('billing.items.form.type.recurring', 'Recurring (per cycle)')}
321
+ </SelectItem>
322
+ <SelectItem value="usage">
323
+ {t('billing.items.form.type.usage', 'Usage (metered)')}
324
+ </SelectItem>
325
+ </SelectContent>
326
+ </Select>
327
+ </label>
328
+ ) : null}
329
+
330
+ <label className="flex flex-col gap-1 text-sm">
331
+ <span className="text-muted-foreground">
332
+ {t('billing.items.form.start_date', 'Bill start date')}
333
+ <span className="text-destructive"> *</span>
334
+ </span>
335
+ <Input
336
+ type="date"
337
+ value={values.billStartDate}
338
+ onChange={(event) => set('billStartDate', event.currentTarget.value)}
339
+ />
340
+ </label>
341
+
342
+ <label className="flex flex-col gap-1 text-sm">
343
+ <span className="text-muted-foreground">
344
+ {t('billing.items.form.end_date', 'Bill end date (optional)')}
345
+ </span>
346
+ <Input
347
+ type="date"
348
+ value={values.billEndDate}
349
+ onChange={(event) => set('billEndDate', event.currentTarget.value)}
350
+ />
351
+ </label>
352
+
353
+ <label className="flex flex-col gap-1 text-sm col-span-2">
354
+ <span className="text-muted-foreground">
355
+ {t('billing.items.form.description', 'Description')}
356
+ <span className="text-destructive"> *</span>
357
+ </span>
358
+ <Textarea
359
+ value={values.description}
360
+ rows={2}
361
+ onChange={(event) => set('description', event.currentTarget.value)}
362
+ />
363
+ <span className="text-xs text-muted-foreground">
364
+ {t(
365
+ 'billing.items.form.description.help',
366
+ 'Shown verbatim on the invoice line.',
367
+ )}
368
+ </span>
369
+ </label>
370
+ </div>
371
+
372
+ <h3 className="text-sm font-semibold mt-6 mb-2">
373
+ {t('billing.items.form.rate.title', 'Rate')}
374
+ </h3>
375
+ <div className="grid grid-cols-2 gap-4">
376
+ {values.type === 'one_time' ? (
377
+ <label className="flex flex-col gap-1 text-sm">
378
+ <span className="text-muted-foreground">
379
+ {t('billing.items.form.rate.amount', 'Amount')}
380
+ <span className="text-destructive"> *</span>
381
+ </span>
382
+ <Input
383
+ type="number"
384
+ step="0.0001"
385
+ value={values.oneTimeAmount}
386
+ onChange={(event) => set('oneTimeAmount', event.currentTarget.value)}
387
+ />
388
+ <span className="text-xs text-muted-foreground">
389
+ {t(
390
+ 'billing.items.form.rate.amount.help',
391
+ 'Negative allowed for refunds / credits / proration credits.',
392
+ )}
393
+ </span>
394
+ </label>
395
+ ) : null}
396
+
397
+ {values.type === 'recurring' ? (
398
+ <label className="flex flex-col gap-1 text-sm">
399
+ <span className="text-muted-foreground">
400
+ {t('billing.items.form.rate.unit_price_recurring', 'Unit price per cycle')}
401
+ <span className="text-destructive"> *</span>
402
+ </span>
403
+ <Input
404
+ type="number"
405
+ step="0.0001"
406
+ value={values.recurringUnitPrice}
407
+ onChange={(event) => set('recurringUnitPrice', event.currentTarget.value)}
408
+ />
409
+ </label>
410
+ ) : null}
411
+
412
+ {values.type === 'usage' ? (
413
+ <>
414
+ <label className="flex flex-col gap-1 text-sm">
415
+ <span className="text-muted-foreground">
416
+ {t('billing.items.form.rate.usage_mode', 'Rate model')}
417
+ </span>
418
+ <Select
419
+ value={values.usageMode}
420
+ onValueChange={(next) => set('usageMode', next as UsageRateMode)}
421
+ >
422
+ <SelectTrigger>
423
+ <SelectValue />
424
+ </SelectTrigger>
425
+ <SelectContent>
426
+ <SelectItem value="simple">
427
+ {t('billing.items.form.rate.usage_mode.simple', 'Simple flat rate')}
428
+ </SelectItem>
429
+ <SelectItem value="tiered">
430
+ {t('billing.items.form.rate.usage_mode.tiered', 'Tiered (volume / graduated / flat)')}
431
+ </SelectItem>
432
+ </SelectContent>
433
+ </Select>
434
+ </label>
435
+
436
+ {values.usageMode === 'simple' ? (
437
+ <label className="flex flex-col gap-1 text-sm">
438
+ <span className="text-muted-foreground">
439
+ {t('billing.items.form.rate.unit_price_usage', 'Unit price per unit consumed')}
440
+ <span className="text-destructive"> *</span>
441
+ </span>
442
+ <Input
443
+ type="number"
444
+ step="0.000001"
445
+ value={values.usageUnitPrice}
446
+ onChange={(event) => set('usageUnitPrice', event.currentTarget.value)}
447
+ />
448
+ </label>
449
+ ) : (
450
+ <label className="flex flex-col gap-1 text-sm col-span-2">
451
+ <span className="text-muted-foreground">
452
+ {t('billing.items.form.rate.tiered_json', 'Tiered rate JSON')}
453
+ <span className="text-destructive"> *</span>
454
+ </span>
455
+ <Textarea
456
+ rows={6}
457
+ value={values.usageTieredJson}
458
+ placeholder='{ "model": "graduated", "tiers": [{ "up_to": 10000, "unit_price": 0 }, { "up_to": null, "unit_price": 0.001 }] }'
459
+ onChange={(event) => set('usageTieredJson', event.currentTarget.value)}
460
+ />
461
+ <span className="text-xs text-muted-foreground">
462
+ {t(
463
+ 'billing.items.form.rate.tiered_json.help',
464
+ 'Models: volume / graduated / flat. Exactly one tier must have `up_to: null` (the open-ended top tier). Server-side validators reject malformed shapes.',
465
+ )}
466
+ </span>
467
+ </label>
468
+ )}
469
+
470
+ <label className="flex flex-col gap-1 text-sm">
471
+ <span className="text-muted-foreground">
472
+ {t('billing.items.form.uom_code', 'UoM code')}
473
+ <span className="text-destructive"> *</span>
474
+ </span>
475
+ <Input
476
+ value={values.uomCode}
477
+ placeholder="api_request, gb, seat, …"
478
+ onChange={(event) => set('uomCode', event.currentTarget.value)}
479
+ />
480
+ <span className="text-xs text-muted-foreground">
481
+ {t(
482
+ 'billing.items.form.uom_code.help',
483
+ 'Must exactly match the `uom_code` on usage records. No conversion.',
484
+ )}
485
+ </span>
486
+ </label>
487
+ </>
488
+ ) : null}
489
+ </div>
490
+
491
+ <h3 className="text-sm font-semibold mt-6 mb-2">
492
+ {t('billing.items.form.refs.title', 'External references (optional)')}
493
+ </h3>
494
+ <div className="grid grid-cols-2 gap-4">
495
+ <label className="flex flex-col gap-1 text-sm">
496
+ <span className="text-muted-foreground">
497
+ {t('billing.items.form.subscription_id', 'Subscription ID')}
498
+ </span>
499
+ <Input
500
+ value={values.subscriptionId}
501
+ onChange={(event) => set('subscriptionId', event.currentTarget.value)}
502
+ />
503
+ </label>
504
+ <label className="flex flex-col gap-1 text-sm">
505
+ <span className="text-muted-foreground">
506
+ {t('billing.items.form.subscription_item_id', 'Subscription item ID')}
507
+ </span>
508
+ <Input
509
+ value={values.subscriptionItemId}
510
+ onChange={(event) => set('subscriptionItemId', event.currentTarget.value)}
511
+ />
512
+ </label>
513
+ </div>
514
+
515
+ {isEdit ? (
516
+ <div className="mt-6 flex items-center justify-between gap-3">
517
+ <div>
518
+ <div className="text-sm font-medium">
519
+ {t('billing.items.form.active', 'Active')}
520
+ </div>
521
+ <div className="text-xs text-muted-foreground">
522
+ {t(
523
+ 'billing.items.form.active.help',
524
+ 'Inactive items are skipped by the Bill Run engine.',
525
+ )}
526
+ </div>
527
+ </div>
528
+ <Switch
529
+ checked={values.isActive}
530
+ onCheckedChange={(next) => set('isActive', next)}
531
+ />
532
+ </div>
533
+ ) : null}
534
+
535
+ {error ? <div className="mt-4 text-sm text-destructive">{error}</div> : null}
536
+
537
+ <div className="mt-6 flex items-center justify-between gap-3">
538
+ <span className="text-xs text-muted-foreground">
539
+ <KbdShortcut keys={['Ctrl/⌘', 'Enter']} />{' '}
540
+ {t('billing.items.form.submit_hint', 'to save,')} <Kbd>Esc</Kbd>{' '}
541
+ {t('billing.items.form.cancel_hint', 'to discard')}
542
+ </span>
543
+ <div className="flex items-center gap-2">
544
+ <Button type="button" variant="outline" onClick={onCancel}>
545
+ {t('billing.common.cancel', 'Cancel')}
546
+ </Button>
547
+ <Button type="button" onClick={() => void handleSubmit()} disabled={submitting}>
548
+ {submitting
549
+ ? t('billing.items.form.saving', 'Saving…')
550
+ : isEdit
551
+ ? t('billing.items.form.save', 'Save changes')
552
+ : t('billing.items.form.create', 'Create item')}
553
+ </Button>
554
+ </div>
555
+ </div>
556
+ </div>
557
+ )
558
+ }