@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,574 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import Link from 'next/link'
4
+ import { Pencil, Plus } from 'lucide-react'
5
+ import { useParams, useRouter } from 'next/navigation'
6
+ import type { ColumnDef } from '@tanstack/react-table'
7
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
8
+ import { Page, PageBody, PageHeader } from '@open-mercato/ui/backend/Page'
9
+ import { FormHeader } from '@open-mercato/ui/backend/forms'
10
+ import { DataTable } from '@open-mercato/ui/backend/DataTable'
11
+ import { Tag } from '@open-mercato/ui/primitives/tag'
12
+ import { Button } from '@open-mercato/ui/primitives/button'
13
+ import { LoadingMessage, ErrorMessage } from '@open-mercato/ui/backend/detail'
14
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
15
+ import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
16
+ import {
17
+ apiCallOrThrow,
18
+ readApiResultOrThrow,
19
+ } from '@open-mercato/ui/backend/utils/apiCall'
20
+ import { normalizeCrudServerError } from '@open-mercato/ui/backend/utils/serverErrors'
21
+ import { AccountForm, type AccountFormValues } from '../../../../components/AccountForm'
22
+ import { DetailCard, DetailField } from '../../../../components/DetailFields'
23
+
24
+ /**
25
+ * `/backend/billing/accounts/[id]` — Billing Account detail.
26
+ *
27
+ * Opens read-only (field grid + the account's items). The `Edit`
28
+ * button swaps the grid for the `AccountForm`; Save / Cancel return
29
+ * to the read-only view. Loads via `GET /api/billing/accounts?id=<id>`,
30
+ * edits through `PUT`, soft-delete through `DELETE`.
31
+ *
32
+ * `customerId` + `currencyCode` are immutable on update — the form
33
+ * disables both in `mode='edit'` and the schema rejects them per
34
+ * `billingAccountUpdateSchema`.
35
+ */
36
+
37
+ // API list rows are snake_case (see `api/accounts/route.ts` `fields`).
38
+ type AccountRow = {
39
+ id: string
40
+ customer_id: string
41
+ name: string
42
+ currency_code: string
43
+ bill_cycle: 'weekly' | 'monthly' | 'quarterly' | 'annually'
44
+ bill_cycle_anchor: string
45
+ invoice_email: string
46
+ invoice_language: string
47
+ tax_id: string | null
48
+ invoice_address: {
49
+ line1?: string
50
+ line2?: string
51
+ city?: string
52
+ postal_code?: string
53
+ country?: string
54
+ } | null
55
+ next_bill_date: string
56
+ last_bill_date: string | null
57
+ is_active: boolean
58
+ created_at: string
59
+ updated_at: string
60
+ }
61
+
62
+ type ListResponse = { items: AccountRow[] }
63
+
64
+ // Minimal item shape for the embedded "Items" table.
65
+ type AccountItemRow = {
66
+ id: string
67
+ description: string
68
+ type: 'one_time' | 'recurring' | 'usage'
69
+ bill_start_date: string
70
+ is_active: boolean
71
+ }
72
+
73
+ // Minimal usage shape for the embedded "Usage" table. Mirrors the
74
+ // snake_case `fields` projection in `api/usage/route.ts`. Usage is
75
+ // append-only — `rated_in_bill_run_id` is null until a Bill Run rates it.
76
+ type AccountUsageRow = {
77
+ id: string
78
+ uom_code: string
79
+ quantity: string
80
+ period_start: string
81
+ period_end: string
82
+ line_description: string | null
83
+ rated_in_bill_run_id: string | null
84
+ }
85
+
86
+ function formatDate(value: string | null | undefined): string {
87
+ if (!value) return '—'
88
+ const parsed = new Date(value)
89
+ return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleDateString()
90
+ }
91
+
92
+ function toFormValues(row: AccountRow): AccountFormValues {
93
+ return {
94
+ customerId: row.customer_id,
95
+ name: row.name,
96
+ currencyCode: row.currency_code,
97
+ billCycle: row.bill_cycle,
98
+ billCycleAnchor: row.bill_cycle_anchor,
99
+ invoiceEmail: row.invoice_email,
100
+ invoiceLanguage: row.invoice_language,
101
+ taxId: row.tax_id ?? '',
102
+ invoiceAddress: {
103
+ line1: row.invoice_address?.line1 ?? '',
104
+ line2: row.invoice_address?.line2 ?? '',
105
+ city: row.invoice_address?.city ?? '',
106
+ postal_code: row.invoice_address?.postal_code ?? '',
107
+ country: row.invoice_address?.country ?? '',
108
+ },
109
+ nextBillDate: row.next_bill_date?.slice(0, 10) ?? '',
110
+ isActive: row.is_active,
111
+ }
112
+ }
113
+
114
+ // ─── Read-only view ──────────────────────────────────────────────
115
+
116
+ function AccountView({ row }: { row: AccountRow }) {
117
+ const t = useT()
118
+ const addr = row.invoice_address
119
+ const addressText = addr
120
+ ? [
121
+ addr.line1,
122
+ addr.line2,
123
+ [addr.postal_code, addr.city].filter(Boolean).join(' '),
124
+ addr.country,
125
+ ]
126
+ .map((part) => (part ?? '').trim())
127
+ .filter(Boolean)
128
+ .join(', ')
129
+ : ''
130
+ return (
131
+ <DetailCard>
132
+ <DetailField label={t('billing.accounts.form.customer_id', 'Customer ID')}>
133
+ <span className="font-mono text-xs">{row.customer_id}</span>
134
+ </DetailField>
135
+ <DetailField label={t('billing.accounts.columns.currency', 'Currency')}>
136
+ {row.currency_code}
137
+ </DetailField>
138
+ <DetailField label={t('billing.common.id', 'ID')}>
139
+ <span className="font-mono text-xs">{row.id}</span>
140
+ </DetailField>
141
+ <DetailField label={t('billing.accounts.columns.cycle', 'Cycle')}>
142
+ {row.bill_cycle}
143
+ <span className="text-muted-foreground"> · {row.bill_cycle_anchor}</span>
144
+ </DetailField>
145
+ <DetailField label={t('billing.accounts.columns.next_bill', 'Next bill')}>
146
+ {formatDate(row.next_bill_date)}
147
+ </DetailField>
148
+ <DetailField label={t('billing.accounts.columns.last_bill', 'Last bill')}>
149
+ {formatDate(row.last_bill_date)}
150
+ </DetailField>
151
+ <DetailField label={t('billing.accounts.form.invoice_email', 'Invoice email')}>
152
+ {row.invoice_email}
153
+ </DetailField>
154
+ <DetailField label={t('billing.accounts.form.invoice_language', 'Invoice language')}>
155
+ {row.invoice_language}
156
+ </DetailField>
157
+ <DetailField label={t('billing.accounts.form.tax_id', 'Tax ID')}>
158
+ {row.tax_id || '—'}
159
+ </DetailField>
160
+ <DetailField
161
+ label={t('billing.accounts.form.address.title', 'Invoice address')}
162
+ fullWidth
163
+ >
164
+ {addressText || '—'}
165
+ </DetailField>
166
+ </DetailCard>
167
+ )
168
+ }
169
+
170
+ // ─── Embedded items table ────────────────────────────────────────
171
+
172
+ function AccountItemsSection({ accountId }: { accountId: string }) {
173
+ const t = useT()
174
+ const router = useRouter()
175
+ const [rows, setRows] = React.useState<AccountItemRow[]>([])
176
+ const [page, setPage] = React.useState(1)
177
+ const [total, setTotal] = React.useState(0)
178
+ const [totalPages, setTotalPages] = React.useState(1)
179
+ const [loading, setLoading] = React.useState(true)
180
+ const pageSize = 25
181
+
182
+ const load = React.useCallback(async () => {
183
+ setLoading(true)
184
+ try {
185
+ const result = await readApiResultOrThrow<{
186
+ items: AccountItemRow[]
187
+ total: number
188
+ totalPages: number
189
+ }>(
190
+ `/api/billing/items?billAccountId=${accountId}&page=${page}` +
191
+ `&pageSize=${pageSize}&sortField=createdAt&sortDir=desc`,
192
+ )
193
+ setRows(result.items ?? [])
194
+ setTotal(result.total ?? 0)
195
+ setTotalPages(result.totalPages ?? 1)
196
+ } finally {
197
+ setLoading(false)
198
+ }
199
+ }, [accountId, page])
200
+
201
+ React.useEffect(() => {
202
+ void load()
203
+ }, [load])
204
+
205
+ const columns = React.useMemo<ColumnDef<AccountItemRow>[]>(
206
+ () => [
207
+ {
208
+ accessorKey: 'description',
209
+ header: t('billing.items.columns.description', 'Description'),
210
+ cell: ({ row }) => (
211
+ <span className="text-sm font-medium text-primary">
212
+ {row.original.description}
213
+ </span>
214
+ ),
215
+ },
216
+ {
217
+ accessorKey: 'type',
218
+ header: t('billing.items.columns.type', 'Type'),
219
+ cell: ({ row }) => <Tag variant="info">{row.original.type}</Tag>,
220
+ },
221
+ {
222
+ accessorKey: 'bill_start_date',
223
+ header: t('billing.items.columns.start', 'Start'),
224
+ cell: ({ row }) => formatDate(row.original.bill_start_date),
225
+ },
226
+ {
227
+ accessorKey: 'is_active',
228
+ header: t('billing.items.columns.status', 'Status'),
229
+ cell: ({ row }) =>
230
+ row.original.is_active ? (
231
+ <Tag variant="success">{t('billing.common.active', 'Active')}</Tag>
232
+ ) : (
233
+ <Tag variant="default">{t('billing.common.inactive', 'Inactive')}</Tag>
234
+ ),
235
+ },
236
+ ],
237
+ [t],
238
+ )
239
+
240
+ return (
241
+ <div className="mt-6">
242
+ <DataTable
243
+ title={t('billing.accounts.detail.items.title', 'Items')}
244
+ columns={columns}
245
+ data={rows}
246
+ isLoading={loading}
247
+ onRowClick={(row) => router.push(`/backend/billing/items/${row.id}`)}
248
+ pagination={{ page, pageSize, total, totalPages, onPageChange: setPage }}
249
+ emptyState={
250
+ <div className="rounded-lg border bg-card p-6 text-center text-sm text-muted-foreground">
251
+ {t('billing.accounts.detail.items.empty', 'No items for this account yet.')}
252
+ </div>
253
+ }
254
+ />
255
+ </div>
256
+ )
257
+ }
258
+
259
+ // ─── Embedded usage table ────────────────────────────────────────
260
+
261
+ function AccountUsageSection({ accountId }: { accountId: string }) {
262
+ const t = useT()
263
+ const [rows, setRows] = React.useState<AccountUsageRow[]>([])
264
+ const [page, setPage] = React.useState(1)
265
+ const [total, setTotal] = React.useState(0)
266
+ const [totalPages, setTotalPages] = React.useState(1)
267
+ const [loading, setLoading] = React.useState(true)
268
+ const pageSize = 25
269
+
270
+ const load = React.useCallback(async () => {
271
+ setLoading(true)
272
+ try {
273
+ const result = await readApiResultOrThrow<{
274
+ items: AccountUsageRow[]
275
+ total: number
276
+ totalPages: number
277
+ }>(
278
+ `/api/billing/usage?billAccountId=${accountId}&page=${page}` +
279
+ `&pageSize=${pageSize}&sortField=periodStart&sortDir=desc`,
280
+ )
281
+ setRows(result.items ?? [])
282
+ setTotal(result.total ?? 0)
283
+ setTotalPages(result.totalPages ?? 1)
284
+ } finally {
285
+ setLoading(false)
286
+ }
287
+ }, [accountId, page])
288
+
289
+ React.useEffect(() => {
290
+ void load()
291
+ }, [load])
292
+
293
+ const columns = React.useMemo<ColumnDef<AccountUsageRow>[]>(
294
+ () => [
295
+ {
296
+ accessorKey: 'uom_code',
297
+ header: t('billing.usage.columns.uom', 'Unit'),
298
+ cell: ({ row }) => (
299
+ <span className="font-mono text-xs">{row.original.uom_code}</span>
300
+ ),
301
+ },
302
+ {
303
+ accessorKey: 'quantity',
304
+ header: t('billing.usage.columns.quantity', 'Quantity'),
305
+ cell: ({ row }) => (
306
+ <span className="font-mono tabular-nums">{row.original.quantity}</span>
307
+ ),
308
+ },
309
+ {
310
+ accessorKey: 'period_start',
311
+ header: t('billing.usage.columns.period', 'Period'),
312
+ cell: ({ row }) =>
313
+ `${formatDate(row.original.period_start)} – ${formatDate(row.original.period_end)}`,
314
+ },
315
+ {
316
+ accessorKey: 'line_description',
317
+ header: t('billing.usage.columns.description', 'Description'),
318
+ cell: ({ row }) => row.original.line_description || '—',
319
+ },
320
+ {
321
+ accessorKey: 'rated_in_bill_run_id',
322
+ header: t('billing.usage.columns.rated', 'Rated'),
323
+ cell: ({ row }) =>
324
+ row.original.rated_in_bill_run_id ? (
325
+ // Rated records carry the Bill Run that priced them — link to it.
326
+ // Unrated records have no destination, so they stay non-interactive
327
+ // (no misleading row-level click affordance).
328
+ <Link
329
+ href={`/backend/billing/runs/${row.original.rated_in_bill_run_id}`}
330
+ className="rounded-sm hover:underline"
331
+ >
332
+ <Tag variant="success">{t('billing.usage.rated.yes', 'Rated')}</Tag>
333
+ </Link>
334
+ ) : (
335
+ <Tag variant="default">{t('billing.usage.rated.no', 'Unrated')}</Tag>
336
+ ),
337
+ },
338
+ ],
339
+ [t],
340
+ )
341
+
342
+ return (
343
+ <div className="mt-6">
344
+ <DataTable
345
+ title={t('billing.accounts.detail.usage.title', 'Usage')}
346
+ columns={columns}
347
+ data={rows}
348
+ isLoading={loading}
349
+ pagination={{ page, pageSize, total, totalPages, onPageChange: setPage }}
350
+ emptyState={
351
+ <div className="rounded-lg border bg-card p-6 text-center text-sm text-muted-foreground">
352
+ {t('billing.accounts.detail.usage.empty', 'No usage records for this account yet.')}
353
+ </div>
354
+ }
355
+ />
356
+ </div>
357
+ )
358
+ }
359
+
360
+ export default function BillingAccountDetailPage(props: { params?: { id?: string } }) {
361
+ const t = useT()
362
+ const router = useRouter()
363
+ // OM serves backend pages through a catch-all route — the dynamic
364
+ // `[id]` segment arrives as a page prop; `useParams()` is the fallback.
365
+ const urlParams = useParams<{ id: string }>()
366
+ const accountId = (props.params?.id ?? urlParams?.id ?? '') as string
367
+
368
+ const [row, setRow] = React.useState<AccountRow | null>(null)
369
+ const [loading, setLoading] = React.useState(true)
370
+ const [error, setError] = React.useState<string | null>(null)
371
+ const [editing, setEditing] = React.useState(false)
372
+ const [saving, setSaving] = React.useState(false)
373
+ const [deleting, setDeleting] = React.useState(false)
374
+
375
+ const { confirm, ConfirmDialogElement } = useConfirmDialog()
376
+
377
+ const load = React.useCallback(async () => {
378
+ if (!accountId) return
379
+ setLoading(true)
380
+ setError(null)
381
+ try {
382
+ const result = await readApiResultOrThrow<ListResponse>(
383
+ `/api/billing/accounts?id=${accountId}&pageSize=1`,
384
+ )
385
+ const found = result.items?.[0]
386
+ if (!found) {
387
+ setError(t('billing.accounts.detail.error.not_found', 'Account not found'))
388
+ return
389
+ }
390
+ setRow(found)
391
+ } catch (err) {
392
+ const { message } = normalizeCrudServerError(err)
393
+ setError(message || t('billing.accounts.detail.error.load', 'Failed to load account'))
394
+ } finally {
395
+ setLoading(false)
396
+ }
397
+ }, [accountId, t])
398
+
399
+ React.useEffect(() => {
400
+ void load()
401
+ }, [load])
402
+
403
+ const handleSubmit = React.useCallback(
404
+ async (values: AccountFormValues) => {
405
+ if (!row) return
406
+ setSaving(true)
407
+ try {
408
+ const payload: Record<string, unknown> = {
409
+ id: row.id,
410
+ name: values.name.trim(),
411
+ billCycle: values.billCycle,
412
+ billCycleAnchor: values.billCycleAnchor.trim(),
413
+ invoiceEmail: values.invoiceEmail.trim(),
414
+ invoiceLanguage: values.invoiceLanguage.trim().toLowerCase(),
415
+ invoiceAddress: {
416
+ line1: values.invoiceAddress.line1.trim(),
417
+ ...(values.invoiceAddress.line2.trim()
418
+ ? { line2: values.invoiceAddress.line2.trim() }
419
+ : {}),
420
+ city: values.invoiceAddress.city.trim(),
421
+ postal_code: values.invoiceAddress.postal_code.trim(),
422
+ country: values.invoiceAddress.country.trim().toUpperCase(),
423
+ },
424
+ nextBillDate: values.nextBillDate,
425
+ isActive: values.isActive,
426
+ }
427
+ payload.taxId = values.taxId.trim() === '' ? null : values.taxId.trim()
428
+
429
+ await apiCallOrThrow('/api/billing/accounts', {
430
+ method: 'PUT',
431
+ headers: { 'Content-Type': 'application/json' },
432
+ body: JSON.stringify(payload),
433
+ })
434
+ flash(t('billing.accounts.detail.save.success', 'Account updated'), 'success')
435
+ setEditing(false)
436
+ void load()
437
+ } catch (err) {
438
+ const { message, fieldErrors } = normalizeCrudServerError(err)
439
+ const fieldDetail =
440
+ fieldErrors && Object.keys(fieldErrors).length
441
+ ? Object.entries(fieldErrors)
442
+ .map(([field, msg]) => `${field}: ${msg}`)
443
+ .join('; ')
444
+ : null
445
+ const detailed =
446
+ fieldDetail && message
447
+ ? `${message} — ${fieldDetail}`
448
+ : fieldDetail || message
449
+ flash(detailed || t('billing.accounts.detail.save.error', 'Save failed'), 'error')
450
+ } finally {
451
+ setSaving(false)
452
+ }
453
+ },
454
+ [load, row, t],
455
+ )
456
+
457
+ const handleDelete = React.useCallback(async () => {
458
+ if (!row) return
459
+ const ok = await confirm({
460
+ title: t('billing.accounts.detail.delete.confirm.title', 'Delete account?'),
461
+ text: t(
462
+ 'billing.accounts.detail.delete.confirm.text',
463
+ "Soft-deletes the account. Its items + usage records stay in the database for audit; the Bill Run engine will stop picking it up immediately.",
464
+ ),
465
+ confirmText: t('billing.accounts.detail.delete.confirm.button', 'Soft delete'),
466
+ variant: 'destructive',
467
+ })
468
+ if (!ok) return
469
+ setDeleting(true)
470
+ try {
471
+ await apiCallOrThrow('/api/billing/accounts', {
472
+ method: 'DELETE',
473
+ headers: { 'Content-Type': 'application/json' },
474
+ body: JSON.stringify({ id: row.id }),
475
+ })
476
+ flash(t('billing.accounts.detail.delete.success', 'Account deleted'), 'success')
477
+ router.push('/backend/billing/accounts')
478
+ } catch (err) {
479
+ const { message } = normalizeCrudServerError(err)
480
+ flash(message || t('billing.accounts.detail.delete.error', 'Delete failed'), 'error')
481
+ setDeleting(false)
482
+ }
483
+ }, [confirm, router, row, t])
484
+
485
+ if (error) {
486
+ return (
487
+ <Page>
488
+ <PageHeader title={t('billing.accounts.detail.title', 'Billing Account')} />
489
+ <PageBody>
490
+ <ErrorMessage label={error} />
491
+ </PageBody>
492
+ </Page>
493
+ )
494
+ }
495
+
496
+ if (loading || !row) {
497
+ return (
498
+ <Page>
499
+ <PageHeader title={t('billing.accounts.detail.title', 'Billing Account')} />
500
+ <PageBody>
501
+ <LoadingMessage
502
+ label={t('billing.accounts.detail.loading', 'Loading account…')}
503
+ />
504
+ </PageBody>
505
+ </Page>
506
+ )
507
+ }
508
+
509
+ return (
510
+ <Page>
511
+ <FormHeader
512
+ mode="detail"
513
+ backHref="/backend/billing/accounts"
514
+ entityTypeLabel={t('billing.accounts.detail.title', 'Billing Account')}
515
+ title={row.name}
516
+ statusBadge={
517
+ row.is_active ? (
518
+ <Tag variant="success">{t('billing.common.active', 'Active')}</Tag>
519
+ ) : (
520
+ <Tag variant="default">{t('billing.common.inactive', 'Inactive')}</Tag>
521
+ )
522
+ }
523
+ actionsContent={
524
+ <>
525
+ <Button asChild variant="outline">
526
+ <Link href={`/backend/billing/items/create?billAccountId=${row.id}`}>
527
+ <Plus size={16} />
528
+ {t('billing.accounts.detail.add_item', 'Add item')}
529
+ </Link>
530
+ </Button>
531
+ <Button asChild variant="outline">
532
+ <Link href={`/backend/billing/usage/create?billAccountId=${row.id}`}>
533
+ <Plus size={16} />
534
+ {t('billing.accounts.detail.add_usage', 'Add usage')}
535
+ </Link>
536
+ </Button>
537
+ {!editing ? (
538
+ <Button type="button" onClick={() => setEditing(true)}>
539
+ <Pencil size={16} />
540
+ {t('billing.common.edit', 'Edit')}
541
+ </Button>
542
+ ) : null}
543
+ <Button
544
+ type="button"
545
+ variant="outline"
546
+ onClick={handleDelete}
547
+ disabled={deleting}
548
+ >
549
+ {deleting
550
+ ? t('billing.accounts.detail.delete.in_progress', 'Deleting…')
551
+ : t('billing.accounts.detail.delete.action', 'Soft delete')}
552
+ </Button>
553
+ </>
554
+ }
555
+ />
556
+ <PageBody>
557
+ {editing ? (
558
+ <AccountForm
559
+ mode="edit"
560
+ initial={toFormValues(row)}
561
+ submitting={saving}
562
+ onSubmit={handleSubmit}
563
+ onCancel={() => setEditing(false)}
564
+ />
565
+ ) : (
566
+ <AccountView row={row} />
567
+ )}
568
+ <AccountItemsSection accountId={row.id} />
569
+ <AccountUsageSection accountId={row.id} />
570
+ {ConfirmDialogElement}
571
+ </PageBody>
572
+ </Page>
573
+ )
574
+ }
@@ -0,0 +1,23 @@
1
+ import React from 'react'
2
+ import { Building2 } from 'lucide-react'
3
+
4
+ const accountIcon = React.createElement(Building2, { size: 16 })
5
+
6
+ export const metadata = {
7
+ requireAuth: true,
8
+ requireFeatures: ['billing.account.manage'],
9
+ pageTitle: 'Create Billing Account',
10
+ pageTitleKey: 'billing.accounts.create.title',
11
+ pageGroup: 'Billing',
12
+ pageGroupKey: 'billing.nav.group',
13
+ navHidden: true,
14
+ icon: accountIcon,
15
+ breadcrumb: [
16
+ {
17
+ label: 'Billing Accounts',
18
+ labelKey: 'billing.accounts.title',
19
+ href: '/backend/billing/accounts',
20
+ },
21
+ { label: 'Create', labelKey: 'billing.accounts.create.title' },
22
+ ],
23
+ } as const
@@ -0,0 +1,101 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import { useRouter } from 'next/navigation'
4
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
5
+ import { Page, PageBody, PageHeader } from '@open-mercato/ui/backend/Page'
6
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
7
+ import { apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
8
+ import { normalizeCrudServerError } from '@open-mercato/ui/backend/utils/serverErrors'
9
+ import { AccountForm, type AccountFormValues } from '../../../../components/AccountForm'
10
+
11
+ /**
12
+ * `/backend/billing/accounts/create` — create a Billing Account.
13
+ *
14
+ * POSTs `/api/billing/accounts`. On success, flash + navigate to the
15
+ * new account's detail page (`/backend/billing/accounts/<id>`) so the
16
+ * operator can continue editing if they need to tweak after-creation
17
+ * details.
18
+ */
19
+ export default function CreateBillingAccountPage() {
20
+ const t = useT()
21
+ const router = useRouter()
22
+ const [submitting, setSubmitting] = React.useState(false)
23
+
24
+ const handleSubmit = React.useCallback(
25
+ async (values: AccountFormValues) => {
26
+ setSubmitting(true)
27
+ try {
28
+ const payload: Record<string, unknown> = {
29
+ customerId: values.customerId.trim(),
30
+ name: values.name.trim(),
31
+ currencyCode: values.currencyCode.trim().toUpperCase(),
32
+ billCycle: values.billCycle,
33
+ billCycleAnchor: values.billCycleAnchor.trim(),
34
+ invoiceEmail: values.invoiceEmail.trim(),
35
+ invoiceLanguage: values.invoiceLanguage.trim().toLowerCase(),
36
+ invoiceAddress: {
37
+ line1: values.invoiceAddress.line1.trim(),
38
+ ...(values.invoiceAddress.line2.trim()
39
+ ? { line2: values.invoiceAddress.line2.trim() }
40
+ : {}),
41
+ city: values.invoiceAddress.city.trim(),
42
+ postal_code: values.invoiceAddress.postal_code.trim(),
43
+ country: values.invoiceAddress.country.trim().toUpperCase(),
44
+ },
45
+ nextBillDate: values.nextBillDate,
46
+ }
47
+ if (values.taxId.trim()) payload.taxId = values.taxId.trim()
48
+
49
+ const call = await apiCallOrThrow<{ id: string }>('/api/billing/accounts', {
50
+ method: 'POST',
51
+ headers: { 'Content-Type': 'application/json' },
52
+ body: JSON.stringify(payload),
53
+ })
54
+ const id = call.result?.id
55
+ flash(t('billing.accounts.create.success', 'Billing account created'), 'success')
56
+ if (id) {
57
+ router.push(`/backend/billing/accounts/${id}`)
58
+ } else {
59
+ router.push('/backend/billing/accounts')
60
+ }
61
+ } catch (err) {
62
+ const { message, fieldErrors } = normalizeCrudServerError(err)
63
+ const fieldDetail =
64
+ fieldErrors && Object.keys(fieldErrors).length
65
+ ? Object.entries(fieldErrors)
66
+ .map(([field, msg]) => `${field}: ${msg}`)
67
+ .join('; ')
68
+ : null
69
+ const detailed =
70
+ fieldDetail && message
71
+ ? `${message} — ${fieldDetail}`
72
+ : fieldDetail || message
73
+ flash(
74
+ detailed || t('billing.accounts.create.error', 'Failed to create account'),
75
+ 'error',
76
+ )
77
+ } finally {
78
+ setSubmitting(false)
79
+ }
80
+ },
81
+ [router, t],
82
+ )
83
+
84
+ const handleCancel = React.useCallback(() => {
85
+ router.push('/backend/billing/accounts')
86
+ }, [router])
87
+
88
+ return (
89
+ <Page>
90
+ <PageHeader title={t('billing.accounts.create.title', 'Create Billing Account')} />
91
+ <PageBody>
92
+ <AccountForm
93
+ mode="create"
94
+ submitting={submitting}
95
+ onSubmit={handleSubmit}
96
+ onCancel={handleCancel}
97
+ />
98
+ </PageBody>
99
+ </Page>
100
+ )
101
+ }