@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,245 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import { useRouter } from 'next/navigation'
4
+ import type { ColumnDef } from '@tanstack/react-table'
5
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
6
+ import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
7
+ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
8
+ import { DataTable } from '@open-mercato/ui/backend/DataTable'
9
+ import { Tag } from '@open-mercato/ui/primitives/tag'
10
+ import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
11
+
12
+ /**
13
+ * Billing Invoices list — what the operator actually opens every
14
+ * morning to "review and post drafts".
15
+ *
16
+ * Scope: only invoices the billing engine emitted (`metadata.bill_run_id`
17
+ * set). Plain `core/sales` invoices (created by sales orders, etc.)
18
+ * are NOT shown here — billing has its own surface so the operator's
19
+ * review queue is small and focused.
20
+ */
21
+
22
+ type BillingInvoiceRow = {
23
+ id: string
24
+ invoice_number: string
25
+ status: 'draft' | 'posted' | 'paid' | 'void' | null
26
+ currency_code: string
27
+ issue_date: string | null
28
+ due_date: string | null
29
+ subtotal_net_amount: string
30
+ grand_total_net_amount: string
31
+ grand_total_gross_amount: string
32
+ paid_total_amount: string
33
+ outstanding_amount: string
34
+ metadata: {
35
+ bill_run_id?: string
36
+ bill_account_id?: string
37
+ bill_period_start?: string
38
+ bill_period_end?: string
39
+ test_run?: boolean
40
+ } | null
41
+ created_at: string
42
+ updated_at: string
43
+ }
44
+
45
+ type ListResponse = {
46
+ items: BillingInvoiceRow[]
47
+ total: number
48
+ totalPages: number
49
+ }
50
+
51
+ function formatDate(value: string | null | undefined): string {
52
+ if (!value) return '—'
53
+ const parsed = new Date(value)
54
+ return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleDateString()
55
+ }
56
+
57
+ function statusVariant(
58
+ status: BillingInvoiceRow['status'],
59
+ ): 'default' | 'success' | 'warning' | 'error' {
60
+ switch (status) {
61
+ case 'paid':
62
+ return 'success'
63
+ case 'posted':
64
+ return 'warning'
65
+ case 'void':
66
+ return 'error'
67
+ case 'draft':
68
+ default:
69
+ return 'default'
70
+ }
71
+ }
72
+
73
+ function formatMoney(value: string, currency: string): string {
74
+ const n = Number.parseFloat(value)
75
+ if (!Number.isFinite(n)) return value
76
+ try {
77
+ return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(n)
78
+ } catch {
79
+ return `${value} ${currency}`
80
+ }
81
+ }
82
+
83
+ export default function BillingInvoicesListPage() {
84
+ const t = useT()
85
+ const router = useRouter()
86
+ const [rows, setRows] = React.useState<BillingInvoiceRow[]>([])
87
+ const [page, setPage] = React.useState(1)
88
+ const [pageSize] = React.useState(25)
89
+ const [filters, setFilters] = React.useState<FilterValues>({})
90
+ const [search, setSearch] = React.useState('')
91
+ const [total, setTotal] = React.useState(0)
92
+ const [totalPages, setTotalPages] = React.useState(1)
93
+ const [loading, setLoading] = React.useState(true)
94
+
95
+ const filterDefs = React.useMemo<FilterDef[]>(
96
+ () => [
97
+ {
98
+ id: 'status',
99
+ label: t('billing.invoices.filters.status', 'Status'),
100
+ type: 'select',
101
+ options: [
102
+ { value: 'draft', label: t('billing.invoices.status.draft', 'Draft') },
103
+ { value: 'posted', label: t('billing.invoices.status.posted', 'Posted') },
104
+ { value: 'paid', label: t('billing.invoices.status.paid', 'Paid') },
105
+ { value: 'void', label: t('billing.invoices.status.void', 'Void') },
106
+ ],
107
+ },
108
+ {
109
+ id: 'testRun',
110
+ label: t('billing.invoices.filters.test_run', 'Test runs'),
111
+ type: 'select',
112
+ options: [
113
+ { value: 'true', label: t('billing.invoices.filters.test_run.only', 'Only test') },
114
+ {
115
+ value: 'false',
116
+ label: t('billing.invoices.filters.test_run.exclude', 'Exclude test'),
117
+ },
118
+ ],
119
+ },
120
+ ],
121
+ [t],
122
+ )
123
+
124
+ const loadRows = React.useCallback(async () => {
125
+ setLoading(true)
126
+ try {
127
+ const params = new URLSearchParams({
128
+ page: String(page),
129
+ pageSize: String(pageSize),
130
+ })
131
+ if (search.trim()) params.set('search', search.trim())
132
+ if (typeof filters.status === 'string' && filters.status) {
133
+ params.set('status', filters.status)
134
+ }
135
+ if (filters.testRun === 'true' || filters.testRun === 'false') {
136
+ params.set('testRun', String(filters.testRun))
137
+ }
138
+ const result = await readApiResultOrThrow<ListResponse>(
139
+ `/api/billing/invoices?${params.toString()}`,
140
+ )
141
+ setRows(result.items ?? [])
142
+ setTotal(result.total ?? 0)
143
+ setTotalPages(result.totalPages ?? 1)
144
+ } finally {
145
+ setLoading(false)
146
+ }
147
+ }, [filters.status, filters.testRun, page, pageSize, search])
148
+
149
+ React.useEffect(() => {
150
+ void loadRows()
151
+ }, [loadRows])
152
+
153
+ const columns = React.useMemo<ColumnDef<BillingInvoiceRow>[]>(
154
+ () => [
155
+ {
156
+ accessorKey: 'invoice_number',
157
+ header: t('billing.invoices.columns.number', 'Number'),
158
+ cell: ({ row }) => {
159
+ const isTest = row.original.metadata?.test_run === true
160
+ return (
161
+ <div className="flex items-center gap-2">
162
+ <span className="text-sm font-medium text-primary">
163
+ {row.original.invoice_number}
164
+ </span>
165
+ {isTest ? <Tag variant="warning">TEST</Tag> : null}
166
+ </div>
167
+ )
168
+ },
169
+ },
170
+ {
171
+ accessorKey: 'status',
172
+ header: t('billing.invoices.columns.status', 'Status'),
173
+ cell: ({ row }) => (
174
+ <Tag variant={statusVariant(row.original.status)}>{row.original.status ?? '—'}</Tag>
175
+ ),
176
+ },
177
+ {
178
+ id: 'period',
179
+ header: t('billing.invoices.columns.period', 'Period'),
180
+ cell: ({ row }) => {
181
+ const start = row.original.metadata?.bill_period_start
182
+ const end = row.original.metadata?.bill_period_end
183
+ if (!start || !end) return '—'
184
+ return (
185
+ <span className="text-xs">
186
+ {start} → {end}
187
+ </span>
188
+ )
189
+ },
190
+ },
191
+ {
192
+ id: 'total',
193
+ header: t('billing.invoices.columns.total', 'Total'),
194
+ cell: ({ row }) =>
195
+ formatMoney(row.original.grand_total_gross_amount, row.original.currency_code),
196
+ },
197
+ {
198
+ accessorKey: 'outstanding_amount',
199
+ header: t('billing.invoices.columns.outstanding', 'Outstanding'),
200
+ cell: ({ row }) =>
201
+ formatMoney(row.original.outstanding_amount, row.original.currency_code),
202
+ },
203
+ {
204
+ accessorKey: 'issue_date',
205
+ header: t('billing.invoices.columns.issued', 'Issued'),
206
+ cell: ({ row }) => formatDate(row.original.issue_date),
207
+ },
208
+ ],
209
+ [t],
210
+ )
211
+
212
+ return (
213
+ <Page>
214
+ <PageBody>
215
+ <DataTable
216
+ title={t('billing.invoices.title', 'Billing Invoices')}
217
+ refreshButton={{
218
+ label: t('billing.common.refresh', 'Refresh'),
219
+ onRefresh: loadRows,
220
+ isRefreshing: loading,
221
+ }}
222
+ columnChooser={{ auto: true }}
223
+ perspective={{ tableId: 'billing-invoices' }}
224
+ columns={columns}
225
+ data={rows}
226
+ onRowClick={(row) => router.push(`/backend/billing/invoices/${row.id}`)}
227
+ isLoading={loading}
228
+ pagination={{
229
+ page,
230
+ pageSize,
231
+ total,
232
+ totalPages,
233
+ onPageChange: setPage,
234
+ }}
235
+ filters={filterDefs}
236
+ filterValues={filters}
237
+ onFiltersApply={setFilters}
238
+ onFiltersClear={() => setFilters({})}
239
+ searchValue={search}
240
+ onSearchChange={setSearch}
241
+ />
242
+ </PageBody>
243
+ </Page>
244
+ )
245
+ }
@@ -0,0 +1,23 @@
1
+ import React from 'react'
2
+ import { Receipt } from 'lucide-react'
3
+
4
+ const itemIcon = React.createElement(Receipt, { size: 16 })
5
+
6
+ export const metadata = {
7
+ requireAuth: true,
8
+ requireFeatures: ['billing.item.view'],
9
+ pageTitle: 'Billing Item',
10
+ pageTitleKey: 'billing.items.detail.title',
11
+ pageGroup: 'Billing',
12
+ pageGroupKey: 'billing.nav.group',
13
+ navHidden: true,
14
+ icon: itemIcon,
15
+ breadcrumb: [
16
+ {
17
+ label: 'Billing Items',
18
+ labelKey: 'billing.items.title',
19
+ href: '/backend/billing/items',
20
+ },
21
+ { label: 'Item', labelKey: 'billing.items.detail.title' },
22
+ ],
23
+ } as const
@@ -0,0 +1,366 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import Link from 'next/link'
4
+ import { Pencil } from 'lucide-react'
5
+ import { useParams, useRouter } from 'next/navigation'
6
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
7
+ import { Page, PageBody, PageHeader } from '@open-mercato/ui/backend/Page'
8
+ import { FormHeader } from '@open-mercato/ui/backend/forms'
9
+ import { Button } from '@open-mercato/ui/primitives/button'
10
+ import { Tag } from '@open-mercato/ui/primitives/tag'
11
+ import { LoadingMessage, ErrorMessage } from '@open-mercato/ui/backend/detail'
12
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
13
+ import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
14
+ import {
15
+ apiCallOrThrow,
16
+ readApiResultOrThrow,
17
+ } from '@open-mercato/ui/backend/utils/apiCall'
18
+ import { normalizeCrudServerError } from '@open-mercato/ui/backend/utils/serverErrors'
19
+ import {
20
+ ItemForm,
21
+ type AssembledItemPayload,
22
+ type ItemFormValues,
23
+ } from '../../../../components/ItemForm'
24
+ import { DetailCard, DetailField } from '../../../../components/DetailFields'
25
+
26
+ /**
27
+ * `/backend/billing/items/[id]` — Billing Item detail.
28
+ *
29
+ * Opens read-only (field grid). The `Edit` button swaps the grid for
30
+ * the `ItemForm`; Save / Cancel return to the read-only view. Loads
31
+ * via the list endpoint (`?id=<id>`), edits through `PUT`, soft-delete
32
+ * via `DELETE` with a `ConfirmDialog` gate.
33
+ *
34
+ * Two read-only badges at the top: `currencyMismatch` and
35
+ * `billedToDate` (Bill Run engine-managed columns the operator
36
+ * cannot edit but needs to see for troubleshooting).
37
+ */
38
+
39
+ // API list rows are snake_case (see `api/items/route.ts` `fields`).
40
+ // `bill_account_name` is resolved by the route's `afterList` hook.
41
+ type BillingItemRow = {
42
+ id: string
43
+ bill_account_id: string
44
+ bill_account_name: string | null
45
+ type: 'one_time' | 'recurring' | 'usage'
46
+ bill_start_date: string
47
+ bill_end_date: string | null
48
+ description: string
49
+ rate_json: Record<string, unknown> | null
50
+ uom_code: string | null
51
+ subscription_id: string | null
52
+ subscription_item_id: string | null
53
+ source_ref: string | null
54
+ currency_mismatch: boolean
55
+ billed_to_date: string | null
56
+ is_active: boolean
57
+ created_at: string
58
+ updated_at: string
59
+ }
60
+
61
+ type ListResponse = { items: BillingItemRow[] }
62
+
63
+ function formatDate(value: string | null | undefined): string {
64
+ if (!value) return '—'
65
+ const parsed = new Date(value)
66
+ return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleDateString()
67
+ }
68
+
69
+ function formatRate(row: BillingItemRow): string {
70
+ const rate = (row.rate_json ?? {}) as Record<string, unknown>
71
+ if (row.type === 'one_time' && typeof rate.amount === 'number') {
72
+ return String(rate.amount)
73
+ }
74
+ if (row.type === 'recurring' && typeof rate.unit_price === 'number') {
75
+ return String(rate.unit_price)
76
+ }
77
+ if (row.type === 'usage') {
78
+ if ('model' in rate && 'tiers' in rate) {
79
+ return `tiered (${String(rate.model)})`
80
+ }
81
+ if (typeof rate.unit_price === 'number') {
82
+ return String(rate.unit_price)
83
+ }
84
+ }
85
+ return '—'
86
+ }
87
+
88
+ function toFormValues(row: BillingItemRow): Partial<ItemFormValues> {
89
+ const rate = (row.rate_json ?? {}) as Record<string, unknown>
90
+ const out: Partial<ItemFormValues> = {
91
+ billAccountId: row.bill_account_id,
92
+ type: row.type,
93
+ billStartDate: row.bill_start_date?.slice(0, 10) ?? '',
94
+ billEndDate: row.bill_end_date?.slice(0, 10) ?? '',
95
+ description: row.description,
96
+ uomCode: row.uom_code ?? '',
97
+ subscriptionId: row.subscription_id ?? '',
98
+ subscriptionItemId: row.subscription_item_id ?? '',
99
+ isActive: row.is_active,
100
+ }
101
+ if (row.type === 'one_time' && typeof rate.amount === 'number') {
102
+ out.oneTimeAmount = String(rate.amount)
103
+ } else if (row.type === 'recurring' && typeof rate.unit_price === 'number') {
104
+ out.recurringUnitPrice = String(rate.unit_price)
105
+ } else if (row.type === 'usage') {
106
+ if ('model' in rate && 'tiers' in rate) {
107
+ out.usageMode = 'tiered'
108
+ out.usageTieredJson = JSON.stringify(rate, null, 2)
109
+ } else if (typeof rate.unit_price === 'number') {
110
+ out.usageMode = 'simple'
111
+ out.usageUnitPrice = String(rate.unit_price)
112
+ }
113
+ }
114
+ return out
115
+ }
116
+
117
+ // ─── Read-only view ──────────────────────────────────────────────
118
+
119
+ function ItemView({ row }: { row: BillingItemRow }) {
120
+ const t = useT()
121
+ return (
122
+ <DetailCard>
123
+ <DetailField label={t('billing.items.detail.field.account', 'Account')}>
124
+ <Link
125
+ href={`/backend/billing/accounts/${row.bill_account_id}`}
126
+ className="text-primary hover:underline"
127
+ >
128
+ {row.bill_account_name ?? row.bill_account_id}
129
+ </Link>
130
+ </DetailField>
131
+ <DetailField label={t('billing.items.columns.type', 'Type')}>
132
+ <Tag variant="info">{row.type}</Tag>
133
+ </DetailField>
134
+ <DetailField label={t('billing.items.columns.start', 'Start')}>
135
+ {formatDate(row.bill_start_date)}
136
+ </DetailField>
137
+ <DetailField label={t('billing.items.columns.end', 'End')}>
138
+ {formatDate(row.bill_end_date)}
139
+ </DetailField>
140
+ <DetailField label={t('billing.items.form.rate.title', 'Rate')}>
141
+ {formatRate(row)}
142
+ </DetailField>
143
+ <DetailField label={t('billing.items.columns.uom', 'UoM')}>
144
+ {row.uom_code || '—'}
145
+ </DetailField>
146
+ <DetailField label={t('billing.items.form.subscription_id', 'Subscription ID')}>
147
+ {row.subscription_id || '—'}
148
+ </DetailField>
149
+ <DetailField
150
+ label={t('billing.items.form.subscription_item_id', 'Subscription item ID')}
151
+ >
152
+ {row.subscription_item_id || '—'}
153
+ </DetailField>
154
+ <DetailField label={t('billing.items.detail.field.source_ref', 'Source ref')}>
155
+ {row.source_ref ? (
156
+ <span className="font-mono text-xs">{row.source_ref}</span>
157
+ ) : (
158
+ '—'
159
+ )}
160
+ </DetailField>
161
+ <DetailField label={t('billing.items.detail.field.billed_to', 'Billed through')}>
162
+ {row.billed_to_date ? formatDate(row.billed_to_date) : '—'}
163
+ </DetailField>
164
+ {row.currency_mismatch ? (
165
+ <DetailField
166
+ label={t('billing.items.detail.currency_mismatch', 'Currency mismatch')}
167
+ >
168
+ <Tag variant="warning">{t('billing.common.yes', 'Yes')}</Tag>
169
+ </DetailField>
170
+ ) : null}
171
+ <DetailField label={t('billing.items.columns.status', 'Status')}>
172
+ {row.is_active ? (
173
+ <Tag variant="success">{t('billing.common.active', 'Active')}</Tag>
174
+ ) : (
175
+ <Tag variant="default">{t('billing.common.inactive', 'Inactive')}</Tag>
176
+ )}
177
+ </DetailField>
178
+ <DetailField label={t('billing.common.id', 'ID')}>
179
+ <span className="font-mono text-xs">{row.id}</span>
180
+ </DetailField>
181
+ </DetailCard>
182
+ )
183
+ }
184
+
185
+ export default function BillingItemDetailPage(props: { params?: { id?: string } }) {
186
+ const t = useT()
187
+ const router = useRouter()
188
+ // OM serves backend pages through a catch-all route — the dynamic
189
+ // `[id]` segment arrives as a page prop; `useParams()` is the fallback.
190
+ const urlParams = useParams<{ id: string }>()
191
+ const itemId = (props.params?.id ?? urlParams?.id ?? '') as string
192
+
193
+ const [row, setRow] = React.useState<BillingItemRow | null>(null)
194
+ const [loading, setLoading] = React.useState(true)
195
+ const [error, setError] = React.useState<string | null>(null)
196
+ const [editing, setEditing] = React.useState(false)
197
+ const [saving, setSaving] = React.useState(false)
198
+ const [deleting, setDeleting] = React.useState(false)
199
+
200
+ const { confirm, ConfirmDialogElement } = useConfirmDialog()
201
+
202
+ const load = React.useCallback(async () => {
203
+ if (!itemId) return
204
+ setLoading(true)
205
+ setError(null)
206
+ try {
207
+ const result = await readApiResultOrThrow<ListResponse>(
208
+ `/api/billing/items?id=${itemId}&pageSize=1`,
209
+ )
210
+ const found = result.items?.[0]
211
+ if (!found) {
212
+ setError(t('billing.items.detail.error.not_found', 'Item not found'))
213
+ return
214
+ }
215
+ setRow(found)
216
+ } catch (err) {
217
+ const { message } = normalizeCrudServerError(err)
218
+ setError(message || t('billing.items.detail.error.load', 'Failed to load item'))
219
+ } finally {
220
+ setLoading(false)
221
+ }
222
+ }, [itemId, t])
223
+
224
+ React.useEffect(() => {
225
+ void load()
226
+ }, [load])
227
+
228
+ const handleSubmit = React.useCallback(
229
+ async (assembled: AssembledItemPayload) => {
230
+ if (!row) return
231
+ setSaving(true)
232
+ try {
233
+ const payload: Record<string, unknown> = {
234
+ id: row.id,
235
+ billStartDate: assembled.billStartDate,
236
+ description: assembled.description,
237
+ rateJson: assembled.rateJson,
238
+ isActive: assembled.isActive ?? row.is_active,
239
+ }
240
+ // Optional fields — submit null when the operator cleared them
241
+ // so the column actually resets (the schema validators accept
242
+ // null for nullable columns).
243
+ payload.billEndDate = assembled.billEndDate ?? null
244
+ payload.uomCode = assembled.uomCode ?? null
245
+ payload.subscriptionId = assembled.subscriptionId ?? null
246
+ payload.subscriptionItemId = assembled.subscriptionItemId ?? null
247
+
248
+ await apiCallOrThrow('/api/billing/items', {
249
+ method: 'PUT',
250
+ headers: { 'Content-Type': 'application/json' },
251
+ body: JSON.stringify(payload),
252
+ })
253
+ flash(t('billing.items.detail.save.success', 'Item updated'), 'success')
254
+ setEditing(false)
255
+ void load()
256
+ } catch (err) {
257
+ const { message } = normalizeCrudServerError(err)
258
+ flash(message || t('billing.items.detail.save.error', 'Save failed'), 'error')
259
+ } finally {
260
+ setSaving(false)
261
+ }
262
+ },
263
+ [load, row, t],
264
+ )
265
+
266
+ const handleDelete = React.useCallback(async () => {
267
+ if (!row) return
268
+ const ok = await confirm({
269
+ title: t('billing.items.detail.delete.confirm.title', 'Delete item?'),
270
+ text: t(
271
+ 'billing.items.detail.delete.confirm.text',
272
+ 'Soft-deletes the item. Its row stays in the database for audit; the Bill Run engine will stop including it immediately.',
273
+ ),
274
+ confirmText: t('billing.items.detail.delete.confirm.button', 'Soft delete'),
275
+ variant: 'destructive',
276
+ })
277
+ if (!ok) return
278
+ setDeleting(true)
279
+ try {
280
+ await apiCallOrThrow('/api/billing/items', {
281
+ method: 'DELETE',
282
+ headers: { 'Content-Type': 'application/json' },
283
+ body: JSON.stringify({ id: row.id }),
284
+ })
285
+ flash(t('billing.items.detail.delete.success', 'Item deleted'), 'success')
286
+ router.push('/backend/billing/items')
287
+ } catch (err) {
288
+ const { message } = normalizeCrudServerError(err)
289
+ flash(message || t('billing.items.detail.delete.error', 'Delete failed'), 'error')
290
+ setDeleting(false)
291
+ }
292
+ }, [confirm, router, row, t])
293
+
294
+ if (error) {
295
+ return (
296
+ <Page>
297
+ <PageHeader title={t('billing.items.detail.title', 'Billing Item')} />
298
+ <PageBody>
299
+ <ErrorMessage label={error} />
300
+ </PageBody>
301
+ </Page>
302
+ )
303
+ }
304
+ if (loading || !row) {
305
+ return (
306
+ <Page>
307
+ <PageHeader title={t('billing.items.detail.title', 'Billing Item')} />
308
+ <PageBody>
309
+ <LoadingMessage label={t('billing.items.detail.loading', 'Loading item…')} />
310
+ </PageBody>
311
+ </Page>
312
+ )
313
+ }
314
+
315
+ return (
316
+ <Page>
317
+ <FormHeader
318
+ mode="detail"
319
+ backHref="/backend/billing/items"
320
+ entityTypeLabel={t('billing.items.detail.title', 'Billing Item')}
321
+ title={row.description}
322
+ statusBadge={
323
+ row.is_active ? (
324
+ <Tag variant="success">{t('billing.common.active', 'Active')}</Tag>
325
+ ) : (
326
+ <Tag variant="default">{t('billing.common.inactive', 'Inactive')}</Tag>
327
+ )
328
+ }
329
+ actionsContent={
330
+ <>
331
+ {!editing ? (
332
+ <Button type="button" onClick={() => setEditing(true)}>
333
+ <Pencil size={16} />
334
+ {t('billing.common.edit', 'Edit')}
335
+ </Button>
336
+ ) : null}
337
+ <Button
338
+ type="button"
339
+ variant="outline"
340
+ onClick={handleDelete}
341
+ disabled={deleting}
342
+ >
343
+ {deleting
344
+ ? t('billing.items.detail.delete.in_progress', 'Deleting…')
345
+ : t('billing.items.detail.delete.action', 'Soft delete')}
346
+ </Button>
347
+ </>
348
+ }
349
+ />
350
+ <PageBody>
351
+ {editing ? (
352
+ <ItemForm
353
+ mode="edit"
354
+ initial={toFormValues(row)}
355
+ submitting={saving}
356
+ onSubmit={handleSubmit}
357
+ onCancel={() => setEditing(false)}
358
+ />
359
+ ) : (
360
+ <ItemView row={row} />
361
+ )}
362
+ {ConfirmDialogElement}
363
+ </PageBody>
364
+ </Page>
365
+ )
366
+ }
@@ -0,0 +1,23 @@
1
+ import React from 'react'
2
+ import { Receipt } from 'lucide-react'
3
+
4
+ const itemIcon = React.createElement(Receipt, { size: 16 })
5
+
6
+ export const metadata = {
7
+ requireAuth: true,
8
+ requireFeatures: ['billing.item.manage'],
9
+ pageTitle: 'Create Billing Item',
10
+ pageTitleKey: 'billing.items.create.title',
11
+ pageGroup: 'Billing',
12
+ pageGroupKey: 'billing.nav.group',
13
+ navHidden: true,
14
+ icon: itemIcon,
15
+ breadcrumb: [
16
+ {
17
+ label: 'Billing Items',
18
+ labelKey: 'billing.items.title',
19
+ href: '/backend/billing/items',
20
+ },
21
+ { label: 'Create', labelKey: 'billing.items.create.title' },
22
+ ],
23
+ } as const