@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,426 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import { useRouter } from 'next/navigation'
4
+ import { Play } from 'lucide-react'
5
+ import type { ColumnDef } from '@tanstack/react-table'
6
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
7
+ import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
8
+ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
9
+ import { DataTable } from '@open-mercato/ui/backend/DataTable'
10
+ import { Tag } from '@open-mercato/ui/primitives/tag'
11
+ import { Button } from '@open-mercato/ui/primitives/button'
12
+ import { Input } from '@open-mercato/ui/primitives/input'
13
+ import {
14
+ Select,
15
+ SelectContent,
16
+ SelectItem,
17
+ SelectTrigger,
18
+ SelectValue,
19
+ } from '@open-mercato/ui/primitives/select'
20
+ import {
21
+ Dialog,
22
+ DialogContent,
23
+ DialogDescription,
24
+ DialogFooter,
25
+ DialogHeader,
26
+ DialogTitle,
27
+ } from '@open-mercato/ui/primitives/dialog'
28
+ import { Kbd, KbdShortcut } from '@open-mercato/ui/primitives/kbd'
29
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
30
+ import { apiCallOrThrow, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
31
+ import { normalizeCrudServerError } from '@open-mercato/ui/backend/utils/serverErrors'
32
+ import { billRunName } from '../../../lib/billRunLabel'
33
+
34
+ /**
35
+ * Bill Runs admin list page.
36
+ *
37
+ * Lists every Bill Run for the operator's tenant with status, mode,
38
+ * summary stats, and a link into the detail page. The "New run" button
39
+ * opens an inline trigger dialog that posts to `/api/billing/runs` —
40
+ * same endpoint operators previously had to curl by hand. Retry-failed
41
+ * stays on the run detail page (scoped to a specific run's failures).
42
+ */
43
+
44
+ type TriggerMode = 'real' | 'dry' | 'test'
45
+
46
+ function todayIso(): string {
47
+ return new Date().toISOString().slice(0, 10)
48
+ }
49
+
50
+ // API list rows are snake_case (see `api/runs/route.ts` `fields`).
51
+ type BillRunRow = {
52
+ id: string
53
+ triggered_by: 'schedule' | 'manual'
54
+ parent_run_id: string | null
55
+ dry_run: boolean
56
+ test_mode: boolean
57
+ catch_up: boolean
58
+ as_of_date: string
59
+ started_at: string | null
60
+ finished_at: string | null
61
+ status: 'running' | 'completed' | 'partial_failure' | 'failed'
62
+ summary: {
63
+ accounts_processed?: number
64
+ drafts_created?: number
65
+ drafts_skipped_existing?: number
66
+ accounts_failed?: number
67
+ accounts_with_warnings?: number
68
+ usage_records_rated?: number
69
+ } | null
70
+ created_at: string
71
+ updated_at: string
72
+ }
73
+
74
+ type ListResponse = {
75
+ items: BillRunRow[]
76
+ total: number
77
+ totalPages: number
78
+ }
79
+
80
+ function formatDate(value: string | null | undefined): string {
81
+ if (!value) return '—'
82
+ const parsed = new Date(value)
83
+ return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString()
84
+ }
85
+
86
+ function formatDateOnly(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 statusVariant(
93
+ status: BillRunRow['status'],
94
+ ): 'default' | 'success' | 'warning' | 'error' {
95
+ switch (status) {
96
+ case 'completed':
97
+ return 'success'
98
+ case 'partial_failure':
99
+ return 'warning'
100
+ case 'failed':
101
+ return 'error'
102
+ case 'running':
103
+ default:
104
+ return 'default'
105
+ }
106
+ }
107
+
108
+ function modeBadge(row: BillRunRow): { label: string; variant: 'default' | 'warning' } | null {
109
+ if (row.dry_run) return { label: 'Dry-run', variant: 'warning' }
110
+ if (row.test_mode) return { label: 'Test', variant: 'warning' }
111
+ return null
112
+ }
113
+
114
+ export default function BillRunsListPage() {
115
+ const t = useT()
116
+ const router = useRouter()
117
+ const [rows, setRows] = React.useState<BillRunRow[]>([])
118
+ const [page, setPage] = React.useState(1)
119
+ const [pageSize] = React.useState(25)
120
+ const [filters, setFilters] = React.useState<FilterValues>({})
121
+ const [total, setTotal] = React.useState(0)
122
+ const [totalPages, setTotalPages] = React.useState(1)
123
+ const [loading, setLoading] = React.useState(true)
124
+ const [search, setSearch] = React.useState('')
125
+
126
+ // Trigger dialog — controls the New-run modal and its submit state.
127
+ const [triggerOpen, setTriggerOpen] = React.useState(false)
128
+ const [triggerMode, setTriggerMode] = React.useState<TriggerMode>('real')
129
+ const [triggerAsOfDate, setTriggerAsOfDate] = React.useState<string>(todayIso())
130
+ const [triggering, setTriggering] = React.useState(false)
131
+
132
+ const filterDefs = React.useMemo<FilterDef[]>(
133
+ () => [
134
+ {
135
+ id: 'status',
136
+ label: t('billing.runs.filters.status', 'Status'),
137
+ type: 'select',
138
+ options: [
139
+ { value: 'running', label: t('billing.runs.status.running', 'Running') },
140
+ { value: 'completed', label: t('billing.runs.status.completed', 'Completed') },
141
+ {
142
+ value: 'partial_failure',
143
+ label: t('billing.runs.status.partial_failure', 'Partial failure'),
144
+ },
145
+ { value: 'failed', label: t('billing.runs.status.failed', 'Failed') },
146
+ ],
147
+ },
148
+ {
149
+ id: 'triggeredBy',
150
+ label: t('billing.runs.filters.triggered_by', 'Trigger'),
151
+ type: 'select',
152
+ options: [
153
+ { value: 'schedule', label: t('billing.runs.trigger.schedule', 'Schedule') },
154
+ { value: 'manual', label: t('billing.runs.trigger.manual', 'Manual') },
155
+ ],
156
+ },
157
+ ],
158
+ [t],
159
+ )
160
+
161
+ const loadRows = React.useCallback(async () => {
162
+ setLoading(true)
163
+ try {
164
+ const params = new URLSearchParams({
165
+ page: String(page),
166
+ pageSize: String(pageSize),
167
+ sortField: 'startedAt',
168
+ sortDir: 'desc',
169
+ })
170
+ if (search.trim()) params.set('search', search.trim())
171
+ if (typeof filters.status === 'string' && filters.status) {
172
+ params.set('status', filters.status)
173
+ }
174
+ if (typeof filters.triggeredBy === 'string' && filters.triggeredBy) {
175
+ params.set('triggeredBy', filters.triggeredBy)
176
+ }
177
+ const result = await readApiResultOrThrow<ListResponse>(
178
+ `/api/billing/runs?${params.toString()}`,
179
+ )
180
+ setRows(result.items ?? [])
181
+ setTotal(result.total ?? 0)
182
+ setTotalPages(result.totalPages ?? 1)
183
+ } finally {
184
+ setLoading(false)
185
+ }
186
+ }, [filters.status, filters.triggeredBy, page, pageSize, search])
187
+
188
+ React.useEffect(() => {
189
+ void loadRows()
190
+ }, [loadRows])
191
+
192
+ const handleTrigger = React.useCallback(async () => {
193
+ setTriggering(true)
194
+ try {
195
+ const payload: Record<string, unknown> = { mode: triggerMode }
196
+ if (triggerAsOfDate) payload.asOfDate = triggerAsOfDate
197
+ const call = await apiCallOrThrow<{ billRunId: string | null }>(
198
+ '/api/billing/runs',
199
+ {
200
+ method: 'POST',
201
+ headers: { 'Content-Type': 'application/json' },
202
+ body: JSON.stringify(payload),
203
+ },
204
+ )
205
+ flash(t('billing.runs.trigger.success', 'Bill Run triggered'), 'success')
206
+ setTriggerOpen(false)
207
+ const newId = call.result?.billRunId
208
+ if (newId) {
209
+ router.push(`/backend/billing/runs/${newId}`)
210
+ } else {
211
+ void loadRows()
212
+ }
213
+ } catch (err) {
214
+ const { message, fieldErrors } = normalizeCrudServerError(err)
215
+ const fieldDetail =
216
+ fieldErrors && Object.keys(fieldErrors).length
217
+ ? Object.entries(fieldErrors)
218
+ .map(([field, msg]) => `${field}: ${msg}`)
219
+ .join('; ')
220
+ : null
221
+ const detailed =
222
+ fieldDetail && message ? `${message} — ${fieldDetail}` : fieldDetail || message
223
+ flash(detailed || t('billing.runs.trigger.error', 'Failed to trigger run'), 'error')
224
+ } finally {
225
+ setTriggering(false)
226
+ }
227
+ }, [loadRows, router, t, triggerAsOfDate, triggerMode])
228
+
229
+ const handleTriggerKeyDown = React.useCallback(
230
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
231
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
232
+ event.preventDefault()
233
+ if (!triggering) void handleTrigger()
234
+ }
235
+ },
236
+ [handleTrigger, triggering],
237
+ )
238
+
239
+ const columns = React.useMemo<ColumnDef<BillRunRow>[]>(
240
+ () => [
241
+ {
242
+ id: 'name',
243
+ header: t('billing.runs.columns.name', 'Bill Run'),
244
+ cell: ({ row }) => (
245
+ <span className="text-sm font-medium text-primary">
246
+ {billRunName(row.original.as_of_date)}
247
+ </span>
248
+ ),
249
+ },
250
+ {
251
+ accessorKey: 'status',
252
+ header: t('billing.runs.columns.status', 'Status'),
253
+ cell: ({ row }) => {
254
+ const mode = modeBadge(row.original)
255
+ return (
256
+ <div className="flex items-center gap-2">
257
+ <Tag variant={statusVariant(row.original.status)}>
258
+ {row.original.status}
259
+ </Tag>
260
+ {mode ? <Tag variant={mode.variant}>{mode.label}</Tag> : null}
261
+ </div>
262
+ )
263
+ },
264
+ },
265
+ {
266
+ accessorKey: 'as_of_date',
267
+ header: t('billing.runs.columns.as_of_date', 'As-of date'),
268
+ cell: ({ row }) => formatDateOnly(row.original.as_of_date),
269
+ },
270
+ {
271
+ accessorKey: 'triggered_by',
272
+ header: t('billing.runs.columns.triggered_by', 'Trigger'),
273
+ },
274
+ {
275
+ id: 'drafts_created',
276
+ header: t('billing.runs.columns.drafts', 'Drafts'),
277
+ cell: ({ row }) => row.original.summary?.drafts_created ?? '—',
278
+ },
279
+ {
280
+ id: 'accounts_processed',
281
+ header: t('billing.runs.columns.accounts', 'Accounts'),
282
+ cell: ({ row }) => row.original.summary?.accounts_processed ?? '—',
283
+ },
284
+ {
285
+ id: 'accounts_failed',
286
+ header: t('billing.runs.columns.failed', 'Failed'),
287
+ cell: ({ row }) => {
288
+ const failed = row.original.summary?.accounts_failed ?? 0
289
+ if (failed === 0) return '—'
290
+ return <Tag variant="error">{failed}</Tag>
291
+ },
292
+ },
293
+ {
294
+ accessorKey: 'started_at',
295
+ header: t('billing.runs.columns.started_at', 'Started'),
296
+ cell: ({ row }) => formatDate(row.original.started_at),
297
+ },
298
+ {
299
+ accessorKey: 'finished_at',
300
+ header: t('billing.runs.columns.finished_at', 'Finished'),
301
+ cell: ({ row }) => formatDate(row.original.finished_at),
302
+ },
303
+ ],
304
+ [t],
305
+ )
306
+
307
+ return (
308
+ <Page>
309
+ <PageBody>
310
+ <DataTable
311
+ title={t('billing.runs.title', 'Bill Runs')}
312
+ actions={
313
+ <Button onClick={() => setTriggerOpen(true)}>
314
+ <Play size={16} />
315
+ {t('billing.runs.trigger.action', 'New run')}
316
+ </Button>
317
+ }
318
+ refreshButton={{
319
+ label: t('billing.common.refresh', 'Refresh'),
320
+ onRefresh: loadRows,
321
+ isRefreshing: loading,
322
+ }}
323
+ columnChooser={{ auto: true }}
324
+ perspective={{ tableId: 'billing-runs' }}
325
+ columns={columns}
326
+ data={rows}
327
+ onRowClick={(row) => router.push(`/backend/billing/runs/${row.id}`)}
328
+ isLoading={loading}
329
+ pagination={{
330
+ page,
331
+ pageSize,
332
+ total,
333
+ totalPages,
334
+ onPageChange: setPage,
335
+ }}
336
+ filters={filterDefs}
337
+ filterValues={filters}
338
+ onFiltersApply={setFilters}
339
+ onFiltersClear={() => setFilters({})}
340
+ searchValue={search}
341
+ onSearchChange={setSearch}
342
+ />
343
+
344
+ <Dialog open={triggerOpen} onOpenChange={(next) => !triggering && setTriggerOpen(next)}>
345
+ <DialogContent onKeyDown={handleTriggerKeyDown}>
346
+ <DialogHeader>
347
+ <DialogTitle>
348
+ {t('billing.runs.trigger.dialog.title', 'Trigger Bill Run')}
349
+ </DialogTitle>
350
+ <DialogDescription>
351
+ {t(
352
+ 'billing.runs.trigger.dialog.description',
353
+ 'Runs the bill cycle for every account whose next bill date has passed. Dry-run / Test produce drafts without posting any invoices.',
354
+ )}
355
+ </DialogDescription>
356
+ </DialogHeader>
357
+ <div className="space-y-4">
358
+ <label className="flex flex-col gap-1 text-sm">
359
+ <span className="text-muted-foreground">
360
+ {t('billing.runs.trigger.dialog.mode', 'Mode')}
361
+ </span>
362
+ <Select
363
+ value={triggerMode}
364
+ onValueChange={(next) => setTriggerMode(next as TriggerMode)}
365
+ >
366
+ <SelectTrigger>
367
+ <SelectValue />
368
+ </SelectTrigger>
369
+ <SelectContent>
370
+ <SelectItem value="real">
371
+ {t('billing.runs.trigger.dialog.mode.real', 'Real — create draft invoices')}
372
+ </SelectItem>
373
+ <SelectItem value="dry">
374
+ {t('billing.runs.trigger.dialog.mode.dry', 'Dry-run — no rows written')}
375
+ </SelectItem>
376
+ <SelectItem value="test">
377
+ {t('billing.runs.trigger.dialog.mode.test', 'Test — drafts tagged for cleanup')}
378
+ </SelectItem>
379
+ </SelectContent>
380
+ </Select>
381
+ </label>
382
+ <label className="flex flex-col gap-1 text-sm">
383
+ <span className="text-muted-foreground">
384
+ {t('billing.runs.trigger.dialog.as_of_date', 'As-of date')}
385
+ </span>
386
+ <Input
387
+ type="date"
388
+ value={triggerAsOfDate}
389
+ onChange={(event) => setTriggerAsOfDate(event.currentTarget.value)}
390
+ />
391
+ <span className="text-xs text-muted-foreground">
392
+ {t(
393
+ 'billing.runs.trigger.dialog.as_of_date.help',
394
+ 'Accounts whose next bill date is on or before this date will be billed.',
395
+ )}
396
+ </span>
397
+ </label>
398
+ </div>
399
+ <DialogFooter className="flex items-center justify-between gap-3 sm:justify-between">
400
+ <span className="text-xs text-muted-foreground">
401
+ <KbdShortcut keys={['Ctrl/⌘', 'Enter']} />{' '}
402
+ {t('billing.runs.trigger.dialog.submit_hint', 'to run,')} <Kbd>Esc</Kbd>{' '}
403
+ {t('billing.runs.trigger.dialog.cancel_hint', 'to cancel')}
404
+ </span>
405
+ <div className="flex items-center gap-2">
406
+ <Button
407
+ type="button"
408
+ variant="outline"
409
+ onClick={() => setTriggerOpen(false)}
410
+ disabled={triggering}
411
+ >
412
+ {t('billing.common.cancel', 'Cancel')}
413
+ </Button>
414
+ <Button type="button" onClick={() => void handleTrigger()} disabled={triggering}>
415
+ {triggering
416
+ ? t('billing.runs.trigger.dialog.running', 'Running…')
417
+ : t('billing.runs.trigger.dialog.submit', 'Run now')}
418
+ </Button>
419
+ </div>
420
+ </DialogFooter>
421
+ </DialogContent>
422
+ </Dialog>
423
+ </PageBody>
424
+ </Page>
425
+ )
426
+ }
@@ -0,0 +1,23 @@
1
+ import React from 'react'
2
+ import { Gauge } from 'lucide-react'
3
+
4
+ const usageIcon = React.createElement(Gauge, { size: 16 })
5
+
6
+ export const metadata = {
7
+ requireAuth: true,
8
+ requireFeatures: ['billing.usage.ingest'],
9
+ pageTitle: 'Record Usage',
10
+ pageTitleKey: 'billing.usage.create.title',
11
+ pageGroup: 'Billing',
12
+ pageGroupKey: 'billing.nav.group',
13
+ navHidden: true,
14
+ icon: usageIcon,
15
+ breadcrumb: [
16
+ {
17
+ label: 'Billing Accounts',
18
+ labelKey: 'billing.accounts.title',
19
+ href: '/backend/billing/accounts',
20
+ },
21
+ { label: 'Record Usage', labelKey: 'billing.usage.create.title' },
22
+ ],
23
+ } as const
@@ -0,0 +1,120 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import { useRouter, useSearchParams } 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 { LoadingMessage } from '@open-mercato/ui/backend/detail'
7
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
8
+ import {
9
+ apiCallOrThrow,
10
+ readApiResultOrThrow,
11
+ } from '@open-mercato/ui/backend/utils/apiCall'
12
+ import { normalizeCrudServerError } from '@open-mercato/ui/backend/utils/serverErrors'
13
+ import {
14
+ UsageForm,
15
+ type UsageAccountOption,
16
+ type AssembledUsagePayload,
17
+ } from '../../../../components/UsageForm'
18
+
19
+ /**
20
+ * `/backend/billing/usage/create` — manually record a usage entry.
21
+ *
22
+ * Mirrors `items/create`: loads the account list once to drive the
23
+ * picker, supports `?billAccountId=<id>` deep-link from the account
24
+ * detail page's "Add usage" CTA, and POSTs to `/api/billing/usage`
25
+ * (the same endpoint external meters use). Usage has no detail page —
26
+ * on success we return to the account detail where the new record
27
+ * shows up in the Usage section.
28
+ */
29
+ export default function CreateBillingUsagePage() {
30
+ const t = useT()
31
+ const router = useRouter()
32
+ const searchParams = useSearchParams()
33
+ const preselectedAccountId = searchParams.get('billAccountId') ?? ''
34
+
35
+ const [accounts, setAccounts] = React.useState<UsageAccountOption[] | null>(null)
36
+ const [submitting, setSubmitting] = React.useState(false)
37
+
38
+ React.useEffect(() => {
39
+ let cancelled = false
40
+ void (async () => {
41
+ try {
42
+ const result = await readApiResultOrThrow<{ items: UsageAccountOption[] }>(
43
+ '/api/billing/accounts?pageSize=100&sortField=name&sortDir=asc',
44
+ )
45
+ if (!cancelled) setAccounts(result.items ?? [])
46
+ } catch {
47
+ if (!cancelled) setAccounts([])
48
+ }
49
+ })()
50
+ return () => {
51
+ cancelled = true
52
+ }
53
+ }, [])
54
+
55
+ const handleSubmit = React.useCallback(
56
+ async (payload: AssembledUsagePayload) => {
57
+ setSubmitting(true)
58
+ try {
59
+ const call = await apiCallOrThrow<{ id: string; deduplicated: boolean }>(
60
+ '/api/billing/usage',
61
+ {
62
+ method: 'POST',
63
+ headers: { 'Content-Type': 'application/json' },
64
+ body: JSON.stringify(payload),
65
+ },
66
+ )
67
+ if (call.result?.deduplicated) {
68
+ flash(
69
+ t(
70
+ 'billing.usage.create.deduplicated',
71
+ 'A usage record with that source reference already existed — nothing was duplicated.',
72
+ ),
73
+ 'success',
74
+ )
75
+ } else {
76
+ flash(t('billing.usage.create.success', 'Usage record created'), 'success')
77
+ }
78
+ router.push(`/backend/billing/accounts/${payload.billAccountId}`)
79
+ } catch (err) {
80
+ const { message } = normalizeCrudServerError(err)
81
+ flash(
82
+ message || t('billing.usage.create.error', 'Failed to record usage'),
83
+ 'error',
84
+ )
85
+ } finally {
86
+ setSubmitting(false)
87
+ }
88
+ },
89
+ [router, t],
90
+ )
91
+
92
+ const handleCancel = React.useCallback(() => {
93
+ if (preselectedAccountId) {
94
+ router.push(`/backend/billing/accounts/${preselectedAccountId}`)
95
+ } else {
96
+ router.push('/backend/billing/accounts')
97
+ }
98
+ }, [preselectedAccountId, router])
99
+
100
+ return (
101
+ <Page>
102
+ <PageHeader title={t('billing.usage.create.title', 'Record Usage')} />
103
+ <PageBody>
104
+ {accounts === null ? (
105
+ <LoadingMessage label={t('billing.usage.create.loading', 'Loading accounts…')} />
106
+ ) : (
107
+ <UsageForm
108
+ accountOptions={accounts}
109
+ initial={
110
+ preselectedAccountId ? { billAccountId: preselectedAccountId } : undefined
111
+ }
112
+ submitting={submitting}
113
+ onSubmit={handleSubmit}
114
+ onCancel={handleCancel}
115
+ />
116
+ )}
117
+ </PageBody>
118
+ </Page>
119
+ )
120
+ }
@@ -0,0 +1,74 @@
1
+ import type { CustomEntitySpec } from '@open-mercato/shared/modules/entities'
2
+
3
+ /**
4
+ * Billing module entity registry.
5
+ *
6
+ * Listing our MikroORM entities here gets them into the framework's
7
+ * `E.billing.<entity>` generated registry (`entities.ids.generated.ts`),
8
+ * which the CRUD factory, query engine, and indexers reference. Phase 0
9
+ * shipped with no entries because the standalone CLI didn't traverse
10
+ * `@dainamite/*` packages — adding them in Phase 1 once the resolver
11
+ * patch lands.
12
+ *
13
+ * `fields: []` means billing does NOT expose custom-field surfaces on
14
+ * these entities today (no EAV). Adding fields here is reserved for
15
+ * Phase 4+ if operators request per-tenant custom fields on Billing
16
+ * Account or Billing Item.
17
+ *
18
+ * `showInSidebar: false` keeps the Custom Entities admin UI from listing
19
+ * these — they are first-class entities with their own dedicated pages
20
+ * (Phase 4), not generic EAV entries.
21
+ */
22
+ const entities: CustomEntitySpec[] = [
23
+ {
24
+ id: 'billing:billing_account',
25
+ label: 'Billing Account',
26
+ description: 'A per-customer container for billing items, usage, and bill-cycle metadata.',
27
+ labelField: 'name',
28
+ showInSidebar: false,
29
+ fields: [],
30
+ },
31
+ {
32
+ id: 'billing:billing_item',
33
+ label: 'Billing Item',
34
+ description: 'A single charge (one_time / recurring / usage) attached to a Billing Account.',
35
+ labelField: 'description',
36
+ showInSidebar: false,
37
+ fields: [],
38
+ },
39
+ {
40
+ id: 'billing:billing_account_usage',
41
+ label: 'Billing Account Usage',
42
+ description: 'A pre-aggregated usage record uploaded by external metering systems.',
43
+ labelField: 'id',
44
+ showInSidebar: false,
45
+ fields: [],
46
+ },
47
+ {
48
+ id: 'billing:billing_run',
49
+ label: 'Bill Run',
50
+ description: 'A single execution of the Bill Run engine (schedule or manual trigger).',
51
+ labelField: 'id',
52
+ showInSidebar: false,
53
+ fields: [],
54
+ },
55
+ {
56
+ id: 'billing:billing_run_outcome',
57
+ label: 'Bill Run Outcome',
58
+ description: 'Per-account outcome of a Bill Run — success / warnings / skipped / failed.',
59
+ labelField: 'id',
60
+ showInSidebar: false,
61
+ fields: [],
62
+ },
63
+ {
64
+ id: 'billing:billing_draft_invoice_edit',
65
+ label: 'Draft Invoice Edit',
66
+ description: 'Append-only audit of operator edits on draft invoice lines.',
67
+ labelField: 'id',
68
+ showInSidebar: false,
69
+ fields: [],
70
+ },
71
+ ]
72
+
73
+ export { entities }
74
+ export default entities