@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
@@ -53,6 +53,11 @@ export type AccountFormValues = {
53
53
  }
54
54
  nextBillDate: string
55
55
  isActive: boolean
56
+ // SPEC-002 — immutable after create (like currencyCode).
57
+ billingMode: 'postpaid' | 'prepaid'
58
+ creditLimit: string
59
+ // Prepaid-only initial low-balance threshold for the balance row.
60
+ lowBalanceThreshold: string
56
61
  }
57
62
 
58
63
  export type AccountFormProps = {
@@ -81,6 +86,9 @@ const EMPTY_VALUES: AccountFormValues = {
81
86
  },
82
87
  nextBillDate: '',
83
88
  isActive: true,
89
+ billingMode: 'postpaid',
90
+ creditLimit: '0',
91
+ lowBalanceThreshold: '',
84
92
  }
85
93
 
86
94
  function todayIso(): string {
@@ -471,6 +479,64 @@ export function AccountForm({
471
479
  </label>
472
480
  </div>
473
481
 
482
+ <h3 className="text-sm font-semibold mt-6 mb-2">
483
+ {t('billing.accounts.form.mode.title', 'Billing mode & credit')}
484
+ </h3>
485
+ <div className="grid grid-cols-2 gap-4">
486
+ <label className="flex flex-col gap-1 text-sm">
487
+ <span className="text-muted-foreground">
488
+ {t('billing.accounts.columns.mode', 'Mode')}
489
+ </span>
490
+ <Select
491
+ value={values.billingMode}
492
+ disabled={isEdit}
493
+ onValueChange={(next) => set('billingMode', next as AccountFormValues['billingMode'])}
494
+ >
495
+ <SelectTrigger>
496
+ <SelectValue />
497
+ </SelectTrigger>
498
+ <SelectContent>
499
+ <SelectItem value="postpaid">{t('billing.mode.postpaid', 'Postpaid')}</SelectItem>
500
+ <SelectItem value="prepaid">{t('billing.mode.prepaid', 'Prepaid')}</SelectItem>
501
+ </SelectContent>
502
+ </Select>
503
+ {isEdit ? (
504
+ <span className="text-xs text-muted-foreground">
505
+ {t('billing.accounts.form.mode.locked', 'Immutable — frozen at account create time.')}
506
+ </span>
507
+ ) : null}
508
+ </label>
509
+
510
+ <label className="flex flex-col gap-1 text-sm">
511
+ <span className="text-muted-foreground">
512
+ {t('billing.accounts.columns.credit_limit', 'Credit limit')}
513
+ </span>
514
+ <Input
515
+ type="number"
516
+ min="0"
517
+ step="0.01"
518
+ value={values.creditLimit}
519
+ onChange={(event) => set('creditLimit', event.currentTarget.value)}
520
+ />
521
+ </label>
522
+
523
+ {values.billingMode === 'prepaid' ? (
524
+ <label className="flex flex-col gap-1 text-sm">
525
+ <span className="text-muted-foreground">
526
+ {t('billing.prepaid.panel.threshold', 'Low threshold')}
527
+ </span>
528
+ <Input
529
+ type="number"
530
+ min="0"
531
+ step="0.01"
532
+ value={values.lowBalanceThreshold}
533
+ placeholder={t('billing.accounts.form.threshold.hint', 'Blank = tenant default')}
534
+ onChange={(event) => set('lowBalanceThreshold', event.currentTarget.value)}
535
+ />
536
+ </label>
537
+ ) : null}
538
+ </div>
539
+
474
540
  {isEdit ? (
475
541
  <div className="mt-6 flex items-center justify-between gap-3">
476
542
  <div>
@@ -0,0 +1,382 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import { useRouter } from 'next/navigation'
4
+ import { Plus, SlidersHorizontal } from 'lucide-react'
5
+ import type { ColumnDef } from '@tanstack/react-table'
6
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
7
+ import { DataTable } from '@open-mercato/ui/backend/DataTable'
8
+ import { Tag } from '@open-mercato/ui/primitives/tag'
9
+ import { Button } from '@open-mercato/ui/primitives/button'
10
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
11
+ import { apiCallOrThrow, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
12
+ import { normalizeCrudServerError } from '@open-mercato/ui/backend/utils/serverErrors'
13
+ import { StatCard, Stat } from './DetailFields'
14
+
15
+ /**
16
+ * Prepaid panel (SPEC-002 P4) injected into the Billing Account detail page
17
+ * for `billing_mode='prepaid'` accounts: balance + status, credit status,
18
+ * a top-up launcher, and the recent balance transactions.
19
+ */
20
+
21
+ type BalanceSnapshot = {
22
+ balance: string
23
+ balanceStatus: 'ok' | 'low' | 'exhausted'
24
+ lowBalanceThreshold: string
25
+ lastMovementAt: string | null
26
+ }
27
+
28
+ type CreditSnapshot = {
29
+ mode: string
30
+ creditLimit: string
31
+ creditUsed: string
32
+ availableCredit: string
33
+ creditStatus: 'within_limit' | 'near_limit' | 'over_limit'
34
+ }
35
+
36
+ type TransactionRow = {
37
+ id: string
38
+ type: string
39
+ amount: string
40
+ balance_after: string
41
+ description: string
42
+ created_at: string
43
+ }
44
+
45
+ function balanceVariant(status: string): 'success' | 'warning' | 'error' {
46
+ if (status === 'low') return 'warning'
47
+ if (status === 'exhausted') return 'error'
48
+ return 'success'
49
+ }
50
+
51
+ function creditVariant(status: string): 'success' | 'warning' | 'error' {
52
+ if (status === 'near_limit') return 'warning'
53
+ if (status === 'over_limit') return 'error'
54
+ return 'success'
55
+ }
56
+
57
+ function txnVariant(type: string): 'success' | 'info' | 'neutral' {
58
+ if (type === 'topup' || type === 'adjustment') return 'success'
59
+ if (type === 'usage') return 'info'
60
+ return 'neutral'
61
+ }
62
+
63
+ function formatDateTime(value: string | null | undefined): string {
64
+ if (!value) return '—'
65
+ const d = new Date(value)
66
+ return Number.isNaN(d.getTime()) ? value : d.toLocaleString()
67
+ }
68
+
69
+ export function PrepaidPanel({
70
+ accountId,
71
+ currencyCode,
72
+ }: {
73
+ accountId: string
74
+ currencyCode: string
75
+ }) {
76
+ const t = useT()
77
+ const router = useRouter()
78
+ const [balance, setBalance] = React.useState<BalanceSnapshot | null>(null)
79
+ const [credit, setCredit] = React.useState<CreditSnapshot | null>(null)
80
+ const [rows, setRows] = React.useState<TransactionRow[]>([])
81
+ const [page, setPage] = React.useState(1)
82
+ const [total, setTotal] = React.useState(0)
83
+ const [totalPages, setTotalPages] = React.useState(1)
84
+ const [loading, setLoading] = React.useState(true)
85
+ const [topupOpen, setTopupOpen] = React.useState(false)
86
+ const [amount, setAmount] = React.useState('')
87
+ const [submitting, setSubmitting] = React.useState(false)
88
+ const [adjustOpen, setAdjustOpen] = React.useState(false)
89
+ const [adjAmount, setAdjAmount] = React.useState('')
90
+ const [adjReason, setAdjReason] = React.useState('')
91
+ const [adjSubmitting, setAdjSubmitting] = React.useState(false)
92
+ const pageSize = 10
93
+
94
+ const loadStatus = React.useCallback(async () => {
95
+ const [bal, cred] = await Promise.all([
96
+ readApiResultOrThrow<BalanceSnapshot>(`/api/billing/accounts/${accountId}/balance`),
97
+ readApiResultOrThrow<CreditSnapshot>(`/api/billing/accounts/${accountId}/credit-status`),
98
+ ])
99
+ setBalance(bal)
100
+ setCredit(cred)
101
+ }, [accountId])
102
+
103
+ const loadTransactions = React.useCallback(async () => {
104
+ setLoading(true)
105
+ try {
106
+ const result = await readApiResultOrThrow<{
107
+ items: TransactionRow[]
108
+ total: number
109
+ totalPages: number
110
+ }>(
111
+ `/api/billing/transactions?billAccountId=${accountId}&page=${page}` +
112
+ `&pageSize=${pageSize}&sortField=createdAt&sortDir=desc`,
113
+ )
114
+ setRows(result.items ?? [])
115
+ setTotal(result.total ?? 0)
116
+ setTotalPages(result.totalPages ?? 1)
117
+ } finally {
118
+ setLoading(false)
119
+ }
120
+ }, [accountId, page])
121
+
122
+ React.useEffect(() => {
123
+ void loadStatus().catch(() => undefined)
124
+ }, [loadStatus])
125
+ React.useEffect(() => {
126
+ void loadTransactions()
127
+ }, [loadTransactions])
128
+
129
+ const handleTopup = React.useCallback(async () => {
130
+ const value = Number.parseFloat(amount)
131
+ if (!Number.isFinite(value) || value <= 0) {
132
+ flash(t('billing.prepaid.topup.invalid', 'Enter a positive amount'), 'error')
133
+ return
134
+ }
135
+ setSubmitting(true)
136
+ try {
137
+ const res = await apiCallOrThrow<{ redirectUrl?: string | null }>(
138
+ `/api/billing/accounts/${accountId}/topups`,
139
+ {
140
+ method: 'POST',
141
+ headers: { 'Content-Type': 'application/json' },
142
+ body: JSON.stringify({
143
+ amount: value,
144
+ successUrl: typeof window !== 'undefined' ? window.location.href : undefined,
145
+ cancelUrl: typeof window !== 'undefined' ? window.location.href : undefined,
146
+ }),
147
+ },
148
+ )
149
+ const redirectUrl = res.result?.redirectUrl
150
+ if (redirectUrl && typeof window !== 'undefined') {
151
+ window.location.href = redirectUrl
152
+ return
153
+ }
154
+ flash(t('billing.prepaid.topup.created', 'Top-up session created'), 'success')
155
+ setTopupOpen(false)
156
+ setAmount('')
157
+ void loadStatus().catch(() => undefined)
158
+ void loadTransactions()
159
+ } catch (err) {
160
+ const { message } = normalizeCrudServerError(err)
161
+ flash(message || t('billing.prepaid.topup.error', 'Top-up failed'), 'error')
162
+ } finally {
163
+ setSubmitting(false)
164
+ }
165
+ }, [accountId, amount, loadStatus, loadTransactions, t])
166
+
167
+ const handleAdjust = React.useCallback(async () => {
168
+ const value = Number.parseFloat(adjAmount)
169
+ if (!Number.isFinite(value) || value === 0) {
170
+ flash(t('billing.prepaid.adjust.invalid_amount', 'Enter a non-zero amount'), 'error')
171
+ return
172
+ }
173
+ if (!adjReason.trim()) {
174
+ flash(t('billing.prepaid.adjust.invalid_description', 'Enter a reason'), 'error')
175
+ return
176
+ }
177
+ setAdjSubmitting(true)
178
+ try {
179
+ await apiCallOrThrow('/api/billing/transactions/adjust', {
180
+ method: 'POST',
181
+ headers: { 'Content-Type': 'application/json' },
182
+ body: JSON.stringify({
183
+ billAccountId: accountId,
184
+ amount: value,
185
+ description: adjReason.trim(),
186
+ }),
187
+ })
188
+ flash(t('billing.prepaid.adjust.success', 'Balance adjusted'), 'success')
189
+ setAdjustOpen(false)
190
+ setAdjAmount('')
191
+ setAdjReason('')
192
+ void loadStatus().catch(() => undefined)
193
+ void loadTransactions()
194
+ } catch (err) {
195
+ const { message } = normalizeCrudServerError(err)
196
+ flash(message || t('billing.prepaid.adjust.error', 'Adjustment failed'), 'error')
197
+ } finally {
198
+ setAdjSubmitting(false)
199
+ }
200
+ }, [accountId, adjAmount, adjReason, loadStatus, loadTransactions, t])
201
+
202
+ const columns = React.useMemo<ColumnDef<TransactionRow>[]>(
203
+ () => [
204
+ {
205
+ accessorKey: 'created_at',
206
+ header: t('billing.transactions.columns.when', 'When'),
207
+ cell: ({ row }) => formatDateTime(row.original.created_at),
208
+ },
209
+ {
210
+ accessorKey: 'type',
211
+ header: t('billing.transactions.columns.type', 'Type'),
212
+ cell: ({ row }) => (
213
+ <Tag variant={txnVariant(row.original.type)}>
214
+ {t(`billing.transactions.type.${row.original.type}`, row.original.type)}
215
+ </Tag>
216
+ ),
217
+ },
218
+ {
219
+ accessorKey: 'description',
220
+ header: t('billing.transactions.columns.description', 'Description'),
221
+ cell: ({ row }) => <span className="text-sm">{row.original.description}</span>,
222
+ },
223
+ {
224
+ accessorKey: 'amount',
225
+ header: t('billing.transactions.columns.amount', 'Amount'),
226
+ cell: ({ row }) => (
227
+ <span className="font-mono tabular-nums">{row.original.amount}</span>
228
+ ),
229
+ },
230
+ {
231
+ accessorKey: 'balance_after',
232
+ header: t('billing.transactions.columns.balance_after', 'Balance'),
233
+ cell: ({ row }) => (
234
+ <span className="font-mono tabular-nums">{row.original.balance_after}</span>
235
+ ),
236
+ },
237
+ ],
238
+ [t],
239
+ )
240
+
241
+ return (
242
+ <div className="mt-6 space-y-4">
243
+ <StatCard title={t('billing.prepaid.panel.title', 'Prepaid balance')}>
244
+ <Stat
245
+ label={t('billing.prepaid.panel.balance', 'Balance')}
246
+ value={
247
+ <span className="flex items-center gap-2">
248
+ <span className="font-mono tabular-nums">
249
+ {balance?.balance ?? '—'} {currencyCode}
250
+ </span>
251
+ {balance ? (
252
+ <Tag variant={balanceVariant(balance.balanceStatus)}>
253
+ {t(`billing.prepaid.status.${balance.balanceStatus}`, balance.balanceStatus)}
254
+ </Tag>
255
+ ) : null}
256
+ </span>
257
+ }
258
+ />
259
+ <Stat
260
+ label={t('billing.prepaid.panel.threshold', 'Low threshold')}
261
+ value={
262
+ <span className="font-mono tabular-nums">{balance?.lowBalanceThreshold ?? '—'}</span>
263
+ }
264
+ />
265
+ {credit ? (
266
+ <Stat
267
+ label={t('billing.prepaid.panel.credit', 'Credit')}
268
+ value={
269
+ <span className="flex items-center gap-2">
270
+ <span className="font-mono tabular-nums">
271
+ {credit.creditUsed} / {credit.creditLimit}
272
+ </span>
273
+ <Tag variant={creditVariant(credit.creditStatus)}>
274
+ {t(`billing.prepaid.credit.${credit.creditStatus}`, credit.creditStatus)}
275
+ </Tag>
276
+ </span>
277
+ }
278
+ />
279
+ ) : null}
280
+ <Stat
281
+ label={t('billing.prepaid.panel.last_movement', 'Last movement')}
282
+ value={formatDateTime(balance?.lastMovementAt)}
283
+ />
284
+ </StatCard>
285
+
286
+ <div className="flex flex-wrap items-center gap-2">
287
+ {!topupOpen && !adjustOpen ? (
288
+ <>
289
+ <Button type="button" onClick={() => setTopupOpen(true)}>
290
+ <Plus size={16} />
291
+ {t('billing.prepaid.topup.action', 'Top up')}
292
+ </Button>
293
+ <Button type="button" variant="outline" onClick={() => setAdjustOpen(true)}>
294
+ <SlidersHorizontal size={16} />
295
+ {t('billing.prepaid.adjust.action', 'Adjust')}
296
+ </Button>
297
+ </>
298
+ ) : null}
299
+
300
+ {topupOpen ? (
301
+ <div className="flex items-center gap-2">
302
+ <input
303
+ type="number"
304
+ min="0"
305
+ step="0.01"
306
+ autoFocus
307
+ value={amount}
308
+ onChange={(e) => setAmount(e.target.value)}
309
+ onKeyDown={(e) => {
310
+ if (e.key === 'Enter') void handleTopup()
311
+ if (e.key === 'Escape') setTopupOpen(false)
312
+ }}
313
+ placeholder={t('billing.prepaid.topup.amount', 'Amount')}
314
+ className="h-9 w-32 rounded-md border border-border bg-background px-3 text-sm"
315
+ />
316
+ <Button type="button" onClick={handleTopup} disabled={submitting}>
317
+ {submitting
318
+ ? t('billing.prepaid.topup.submitting', 'Starting…')
319
+ : t('billing.prepaid.topup.confirm', 'Start checkout')}
320
+ </Button>
321
+ <Button type="button" variant="outline" onClick={() => setTopupOpen(false)}>
322
+ {t('billing.common.cancel', 'Cancel')}
323
+ </Button>
324
+ </div>
325
+ ) : null}
326
+
327
+ {adjustOpen ? (
328
+ <div className="flex flex-wrap items-center gap-2">
329
+ <input
330
+ type="number"
331
+ step="0.01"
332
+ autoFocus
333
+ value={adjAmount}
334
+ onChange={(e) => setAdjAmount(e.target.value)}
335
+ onKeyDown={(e) => {
336
+ if (e.key === 'Enter') void handleAdjust()
337
+ if (e.key === 'Escape') setAdjustOpen(false)
338
+ }}
339
+ placeholder={t('billing.prepaid.adjust.amount', 'Amount (+credit / −debit)')}
340
+ className="h-9 w-48 rounded-md border border-border bg-background px-3 text-sm"
341
+ />
342
+ <input
343
+ type="text"
344
+ value={adjReason}
345
+ onChange={(e) => setAdjReason(e.target.value)}
346
+ onKeyDown={(e) => {
347
+ if (e.key === 'Enter') void handleAdjust()
348
+ if (e.key === 'Escape') setAdjustOpen(false)
349
+ }}
350
+ placeholder={t('billing.prepaid.adjust.description', 'Reason')}
351
+ className="h-9 w-64 rounded-md border border-border bg-background px-3 text-sm"
352
+ />
353
+ <Button type="button" onClick={handleAdjust} disabled={adjSubmitting}>
354
+ {adjSubmitting
355
+ ? t('billing.prepaid.adjust.submitting', 'Applying…')
356
+ : t('billing.prepaid.adjust.confirm', 'Apply adjustment')}
357
+ </Button>
358
+ <Button type="button" variant="outline" onClick={() => setAdjustOpen(false)}>
359
+ {t('billing.common.cancel', 'Cancel')}
360
+ </Button>
361
+ </div>
362
+ ) : null}
363
+ </div>
364
+
365
+ <DataTable
366
+ title={t('billing.prepaid.panel.transactions', 'Balance transactions')}
367
+ columns={columns}
368
+ data={rows}
369
+ isLoading={loading}
370
+ onRowClick={(row) => router.push(`/backend/billing/transactions/${row.id}`)}
371
+ pagination={{ page, pageSize, total, totalPages, onPageChange: setPage }}
372
+ emptyState={
373
+ <div className="rounded-lg border bg-card p-6 text-center text-sm text-muted-foreground">
374
+ {t('billing.prepaid.panel.no_transactions', 'No balance movements yet.')}
375
+ </div>
376
+ }
377
+ />
378
+ </div>
379
+ )
380
+ }
381
+
382
+ export default PrepaidPanel
@@ -0,0 +1,23 @@
1
+ import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
2
+
3
+ /**
4
+ * Batch-resolve billing account ids → display names for list views that only
5
+ * carry the FK (`bill_account_id`). One request per page (≤100 ids via the
6
+ * CRUD factory's `ids` filter) — never a per-row fetch.
7
+ */
8
+ export async function fetchAccountNames(ids: Array<string | null | undefined>): Promise<Record<string, string>> {
9
+ const unique = Array.from(new Set(ids.filter((id): id is string => !!id)))
10
+ if (!unique.length) return {}
11
+ const map: Record<string, string> = {}
12
+ // The factory caps page sizes; chunk to stay within the 100 limit.
13
+ for (let i = 0; i < unique.length; i += 100) {
14
+ const chunk = unique.slice(i, i + 100)
15
+ const result = await readApiResultOrThrow<{ items: Array<{ id: string; name: string }> }>(
16
+ `/api/billing/accounts?ids=${chunk.join(',')}&pageSize=${chunk.length}`,
17
+ )
18
+ for (const a of result.items ?? []) {
19
+ if (a?.id) map[a.id] = a.name ?? a.id
20
+ }
21
+ }
22
+ return map
23
+ }