@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,613 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import Link from 'next/link'
4
+ import { useParams } from 'next/navigation'
5
+ import type { ColumnDef } from '@tanstack/react-table'
6
+ import { Pencil, Plus, Trash2 } from 'lucide-react'
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 { IconButton } from '@open-mercato/ui/primitives/icon-button'
14
+ import { LoadingMessage, ErrorMessage } from '@open-mercato/ui/backend/detail'
15
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
16
+ import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
17
+ import {
18
+ apiCallOrThrow,
19
+ readApiResultOrThrow,
20
+ } from '@open-mercato/ui/backend/utils/apiCall'
21
+ import { normalizeCrudServerError } from '@open-mercato/ui/backend/utils/serverErrors'
22
+ import { LineFormDialog, type LineFormValues } from '../../../../components/LineFormDialog'
23
+ import {
24
+ DetailCard,
25
+ DetailField,
26
+ Stat,
27
+ StatCard,
28
+ } from '../../../../components/DetailFields'
29
+ import { billRunName } from '../../../../lib/billRunLabel'
30
+
31
+ type BillingInvoice = {
32
+ id: string
33
+ invoice_number: string
34
+ status: 'draft' | 'posted' | 'paid' | 'void' | null
35
+ currency_code: string
36
+ issue_date: string | null
37
+ due_date: string | null
38
+ subtotal_net_amount: string
39
+ grand_total_net_amount: string
40
+ grand_total_gross_amount: string
41
+ paid_total_amount: string
42
+ outstanding_amount: string
43
+ metadata: {
44
+ bill_run_id?: string
45
+ bill_account_id?: string
46
+ bill_period_start?: string
47
+ bill_period_end?: string
48
+ test_run?: boolean
49
+ } | null
50
+ created_at: string
51
+ updated_at: string
52
+ }
53
+
54
+ type InvoiceLine = {
55
+ id: string
56
+ line_number: number
57
+ kind: string
58
+ description: string | null
59
+ quantity: string
60
+ currency_code: string
61
+ unit_price_net: string
62
+ total_net_amount: string
63
+ total_gross_amount: string
64
+ metadata: {
65
+ billing_item_id?: string
66
+ billing_type?: 'one_time' | 'recurring' | 'usage'
67
+ operator_added?: boolean
68
+ usage_tier_breakdown?: unknown
69
+ } | null
70
+ }
71
+
72
+ type DetailResponse = {
73
+ invoice: BillingInvoice
74
+ lines: InvoiceLine[]
75
+ }
76
+
77
+ function statusVariant(
78
+ status: BillingInvoice['status'],
79
+ ): 'default' | 'success' | 'warning' | 'error' {
80
+ switch (status) {
81
+ case 'paid':
82
+ return 'success'
83
+ case 'posted':
84
+ return 'warning'
85
+ case 'void':
86
+ return 'error'
87
+ case 'draft':
88
+ default:
89
+ return 'default'
90
+ }
91
+ }
92
+
93
+ function lineKindVariant(
94
+ type: InvoiceLine['metadata'] extends infer M
95
+ ? M extends { billing_type?: infer T }
96
+ ? T
97
+ : never
98
+ : never,
99
+ ): 'default' | 'success' | 'warning' | 'error' {
100
+ switch (type) {
101
+ case 'one_time':
102
+ return 'warning'
103
+ case 'recurring':
104
+ return 'success'
105
+ case 'usage':
106
+ return 'default'
107
+ default:
108
+ return 'default'
109
+ }
110
+ }
111
+
112
+ function formatMoney(value: string, currency: string): string {
113
+ const n = Number.parseFloat(value)
114
+ if (!Number.isFinite(n)) return value
115
+ try {
116
+ return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(n)
117
+ } catch {
118
+ return `${value} ${currency}`
119
+ }
120
+ }
121
+
122
+ export default function BillingInvoiceDetailPage(props: { params?: { id?: string } }) {
123
+ const t = useT()
124
+ // OM serves backend pages through a catch-all route — the dynamic
125
+ // `[id]` segment arrives as a page prop; `useParams()` is the fallback.
126
+ const urlParams = useParams<{ id: string }>()
127
+ const invoiceId = (props.params?.id ?? urlParams?.id ?? '') as string
128
+
129
+ const [invoice, setInvoice] = React.useState<BillingInvoice | null>(null)
130
+ const [lines, setLines] = React.useState<InvoiceLine[]>([])
131
+ // Bill Run + Billing Account are stored as FK ids in invoice metadata;
132
+ // resolve their human names so the detail card shows names, not UUIDs.
133
+ const [accountName, setAccountName] = React.useState<string | null>(null)
134
+ const [runName, setRunName] = React.useState<string | null>(null)
135
+ const [error, setError] = React.useState<string | null>(null)
136
+ const [loading, setLoading] = React.useState(true)
137
+ const [posting, setPosting] = React.useState(false)
138
+ const [wiping, setWiping] = React.useState(false)
139
+
140
+ // Client-side pagination for invoice lines. Caps the table at 100 rows
141
+ // per page (per the OM DataTable convention) — invoices typically have
142
+ // <20 lines, but bulk import or a long-running usage-rated subscription
143
+ // can exceed that.
144
+ const [linesPage, setLinesPage] = React.useState(1)
145
+ const linesPageSize = 100
146
+ const visibleLines = React.useMemo(
147
+ () => lines.slice((linesPage - 1) * linesPageSize, linesPage * linesPageSize),
148
+ [lines, linesPage],
149
+ )
150
+ const linesTotalPages = Math.max(1, Math.ceil(lines.length / linesPageSize))
151
+
152
+ // Inline line-edit dialog state. `dialogMode === null` → closed.
153
+ const [dialogMode, setDialogMode] = React.useState<'add' | 'edit' | null>(null)
154
+ const [dialogTargetLine, setDialogTargetLine] = React.useState<InvoiceLine | null>(null)
155
+ const [dialogSubmitting, setDialogSubmitting] = React.useState(false)
156
+
157
+ // Confirm flows: wipe-test + remove-line both use ConfirmDialog
158
+ // (per UI AGENTS.md: NEVER `window.confirm`).
159
+ const { confirm, ConfirmDialogElement } = useConfirmDialog()
160
+
161
+ const load = React.useCallback(async () => {
162
+ if (!invoiceId) return
163
+ setError(null)
164
+ setLoading(true)
165
+ try {
166
+ const data = await readApiResultOrThrow<DetailResponse>(
167
+ `/api/billing/invoices/${invoiceId}`,
168
+ )
169
+ setInvoice(data.invoice)
170
+ setLines(data.lines ?? [])
171
+ } catch (err) {
172
+ const { message } = normalizeCrudServerError(err)
173
+ setError(message || t('billing.invoices.detail.error.load', 'Failed to load invoice'))
174
+ } finally {
175
+ setLoading(false)
176
+ }
177
+ }, [invoiceId, t])
178
+
179
+ React.useEffect(() => {
180
+ void load()
181
+ }, [load])
182
+
183
+ // Resolve the FK names once the invoice is loaded (one bounded
184
+ // request each — Bill Run name is derived from its as-of date).
185
+ const billRunId = invoice?.metadata?.bill_run_id
186
+ const billAccountId = invoice?.metadata?.bill_account_id
187
+ React.useEffect(() => {
188
+ if (billAccountId) {
189
+ readApiResultOrThrow<{ items: Array<{ name: string }> }>(
190
+ `/api/billing/accounts?id=${billAccountId}&pageSize=1`,
191
+ )
192
+ .then((r) => setAccountName(r.items?.[0]?.name ?? null))
193
+ .catch(() => setAccountName(null))
194
+ }
195
+ if (billRunId) {
196
+ readApiResultOrThrow<{ items: Array<{ as_of_date: string }> }>(
197
+ `/api/billing/runs?id=${billRunId}&pageSize=1`,
198
+ )
199
+ .then((r) => {
200
+ const asOf = r.items?.[0]?.as_of_date
201
+ setRunName(asOf ? billRunName(asOf) : null)
202
+ })
203
+ .catch(() => setRunName(null))
204
+ }
205
+ }, [billAccountId, billRunId])
206
+
207
+ const handlePost = React.useCallback(async () => {
208
+ if (!invoice) return
209
+ setPosting(true)
210
+ try {
211
+ const call = await apiCallOrThrow<{ status: string; invoiceNumber: string }>(
212
+ '/api/billing/invoices/post',
213
+ {
214
+ method: 'POST',
215
+ headers: { 'Content-Type': 'application/json' },
216
+ body: JSON.stringify({ invoiceId: invoice.id }),
217
+ },
218
+ )
219
+ flash(
220
+ t('billing.invoices.post.success', 'Invoice posted') +
221
+ ` (${call.result?.invoiceNumber ?? invoice.invoice_number})`,
222
+ 'success',
223
+ )
224
+ void load()
225
+ } catch (err) {
226
+ const { message } = normalizeCrudServerError(err)
227
+ flash(message || t('billing.invoices.post.error', 'Post failed'), 'error')
228
+ } finally {
229
+ setPosting(false)
230
+ }
231
+ }, [invoice, load, t])
232
+
233
+ const handleWipeTest = React.useCallback(async () => {
234
+ if (!invoice) return
235
+ const ok = await confirm({
236
+ title: t('billing.invoices.wipe.confirm.title', 'Wipe test invoices?'),
237
+ text: t(
238
+ 'billing.invoices.wipe.confirm.text',
239
+ 'Every test invoice from this Bill Run will be permanently deleted. This cannot be undone.',
240
+ ),
241
+ confirmText: t('billing.invoices.wipe.confirm.button', 'Delete test invoices'),
242
+ variant: 'destructive',
243
+ })
244
+ if (!ok) return
245
+ setWiping(true)
246
+ try {
247
+ const billRunId = invoice.metadata?.bill_run_id
248
+ const call = await apiCallOrThrow<{ invoicesRemoved: number }>(
249
+ '/api/billing/test-invoices/wipe',
250
+ {
251
+ method: 'POST',
252
+ headers: { 'Content-Type': 'application/json' },
253
+ body: JSON.stringify(billRunId ? { billRunId } : {}),
254
+ },
255
+ )
256
+ flash(
257
+ t('billing.invoices.wipe.success', 'Test invoices removed') +
258
+ ` (${call.result?.invoicesRemoved ?? 0})`,
259
+ 'success',
260
+ )
261
+ // The invoice we were looking at is hard-deleted — navigate away
262
+ // on success by reloading; the detail load will 404 and the
263
+ // user clicks back.
264
+ void load()
265
+ } catch (err) {
266
+ const { message } = normalizeCrudServerError(err)
267
+ flash(message || t('billing.invoices.wipe.error', 'Wipe failed'), 'error')
268
+ } finally {
269
+ setWiping(false)
270
+ }
271
+ }, [confirm, invoice, load, t])
272
+
273
+ // ─── Line add / edit / remove ────────────────────────────────
274
+
275
+ const handleSubmitLine = React.useCallback(
276
+ async (values: LineFormValues) => {
277
+ if (!invoice) return
278
+ setDialogSubmitting(true)
279
+ try {
280
+ if (dialogMode === 'add') {
281
+ await apiCallOrThrow('/api/billing/invoices/add-line', {
282
+ method: 'POST',
283
+ headers: { 'Content-Type': 'application/json' },
284
+ body: JSON.stringify({
285
+ invoiceId: invoice.id,
286
+ description: values.description,
287
+ quantity: values.quantity,
288
+ unitPriceNet: values.unitPriceNet,
289
+ }),
290
+ })
291
+ flash(t('billing.invoices.lines.add.success', 'Line added'), 'success')
292
+ } else if (dialogMode === 'edit' && dialogTargetLine) {
293
+ await apiCallOrThrow('/api/billing/invoices/edit-line', {
294
+ method: 'POST',
295
+ headers: { 'Content-Type': 'application/json' },
296
+ body: JSON.stringify({
297
+ invoiceId: invoice.id,
298
+ invoiceLineId: dialogTargetLine.id,
299
+ changes: {
300
+ description: values.description,
301
+ quantity: values.quantity,
302
+ unitPriceNet: values.unitPriceNet,
303
+ ...(values.totalNetAmount !== undefined
304
+ ? { totalNetAmount: values.totalNetAmount }
305
+ : {}),
306
+ },
307
+ }),
308
+ })
309
+ flash(t('billing.invoices.lines.edit.success', 'Line updated'), 'success')
310
+ }
311
+ setDialogMode(null)
312
+ setDialogTargetLine(null)
313
+ void load()
314
+ } catch (err) {
315
+ const { message } = normalizeCrudServerError(err)
316
+ flash(message || t('billing.invoices.lines.save.error', 'Save failed'), 'error')
317
+ } finally {
318
+ setDialogSubmitting(false)
319
+ }
320
+ },
321
+ [dialogMode, dialogTargetLine, invoice, load, t],
322
+ )
323
+
324
+ const handleRemoveLine = React.useCallback(
325
+ async (line: InvoiceLine) => {
326
+ if (!invoice) return
327
+ const ok = await confirm({
328
+ title: t('billing.invoices.lines.remove.confirm.title', 'Remove line?'),
329
+ text: t(
330
+ 'billing.invoices.lines.remove.confirm.text',
331
+ 'The line will be deleted and recorded in the draft-edit audit log.',
332
+ ),
333
+ confirmText: t('billing.invoices.lines.remove.confirm.button', 'Remove line'),
334
+ variant: 'destructive',
335
+ })
336
+ if (!ok) return
337
+ try {
338
+ await apiCallOrThrow('/api/billing/invoices/remove-line', {
339
+ method: 'POST',
340
+ headers: { 'Content-Type': 'application/json' },
341
+ body: JSON.stringify({
342
+ invoiceId: invoice.id,
343
+ invoiceLineId: line.id,
344
+ }),
345
+ })
346
+ flash(t('billing.invoices.lines.remove.success', 'Line removed'), 'success')
347
+ void load()
348
+ } catch (err) {
349
+ const { message } = normalizeCrudServerError(err)
350
+ flash(message || t('billing.invoices.lines.remove.error', 'Remove failed'), 'error')
351
+ }
352
+ },
353
+ [confirm, invoice, load, t],
354
+ )
355
+
356
+ const canEditLines = invoice?.status === 'draft'
357
+
358
+ const lineColumns = React.useMemo<ColumnDef<InvoiceLine>[]>(
359
+ () => [
360
+ {
361
+ accessorKey: 'line_number',
362
+ header: '#',
363
+ },
364
+ {
365
+ accessorKey: 'description',
366
+ header: t('billing.invoices.lines.columns.description', 'Description'),
367
+ cell: ({ row }) => {
368
+ const type = row.original.metadata?.billing_type
369
+ const operatorAdded = row.original.metadata?.operator_added === true
370
+ return (
371
+ <div className="flex flex-col gap-1">
372
+ <span>{row.original.description ?? '—'}</span>
373
+ <div className="flex items-center gap-1 flex-wrap">
374
+ {type ? <Tag variant={lineKindVariant(type)}>{type}</Tag> : null}
375
+ {operatorAdded ? (
376
+ <Tag variant="info">
377
+ {t('billing.invoices.lines.operator_added', 'Operator-added')}
378
+ </Tag>
379
+ ) : null}
380
+ </div>
381
+ </div>
382
+ )
383
+ },
384
+ },
385
+ {
386
+ accessorKey: 'quantity',
387
+ header: t('billing.invoices.lines.columns.quantity', 'Qty'),
388
+ },
389
+ {
390
+ accessorKey: 'unit_price_net',
391
+ header: t('billing.invoices.lines.columns.unit_price', 'Unit price'),
392
+ cell: ({ row }) =>
393
+ formatMoney(row.original.unit_price_net, row.original.currency_code),
394
+ },
395
+ {
396
+ accessorKey: 'total_net_amount',
397
+ header: t('billing.invoices.lines.columns.total', 'Total net'),
398
+ cell: ({ row }) =>
399
+ formatMoney(row.original.total_net_amount, row.original.currency_code),
400
+ },
401
+ {
402
+ id: 'actions',
403
+ header: '',
404
+ cell: ({ row }) => {
405
+ if (!canEditLines) return null
406
+ return (
407
+ <div className="flex items-center justify-end gap-1">
408
+ <IconButton
409
+ type="button"
410
+ variant="ghost"
411
+ aria-label={t('billing.invoices.lines.actions.edit', 'Edit line')}
412
+ onClick={() => {
413
+ setDialogTargetLine(row.original)
414
+ setDialogMode('edit')
415
+ }}
416
+ >
417
+ <Pencil size={16} />
418
+ </IconButton>
419
+ <IconButton
420
+ type="button"
421
+ variant="ghost"
422
+ aria-label={t('billing.invoices.lines.actions.remove', 'Remove line')}
423
+ onClick={() => void handleRemoveLine(row.original)}
424
+ >
425
+ <Trash2 size={16} />
426
+ </IconButton>
427
+ </div>
428
+ )
429
+ },
430
+ },
431
+ ],
432
+ [canEditLines, handleRemoveLine, t],
433
+ )
434
+
435
+ const dialogInitial = React.useMemo<Partial<LineFormValues> | undefined>(() => {
436
+ if (dialogMode !== 'edit' || !dialogTargetLine) return undefined
437
+ return {
438
+ description: dialogTargetLine.description ?? '',
439
+ quantity: Number.parseFloat(dialogTargetLine.quantity),
440
+ unitPriceNet: Number.parseFloat(dialogTargetLine.unit_price_net),
441
+ }
442
+ }, [dialogMode, dialogTargetLine])
443
+
444
+ if (error) {
445
+ return (
446
+ <Page>
447
+ <PageHeader title={t('billing.invoices.detail.title', 'Billing Invoice')} />
448
+ <PageBody>
449
+ <ErrorMessage label={error} />
450
+ </PageBody>
451
+ </Page>
452
+ )
453
+ }
454
+ if (loading || !invoice) {
455
+ return (
456
+ <Page>
457
+ <PageHeader title={t('billing.invoices.detail.title', 'Billing Invoice')} />
458
+ <PageBody>
459
+ <LoadingMessage label={t('billing.invoices.detail.loading', 'Loading invoice…')} />
460
+ </PageBody>
461
+ </Page>
462
+ )
463
+ }
464
+
465
+ const isTest = invoice.metadata?.test_run === true
466
+ const isDraft = invoice.status === 'draft'
467
+
468
+ return (
469
+ <Page>
470
+ <FormHeader
471
+ mode="detail"
472
+ backHref="/backend/billing/invoices"
473
+ entityTypeLabel={t('billing.invoices.detail.title', 'Billing Invoice')}
474
+ title={invoice.invoice_number}
475
+ statusBadge={
476
+ <div className="flex items-center gap-2 flex-wrap">
477
+ <Tag variant={statusVariant(invoice.status)}>{invoice.status ?? '—'}</Tag>
478
+ {isTest ? <Tag variant="warning">TEST</Tag> : null}
479
+ </div>
480
+ }
481
+ actionsContent={
482
+ <>
483
+ {isDraft && !isTest ? (
484
+ <Button onClick={handlePost} disabled={posting}>
485
+ {posting
486
+ ? t('billing.invoices.post.in_progress', 'Posting…')
487
+ : t('billing.invoices.post.action', 'Post invoice')}
488
+ </Button>
489
+ ) : null}
490
+ {isTest ? (
491
+ <Button
492
+ variant="outline"
493
+ onClick={handleWipeTest}
494
+ disabled={wiping}
495
+ >
496
+ {wiping
497
+ ? t('billing.invoices.wipe.in_progress', 'Wiping…')
498
+ : t('billing.invoices.wipe.action', 'Wipe test invoices for this run')}
499
+ </Button>
500
+ ) : null}
501
+ </>
502
+ }
503
+ />
504
+ <PageBody>
505
+ <div className="space-y-6">
506
+ <DetailCard>
507
+ <DetailField label={t('billing.invoices.detail.field.period', 'Bill period')}>
508
+ {invoice.metadata?.bill_period_start ?? '—'} →{' '}
509
+ {invoice.metadata?.bill_period_end ?? '—'}
510
+ </DetailField>
511
+ <DetailField label={t('billing.invoices.detail.field.bill_run', 'Bill Run')}>
512
+ {invoice.metadata?.bill_run_id ? (
513
+ <Link
514
+ href={`/backend/billing/runs/${invoice.metadata.bill_run_id}`}
515
+ className="text-primary hover:underline"
516
+ >
517
+ {runName ?? invoice.metadata.bill_run_id}
518
+ </Link>
519
+ ) : (
520
+ '—'
521
+ )}
522
+ </DetailField>
523
+ <DetailField
524
+ label={t('billing.invoices.detail.field.account', 'Billing Account')}
525
+ >
526
+ {invoice.metadata?.bill_account_id ? (
527
+ <Link
528
+ href={`/backend/billing/accounts/${invoice.metadata.bill_account_id}`}
529
+ className="text-primary hover:underline"
530
+ >
531
+ {accountName ?? invoice.metadata.bill_account_id}
532
+ </Link>
533
+ ) : (
534
+ '—'
535
+ )}
536
+ </DetailField>
537
+ <DetailField label={t('billing.invoices.detail.field.issued', 'Issued')}>
538
+ {invoice.issue_date?.slice(0, 10) ?? '—'}
539
+ </DetailField>
540
+ <DetailField label={t('billing.common.id', 'ID')}>
541
+ <span className="font-mono text-xs">{invoice.id}</span>
542
+ </DetailField>
543
+ </DetailCard>
544
+
545
+ <StatCard>
546
+ <Stat
547
+ label={t('billing.invoices.detail.subtotal', 'Subtotal net')}
548
+ value={formatMoney(invoice.subtotal_net_amount, invoice.currency_code)}
549
+ />
550
+ <Stat
551
+ label={t('billing.invoices.detail.total', 'Grand total')}
552
+ value={formatMoney(invoice.grand_total_gross_amount, invoice.currency_code)}
553
+ />
554
+ <Stat
555
+ label={t('billing.invoices.detail.paid', 'Paid')}
556
+ value={formatMoney(invoice.paid_total_amount, invoice.currency_code)}
557
+ />
558
+ <Stat
559
+ label={t('billing.invoices.detail.outstanding', 'Outstanding')}
560
+ value={formatMoney(invoice.outstanding_amount, invoice.currency_code)}
561
+ />
562
+ </StatCard>
563
+
564
+ <div>
565
+ <div className="flex items-center justify-between mb-2">
566
+ <h2 className="text-base font-semibold">
567
+ {t('billing.invoices.lines.title', 'Lines')}
568
+ </h2>
569
+ {canEditLines ? (
570
+ <Button
571
+ type="button"
572
+ variant="outline"
573
+ onClick={() => {
574
+ setDialogTargetLine(null)
575
+ setDialogMode('add')
576
+ }}
577
+ >
578
+ <Plus size={16} />
579
+ {t('billing.invoices.lines.add.action', 'Add line')}
580
+ </Button>
581
+ ) : null}
582
+ </div>
583
+ <DataTable
584
+ columns={lineColumns}
585
+ data={visibleLines}
586
+ isLoading={false}
587
+ pagination={{
588
+ page: linesPage,
589
+ pageSize: linesPageSize,
590
+ total: lines.length,
591
+ totalPages: linesTotalPages,
592
+ onPageChange: setLinesPage,
593
+ }}
594
+ />
595
+ </div>
596
+ </div>
597
+
598
+ <LineFormDialog
599
+ open={dialogMode !== null}
600
+ mode={dialogMode ?? 'add'}
601
+ initial={dialogInitial}
602
+ submitting={dialogSubmitting}
603
+ onCancel={() => {
604
+ setDialogMode(null)
605
+ setDialogTargetLine(null)
606
+ }}
607
+ onSubmit={handleSubmitLine}
608
+ />
609
+ {ConfirmDialogElement}
610
+ </PageBody>
611
+ </Page>
612
+ )
613
+ }
@@ -0,0 +1,16 @@
1
+ import React from 'react'
2
+ import { FileText } from 'lucide-react'
3
+
4
+ const invoicesIcon = React.createElement(FileText, { size: 16 })
5
+
6
+ export const metadata = {
7
+ requireAuth: true,
8
+ requireFeatures: ['billing.invoice.view'],
9
+ pageTitle: 'Billing Invoices',
10
+ pageTitleKey: 'billing.invoices.title',
11
+ pageGroup: 'Billing',
12
+ pageGroupKey: 'billing.nav.group',
13
+ pageOrder: 40,
14
+ icon: invoicesIcon,
15
+ breadcrumb: [{ label: 'Billing Invoices', labelKey: 'billing.invoices.title' }],
16
+ } as const