@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,253 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import {
4
+ Dialog,
5
+ DialogContent,
6
+ DialogDescription,
7
+ DialogFooter,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ } from '@open-mercato/ui/primitives/dialog'
11
+ import { Button } from '@open-mercato/ui/primitives/button'
12
+ import { Input } from '@open-mercato/ui/primitives/input'
13
+ import { Textarea } from '@open-mercato/ui/primitives/textarea'
14
+ import { Kbd, KbdShortcut } from '@open-mercato/ui/primitives/kbd'
15
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
16
+
17
+ /**
18
+ * Shared dialog for add / edit of an invoice line.
19
+ *
20
+ * - `mode='add'` shows empty fields and calls the
21
+ * `billing.invoices.add_draft_line` route on submit.
22
+ * - `mode='edit'` pre-fills from `initial` and calls
23
+ * `billing.invoices.edit_draft_line`.
24
+ *
25
+ * The caller owns the network call (so we can stay simple and avoid
26
+ * threading apiCall context through the component). On submit, this
27
+ * dialog only emits `onSubmit(values)` and the parent decides which
28
+ * command to hit.
29
+ *
30
+ * UX conventions per `@open-mercato/ui`:
31
+ * - `Cmd/Ctrl + Enter` submits.
32
+ * - `Escape` cancels (Radix Dialog handles this for free).
33
+ * - Hint row at the bottom lists both shortcuts via `Kbd` /
34
+ * `KbdShortcut` (not raw `<span>`).
35
+ */
36
+
37
+ export type LineFormValues = {
38
+ description: string
39
+ quantity: number
40
+ unitPriceNet: number
41
+ /** Optional explicit total override (operator "I know better" path). */
42
+ totalNetAmount?: number
43
+ }
44
+
45
+ export type LineFormDialogProps = {
46
+ open: boolean
47
+ mode: 'add' | 'edit'
48
+ /** Pre-fill values for edit mode. */
49
+ initial?: Partial<LineFormValues>
50
+ submitting?: boolean
51
+ onSubmit: (values: LineFormValues) => Promise<void> | void
52
+ onCancel: () => void
53
+ }
54
+
55
+ function toNumber(raw: string): number | null {
56
+ if (raw.trim() === '') return null
57
+ const n = Number.parseFloat(raw)
58
+ return Number.isFinite(n) ? n : null
59
+ }
60
+
61
+ export function LineFormDialog({
62
+ open,
63
+ mode,
64
+ initial,
65
+ submitting = false,
66
+ onSubmit,
67
+ onCancel,
68
+ }: LineFormDialogProps) {
69
+ const t = useT()
70
+ const [description, setDescription] = React.useState(initial?.description ?? '')
71
+ const [quantity, setQuantity] = React.useState(
72
+ initial?.quantity != null ? String(initial.quantity) : '',
73
+ )
74
+ const [unitPriceNet, setUnitPriceNet] = React.useState(
75
+ initial?.unitPriceNet != null ? String(initial.unitPriceNet) : '',
76
+ )
77
+ const [totalOverride, setTotalOverride] = React.useState(
78
+ initial?.totalNetAmount != null ? String(initial.totalNetAmount) : '',
79
+ )
80
+ const [error, setError] = React.useState<string | null>(null)
81
+
82
+ React.useEffect(() => {
83
+ if (open) {
84
+ setDescription(initial?.description ?? '')
85
+ setQuantity(initial?.quantity != null ? String(initial.quantity) : '')
86
+ setUnitPriceNet(initial?.unitPriceNet != null ? String(initial.unitPriceNet) : '')
87
+ setTotalOverride(initial?.totalNetAmount != null ? String(initial.totalNetAmount) : '')
88
+ setError(null)
89
+ }
90
+ }, [open, initial])
91
+
92
+ const handleSubmit = React.useCallback(async () => {
93
+ const trimmedDescription = description.trim()
94
+ if (!trimmedDescription) {
95
+ setError(t('billing.invoices.lines.form.error.description_required', 'Description is required'))
96
+ return
97
+ }
98
+ const quantityValue = toNumber(quantity)
99
+ if (quantityValue == null || quantityValue < 0) {
100
+ setError(t('billing.invoices.lines.form.error.quantity_invalid', 'Quantity must be a non-negative number'))
101
+ return
102
+ }
103
+ const unitPriceValue = toNumber(unitPriceNet)
104
+ if (unitPriceValue == null) {
105
+ setError(t('billing.invoices.lines.form.error.unit_price_invalid', 'Unit price must be a number'))
106
+ return
107
+ }
108
+ const totalOverrideRaw = totalOverride.trim()
109
+ let totalOverrideValue: number | undefined
110
+ if (totalOverrideRaw !== '') {
111
+ const parsed = toNumber(totalOverrideRaw)
112
+ if (parsed == null) {
113
+ setError(t('billing.invoices.lines.form.error.total_invalid', 'Total override must be a number when set'))
114
+ return
115
+ }
116
+ totalOverrideValue = parsed
117
+ }
118
+ setError(null)
119
+ await onSubmit({
120
+ description: trimmedDescription,
121
+ quantity: quantityValue,
122
+ unitPriceNet: unitPriceValue,
123
+ totalNetAmount: totalOverrideValue,
124
+ })
125
+ }, [description, onSubmit, quantity, t, totalOverride, unitPriceNet])
126
+
127
+ const handleKeyDown = React.useCallback(
128
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
129
+ // Cmd/Ctrl + Enter submits — required by the OM UX convention
130
+ // for every dialog. Escape is handled by Radix Dialog itself.
131
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
132
+ event.preventDefault()
133
+ void handleSubmit()
134
+ }
135
+ },
136
+ [handleSubmit],
137
+ )
138
+
139
+ // Auto-compute total for the helper text — purely informational, not
140
+ // submitted unless the operator types into `totalOverride`.
141
+ const computedTotal = React.useMemo(() => {
142
+ const q = toNumber(quantity)
143
+ const u = toNumber(unitPriceNet)
144
+ if (q == null || u == null) return null
145
+ return (Math.round(q * u * 100) / 100).toFixed(2)
146
+ }, [quantity, unitPriceNet])
147
+
148
+ return (
149
+ <Dialog open={open} onOpenChange={(next) => { if (!next) onCancel() }}>
150
+ <DialogContent onKeyDown={handleKeyDown}>
151
+ <DialogHeader>
152
+ <DialogTitle>
153
+ {mode === 'add'
154
+ ? t('billing.invoices.lines.form.title_add', 'Add invoice line')
155
+ : t('billing.invoices.lines.form.title_edit', 'Edit invoice line')}
156
+ </DialogTitle>
157
+ <DialogDescription>
158
+ {t(
159
+ 'billing.invoices.lines.form.help',
160
+ 'Changes are recorded in the draft-edit audit log.',
161
+ )}
162
+ </DialogDescription>
163
+ </DialogHeader>
164
+ <div className="flex flex-col gap-4 py-2">
165
+ <label className="flex flex-col gap-1 text-sm">
166
+ <span className="text-muted-foreground">
167
+ {t('billing.invoices.lines.form.description', 'Description')}
168
+ </span>
169
+ <Textarea
170
+ value={description}
171
+ onChange={(event) => setDescription(event.currentTarget.value)}
172
+ rows={2}
173
+ />
174
+ </label>
175
+ <div className="grid grid-cols-2 gap-3">
176
+ <label className="flex flex-col gap-1 text-sm">
177
+ <span className="text-muted-foreground">
178
+ {t('billing.invoices.lines.form.quantity', 'Quantity')}
179
+ </span>
180
+ <Input
181
+ type="number"
182
+ value={quantity}
183
+ step="0.0001"
184
+ onChange={(event) => setQuantity(event.currentTarget.value)}
185
+ />
186
+ </label>
187
+ <label className="flex flex-col gap-1 text-sm">
188
+ <span className="text-muted-foreground">
189
+ {t('billing.invoices.lines.form.unit_price', 'Unit price (net)')}
190
+ </span>
191
+ <Input
192
+ type="number"
193
+ value={unitPriceNet}
194
+ step="0.0001"
195
+ onChange={(event) => setUnitPriceNet(event.currentTarget.value)}
196
+ />
197
+ </label>
198
+ </div>
199
+ <label className="flex flex-col gap-1 text-sm">
200
+ <span className="text-muted-foreground">
201
+ {t('billing.invoices.lines.form.total_override', 'Total override (optional)')}
202
+ </span>
203
+ <Input
204
+ type="number"
205
+ value={totalOverride}
206
+ step="0.0001"
207
+ placeholder={computedTotal ?? ''}
208
+ onChange={(event) => setTotalOverride(event.currentTarget.value)}
209
+ />
210
+ <span className="text-xs text-muted-foreground">
211
+ {computedTotal != null
212
+ ? t(
213
+ 'billing.invoices.lines.form.computed_total',
214
+ 'Computed total: {computed}',
215
+ ).replace('{computed}', computedTotal)
216
+ : null}
217
+ </span>
218
+ </label>
219
+ {error ? (
220
+ <div className="text-sm text-destructive">{error}</div>
221
+ ) : null}
222
+ </div>
223
+ <DialogFooter>
224
+ <div className="flex items-center justify-between gap-3 w-full">
225
+ <span className="text-xs text-muted-foreground">
226
+ <KbdShortcut keys={['Ctrl/⌘', 'Enter']} />
227
+ {' '}
228
+ {t('billing.invoices.lines.form.submit_hint', 'to save,')}
229
+ {' '}
230
+ <Kbd>Esc</Kbd>
231
+ {' '}
232
+ {t('billing.invoices.lines.form.cancel_hint', 'to cancel')}
233
+ </span>
234
+ <div className="flex items-center gap-2">
235
+ <Button type="button" variant="outline" onClick={onCancel}>
236
+ {t('billing.common.cancel', 'Cancel')}
237
+ </Button>
238
+ <Button
239
+ type="button"
240
+ onClick={() => void handleSubmit()}
241
+ disabled={submitting}
242
+ >
243
+ {submitting
244
+ ? t('billing.invoices.lines.form.saving', 'Saving…')
245
+ : t('billing.common.save', 'Save')}
246
+ </Button>
247
+ </div>
248
+ </div>
249
+ </DialogFooter>
250
+ </DialogContent>
251
+ </Dialog>
252
+ )
253
+ }
@@ -0,0 +1,326 @@
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 { Kbd, KbdShortcut } from '@open-mercato/ui/primitives/kbd'
14
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
15
+
16
+ /**
17
+ * Manual Billing Account Usage entry form.
18
+ *
19
+ * Usage is normally ingested by an external metering system via
20
+ * `POST /api/billing/usage` + an `X-Api-Key` scoped to
21
+ * `billing.usage.ingest`. This form gives operators the same path from
22
+ * the admin UI — for corrections, demos, or customers without an
23
+ * automated meter. It POSTs to the very same endpoint, so the
24
+ * idempotency (`sourceRef`) and append-only guarantees are identical.
25
+ *
26
+ * Usage records are immutable once written — there is no edit/delete,
27
+ * hence this is a create-only form (mirrors the `billing.usage.create`
28
+ * command, which has no update sibling).
29
+ */
30
+
31
+ // The accounts list API returns snake_case rows — mirror that here so
32
+ // the picker reads real values (camelCase would resolve to `undefined`).
33
+ export type UsageAccountOption = {
34
+ id: string
35
+ name: string
36
+ customer_id: string
37
+ currency_code: string
38
+ }
39
+
40
+ export type UsageFormValues = {
41
+ billAccountId: string
42
+ uomCode: string
43
+ quantity: string
44
+ periodStart: string
45
+ periodEnd: string
46
+ lineDescription: string
47
+ sourceRef: string
48
+ }
49
+
50
+ export type AssembledUsagePayload = {
51
+ billAccountId: string
52
+ uomCode: string
53
+ quantity: number
54
+ periodStart: string
55
+ periodEnd: string
56
+ lineDescription?: string
57
+ sourceRef?: string
58
+ }
59
+
60
+ export type UsageFormProps = {
61
+ accountOptions: UsageAccountOption[]
62
+ initial?: Partial<UsageFormValues>
63
+ submitting?: boolean
64
+ onSubmit: (assembled: AssembledUsagePayload) => Promise<void> | void
65
+ onCancel: () => void
66
+ }
67
+
68
+ const EMPTY_VALUES: UsageFormValues = {
69
+ billAccountId: '',
70
+ uomCode: '',
71
+ quantity: '',
72
+ periodStart: '',
73
+ periodEnd: '',
74
+ lineDescription: '',
75
+ sourceRef: '',
76
+ }
77
+
78
+ function firstOfMonthIso(): string {
79
+ const now = new Date()
80
+ return new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10)
81
+ }
82
+
83
+ function todayIso(): string {
84
+ return new Date().toISOString().slice(0, 10)
85
+ }
86
+
87
+ function mergeInitial(initial: Partial<UsageFormValues> | undefined): UsageFormValues {
88
+ return {
89
+ ...EMPTY_VALUES,
90
+ periodStart: EMPTY_VALUES.periodStart || firstOfMonthIso(),
91
+ periodEnd: EMPTY_VALUES.periodEnd || todayIso(),
92
+ ...initial,
93
+ }
94
+ }
95
+
96
+ function toNumber(raw: string): number | null {
97
+ if (raw.trim() === '') return null
98
+ const n = Number.parseFloat(raw)
99
+ return Number.isFinite(n) ? n : null
100
+ }
101
+
102
+ export function UsageForm({
103
+ accountOptions,
104
+ initial,
105
+ submitting = false,
106
+ onSubmit,
107
+ onCancel,
108
+ }: UsageFormProps) {
109
+ const t = useT()
110
+ const [values, setValues] = React.useState<UsageFormValues>(() => mergeInitial(initial))
111
+ const [error, setError] = React.useState<string | null>(null)
112
+
113
+ const initialKey = React.useMemo(() => JSON.stringify(initial ?? null), [initial])
114
+ React.useEffect(() => {
115
+ // Depend on the serialized key only — `initial` is a fresh object ref on
116
+ // every parent render, so including it would re-run this effect (and wipe
117
+ // in-progress edits) whenever the parent re-renders for an unrelated reason.
118
+ setValues(mergeInitial(initial))
119
+ // eslint-disable-next-line react-hooks/exhaustive-deps
120
+ }, [initialKey])
121
+
122
+ const set = React.useCallback(
123
+ <K extends keyof UsageFormValues>(key: K, value: UsageFormValues[K]) => {
124
+ setValues((prev) => ({ ...prev, [key]: value }))
125
+ },
126
+ [],
127
+ )
128
+
129
+ const handleSubmit = React.useCallback(async () => {
130
+ if (!values.billAccountId.trim()) {
131
+ setError(t('billing.usage.form.error.account_required', 'Billing Account is required'))
132
+ return
133
+ }
134
+ if (!values.uomCode.trim()) {
135
+ setError(t('billing.usage.form.error.uom_required', 'UoM code is required'))
136
+ return
137
+ }
138
+ const quantity = toNumber(values.quantity)
139
+ if (quantity == null || quantity < 0) {
140
+ setError(
141
+ t('billing.usage.form.error.quantity_invalid', 'Quantity must be a non-negative number'),
142
+ )
143
+ return
144
+ }
145
+ if (!values.periodStart || !values.periodEnd) {
146
+ setError(t('billing.usage.form.error.period_required', 'Period start and end are required'))
147
+ return
148
+ }
149
+ if (values.periodEnd < values.periodStart) {
150
+ setError(
151
+ t('billing.usage.form.error.period_order', 'Period end must be on or after period start'),
152
+ )
153
+ return
154
+ }
155
+ setError(null)
156
+
157
+ const payload: AssembledUsagePayload = {
158
+ billAccountId: values.billAccountId,
159
+ uomCode: values.uomCode.trim(),
160
+ quantity,
161
+ periodStart: values.periodStart,
162
+ periodEnd: values.periodEnd,
163
+ }
164
+ if (values.lineDescription.trim()) payload.lineDescription = values.lineDescription.trim()
165
+ if (values.sourceRef.trim()) payload.sourceRef = values.sourceRef.trim()
166
+ await onSubmit(payload)
167
+ }, [onSubmit, t, values])
168
+
169
+ const handleKeyDown = React.useCallback(
170
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
171
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
172
+ event.preventDefault()
173
+ void handleSubmit()
174
+ } else if (event.key === 'Escape') {
175
+ event.preventDefault()
176
+ onCancel()
177
+ }
178
+ },
179
+ [handleSubmit, onCancel],
180
+ )
181
+
182
+ return (
183
+ <div className="rounded-lg border border-border bg-card p-6" onKeyDown={handleKeyDown}>
184
+ <div className="grid grid-cols-2 gap-4">
185
+ <label className="flex flex-col gap-1 text-sm col-span-2">
186
+ <span className="text-muted-foreground">
187
+ {t('billing.usage.form.account', 'Billing Account')}
188
+ <span className="text-destructive"> *</span>
189
+ </span>
190
+ <Select
191
+ value={values.billAccountId}
192
+ onValueChange={(next) => set('billAccountId', next)}
193
+ >
194
+ <SelectTrigger>
195
+ <SelectValue
196
+ placeholder={t('billing.usage.form.account.placeholder', 'Select an account')}
197
+ />
198
+ </SelectTrigger>
199
+ <SelectContent>
200
+ {accountOptions.map((opt) => (
201
+ <SelectItem key={opt.id} value={opt.id}>
202
+ {opt.name} · {opt.customer_id} · {opt.currency_code}
203
+ </SelectItem>
204
+ ))}
205
+ </SelectContent>
206
+ </Select>
207
+ </label>
208
+
209
+ <label className="flex flex-col gap-1 text-sm">
210
+ <span className="text-muted-foreground">
211
+ {t('billing.usage.form.uom_code', 'UoM code')}
212
+ <span className="text-destructive"> *</span>
213
+ </span>
214
+ <Input
215
+ value={values.uomCode}
216
+ placeholder={t('billing.usage.form.uom_code.placeholder', 'api_request, gb, seat, …')}
217
+ onChange={(event) => set('uomCode', event.currentTarget.value)}
218
+ />
219
+ <span className="text-xs text-muted-foreground">
220
+ {t(
221
+ 'billing.usage.form.uom_code.help',
222
+ 'Must exactly match the UoM on a usage Billing Item for this account — no conversion.',
223
+ )}
224
+ </span>
225
+ </label>
226
+
227
+ <label className="flex flex-col gap-1 text-sm">
228
+ <span className="text-muted-foreground">
229
+ {t('billing.usage.form.quantity', 'Quantity')}
230
+ <span className="text-destructive"> *</span>
231
+ </span>
232
+ <Input
233
+ type="number"
234
+ step="0.0001"
235
+ min="0"
236
+ value={values.quantity}
237
+ onChange={(event) => set('quantity', event.currentTarget.value)}
238
+ />
239
+ <span className="text-xs text-muted-foreground">
240
+ {t(
241
+ 'billing.usage.form.quantity.help',
242
+ 'Total units consumed in the period (pre-aggregated).',
243
+ )}
244
+ </span>
245
+ </label>
246
+
247
+ <label className="flex flex-col gap-1 text-sm">
248
+ <span className="text-muted-foreground">
249
+ {t('billing.usage.form.period_start', 'Period start')}
250
+ <span className="text-destructive"> *</span>
251
+ </span>
252
+ <Input
253
+ type="date"
254
+ value={values.periodStart}
255
+ onChange={(event) => set('periodStart', event.currentTarget.value)}
256
+ />
257
+ </label>
258
+
259
+ <label className="flex flex-col gap-1 text-sm">
260
+ <span className="text-muted-foreground">
261
+ {t('billing.usage.form.period_end', 'Period end')}
262
+ <span className="text-destructive"> *</span>
263
+ </span>
264
+ <Input
265
+ type="date"
266
+ value={values.periodEnd}
267
+ onChange={(event) => set('periodEnd', event.currentTarget.value)}
268
+ />
269
+ </label>
270
+
271
+ <label className="flex flex-col gap-1 text-sm col-span-2">
272
+ <span className="text-muted-foreground">
273
+ {t('billing.usage.form.description', 'Description (optional)')}
274
+ </span>
275
+ <Textarea
276
+ value={values.lineDescription}
277
+ rows={2}
278
+ onChange={(event) => set('lineDescription', event.currentTarget.value)}
279
+ />
280
+ <span className="text-xs text-muted-foreground">
281
+ {t(
282
+ 'billing.usage.form.description.help',
283
+ 'Shown on its own invoice line when set; otherwise matching records are aggregated into one line.',
284
+ )}
285
+ </span>
286
+ </label>
287
+
288
+ <label className="flex flex-col gap-1 text-sm col-span-2">
289
+ <span className="text-muted-foreground">
290
+ {t('billing.usage.form.source_ref', 'Source reference (optional)')}
291
+ </span>
292
+ <Input
293
+ value={values.sourceRef}
294
+ onChange={(event) => set('sourceRef', event.currentTarget.value)}
295
+ />
296
+ <span className="text-xs text-muted-foreground">
297
+ {t(
298
+ 'billing.usage.form.source_ref.help',
299
+ 'Idempotency key — re-submitting the same reference for this account is deduplicated.',
300
+ )}
301
+ </span>
302
+ </label>
303
+ </div>
304
+
305
+ {error ? <div className="mt-4 text-sm text-destructive">{error}</div> : null}
306
+
307
+ <div className="mt-6 flex items-center justify-between gap-3">
308
+ <span className="text-xs text-muted-foreground">
309
+ <KbdShortcut keys={['Ctrl/⌘', 'Enter']} />{' '}
310
+ {t('billing.usage.form.submit_hint', 'to save,')} <Kbd>Esc</Kbd>{' '}
311
+ {t('billing.usage.form.cancel_hint', 'to discard')}
312
+ </span>
313
+ <div className="flex items-center gap-2">
314
+ <Button type="button" variant="outline" onClick={onCancel}>
315
+ {t('billing.common.cancel', 'Cancel')}
316
+ </Button>
317
+ <Button type="button" onClick={() => void handleSubmit()} disabled={submitting}>
318
+ {submitting
319
+ ? t('billing.usage.form.saving', 'Saving…')
320
+ : t('billing.usage.form.create', 'Record usage')}
321
+ </Button>
322
+ </div>
323
+ </div>
324
+ </div>
325
+ )
326
+ }