@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,117 @@
1
+ import { z } from 'zod'
2
+ import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
3
+ import { BillingStatement } from '../../data/entities'
4
+ import { billingEntityIds } from '../../data/entityIds'
5
+ import { billingStatementListQuerySchema } from '../../data/validators'
6
+ import {
7
+ createBillingCrudOpenApi,
8
+ createPagedListResponseSchema,
9
+ } from '../openapi'
10
+
11
+ /**
12
+ * `GET /api/billing/statements` — paged consumption statements (SPEC-002 P3).
13
+ * Filter by account, status, and a `period_end` window. `?id=` narrows to one
14
+ * row for the detail page. Generation goes through
15
+ * POST /api/billing/statements/generate.
16
+ */
17
+
18
+ const routeMetadata = {
19
+ GET: { requireAuth: true, requireFeatures: ['billing.statement.view'] },
20
+ }
21
+
22
+ export const metadata = routeMetadata
23
+
24
+ const billingStatementListItemSchema = z.object({
25
+ id: z.string().uuid(),
26
+ bill_account_id: z.string().uuid(),
27
+ bill_run_id: z.string().uuid().nullable(),
28
+ period_start: z.string(),
29
+ period_end: z.string(),
30
+ currency_code: z.string(),
31
+ opening_balance: z.string(),
32
+ total_topups: z.string(),
33
+ total_usage: z.string(),
34
+ total_recurring: z.string(),
35
+ total_adjustments: z.string(),
36
+ closing_balance: z.string(),
37
+ debit_breakdown: z.record(z.string(), z.unknown()).nullable(),
38
+ status: z.string(),
39
+ organization_id: z.string().uuid(),
40
+ tenant_id: z.string().uuid(),
41
+ created_at: z.string(),
42
+ updated_at: z.string(),
43
+ })
44
+
45
+ const crud = makeCrudRoute({
46
+ metadata: routeMetadata,
47
+ orm: {
48
+ entity: BillingStatement,
49
+ idField: 'id',
50
+ orgField: 'organizationId',
51
+ tenantField: 'tenantId',
52
+ },
53
+ list: {
54
+ schema: billingStatementListQuerySchema,
55
+ entityId: billingEntityIds.statement,
56
+ fields: [
57
+ 'id',
58
+ 'bill_account_id',
59
+ 'bill_run_id',
60
+ 'period_start',
61
+ 'period_end',
62
+ 'currency_code',
63
+ 'opening_balance',
64
+ 'total_topups',
65
+ 'total_usage',
66
+ 'total_recurring',
67
+ 'total_adjustments',
68
+ 'closing_balance',
69
+ 'debit_breakdown',
70
+ 'status',
71
+ 'organization_id',
72
+ 'tenant_id',
73
+ 'created_at',
74
+ 'updated_at',
75
+ ],
76
+ sortFieldMap: {
77
+ periodEnd: 'period_end',
78
+ periodStart: 'period_start',
79
+ createdAt: 'created_at',
80
+ },
81
+ buildFilters: async (query) => {
82
+ const filters: Record<string, unknown> = {}
83
+ if (typeof query.id === 'string' && query.id) {
84
+ filters.id = { $eq: query.id }
85
+ }
86
+ if (typeof query.billAccountId === 'string' && query.billAccountId) {
87
+ filters.bill_account_id = { $eq: query.billAccountId }
88
+ }
89
+ if (typeof query.status === 'string' && query.status) {
90
+ filters.status = { $eq: query.status }
91
+ }
92
+ if (query.periodEndFrom instanceof Date) {
93
+ filters.period_end = Object.assign(
94
+ (filters.period_end as Record<string, unknown>) ?? {},
95
+ { $gte: query.periodEndFrom },
96
+ )
97
+ }
98
+ if (query.periodEndTo instanceof Date) {
99
+ filters.period_end = Object.assign(
100
+ (filters.period_end as Record<string, unknown>) ?? {},
101
+ { $lte: query.periodEndTo },
102
+ )
103
+ }
104
+ return filters
105
+ },
106
+ },
107
+ actions: {},
108
+ })
109
+
110
+ export const GET = crud.GET
111
+
112
+ export const openApi = createBillingCrudOpenApi({
113
+ resourceName: 'BillingStatement',
114
+ pluralName: 'BillingStatements',
115
+ querySchema: billingStatementListQuerySchema,
116
+ listResponseSchema: createPagedListResponseSchema(billingStatementListItemSchema),
117
+ })
@@ -0,0 +1,154 @@
1
+ import { z } from 'zod'
2
+ import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
3
+ import { withScopedPayload } from '@open-mercato/shared/lib/api/scoped'
4
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
5
+ import { BillingTopup } from '../../data/entities'
6
+ import { billingEntityIds } from '../../data/entityIds'
7
+ import {
8
+ billingTopupCreateSchema,
9
+ billingTopupListQuerySchema,
10
+ } from '../../data/validators'
11
+ import {
12
+ createBillingCrudOpenApi,
13
+ createPagedListResponseSchema,
14
+ } from '../openapi'
15
+
16
+ /**
17
+ * `/api/billing/topups` — GET (list registered top-ups) + POST (initiate).
18
+ *
19
+ * The nested, spec-faithful `POST /api/billing/accounts/{id}/topups` is a thin
20
+ * wrapper that injects the path id as `billAccountId` and delegates here, so
21
+ * all the RBAC / command-bus / side-effect plumbing lives in one place.
22
+ */
23
+
24
+ const routeMetadata = {
25
+ GET: { requireAuth: true, requireFeatures: ['billing.topup.view'] },
26
+ POST: { requireAuth: true, requireFeatures: ['billing.topup.create'] },
27
+ }
28
+
29
+ export const metadata = routeMetadata
30
+
31
+ const billingTopupListItemSchema = z.object({
32
+ id: z.string().uuid(),
33
+ bill_account_id: z.string().uuid(),
34
+ status: z.string(),
35
+ amount: z.string(),
36
+ currency_code: z.string(),
37
+ provider_key: z.string(),
38
+ payment_id: z.string().uuid(),
39
+ gateway_transaction_id: z.string().uuid().nullable(),
40
+ transaction_id: z.string().uuid().nullable(),
41
+ receipt_invoice_id: z.string().uuid().nullable(),
42
+ source_ref: z.string().nullable(),
43
+ metadata: z.record(z.string(), z.unknown()).nullable(),
44
+ organization_id: z.string().uuid(),
45
+ tenant_id: z.string().uuid(),
46
+ created_at: z.string(),
47
+ updated_at: z.string(),
48
+ })
49
+
50
+ const topupCreateResponseSchema = z.object({
51
+ topupId: z.string().uuid(),
52
+ paymentId: z.string().uuid(),
53
+ status: z.string(),
54
+ transactionId: z.string().uuid().nullable(),
55
+ redirectUrl: z.string().nullable(),
56
+ clientSecret: z.string().nullable(),
57
+ })
58
+
59
+ const rawBodySchema = z.object({}).passthrough()
60
+
61
+ const crud = makeCrudRoute({
62
+ metadata: routeMetadata,
63
+ orm: {
64
+ entity: BillingTopup,
65
+ idField: 'id',
66
+ orgField: 'organizationId',
67
+ tenantField: 'tenantId',
68
+ softDeleteField: 'deletedAt',
69
+ },
70
+ list: {
71
+ schema: billingTopupListQuerySchema,
72
+ entityId: billingEntityIds.topup,
73
+ fields: [
74
+ 'id',
75
+ 'bill_account_id',
76
+ 'status',
77
+ 'amount',
78
+ 'currency_code',
79
+ 'provider_key',
80
+ 'payment_id',
81
+ 'gateway_transaction_id',
82
+ 'transaction_id',
83
+ 'receipt_invoice_id',
84
+ 'source_ref',
85
+ 'metadata',
86
+ 'organization_id',
87
+ 'tenant_id',
88
+ 'created_at',
89
+ 'updated_at',
90
+ ],
91
+ sortFieldMap: {
92
+ createdAt: 'created_at',
93
+ updatedAt: 'updated_at',
94
+ amount: 'amount',
95
+ },
96
+ buildFilters: async (query) => {
97
+ const filters: Record<string, unknown> = {}
98
+ if (typeof query.id === 'string' && query.id) {
99
+ filters.id = { $eq: query.id }
100
+ }
101
+ if (typeof query.billAccountId === 'string' && query.billAccountId) {
102
+ filters.bill_account_id = { $eq: query.billAccountId }
103
+ }
104
+ if (typeof query.status === 'string' && query.status) {
105
+ filters.status = { $eq: query.status }
106
+ }
107
+ if (typeof query.search === 'string' && /^[0-9a-fA-F-]{36}$/.test(query.search.trim())) {
108
+ filters.id = { $eq: query.search.trim() }
109
+ }
110
+ return filters
111
+ },
112
+ },
113
+ actions: {
114
+ create: {
115
+ commandId: 'billing.topups.create',
116
+ schema: rawBodySchema,
117
+ mapInput: async ({ raw, ctx }) => {
118
+ const { translate } = await resolveTranslations()
119
+ const scoped = withScopedPayload(raw ?? {}, ctx, translate)
120
+ return billingTopupCreateSchema.parse(scoped)
121
+ },
122
+ response: ({ result }) => ({
123
+ topupId: result?.topupId ?? null,
124
+ paymentId: result?.paymentId ?? null,
125
+ status: result?.status ?? null,
126
+ transactionId: result?.transactionId ?? null,
127
+ redirectUrl: result?.redirectUrl ?? null,
128
+ clientSecret: result?.clientSecret ?? null,
129
+ }),
130
+ status: 201,
131
+ },
132
+ },
133
+ })
134
+
135
+ const { POST } = crud
136
+ export { POST }
137
+ export const GET = crud.GET
138
+
139
+ export const openApi = createBillingCrudOpenApi({
140
+ resourceName: 'BillingTopup',
141
+ pluralName: 'BillingTopups',
142
+ querySchema: billingTopupListQuerySchema,
143
+ listResponseSchema: createPagedListResponseSchema(billingTopupListItemSchema),
144
+ create: {
145
+ schema: billingTopupCreateSchema,
146
+ responseSchema: topupCreateResponseSchema,
147
+ description:
148
+ 'Registers a prepaid top-up (status=pending) and opens a payment ' +
149
+ 'session via core/payment_gateways. The balance is credited only on ' +
150
+ '`payment_gateways.payment.captured` (the capture subscriber issues the ' +
151
+ 'VAT receipt). Returns the topup id, the payment session redirect URL / ' +
152
+ 'client secret, and the gateway transaction id.',
153
+ },
154
+ })
@@ -0,0 +1,80 @@
1
+ import { z } from 'zod'
2
+ import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
3
+ import { withScopedPayload } from '@open-mercato/shared/lib/api/scoped'
4
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
5
+ import { BillingAccountTransaction } from '../../../data/entities'
6
+ import { billingEntityIds } from '../../../data/entityIds'
7
+ import { billingTransactionAdjustSchema } from '../../../data/validators'
8
+ import { createBillingCrudOpenApi } from '../../openapi'
9
+
10
+ /**
11
+ * `POST /api/billing/transactions/adjust` (SPEC-002 P5) — manual balance
12
+ * credit/debit (`type='adjustment'`), audited with the operator's `user_id`.
13
+ */
14
+
15
+ const routeMetadata = {
16
+ POST: { requireAuth: true, requireFeatures: ['billing.balance.adjust'] },
17
+ }
18
+
19
+ export const metadata = routeMetadata
20
+
21
+ const adjustResponseSchema = z.object({
22
+ transactionId: z.string().uuid(),
23
+ balance: z.string(),
24
+ balanceStatus: z.enum(['ok', 'low', 'exhausted']),
25
+ deduplicated: z.boolean(),
26
+ })
27
+
28
+ const rawBodySchema = z.object({}).passthrough()
29
+
30
+ const crud = makeCrudRoute({
31
+ metadata: routeMetadata,
32
+ orm: {
33
+ entity: BillingAccountTransaction,
34
+ idField: 'id',
35
+ orgField: 'organizationId',
36
+ tenantField: 'tenantId',
37
+ softDeleteField: 'deletedAt',
38
+ },
39
+ list: {
40
+ schema: z.object({}).passthrough(),
41
+ entityId: billingEntityIds.transaction,
42
+ fields: ['id'],
43
+ buildFilters: async () => ({}),
44
+ },
45
+ actions: {
46
+ create: {
47
+ commandId: 'billing.transactions.adjust',
48
+ schema: rawBodySchema,
49
+ mapInput: async ({ raw, ctx }) => {
50
+ const { translate } = await resolveTranslations()
51
+ const scoped = withScopedPayload(raw ?? {}, ctx, translate)
52
+ return billingTransactionAdjustSchema.parse(scoped)
53
+ },
54
+ response: ({ result }) => ({
55
+ transactionId: result?.transactionId ?? null,
56
+ balance: result?.balance ?? null,
57
+ balanceStatus: result?.balanceStatus ?? null,
58
+ deduplicated: Boolean(result?.deduplicated),
59
+ }),
60
+ status: 201,
61
+ },
62
+ },
63
+ })
64
+
65
+ export const POST = crud.POST
66
+
67
+ export const openApi = createBillingCrudOpenApi({
68
+ resourceName: 'BillingBalanceAdjustment',
69
+ pluralName: 'BillingBalanceAdjustments',
70
+ querySchema: z.object({}).passthrough(),
71
+ listResponseSchema: z.object({ items: z.array(z.unknown()) }),
72
+ create: {
73
+ schema: billingTransactionAdjustSchema,
74
+ responseSchema: adjustResponseSchema,
75
+ description:
76
+ 'Manually credits (amount > 0) or debits (amount < 0) a prepaid ' +
77
+ "account's balance. Audited with the operator's user id. Non-fiscal — " +
78
+ 'no receipt is issued. Returns the new balance + status.',
79
+ },
80
+ })
@@ -0,0 +1,116 @@
1
+ import { z } from 'zod'
2
+ import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
3
+ import { BillingAccountTransaction } from '../../data/entities'
4
+ import { billingEntityIds } from '../../data/entityIds'
5
+ import { billingTransactionListQuerySchema } from '../../data/validators'
6
+ import {
7
+ createBillingCrudOpenApi,
8
+ createPagedListResponseSchema,
9
+ } from '../openapi'
10
+
11
+ /**
12
+ * `GET /api/billing/transactions` — paged, append-only balance ledger
13
+ * (SPEC-002). Filter by account, type, and a `created_at` window. The
14
+ * write path is the command bus (real-time consume, top-up capture, period
15
+ * close, manual adjustment) — there is no create/update/delete here.
16
+ */
17
+
18
+ const routeMetadata = {
19
+ GET: { requireAuth: true, requireFeatures: ['billing.balance.view'] },
20
+ }
21
+
22
+ export const metadata = routeMetadata
23
+
24
+ const billingTransactionListItemSchema = z.object({
25
+ id: z.string().uuid(),
26
+ bill_account_id: z.string().uuid(),
27
+ type: z.string(),
28
+ amount: z.string(),
29
+ currency_code: z.string(),
30
+ balance_after: z.string(),
31
+ usage_id: z.string().uuid().nullable(),
32
+ billing_item_id: z.string().uuid().nullable(),
33
+ topup_id: z.string().uuid().nullable(),
34
+ statement_id: z.string().uuid().nullable(),
35
+ description: z.string(),
36
+ source_ref: z.string().nullable(),
37
+ metadata: z.record(z.string(), z.unknown()).nullable(),
38
+ user_id: z.string().uuid().nullable(),
39
+ organization_id: z.string().uuid(),
40
+ tenant_id: z.string().uuid(),
41
+ created_at: z.string(),
42
+ })
43
+
44
+ const crud = makeCrudRoute({
45
+ metadata: routeMetadata,
46
+ orm: {
47
+ entity: BillingAccountTransaction,
48
+ idField: 'id',
49
+ orgField: 'organizationId',
50
+ tenantField: 'tenantId',
51
+ softDeleteField: 'deletedAt',
52
+ },
53
+ list: {
54
+ schema: billingTransactionListQuerySchema,
55
+ entityId: billingEntityIds.transaction,
56
+ fields: [
57
+ 'id',
58
+ 'bill_account_id',
59
+ 'type',
60
+ 'amount',
61
+ 'currency_code',
62
+ 'balance_after',
63
+ 'usage_id',
64
+ 'billing_item_id',
65
+ 'topup_id',
66
+ 'statement_id',
67
+ 'description',
68
+ 'source_ref',
69
+ 'metadata',
70
+ 'user_id',
71
+ 'organization_id',
72
+ 'tenant_id',
73
+ 'created_at',
74
+ ],
75
+ sortFieldMap: {
76
+ createdAt: 'created_at',
77
+ },
78
+ buildFilters: async (query) => {
79
+ const filters: Record<string, unknown> = {}
80
+ if (typeof query.id === 'string' && query.id) {
81
+ filters.id = { $eq: query.id }
82
+ }
83
+ if (typeof query.billAccountId === 'string' && query.billAccountId) {
84
+ filters.bill_account_id = { $eq: query.billAccountId }
85
+ }
86
+ if (typeof query.type === 'string' && query.type) {
87
+ filters.type = { $eq: query.type }
88
+ }
89
+ if (query.createdFrom instanceof Date) {
90
+ filters.created_at = Object.assign(
91
+ (filters.created_at as Record<string, unknown>) ?? {},
92
+ { $gte: query.createdFrom },
93
+ )
94
+ }
95
+ if (query.createdTo instanceof Date) {
96
+ filters.created_at = Object.assign(
97
+ (filters.created_at as Record<string, unknown>) ?? {},
98
+ { $lte: query.createdTo },
99
+ )
100
+ }
101
+ return filters
102
+ },
103
+ },
104
+ // Append-only — no create/update/delete on this surface. Manual
105
+ // adjustments go through POST /api/billing/transactions/adjust.
106
+ actions: {},
107
+ })
108
+
109
+ export const GET = crud.GET
110
+
111
+ export const openApi = createBillingCrudOpenApi({
112
+ resourceName: 'BillingAccountTransaction',
113
+ pluralName: 'BillingAccountTransactions',
114
+ querySchema: billingTransactionListQuerySchema,
115
+ listResponseSchema: createPagedListResponseSchema(billingTransactionListItemSchema),
116
+ })
@@ -46,6 +46,15 @@ const billingUsageListItemSchema = z.object({
46
46
  const usageCreateResponseSchema = z.object({
47
47
  id: z.string().uuid().nullable(),
48
48
  deduplicated: z.boolean(),
49
+ // Prepaid accounts (SPEC-002) get the real-time balance body. Absent for
50
+ // postpaid accounts (the legacy append-only response is unchanged).
51
+ ratedAmount: z.string().optional(),
52
+ currencyCode: z.string().optional(),
53
+ balance: z.string().optional(),
54
+ balanceStatus: z.enum(['ok', 'low', 'exhausted']).optional(),
55
+ creditStatus: z.enum(['within_limit', 'near_limit', 'over_limit']).optional(),
56
+ tierBreakdown: z.array(z.record(z.string(), z.unknown())).optional(),
57
+ warning: z.string().nullable().optional(),
49
58
  })
50
59
 
51
60
  const rawBodySchema = z.object({}).passthrough()
@@ -118,10 +127,27 @@ const crud = makeCrudRoute({
118
127
  const scoped = withScopedPayload(raw ?? {}, ctx, translate)
119
128
  return billingUsageCreateSchema.parse(scoped)
120
129
  },
121
- response: ({ result }) => ({
122
- id: result?.id ?? null,
123
- deduplicated: Boolean(result?.deduplicated),
124
- }),
130
+ response: ({ result }) => {
131
+ const base = {
132
+ id: result?.id ?? null,
133
+ deduplicated: Boolean(result?.deduplicated),
134
+ }
135
+ // Pass through the prepaid balance body when present (prepaid
136
+ // accounts only); postpaid responses keep the legacy 2-field shape.
137
+ if (result && 'balance' in result && result.balance !== undefined) {
138
+ return {
139
+ ...base,
140
+ ratedAmount: result.ratedAmount,
141
+ currencyCode: result.currencyCode,
142
+ balance: result.balance,
143
+ balanceStatus: result.balanceStatus,
144
+ creditStatus: result.creditStatus,
145
+ tierBreakdown: result.tierBreakdown ?? [],
146
+ warning: result.warning ?? null,
147
+ }
148
+ }
149
+ return base
150
+ },
125
151
  status: 200,
126
152
  },
127
153
  // Usage has no update or delete by design — see file header.
@@ -143,6 +169,11 @@ export const openApi = createBillingCrudOpenApi({
143
169
  description:
144
170
  'Ingests a pre-aggregated usage record. Idempotent via `sourceRef`. ' +
145
171
  'Usage records are immutable once persisted — to correct a mis-aggregation, ' +
146
- "post a `one_time` Billing Item credit (the spec's refund model).",
172
+ "post a `one_time` Billing Item credit (the spec's refund model). " +
173
+ 'For a PREPAID account (SPEC-002) the upload is rated synchronously, ' +
174
+ 'debited from the balance, and the response carries `ratedAmount`, the ' +
175
+ 'remaining `balance`, `balanceStatus` (ok/low/exhausted), `creditStatus`, ' +
176
+ 'and the `tierBreakdown`. Usage is never rejected — the balance may go ' +
177
+ 'negative and the client is expected to throttle itself.',
147
178
  },
148
179
  })
@@ -20,6 +20,7 @@ import {
20
20
  import { normalizeCrudServerError } from '@open-mercato/ui/backend/utils/serverErrors'
21
21
  import { AccountForm, type AccountFormValues } from '../../../../components/AccountForm'
22
22
  import { DetailCard, DetailField } from '../../../../components/DetailFields'
23
+ import { PrepaidPanel } from '../../../../components/PrepaidPanel'
23
24
 
24
25
  /**
25
26
  * `/backend/billing/accounts/[id]` — Billing Account detail.
@@ -54,6 +55,8 @@ type AccountRow = {
54
55
  } | null
55
56
  next_bill_date: string
56
57
  last_bill_date: string | null
58
+ billing_mode: 'postpaid' | 'prepaid'
59
+ credit_limit: string
57
60
  is_active: boolean
58
61
  created_at: string
59
62
  updated_at: string
@@ -108,6 +111,11 @@ function toFormValues(row: AccountRow): AccountFormValues {
108
111
  },
109
112
  nextBillDate: row.next_bill_date?.slice(0, 10) ?? '',
110
113
  isActive: row.is_active,
114
+ billingMode: row.billing_mode,
115
+ creditLimit: row.credit_limit ?? '0',
116
+ // The threshold lives on the balance row, not the account row — blank
117
+ // means "leave unchanged" on save.
118
+ lowBalanceThreshold: '',
111
119
  }
112
120
  }
113
121
 
@@ -135,6 +143,16 @@ function AccountView({ row }: { row: AccountRow }) {
135
143
  <DetailField label={t('billing.accounts.columns.currency', 'Currency')}>
136
144
  {row.currency_code}
137
145
  </DetailField>
146
+ <DetailField label={t('billing.accounts.columns.mode', 'Mode')}>
147
+ {row.billing_mode === 'prepaid' ? (
148
+ <Tag variant="brand">{t('billing.mode.prepaid', 'Prepaid')}</Tag>
149
+ ) : (
150
+ <Tag variant="neutral">{t('billing.mode.postpaid', 'Postpaid')}</Tag>
151
+ )}
152
+ </DetailField>
153
+ <DetailField label={t('billing.accounts.columns.credit_limit', 'Credit limit')}>
154
+ <span className="font-mono tabular-nums">{row.credit_limit}</span>
155
+ </DetailField>
138
156
  <DetailField label={t('billing.common.id', 'ID')}>
139
157
  <span className="font-mono text-xs">{row.id}</span>
140
158
  </DetailField>
@@ -425,6 +443,13 @@ export default function BillingAccountDetailPage(props: { params?: { id?: string
425
443
  isActive: values.isActive,
426
444
  }
427
445
  payload.taxId = values.taxId.trim() === '' ? null : values.taxId.trim()
446
+ // billingMode is immutable on update; creditLimit + threshold are editable.
447
+ const creditLimit = Number.parseFloat(values.creditLimit)
448
+ if (Number.isFinite(creditLimit) && creditLimit >= 0) payload.creditLimit = creditLimit
449
+ if (row.billing_mode === 'prepaid' && values.lowBalanceThreshold.trim()) {
450
+ const threshold = Number.parseFloat(values.lowBalanceThreshold)
451
+ if (Number.isFinite(threshold) && threshold >= 0) payload.lowBalanceThreshold = threshold
452
+ }
428
453
 
429
454
  await apiCallOrThrow('/api/billing/accounts', {
430
455
  method: 'PUT',
@@ -565,6 +590,9 @@ export default function BillingAccountDetailPage(props: { params?: { id?: string
565
590
  ) : (
566
591
  <AccountView row={row} />
567
592
  )}
593
+ {row.billing_mode === 'prepaid' ? (
594
+ <PrepaidPanel accountId={row.id} currencyCode={row.currency_code} />
595
+ ) : null}
568
596
  <AccountItemsSection accountId={row.id} />
569
597
  <AccountUsageSection accountId={row.id} />
570
598
  {ConfirmDialogElement}
@@ -43,8 +43,15 @@ export default function CreateBillingAccountPage() {
43
43
  country: values.invoiceAddress.country.trim().toUpperCase(),
44
44
  },
45
45
  nextBillDate: values.nextBillDate,
46
+ billingMode: values.billingMode,
46
47
  }
47
48
  if (values.taxId.trim()) payload.taxId = values.taxId.trim()
49
+ const creditLimit = Number.parseFloat(values.creditLimit)
50
+ if (Number.isFinite(creditLimit) && creditLimit >= 0) payload.creditLimit = creditLimit
51
+ if (values.billingMode === 'prepaid' && values.lowBalanceThreshold.trim()) {
52
+ const threshold = Number.parseFloat(values.lowBalanceThreshold)
53
+ if (Number.isFinite(threshold) && threshold >= 0) payload.lowBalanceThreshold = threshold
54
+ }
48
55
 
49
56
  const call = await apiCallOrThrow<{ id: string }>('/api/billing/accounts', {
50
57
  method: 'POST',
@@ -32,6 +32,7 @@ type BillingAccountRow = {
32
32
  invoice_email: string
33
33
  next_bill_date: string
34
34
  last_bill_date: string | null
35
+ billing_mode: 'postpaid' | 'prepaid'
35
36
  is_active: boolean
36
37
  created_at: string
37
38
  }
@@ -78,6 +79,15 @@ export default function BillingAccountsListPage() {
78
79
  label: t('billing.accounts.filters.currency', 'Currency'),
79
80
  type: 'text',
80
81
  },
82
+ {
83
+ id: 'billingMode',
84
+ label: t('billing.accounts.columns.mode', 'Mode'),
85
+ type: 'select',
86
+ options: [
87
+ { value: 'postpaid', label: t('billing.mode.postpaid', 'Postpaid') },
88
+ { value: 'prepaid', label: t('billing.mode.prepaid', 'Prepaid') },
89
+ ],
90
+ },
81
91
  {
82
92
  id: 'isActive',
83
93
  label: t('billing.accounts.filters.active', 'Active'),
@@ -107,6 +117,9 @@ export default function BillingAccountsListPage() {
107
117
  if (typeof filters.currencyCode === 'string' && filters.currencyCode.trim()) {
108
118
  params.set('currencyCode', filters.currencyCode.trim().toUpperCase())
109
119
  }
120
+ if (typeof filters.billingMode === 'string' && filters.billingMode) {
121
+ params.set('billingMode', filters.billingMode)
122
+ }
110
123
  if (filters.isActive === 'true') params.set('isActive', 'true')
111
124
  if (filters.isActive === 'false') params.set('isActive', 'false')
112
125
  const result = await readApiResultOrThrow<ListResponse>(
@@ -118,7 +131,15 @@ export default function BillingAccountsListPage() {
118
131
  } finally {
119
132
  setLoading(false)
120
133
  }
121
- }, [filters.billCycle, filters.currencyCode, filters.isActive, page, pageSize, search])
134
+ }, [
135
+ filters.billCycle,
136
+ filters.currencyCode,
137
+ filters.billingMode,
138
+ filters.isActive,
139
+ page,
140
+ pageSize,
141
+ search,
142
+ ])
122
143
 
123
144
  React.useEffect(() => {
124
145
  void loadRows()
@@ -154,6 +175,16 @@ export default function BillingAccountsListPage() {
154
175
  </span>
155
176
  ),
156
177
  },
178
+ {
179
+ accessorKey: 'billing_mode',
180
+ header: t('billing.accounts.columns.mode', 'Mode'),
181
+ cell: ({ row }) =>
182
+ row.original.billing_mode === 'prepaid' ? (
183
+ <Tag variant="brand">{t('billing.mode.prepaid', 'Prepaid')}</Tag>
184
+ ) : (
185
+ <Tag variant="neutral">{t('billing.mode.postpaid', 'Postpaid')}</Tag>
186
+ ),
187
+ },
157
188
  {
158
189
  accessorKey: 'next_bill_date',
159
190
  header: t('billing.accounts.columns.next_bill', 'Next bill'),