@dainamite/billing 0.17.0 → 0.18.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 (205) hide show
  1. package/CHANGELOG.md +81 -19
  2. package/dist/modules/billing/acl.js +9 -1
  3. package/dist/modules/billing/acl.js.map +2 -2
  4. package/dist/modules/billing/api/accounts/[id]/balance/route.js +62 -0
  5. package/dist/modules/billing/api/accounts/[id]/balance/route.js.map +7 -0
  6. package/dist/modules/billing/api/accounts/[id]/credit-status/route.js +56 -0
  7. package/dist/modules/billing/api/accounts/[id]/credit-status/route.js.map +7 -0
  8. package/dist/modules/billing/api/accounts/[id]/topups/route.js +36 -0
  9. package/dist/modules/billing/api/accounts/[id]/topups/route.js.map +7 -0
  10. package/dist/modules/billing/api/accounts/route.js +7 -0
  11. package/dist/modules/billing/api/accounts/route.js.map +2 -2
  12. package/dist/modules/billing/api/export/account/[id]/route.js +48 -3
  13. package/dist/modules/billing/api/export/account/[id]/route.js.map +2 -2
  14. package/dist/modules/billing/api/statements/generate/route.js +68 -0
  15. package/dist/modules/billing/api/statements/generate/route.js.map +7 -0
  16. package/dist/modules/billing/api/statements/route.js +110 -0
  17. package/dist/modules/billing/api/statements/route.js.map +7 -0
  18. package/dist/modules/billing/api/topups/route.js +139 -0
  19. package/dist/modules/billing/api/topups/route.js.map +7 -0
  20. package/dist/modules/billing/api/transactions/adjust/route.js +71 -0
  21. package/dist/modules/billing/api/transactions/adjust/route.js.map +7 -0
  22. package/dist/modules/billing/api/transactions/route.js +109 -0
  23. package/dist/modules/billing/api/transactions/route.js.map +7 -0
  24. package/dist/modules/billing/api/usage/route.js +30 -6
  25. package/dist/modules/billing/api/usage/route.js.map +2 -2
  26. package/dist/modules/billing/backend/billing/accounts/[id]/page.js +16 -1
  27. package/dist/modules/billing/backend/billing/accounts/[id]/page.js.map +2 -2
  28. package/dist/modules/billing/backend/billing/accounts/create/page.js +8 -1
  29. package/dist/modules/billing/backend/billing/accounts/create/page.js.map +2 -2
  30. package/dist/modules/billing/backend/billing/accounts/page.js +26 -1
  31. package/dist/modules/billing/backend/billing/accounts/page.js.map +2 -2
  32. package/dist/modules/billing/backend/billing/statements/[id]/page.js +129 -0
  33. package/dist/modules/billing/backend/billing/statements/[id]/page.js.map +7 -0
  34. package/dist/modules/billing/backend/billing/statements/[id]/page.meta.js +21 -0
  35. package/dist/modules/billing/backend/billing/statements/[id]/page.meta.js.map +7 -0
  36. package/dist/modules/billing/backend/billing/statements/page.js +152 -0
  37. package/dist/modules/billing/backend/billing/statements/page.js.map +7 -0
  38. package/dist/modules/billing/backend/billing/statements/page.meta.js +18 -0
  39. package/dist/modules/billing/backend/billing/statements/page.meta.js.map +7 -0
  40. package/dist/modules/billing/backend/billing/topups/[id]/page.js +90 -0
  41. package/dist/modules/billing/backend/billing/topups/[id]/page.js.map +7 -0
  42. package/dist/modules/billing/backend/billing/topups/[id]/page.meta.js +21 -0
  43. package/dist/modules/billing/backend/billing/topups/[id]/page.meta.js.map +7 -0
  44. package/dist/modules/billing/backend/billing/topups/page.js +147 -0
  45. package/dist/modules/billing/backend/billing/topups/page.js.map +7 -0
  46. package/dist/modules/billing/backend/billing/topups/page.meta.js +18 -0
  47. package/dist/modules/billing/backend/billing/topups/page.meta.js.map +7 -0
  48. package/dist/modules/billing/backend/billing/transactions/[id]/page.js +114 -0
  49. package/dist/modules/billing/backend/billing/transactions/[id]/page.js.map +7 -0
  50. package/dist/modules/billing/backend/billing/transactions/[id]/page.meta.js +21 -0
  51. package/dist/modules/billing/backend/billing/transactions/[id]/page.meta.js.map +7 -0
  52. package/dist/modules/billing/backend/billing/transactions/page.js +149 -0
  53. package/dist/modules/billing/backend/billing/transactions/page.js.map +7 -0
  54. package/dist/modules/billing/backend/billing/transactions/page.meta.js +18 -0
  55. package/dist/modules/billing/backend/billing/transactions/page.meta.js.map +7 -0
  56. package/dist/modules/billing/commands/accounts.js +26 -1
  57. package/dist/modules/billing/commands/accounts.js.map +2 -2
  58. package/dist/modules/billing/commands/index.js +3 -0
  59. package/dist/modules/billing/commands/index.js.map +2 -2
  60. package/dist/modules/billing/commands/invoices.js +21 -1
  61. package/dist/modules/billing/commands/invoices.js.map +2 -2
  62. package/dist/modules/billing/commands/statements.js +74 -0
  63. package/dist/modules/billing/commands/statements.js.map +7 -0
  64. package/dist/modules/billing/commands/topups.js +139 -0
  65. package/dist/modules/billing/commands/topups.js.map +7 -0
  66. package/dist/modules/billing/commands/transactions.js +119 -0
  67. package/dist/modules/billing/commands/transactions.js.map +7 -0
  68. package/dist/modules/billing/commands/usage.js +72 -0
  69. package/dist/modules/billing/commands/usage.js.map +2 -2
  70. package/dist/modules/billing/components/AccountForm.js +53 -1
  71. package/dist/modules/billing/components/AccountForm.js.map +2 -2
  72. package/dist/modules/billing/components/PrepaidPanel.js +314 -0
  73. package/dist/modules/billing/components/PrepaidPanel.js.map +7 -0
  74. package/dist/modules/billing/components/accountNames.js +20 -0
  75. package/dist/modules/billing/components/accountNames.js.map +7 -0
  76. package/dist/modules/billing/data/entities.js +290 -0
  77. package/dist/modules/billing/data/entities.js.map +2 -2
  78. package/dist/modules/billing/data/entityIds.js +6 -1
  79. package/dist/modules/billing/data/entityIds.js.map +2 -2
  80. package/dist/modules/billing/data/validators.js +107 -2
  81. package/dist/modules/billing/data/validators.js.map +2 -2
  82. package/dist/modules/billing/events.js +52 -0
  83. package/dist/modules/billing/events.js.map +2 -2
  84. package/dist/modules/billing/i18n/en.json +112 -4
  85. package/dist/modules/billing/i18n/pl.json +112 -4
  86. package/dist/modules/billing/index.js +1 -1
  87. package/dist/modules/billing/index.js.map +2 -2
  88. package/dist/modules/billing/lib/accountStatus.js +106 -0
  89. package/dist/modules/billing/lib/accountStatus.js.map +7 -0
  90. package/dist/modules/billing/lib/balanceEvents.js +48 -0
  91. package/dist/modules/billing/lib/balanceEvents.js.map +7 -0
  92. package/dist/modules/billing/lib/balanceLedger.js +136 -0
  93. package/dist/modules/billing/lib/balanceLedger.js.map +7 -0
  94. package/dist/modules/billing/lib/balanceStatus.js +29 -0
  95. package/dist/modules/billing/lib/balanceStatus.js.map +7 -0
  96. package/dist/modules/billing/lib/billRunEngine.js +40 -2
  97. package/dist/modules/billing/lib/billRunEngine.js.map +2 -2
  98. package/dist/modules/billing/lib/creditStatus.js +65 -0
  99. package/dist/modules/billing/lib/creditStatus.js.map +7 -0
  100. package/dist/modules/billing/lib/money.js +47 -0
  101. package/dist/modules/billing/lib/money.js.map +7 -0
  102. package/dist/modules/billing/lib/prepaidConfig.js +45 -0
  103. package/dist/modules/billing/lib/prepaidConfig.js.map +7 -0
  104. package/dist/modules/billing/lib/prepaidConsume.js +214 -0
  105. package/dist/modules/billing/lib/prepaidConsume.js.map +7 -0
  106. package/dist/modules/billing/lib/prepaidPeriodClose.js +285 -0
  107. package/dist/modules/billing/lib/prepaidPeriodClose.js.map +7 -0
  108. package/dist/modules/billing/lib/topupCapture.js +62 -0
  109. package/dist/modules/billing/lib/topupCapture.js.map +7 -0
  110. package/dist/modules/billing/lib/topupReceipt.js +97 -0
  111. package/dist/modules/billing/lib/topupReceipt.js.map +7 -0
  112. package/dist/modules/billing/lib/topupTransition.js +19 -0
  113. package/dist/modules/billing/lib/topupTransition.js.map +7 -0
  114. package/dist/modules/billing/migrations/Migration20260619000000_billing_prepaid.js +110 -0
  115. package/dist/modules/billing/migrations/Migration20260619000000_billing_prepaid.js.map +7 -0
  116. package/dist/modules/billing/setup.js +32 -1
  117. package/dist/modules/billing/setup.js.map +2 -2
  118. package/dist/modules/billing/subscribers/prepaid-topup-cancelled.js +26 -0
  119. package/dist/modules/billing/subscribers/prepaid-topup-cancelled.js.map +7 -0
  120. package/dist/modules/billing/subscribers/prepaid-topup-captured.js +87 -0
  121. package/dist/modules/billing/subscribers/prepaid-topup-captured.js.map +7 -0
  122. package/dist/modules/billing/subscribers/prepaid-topup-expired.js +26 -0
  123. package/dist/modules/billing/subscribers/prepaid-topup-expired.js.map +7 -0
  124. package/dist/modules/billing/subscribers/prepaid-topup-failed.js +26 -0
  125. package/dist/modules/billing/subscribers/prepaid-topup-failed.js.map +7 -0
  126. package/dist/modules/billing/workers/reconcile-prepaid-balances.js +50 -0
  127. package/dist/modules/billing/workers/reconcile-prepaid-balances.js.map +7 -0
  128. package/package.json +1 -1
  129. package/src/modules/billing/__tests__/commands/topups.test.ts +104 -0
  130. package/src/modules/billing/__tests__/commands/transactions.test.ts +84 -0
  131. package/src/modules/billing/__tests__/helpers/prepaidMockEm.ts +235 -0
  132. package/src/modules/billing/__tests__/lib/accountStatus.test.ts +93 -0
  133. package/src/modules/billing/__tests__/lib/balanceLedger.test.ts +125 -0
  134. package/src/modules/billing/__tests__/lib/balanceStatus.test.ts +65 -0
  135. package/src/modules/billing/__tests__/lib/creditStatus.test.ts +111 -0
  136. package/src/modules/billing/__tests__/lib/money.test.ts +56 -0
  137. package/src/modules/billing/__tests__/lib/prepaidConsume.test.ts +179 -0
  138. package/src/modules/billing/__tests__/lib/prepaidPeriodClose.test.ts +232 -0
  139. package/src/modules/billing/__tests__/lib/topupCapture.test.ts +123 -0
  140. package/src/modules/billing/__tests__/lib/topupReceipt.test.ts +84 -0
  141. package/src/modules/billing/__tests__/setup.test.ts +28 -2
  142. package/src/modules/billing/acl.ts +8 -0
  143. package/src/modules/billing/api/accounts/[id]/balance/route.ts +74 -0
  144. package/src/modules/billing/api/accounts/[id]/credit-status/route.ts +71 -0
  145. package/src/modules/billing/api/accounts/[id]/topups/route.ts +49 -0
  146. package/src/modules/billing/api/accounts/route.ts +7 -0
  147. package/src/modules/billing/api/export/account/[id]/route.ts +47 -2
  148. package/src/modules/billing/api/statements/generate/route.ts +78 -0
  149. package/src/modules/billing/api/statements/route.ts +117 -0
  150. package/src/modules/billing/api/topups/route.ts +154 -0
  151. package/src/modules/billing/api/transactions/adjust/route.ts +80 -0
  152. package/src/modules/billing/api/transactions/route.ts +116 -0
  153. package/src/modules/billing/api/usage/route.ts +36 -5
  154. package/src/modules/billing/backend/billing/accounts/[id]/page.tsx +28 -0
  155. package/src/modules/billing/backend/billing/accounts/create/page.tsx +7 -0
  156. package/src/modules/billing/backend/billing/accounts/page.tsx +32 -1
  157. package/src/modules/billing/backend/billing/statements/[id]/page.meta.ts +19 -0
  158. package/src/modules/billing/backend/billing/statements/[id]/page.tsx +198 -0
  159. package/src/modules/billing/backend/billing/statements/page.meta.ts +16 -0
  160. package/src/modules/billing/backend/billing/statements/page.tsx +190 -0
  161. package/src/modules/billing/backend/billing/topups/[id]/page.meta.ts +19 -0
  162. package/src/modules/billing/backend/billing/topups/[id]/page.tsx +151 -0
  163. package/src/modules/billing/backend/billing/topups/page.meta.ts +16 -0
  164. package/src/modules/billing/backend/billing/topups/page.tsx +182 -0
  165. package/src/modules/billing/backend/billing/transactions/[id]/page.meta.ts +19 -0
  166. package/src/modules/billing/backend/billing/transactions/[id]/page.tsx +179 -0
  167. package/src/modules/billing/backend/billing/transactions/page.meta.ts +16 -0
  168. package/src/modules/billing/backend/billing/transactions/page.tsx +187 -0
  169. package/src/modules/billing/commands/accounts.ts +42 -1
  170. package/src/modules/billing/commands/index.ts +3 -0
  171. package/src/modules/billing/commands/invoices.ts +26 -2
  172. package/src/modules/billing/commands/statements.ts +101 -0
  173. package/src/modules/billing/commands/topups.ts +181 -0
  174. package/src/modules/billing/commands/transactions.ts +152 -0
  175. package/src/modules/billing/commands/usage.ts +114 -4
  176. package/src/modules/billing/components/AccountForm.tsx +66 -0
  177. package/src/modules/billing/components/PrepaidPanel.tsx +382 -0
  178. package/src/modules/billing/components/accountNames.ts +23 -0
  179. package/src/modules/billing/data/entities.ts +383 -0
  180. package/src/modules/billing/data/entityIds.ts +5 -0
  181. package/src/modules/billing/data/validators.ts +144 -0
  182. package/src/modules/billing/events.ts +52 -0
  183. package/src/modules/billing/i18n/en.json +112 -4
  184. package/src/modules/billing/i18n/pl.json +112 -4
  185. package/src/modules/billing/index.ts +1 -1
  186. package/src/modules/billing/lib/accountStatus.ts +155 -0
  187. package/src/modules/billing/lib/balanceEvents.ts +74 -0
  188. package/src/modules/billing/lib/balanceLedger.ts +258 -0
  189. package/src/modules/billing/lib/balanceStatus.ts +78 -0
  190. package/src/modules/billing/lib/billRunEngine.ts +66 -5
  191. package/src/modules/billing/lib/creditStatus.ts +153 -0
  192. package/src/modules/billing/lib/money.ts +73 -0
  193. package/src/modules/billing/lib/prepaidConfig.ts +58 -0
  194. package/src/modules/billing/lib/prepaidConsume.ts +299 -0
  195. package/src/modules/billing/lib/prepaidPeriodClose.ts +453 -0
  196. package/src/modules/billing/lib/topupCapture.ts +117 -0
  197. package/src/modules/billing/lib/topupReceipt.ts +153 -0
  198. package/src/modules/billing/lib/topupTransition.ts +31 -0
  199. package/src/modules/billing/migrations/Migration20260619000000_billing_prepaid.ts +131 -0
  200. package/src/modules/billing/setup.ts +45 -0
  201. package/src/modules/billing/subscribers/prepaid-topup-cancelled.ts +31 -0
  202. package/src/modules/billing/subscribers/prepaid-topup-captured.ts +120 -0
  203. package/src/modules/billing/subscribers/prepaid-topup-expired.ts +31 -0
  204. package/src/modules/billing/subscribers/prepaid-topup-failed.ts +32 -0
  205. package/src/modules/billing/workers/reconcile-prepaid-balances.ts +68 -0
@@ -0,0 +1,182 @@
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
+ import { fetchAccountNames } from '../../../components/accountNames'
12
+
13
+ /**
14
+ * Registered top-ups admin list (SPEC-002 P2). Read-only — top-ups are
15
+ * initiated from a prepaid account's detail page and progress through the
16
+ * gateway capture lifecycle.
17
+ */
18
+
19
+ type TopupRow = {
20
+ id: string
21
+ bill_account_id: string
22
+ status: string
23
+ amount: string
24
+ currency_code: string
25
+ provider_key: string
26
+ receipt_invoice_id: string | null
27
+ created_at: string
28
+ }
29
+
30
+ type ListResponse = { items: TopupRow[]; total: number; totalPages: number }
31
+
32
+ function formatDate(value: string | null | undefined): string {
33
+ if (!value) return '—'
34
+ const d = new Date(value)
35
+ return Number.isNaN(d.getTime()) ? value : d.toLocaleString()
36
+ }
37
+
38
+ export function statusVariant(status: string): 'success' | 'info' | 'error' | 'default' {
39
+ if (status === 'captured') return 'success'
40
+ if (status === 'pending') return 'info'
41
+ if (status === 'failed') return 'error'
42
+ return 'default' // cancelled / expired
43
+ }
44
+
45
+ export default function BillingTopupsListPage() {
46
+ const t = useT()
47
+ const router = useRouter()
48
+ const [rows, setRows] = React.useState<TopupRow[]>([])
49
+ const [page, setPage] = React.useState(1)
50
+ const [pageSize] = React.useState(25)
51
+ const [filters, setFilters] = React.useState<FilterValues>({})
52
+ const [search, setSearch] = React.useState('')
53
+ const [total, setTotal] = React.useState(0)
54
+ const [totalPages, setTotalPages] = React.useState(1)
55
+ const [loading, setLoading] = React.useState(true)
56
+ const [accountNames, setAccountNames] = React.useState<Record<string, string>>({})
57
+
58
+ const filterDefs = React.useMemo<FilterDef[]>(
59
+ () => [
60
+ {
61
+ id: 'status',
62
+ label: t('billing.topups.filters.status', 'Status'),
63
+ type: 'select',
64
+ options: [
65
+ { value: 'pending', label: t('billing.topups.status.pending', 'Pending') },
66
+ { value: 'captured', label: t('billing.topups.status.captured', 'Captured') },
67
+ { value: 'failed', label: t('billing.topups.status.failed', 'Failed') },
68
+ { value: 'cancelled', label: t('billing.topups.status.cancelled', 'Cancelled') },
69
+ { value: 'expired', label: t('billing.topups.status.expired', 'Expired') },
70
+ ],
71
+ },
72
+ ],
73
+ [t],
74
+ )
75
+
76
+ const loadRows = React.useCallback(async () => {
77
+ setLoading(true)
78
+ try {
79
+ const params = new URLSearchParams({
80
+ page: String(page),
81
+ pageSize: String(pageSize),
82
+ sortField: 'createdAt',
83
+ sortDir: 'desc',
84
+ })
85
+ if (search.trim()) params.set('search', search.trim())
86
+ if (typeof filters.status === 'string' && filters.status) params.set('status', filters.status)
87
+ const result = await readApiResultOrThrow<ListResponse>(
88
+ `/api/billing/topups?${params.toString()}`,
89
+ )
90
+ const items = result.items ?? []
91
+ setRows(items)
92
+ setTotal(result.total ?? 0)
93
+ setTotalPages(result.totalPages ?? 1)
94
+ try {
95
+ const names = await fetchAccountNames(items.map((r) => r.bill_account_id))
96
+ setAccountNames((prev) => ({ ...prev, ...names }))
97
+ } catch {
98
+ // Name resolution is best-effort — fall back to the id in the cell.
99
+ }
100
+ } finally {
101
+ setLoading(false)
102
+ }
103
+ }, [filters.status, page, pageSize, search])
104
+
105
+ React.useEffect(() => {
106
+ void loadRows()
107
+ }, [loadRows])
108
+
109
+ const columns = React.useMemo<ColumnDef<TopupRow>[]>(
110
+ () => [
111
+ {
112
+ accessorKey: 'created_at',
113
+ header: t('billing.topups.columns.created', 'Created'),
114
+ cell: ({ row }) => formatDate(row.original.created_at),
115
+ },
116
+ {
117
+ accessorKey: 'amount',
118
+ header: t('billing.topups.columns.amount', 'Amount'),
119
+ cell: ({ row }) => (
120
+ <span className="font-mono tabular-nums">
121
+ {row.original.amount} {row.original.currency_code}
122
+ </span>
123
+ ),
124
+ },
125
+ {
126
+ accessorKey: 'provider_key',
127
+ header: t('billing.topups.columns.provider', 'Provider'),
128
+ cell: ({ row }) => <span className="font-mono text-xs">{row.original.provider_key}</span>,
129
+ },
130
+ {
131
+ accessorKey: 'status',
132
+ header: t('billing.topups.columns.status', 'Status'),
133
+ cell: ({ row }) => (
134
+ <Tag variant={statusVariant(row.original.status)}>
135
+ {t(`billing.topups.status.${row.original.status}`, row.original.status)}
136
+ </Tag>
137
+ ),
138
+ },
139
+ {
140
+ accessorKey: 'bill_account_id',
141
+ header: t('billing.topups.columns.account', 'Account'),
142
+ cell: ({ row }) => {
143
+ const name = accountNames[row.original.bill_account_id]
144
+ return name ? (
145
+ <span className="text-sm">{name}</span>
146
+ ) : (
147
+ <span className="font-mono text-xs">{row.original.bill_account_id}</span>
148
+ )
149
+ },
150
+ },
151
+ ],
152
+ [t, accountNames],
153
+ )
154
+
155
+ return (
156
+ <Page>
157
+ <PageBody>
158
+ <DataTable
159
+ title={t('billing.topups.title', 'Top-ups')}
160
+ refreshButton={{
161
+ label: t('billing.common.refresh', 'Refresh'),
162
+ onRefresh: loadRows,
163
+ isRefreshing: loading,
164
+ }}
165
+ columnChooser={{ auto: true }}
166
+ perspective={{ tableId: 'billing-topups' }}
167
+ columns={columns}
168
+ data={rows}
169
+ onRowClick={(row) => router.push(`/backend/billing/topups/${row.id}`)}
170
+ isLoading={loading}
171
+ pagination={{ page, pageSize, total, totalPages, onPageChange: setPage }}
172
+ filters={filterDefs}
173
+ filterValues={filters}
174
+ onFiltersApply={setFilters}
175
+ onFiltersClear={() => setFilters({})}
176
+ searchValue={search}
177
+ onSearchChange={setSearch}
178
+ />
179
+ </PageBody>
180
+ </Page>
181
+ )
182
+ }
@@ -0,0 +1,19 @@
1
+ import React from 'react'
2
+ import { ArrowLeftRight } from 'lucide-react'
3
+
4
+ const transactionIcon = React.createElement(ArrowLeftRight, { size: 16 })
5
+
6
+ export const metadata = {
7
+ requireAuth: true,
8
+ requireFeatures: ['billing.balance.view'],
9
+ pageTitle: 'Transaction',
10
+ pageTitleKey: 'billing.transactions.detail.title',
11
+ pageGroup: 'Billing',
12
+ pageGroupKey: 'billing.nav.group',
13
+ navHidden: true,
14
+ icon: transactionIcon,
15
+ breadcrumb: [
16
+ { label: 'Transactions', labelKey: 'billing.transactions.title', href: '/backend/billing/transactions' },
17
+ { label: 'Transaction', labelKey: 'billing.transactions.detail.title' },
18
+ ],
19
+ } as const
@@ -0,0 +1,179 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import Link from 'next/link'
4
+ import { useParams } from 'next/navigation'
5
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
6
+ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
7
+ import { FormHeader } from '@open-mercato/ui/backend/forms'
8
+ import { Tag } from '@open-mercato/ui/primitives/tag'
9
+ import { Button } from '@open-mercato/ui/primitives/button'
10
+ import { LoadingMessage, ErrorMessage } from '@open-mercato/ui/backend/detail'
11
+ import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
12
+ import { DetailCard, DetailField } from '../../../../components/DetailFields'
13
+ import { txnVariant } from '../page'
14
+
15
+ type TransactionRow = {
16
+ id: string
17
+ bill_account_id: string
18
+ type: string
19
+ amount: string
20
+ currency_code: string
21
+ balance_after: string
22
+ usage_id: string | null
23
+ billing_item_id: string | null
24
+ topup_id: string | null
25
+ statement_id: string | null
26
+ description: string
27
+ source_ref: string | null
28
+ user_id: string | null
29
+ created_at: string
30
+ }
31
+
32
+ function formatDate(value: string | null | undefined): string {
33
+ if (!value) return '—'
34
+ const d = new Date(value)
35
+ return Number.isNaN(d.getTime()) ? value : d.toLocaleString()
36
+ }
37
+
38
+ // A monospace value that links somewhere when present, else an em dash.
39
+ function RefField({ id, href }: { id: string | null; href?: string }) {
40
+ if (!id) return <>—</>
41
+ if (href) {
42
+ return (
43
+ <Link href={href} className="font-mono text-xs hover:underline">
44
+ {id}
45
+ </Link>
46
+ )
47
+ }
48
+ return <span className="font-mono text-xs">{id}</span>
49
+ }
50
+
51
+ export default function BillingTransactionDetailPage(props: { params?: { id?: string } }) {
52
+ const t = useT()
53
+ const urlParams = useParams<{ id: string }>()
54
+ const transactionId = (props.params?.id ?? urlParams?.id ?? '') as string
55
+
56
+ const [row, setRow] = React.useState<TransactionRow | null>(null)
57
+ const [loading, setLoading] = React.useState(true)
58
+ const [error, setError] = React.useState<string | null>(null)
59
+
60
+ React.useEffect(() => {
61
+ let cancelled = false
62
+ if (!transactionId) return
63
+ setLoading(true)
64
+ readApiResultOrThrow<{ items: TransactionRow[] }>(
65
+ `/api/billing/transactions?id=${transactionId}&pageSize=1`,
66
+ )
67
+ .then((res) => {
68
+ if (cancelled) return
69
+ const found = res.items?.[0]
70
+ if (!found) setError(t('billing.transactions.detail.not_found', 'Transaction not found'))
71
+ else setRow(found)
72
+ })
73
+ .catch(() => {
74
+ if (!cancelled)
75
+ setError(t('billing.transactions.detail.load_error', 'Failed to load transaction'))
76
+ })
77
+ .finally(() => {
78
+ if (!cancelled) setLoading(false)
79
+ })
80
+ return () => {
81
+ cancelled = true
82
+ }
83
+ }, [transactionId, t])
84
+
85
+ if (error) {
86
+ return (
87
+ <Page>
88
+ <PageBody>
89
+ <ErrorMessage label={error} />
90
+ </PageBody>
91
+ </Page>
92
+ )
93
+ }
94
+ if (loading || !row) {
95
+ return (
96
+ <Page>
97
+ <PageBody>
98
+ <LoadingMessage label={t('billing.transactions.detail.loading', 'Loading transaction…')} />
99
+ </PageBody>
100
+ </Page>
101
+ )
102
+ }
103
+
104
+ return (
105
+ <Page>
106
+ <FormHeader
107
+ mode="detail"
108
+ backHref="/backend/billing/transactions"
109
+ entityTypeLabel={t('billing.transactions.detail.title', 'Transaction')}
110
+ title={`${row.amount} ${row.currency_code}`}
111
+ statusBadge={
112
+ <Tag variant={txnVariant(row.type)}>
113
+ {t(`billing.transactions.type.${row.type}`, row.type)}
114
+ </Tag>
115
+ }
116
+ actionsContent={
117
+ <Button asChild variant="outline">
118
+ <Link href={`/backend/billing/accounts/${row.bill_account_id}`}>
119
+ {t('billing.transactions.detail.view_account', 'View account')}
120
+ </Link>
121
+ </Button>
122
+ }
123
+ />
124
+ <PageBody>
125
+ <DetailCard>
126
+ <DetailField label={t('billing.transactions.columns.amount', 'Amount')}>
127
+ <span className="font-mono tabular-nums">
128
+ {row.amount} {row.currency_code}
129
+ </span>
130
+ </DetailField>
131
+ <DetailField label={t('billing.transactions.columns.balance_after', 'Balance')}>
132
+ <span className="font-mono tabular-nums">{row.balance_after}</span>
133
+ </DetailField>
134
+ <DetailField label={t('billing.transactions.columns.type', 'Type')}>
135
+ <Tag variant={txnVariant(row.type)}>
136
+ {t(`billing.transactions.type.${row.type}`, row.type)}
137
+ </Tag>
138
+ </DetailField>
139
+ <DetailField label={t('billing.transactions.columns.when', 'When')}>
140
+ {formatDate(row.created_at)}
141
+ </DetailField>
142
+ <DetailField label={t('billing.transactions.columns.description', 'Description')} fullWidth>
143
+ {row.description || '—'}
144
+ </DetailField>
145
+ <DetailField label={t('billing.transactions.detail.source_ref', 'Source ref')}>
146
+ <span className="font-mono text-xs">{row.source_ref || '—'}</span>
147
+ </DetailField>
148
+ <DetailField label={t('billing.transactions.detail.topup', 'Top-up')}>
149
+ <RefField
150
+ id={row.topup_id}
151
+ href={row.topup_id ? `/backend/billing/topups/${row.topup_id}` : undefined}
152
+ />
153
+ </DetailField>
154
+ <DetailField label={t('billing.transactions.detail.statement', 'Statement')}>
155
+ <RefField
156
+ id={row.statement_id}
157
+ href={row.statement_id ? `/backend/billing/statements/${row.statement_id}` : undefined}
158
+ />
159
+ </DetailField>
160
+ <DetailField label={t('billing.transactions.detail.usage', 'Usage record')}>
161
+ <RefField id={row.usage_id} />
162
+ </DetailField>
163
+ <DetailField label={t('billing.transactions.detail.item', 'Billing item')}>
164
+ <RefField
165
+ id={row.billing_item_id}
166
+ href={row.billing_item_id ? `/backend/billing/items/${row.billing_item_id}` : undefined}
167
+ />
168
+ </DetailField>
169
+ <DetailField label={t('billing.transactions.detail.user', 'User')}>
170
+ <RefField id={row.user_id} />
171
+ </DetailField>
172
+ <DetailField label={t('billing.common.id', 'ID')}>
173
+ <span className="font-mono text-xs">{row.id}</span>
174
+ </DetailField>
175
+ </DetailCard>
176
+ </PageBody>
177
+ </Page>
178
+ )
179
+ }
@@ -0,0 +1,16 @@
1
+ import React from 'react'
2
+ import { ArrowLeftRight } from 'lucide-react'
3
+
4
+ const transactionsIcon = React.createElement(ArrowLeftRight, { size: 16 })
5
+
6
+ export const metadata = {
7
+ requireAuth: true,
8
+ requireFeatures: ['billing.balance.view'],
9
+ pageTitle: 'Transactions',
10
+ pageTitleKey: 'billing.transactions.title',
11
+ pageGroup: 'Billing',
12
+ pageGroupKey: 'billing.nav.group',
13
+ pageOrder: 70,
14
+ icon: transactionsIcon,
15
+ breadcrumb: [{ label: 'Transactions', labelKey: 'billing.transactions.title' }],
16
+ } as const
@@ -0,0 +1,187 @@
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
+ import { fetchAccountNames } from '../../../components/accountNames'
12
+
13
+ /**
14
+ * Balance transactions admin list (SPEC-002). Read-only — the append-only
15
+ * ledger is written by the command bus (real-time consume, top-up capture,
16
+ * period close, manual adjustment). Each row links to its detail page so an
17
+ * operator can trace a movement back to its source document.
18
+ */
19
+
20
+ type TransactionRow = {
21
+ id: string
22
+ bill_account_id: string
23
+ type: string
24
+ amount: string
25
+ currency_code: string
26
+ balance_after: string
27
+ description: string
28
+ created_at: string
29
+ }
30
+
31
+ type ListResponse = { items: TransactionRow[]; total: number; totalPages: number }
32
+
33
+ function formatDate(value: string | null | undefined): string {
34
+ if (!value) return '—'
35
+ const d = new Date(value)
36
+ return Number.isNaN(d.getTime()) ? value : d.toLocaleString()
37
+ }
38
+
39
+ export function txnVariant(type: string): 'success' | 'info' | 'error' | 'neutral' | 'default' {
40
+ if (type === 'topup' || type === 'adjustment') return 'success'
41
+ if (type === 'usage') return 'info'
42
+ if (type === 'reversal') return 'error'
43
+ return 'neutral' // recurring / one_time
44
+ }
45
+
46
+ export default function BillingTransactionsListPage() {
47
+ const t = useT()
48
+ const router = useRouter()
49
+ const [rows, setRows] = React.useState<TransactionRow[]>([])
50
+ const [page, setPage] = React.useState(1)
51
+ const [pageSize] = React.useState(25)
52
+ const [filters, setFilters] = React.useState<FilterValues>({})
53
+ const [total, setTotal] = React.useState(0)
54
+ const [totalPages, setTotalPages] = React.useState(1)
55
+ const [loading, setLoading] = React.useState(true)
56
+ const [accountNames, setAccountNames] = React.useState<Record<string, string>>({})
57
+
58
+ const filterDefs = React.useMemo<FilterDef[]>(
59
+ () => [
60
+ {
61
+ id: 'type',
62
+ label: t('billing.transactions.filters.type', 'Type'),
63
+ type: 'select',
64
+ options: [
65
+ { value: 'topup', label: t('billing.transactions.type.topup', 'Top-up') },
66
+ { value: 'usage', label: t('billing.transactions.type.usage', 'Usage') },
67
+ { value: 'recurring', label: t('billing.transactions.type.recurring', 'Recurring') },
68
+ { value: 'one_time', label: t('billing.transactions.type.one_time', 'One-time') },
69
+ { value: 'adjustment', label: t('billing.transactions.type.adjustment', 'Adjustment') },
70
+ { value: 'reversal', label: t('billing.transactions.type.reversal', 'Reversal') },
71
+ ],
72
+ },
73
+ ],
74
+ [t],
75
+ )
76
+
77
+ const loadRows = React.useCallback(async () => {
78
+ setLoading(true)
79
+ try {
80
+ const params = new URLSearchParams({
81
+ page: String(page),
82
+ pageSize: String(pageSize),
83
+ sortField: 'createdAt',
84
+ sortDir: 'desc',
85
+ })
86
+ if (typeof filters.type === 'string' && filters.type) params.set('type', filters.type)
87
+ const result = await readApiResultOrThrow<ListResponse>(
88
+ `/api/billing/transactions?${params.toString()}`,
89
+ )
90
+ const items = result.items ?? []
91
+ setRows(items)
92
+ setTotal(result.total ?? 0)
93
+ setTotalPages(result.totalPages ?? 1)
94
+ try {
95
+ const names = await fetchAccountNames(items.map((r) => r.bill_account_id))
96
+ setAccountNames((prev) => ({ ...prev, ...names }))
97
+ } catch {
98
+ // Name resolution is best-effort — fall back to the id in the cell.
99
+ }
100
+ } finally {
101
+ setLoading(false)
102
+ }
103
+ }, [filters.type, page, pageSize])
104
+
105
+ React.useEffect(() => {
106
+ void loadRows()
107
+ }, [loadRows])
108
+
109
+ const columns = React.useMemo<ColumnDef<TransactionRow>[]>(
110
+ () => [
111
+ {
112
+ accessorKey: 'created_at',
113
+ header: t('billing.transactions.columns.when', 'When'),
114
+ cell: ({ row }) => formatDate(row.original.created_at),
115
+ },
116
+ {
117
+ accessorKey: 'type',
118
+ header: t('billing.transactions.columns.type', 'Type'),
119
+ cell: ({ row }) => (
120
+ <Tag variant={txnVariant(row.original.type)}>
121
+ {t(`billing.transactions.type.${row.original.type}`, row.original.type)}
122
+ </Tag>
123
+ ),
124
+ },
125
+ {
126
+ accessorKey: 'description',
127
+ header: t('billing.transactions.columns.description', 'Description'),
128
+ cell: ({ row }) => <span className="text-sm">{row.original.description}</span>,
129
+ },
130
+ {
131
+ accessorKey: 'amount',
132
+ header: t('billing.transactions.columns.amount', 'Amount'),
133
+ cell: ({ row }) => (
134
+ <span className="font-mono tabular-nums">
135
+ {row.original.amount} {row.original.currency_code}
136
+ </span>
137
+ ),
138
+ },
139
+ {
140
+ accessorKey: 'balance_after',
141
+ header: t('billing.transactions.columns.balance_after', 'Balance'),
142
+ cell: ({ row }) => (
143
+ <span className="font-mono tabular-nums">{row.original.balance_after}</span>
144
+ ),
145
+ },
146
+ {
147
+ accessorKey: 'bill_account_id',
148
+ header: t('billing.transactions.columns.account', 'Account'),
149
+ cell: ({ row }) => {
150
+ const name = accountNames[row.original.bill_account_id]
151
+ return name ? (
152
+ <span className="text-sm">{name}</span>
153
+ ) : (
154
+ <span className="font-mono text-xs">{row.original.bill_account_id}</span>
155
+ )
156
+ },
157
+ },
158
+ ],
159
+ [t, accountNames],
160
+ )
161
+
162
+ return (
163
+ <Page>
164
+ <PageBody>
165
+ <DataTable
166
+ title={t('billing.transactions.title', 'Transactions')}
167
+ refreshButton={{
168
+ label: t('billing.common.refresh', 'Refresh'),
169
+ onRefresh: loadRows,
170
+ isRefreshing: loading,
171
+ }}
172
+ columnChooser={{ auto: true }}
173
+ perspective={{ tableId: 'billing-transactions' }}
174
+ columns={columns}
175
+ data={rows}
176
+ onRowClick={(row) => router.push(`/backend/billing/transactions/${row.id}`)}
177
+ isLoading={loading}
178
+ pagination={{ page, pageSize, total, totalPages, onPageChange: setPage }}
179
+ filters={filterDefs}
180
+ filterValues={filters}
181
+ onFiltersApply={setFilters}
182
+ onFiltersClear={() => setFilters({})}
183
+ />
184
+ </PageBody>
185
+ </Page>
186
+ )
187
+ }
@@ -11,7 +11,7 @@ import {
11
11
  } from '@open-mercato/shared/lib/commands/helpers'
12
12
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
13
13
  import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
14
- import { BillingAccount } from '../data/entities'
14
+ import { BillingAccount, BillingAccountBalance } from '../data/entities'
15
15
  import { billingEntityIds } from '../data/entityIds'
16
16
  import {
17
17
  billingAccountCreateSchema,
@@ -21,6 +21,8 @@ import {
21
21
  type BillingAccountDeleteInput,
22
22
  type BillingAccountUpdateInput,
23
23
  } from '../data/validators'
24
+ import { ensureBalanceRow } from '../lib/balanceLedger'
25
+ import { formatMoney } from '../lib/money'
24
26
 
25
27
  /**
26
28
  * BillingAccount writes — create / update / soft-delete.
@@ -59,6 +61,7 @@ const createAccountCommand: CommandHandler<BillingAccountCreateInput, { id: stri
59
61
 
60
62
  const em = getEm(ctx)
61
63
  const now = new Date()
64
+ const billingMode = parsed.billingMode ?? 'postpaid'
62
65
  const entity = em.create(BillingAccount, {
63
66
  organizationId: parsed.organizationId,
64
67
  tenantId: parsed.tenantId,
@@ -73,6 +76,9 @@ const createAccountCommand: CommandHandler<BillingAccountCreateInput, { id: stri
73
76
  invoiceAddress: parsed.invoiceAddress as Record<string, unknown>,
74
77
  nextBillDate: parsed.nextBillDate,
75
78
  lastBillDate: parsed.lastBillDate ?? null,
79
+ billingMode,
80
+ creditLimit:
81
+ parsed.creditLimit !== undefined ? formatMoney(parsed.creditLimit) : '0',
76
82
  isActive: parsed.isActive ?? true,
77
83
  createdAt: now,
78
84
  updatedAt: now,
@@ -80,6 +86,21 @@ const createAccountCommand: CommandHandler<BillingAccountCreateInput, { id: stri
80
86
  em.persist(entity)
81
87
  await em.flush()
82
88
 
89
+ // SPEC-002: a prepaid account gets its 1:1 balance row (starting at 0)
90
+ // at create time, so the consume path never races a missing row.
91
+ if (billingMode === 'prepaid') {
92
+ await ensureBalanceRow(em, {
93
+ organizationId: entity.organizationId,
94
+ tenantId: entity.tenantId,
95
+ billAccountId: entity.id,
96
+ currencyCode: entity.currencyCode,
97
+ lowBalanceThreshold:
98
+ parsed.lowBalanceThreshold !== undefined && parsed.lowBalanceThreshold !== null
99
+ ? formatMoney(parsed.lowBalanceThreshold)
100
+ : null,
101
+ })
102
+ }
103
+
83
104
  await emitCrudSideEffects({
84
105
  dataEngine: getDataEngine(ctx),
85
106
  action: 'created',
@@ -130,8 +151,28 @@ const updateAccountCommand: CommandHandler<BillingAccountUpdateInput, { id: stri
130
151
  if (parsed.nextBillDate !== undefined) record.nextBillDate = parsed.nextBillDate
131
152
  if (parsed.lastBillDate !== undefined) record.lastBillDate = parsed.lastBillDate
132
153
  if (parsed.isActive !== undefined) record.isActive = parsed.isActive
154
+ // SPEC-002: credit_limit is editable in both modes (billing_mode is not).
155
+ if (parsed.creditLimit !== undefined) record.creditLimit = formatMoney(parsed.creditLimit)
133
156
 
134
157
  record.updatedAt = new Date()
158
+
159
+ // SPEC-002: edit the prepaid balance row's low-balance threshold when
160
+ // supplied (null clears it back to the tenant config default). No-op for
161
+ // postpaid accounts (they have no balance row).
162
+ if (parsed.lowBalanceThreshold !== undefined && record.billingMode === 'prepaid') {
163
+ const balanceRow = await em.findOne(BillingAccountBalance, {
164
+ tenantId: parsed.tenantId,
165
+ billAccountId: record.id,
166
+ })
167
+ if (balanceRow) {
168
+ balanceRow.lowBalanceThreshold =
169
+ parsed.lowBalanceThreshold === null
170
+ ? null
171
+ : formatMoney(parsed.lowBalanceThreshold)
172
+ balanceRow.updatedAt = new Date()
173
+ }
174
+ }
175
+
135
176
  await em.flush()
136
177
 
137
178
  await emitCrudSideEffects({
@@ -11,3 +11,6 @@ import './items'
11
11
  import './usage'
12
12
  import './runs'
13
13
  import './invoices'
14
+ import './topups'
15
+ import './transactions'
16
+ import './statements'