@dainamite/billing 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/CHANGELOG.md +658 -0
  2. package/LICENSE +21 -0
  3. package/README.md +77 -0
  4. package/dist/modules/billing/acl.js +20 -0
  5. package/dist/modules/billing/acl.js.map +7 -0
  6. package/dist/modules/billing/api/accounts/route.js +182 -0
  7. package/dist/modules/billing/api/accounts/route.js.map +7 -0
  8. package/dist/modules/billing/api/export/account/[id]/route.js +170 -0
  9. package/dist/modules/billing/api/export/account/[id]/route.js.map +7 -0
  10. package/dist/modules/billing/api/invoices/[id]/route.js +85 -0
  11. package/dist/modules/billing/api/invoices/[id]/route.js.map +7 -0
  12. package/dist/modules/billing/api/invoices/add-line/route.js +69 -0
  13. package/dist/modules/billing/api/invoices/add-line/route.js.map +7 -0
  14. package/dist/modules/billing/api/invoices/edit-line/route.js +78 -0
  15. package/dist/modules/billing/api/invoices/edit-line/route.js.map +7 -0
  16. package/dist/modules/billing/api/invoices/post/route.js +74 -0
  17. package/dist/modules/billing/api/invoices/post/route.js.map +7 -0
  18. package/dist/modules/billing/api/invoices/remove-line/route.js +69 -0
  19. package/dist/modules/billing/api/invoices/remove-line/route.js.map +7 -0
  20. package/dist/modules/billing/api/invoices/route.js +109 -0
  21. package/dist/modules/billing/api/invoices/route.js.map +7 -0
  22. package/dist/modules/billing/api/items/bulk/route.js +74 -0
  23. package/dist/modules/billing/api/items/bulk/route.js.map +7 -0
  24. package/dist/modules/billing/api/items/route.js +224 -0
  25. package/dist/modules/billing/api/items/route.js.map +7 -0
  26. package/dist/modules/billing/api/openapi.js +25 -0
  27. package/dist/modules/billing/api/openapi.js.map +7 -0
  28. package/dist/modules/billing/api/run-outcomes/route.js +122 -0
  29. package/dist/modules/billing/api/run-outcomes/route.js.map +7 -0
  30. package/dist/modules/billing/api/runs/retry-failed/route.js +70 -0
  31. package/dist/modules/billing/api/runs/retry-failed/route.js.map +7 -0
  32. package/dist/modules/billing/api/runs/route.js +143 -0
  33. package/dist/modules/billing/api/runs/route.js.map +7 -0
  34. package/dist/modules/billing/api/test-invoices/wipe/route.js +65 -0
  35. package/dist/modules/billing/api/test-invoices/wipe/route.js.map +7 -0
  36. package/dist/modules/billing/api/usage/route.js +138 -0
  37. package/dist/modules/billing/api/usage/route.js.map +7 -0
  38. package/dist/modules/billing/backend/billing/accounts/[id]/page.js +402 -0
  39. package/dist/modules/billing/backend/billing/accounts/[id]/page.js.map +7 -0
  40. package/dist/modules/billing/backend/billing/accounts/[id]/page.meta.js +25 -0
  41. package/dist/modules/billing/backend/billing/accounts/[id]/page.meta.js.map +7 -0
  42. package/dist/modules/billing/backend/billing/accounts/create/page.js +82 -0
  43. package/dist/modules/billing/backend/billing/accounts/create/page.js.map +7 -0
  44. package/dist/modules/billing/backend/billing/accounts/create/page.meta.js +25 -0
  45. package/dist/modules/billing/backend/billing/accounts/create/page.meta.js.map +7 -0
  46. package/dist/modules/billing/backend/billing/accounts/page.js +173 -0
  47. package/dist/modules/billing/backend/billing/accounts/page.js.map +7 -0
  48. package/dist/modules/billing/backend/billing/accounts/page.meta.js +18 -0
  49. package/dist/modules/billing/backend/billing/accounts/page.meta.js.map +7 -0
  50. package/dist/modules/billing/backend/billing/invoices/[id]/page.js +500 -0
  51. package/dist/modules/billing/backend/billing/invoices/[id]/page.js.map +7 -0
  52. package/dist/modules/billing/backend/billing/invoices/[id]/page.meta.js +25 -0
  53. package/dist/modules/billing/backend/billing/invoices/[id]/page.meta.js.map +7 -0
  54. package/dist/modules/billing/backend/billing/invoices/page.js +187 -0
  55. package/dist/modules/billing/backend/billing/invoices/page.js.map +7 -0
  56. package/dist/modules/billing/backend/billing/invoices/page.meta.js +18 -0
  57. package/dist/modules/billing/backend/billing/invoices/page.meta.js.map +7 -0
  58. package/dist/modules/billing/backend/billing/items/[id]/page.js +265 -0
  59. package/dist/modules/billing/backend/billing/items/[id]/page.js.map +7 -0
  60. package/dist/modules/billing/backend/billing/items/[id]/page.meta.js +25 -0
  61. package/dist/modules/billing/backend/billing/items/[id]/page.meta.js.map +7 -0
  62. package/dist/modules/billing/backend/billing/items/create/page.js +90 -0
  63. package/dist/modules/billing/backend/billing/items/create/page.js.map +7 -0
  64. package/dist/modules/billing/backend/billing/items/create/page.meta.js +25 -0
  65. package/dist/modules/billing/backend/billing/items/create/page.meta.js.map +7 -0
  66. package/dist/modules/billing/backend/billing/items/page.js +208 -0
  67. package/dist/modules/billing/backend/billing/items/page.js.map +7 -0
  68. package/dist/modules/billing/backend/billing/items/page.meta.js +18 -0
  69. package/dist/modules/billing/backend/billing/items/page.meta.js.map +7 -0
  70. package/dist/modules/billing/backend/billing/runs/[id]/page.js +293 -0
  71. package/dist/modules/billing/backend/billing/runs/[id]/page.js.map +7 -0
  72. package/dist/modules/billing/backend/billing/runs/[id]/page.meta.js +21 -0
  73. package/dist/modules/billing/backend/billing/runs/[id]/page.meta.js.map +7 -0
  74. package/dist/modules/billing/backend/billing/runs/page.js +341 -0
  75. package/dist/modules/billing/backend/billing/runs/page.js.map +7 -0
  76. package/dist/modules/billing/backend/billing/runs/page.meta.js +18 -0
  77. package/dist/modules/billing/backend/billing/runs/page.meta.js.map +7 -0
  78. package/dist/modules/billing/backend/billing/usage/create/page.js +100 -0
  79. package/dist/modules/billing/backend/billing/usage/create/page.js.map +7 -0
  80. package/dist/modules/billing/backend/billing/usage/create/page.meta.js +25 -0
  81. package/dist/modules/billing/backend/billing/usage/create/page.meta.js.map +7 -0
  82. package/dist/modules/billing/ce.js +56 -0
  83. package/dist/modules/billing/ce.js.map +7 -0
  84. package/dist/modules/billing/cli.js +67 -0
  85. package/dist/modules/billing/cli.js.map +7 -0
  86. package/dist/modules/billing/commands/accounts.js +157 -0
  87. package/dist/modules/billing/commands/accounts.js.map +7 -0
  88. package/dist/modules/billing/commands/index.js +6 -0
  89. package/dist/modules/billing/commands/index.js.map +7 -0
  90. package/dist/modules/billing/commands/invoices.js +401 -0
  91. package/dist/modules/billing/commands/invoices.js.map +7 -0
  92. package/dist/modules/billing/commands/items.js +317 -0
  93. package/dist/modules/billing/commands/items.js.map +7 -0
  94. package/dist/modules/billing/commands/runs.js +123 -0
  95. package/dist/modules/billing/commands/runs.js.map +7 -0
  96. package/dist/modules/billing/commands/usage.js +88 -0
  97. package/dist/modules/billing/commands/usage.js.map +7 -0
  98. package/dist/modules/billing/components/AccountForm.js +420 -0
  99. package/dist/modules/billing/components/AccountForm.js.map +7 -0
  100. package/dist/modules/billing/components/DetailFields.js +37 -0
  101. package/dist/modules/billing/components/DetailFields.js.map +7 -0
  102. package/dist/modules/billing/components/ItemForm.js +440 -0
  103. package/dist/modules/billing/components/ItemForm.js.map +7 -0
  104. package/dist/modules/billing/components/LineFormDialog.js +195 -0
  105. package/dist/modules/billing/components/LineFormDialog.js.map +7 -0
  106. package/dist/modules/billing/components/UsageForm.js +262 -0
  107. package/dist/modules/billing/components/UsageForm.js.map +7 -0
  108. package/dist/modules/billing/data/entities.js +433 -0
  109. package/dist/modules/billing/data/entities.js.map +7 -0
  110. package/dist/modules/billing/data/entityIds.js +12 -0
  111. package/dist/modules/billing/data/entityIds.js.map +7 -0
  112. package/dist/modules/billing/data/validators.js +396 -0
  113. package/dist/modules/billing/data/validators.js.map +7 -0
  114. package/dist/modules/billing/di.js +6 -0
  115. package/dist/modules/billing/di.js.map +7 -0
  116. package/dist/modules/billing/events.js +35 -0
  117. package/dist/modules/billing/events.js.map +7 -0
  118. package/dist/modules/billing/i18n/en.json +336 -0
  119. package/dist/modules/billing/i18n/pl.json +336 -0
  120. package/dist/modules/billing/index.js +17 -0
  121. package/dist/modules/billing/index.js.map +7 -0
  122. package/dist/modules/billing/lib/billPeriod.js +101 -0
  123. package/dist/modules/billing/lib/billPeriod.js.map +7 -0
  124. package/dist/modules/billing/lib/billRunEngine.js +256 -0
  125. package/dist/modules/billing/lib/billRunEngine.js.map +7 -0
  126. package/dist/modules/billing/lib/billRunLabel.js +8 -0
  127. package/dist/modules/billing/lib/billRunLabel.js.map +7 -0
  128. package/dist/modules/billing/lib/idempotency.js +13 -0
  129. package/dist/modules/billing/lib/idempotency.js.map +7 -0
  130. package/dist/modules/billing/lib/invoiceStatus.js +38 -0
  131. package/dist/modules/billing/lib/invoiceStatus.js.map +7 -0
  132. package/dist/modules/billing/lib/invoiceWriter.js +153 -0
  133. package/dist/modules/billing/lib/invoiceWriter.js.map +7 -0
  134. package/dist/modules/billing/lib/itemSelector.js +77 -0
  135. package/dist/modules/billing/lib/itemSelector.js.map +7 -0
  136. package/dist/modules/billing/lib/seeds/demo.js +130 -0
  137. package/dist/modules/billing/lib/seeds/demo.js.map +7 -0
  138. package/dist/modules/billing/lib/tenantLock.js +32 -0
  139. package/dist/modules/billing/lib/tenantLock.js.map +7 -0
  140. package/dist/modules/billing/lib/usageRater.js +150 -0
  141. package/dist/modules/billing/lib/usageRater.js.map +7 -0
  142. package/dist/modules/billing/lib/usageRunner.js +82 -0
  143. package/dist/modules/billing/lib/usageRunner.js.map +7 -0
  144. package/dist/modules/billing/migrations/Migration20260519000000_billing.js +144 -0
  145. package/dist/modules/billing/migrations/Migration20260519000000_billing.js.map +7 -0
  146. package/dist/modules/billing/setup.js +190 -0
  147. package/dist/modules/billing/setup.js.map +7 -0
  148. package/dist/modules/billing/subscribers/payment-captured-to-paid.js +47 -0
  149. package/dist/modules/billing/subscribers/payment-captured-to-paid.js.map +7 -0
  150. package/dist/modules/billing/translations.js +5 -0
  151. package/dist/modules/billing/translations.js.map +7 -0
  152. package/dist/modules/billing/workers/reap-stale-bill-runs.js +31 -0
  153. package/dist/modules/billing/workers/reap-stale-bill-runs.js.map +7 -0
  154. package/package.json +148 -0
  155. package/src/modules/billing/__integration__/TC-BILL-001.spec.ts +116 -0
  156. package/src/modules/billing/__integration__/TC-BILL-002.spec.ts +169 -0
  157. package/src/modules/billing/__integration__/TC-BILL-003.spec.ts +28 -0
  158. package/src/modules/billing/__integration__/meta.ts +4 -0
  159. package/src/modules/billing/__tests__/commands/invoices.test.ts +464 -0
  160. package/src/modules/billing/__tests__/commands/itemsBulk.test.ts +284 -0
  161. package/src/modules/billing/__tests__/lib/billPeriod.test.ts +131 -0
  162. package/src/modules/billing/__tests__/lib/billRunEngine.test.ts +561 -0
  163. package/src/modules/billing/__tests__/lib/idempotency.test.ts +81 -0
  164. package/src/modules/billing/__tests__/lib/invoiceStatus.test.ts +97 -0
  165. package/src/modules/billing/__tests__/lib/invoiceWriter.test.ts +119 -0
  166. package/src/modules/billing/__tests__/lib/itemSelector.test.ts +192 -0
  167. package/src/modules/billing/__tests__/lib/seedsDemo.test.ts +86 -0
  168. package/src/modules/billing/__tests__/lib/tenantLock.test.ts +126 -0
  169. package/src/modules/billing/__tests__/lib/usageRater.test.ts +190 -0
  170. package/src/modules/billing/__tests__/package-isolation.test.ts +223 -0
  171. package/src/modules/billing/__tests__/setup.test.ts +107 -0
  172. package/src/modules/billing/__tests__/validators.test.ts +594 -0
  173. package/src/modules/billing/acl.ts +16 -0
  174. package/src/modules/billing/api/accounts/route.ts +198 -0
  175. package/src/modules/billing/api/export/account/[id]/route.ts +233 -0
  176. package/src/modules/billing/api/invoices/[id]/route.ts +143 -0
  177. package/src/modules/billing/api/invoices/add-line/route.ts +74 -0
  178. package/src/modules/billing/api/invoices/edit-line/route.ts +97 -0
  179. package/src/modules/billing/api/invoices/post/route.ts +97 -0
  180. package/src/modules/billing/api/invoices/remove-line/route.ts +75 -0
  181. package/src/modules/billing/api/invoices/route.ts +167 -0
  182. package/src/modules/billing/api/items/bulk/route.ts +92 -0
  183. package/src/modules/billing/api/items/route.ts +255 -0
  184. package/src/modules/billing/api/openapi.ts +33 -0
  185. package/src/modules/billing/api/run-outcomes/route.ts +149 -0
  186. package/src/modules/billing/api/runs/retry-failed/route.ts +88 -0
  187. package/src/modules/billing/api/runs/route.ts +170 -0
  188. package/src/modules/billing/api/test-invoices/wipe/route.ts +87 -0
  189. package/src/modules/billing/api/usage/route.ts +148 -0
  190. package/src/modules/billing/backend/billing/accounts/[id]/page.meta.ts +23 -0
  191. package/src/modules/billing/backend/billing/accounts/[id]/page.tsx +574 -0
  192. package/src/modules/billing/backend/billing/accounts/create/page.meta.ts +23 -0
  193. package/src/modules/billing/backend/billing/accounts/create/page.tsx +101 -0
  194. package/src/modules/billing/backend/billing/accounts/page.meta.ts +16 -0
  195. package/src/modules/billing/backend/billing/accounts/page.tsx +222 -0
  196. package/src/modules/billing/backend/billing/invoices/[id]/page.meta.ts +23 -0
  197. package/src/modules/billing/backend/billing/invoices/[id]/page.tsx +613 -0
  198. package/src/modules/billing/backend/billing/invoices/page.meta.ts +16 -0
  199. package/src/modules/billing/backend/billing/invoices/page.tsx +245 -0
  200. package/src/modules/billing/backend/billing/items/[id]/page.meta.ts +23 -0
  201. package/src/modules/billing/backend/billing/items/[id]/page.tsx +366 -0
  202. package/src/modules/billing/backend/billing/items/create/page.meta.ts +23 -0
  203. package/src/modules/billing/backend/billing/items/create/page.tsx +110 -0
  204. package/src/modules/billing/backend/billing/items/page.meta.ts +16 -0
  205. package/src/modules/billing/backend/billing/items/page.tsx +272 -0
  206. package/src/modules/billing/backend/billing/runs/[id]/page.meta.ts +19 -0
  207. package/src/modules/billing/backend/billing/runs/[id]/page.tsx +404 -0
  208. package/src/modules/billing/backend/billing/runs/page.meta.ts +16 -0
  209. package/src/modules/billing/backend/billing/runs/page.tsx +426 -0
  210. package/src/modules/billing/backend/billing/usage/create/page.meta.ts +23 -0
  211. package/src/modules/billing/backend/billing/usage/create/page.tsx +120 -0
  212. package/src/modules/billing/ce.ts +74 -0
  213. package/src/modules/billing/cli.ts +89 -0
  214. package/src/modules/billing/commands/accounts.ts +205 -0
  215. package/src/modules/billing/commands/index.ts +13 -0
  216. package/src/modules/billing/commands/invoices.ts +542 -0
  217. package/src/modules/billing/commands/items.ts +438 -0
  218. package/src/modules/billing/commands/runs.ts +166 -0
  219. package/src/modules/billing/commands/usage.ts +118 -0
  220. package/src/modules/billing/components/AccountForm.tsx +517 -0
  221. package/src/modules/billing/components/DetailFields.tsx +60 -0
  222. package/src/modules/billing/components/ItemForm.tsx +558 -0
  223. package/src/modules/billing/components/LineFormDialog.tsx +253 -0
  224. package/src/modules/billing/components/UsageForm.tsx +326 -0
  225. package/src/modules/billing/data/entities.ts +533 -0
  226. package/src/modules/billing/data/entityIds.ts +25 -0
  227. package/src/modules/billing/data/validators.ts +562 -0
  228. package/src/modules/billing/di.ts +14 -0
  229. package/src/modules/billing/events.ts +47 -0
  230. package/src/modules/billing/i18n/en.json +336 -0
  231. package/src/modules/billing/i18n/pl.json +336 -0
  232. package/src/modules/billing/index.ts +22 -0
  233. package/src/modules/billing/lib/billPeriod.ts +181 -0
  234. package/src/modules/billing/lib/billRunEngine.ts +416 -0
  235. package/src/modules/billing/lib/billRunLabel.ts +14 -0
  236. package/src/modules/billing/lib/idempotency.ts +46 -0
  237. package/src/modules/billing/lib/invoiceStatus.ts +61 -0
  238. package/src/modules/billing/lib/invoiceWriter.ts +260 -0
  239. package/src/modules/billing/lib/itemSelector.ts +154 -0
  240. package/src/modules/billing/lib/seeds/demo.ts +199 -0
  241. package/src/modules/billing/lib/tenantLock.ts +87 -0
  242. package/src/modules/billing/lib/usageRater.ts +250 -0
  243. package/src/modules/billing/lib/usageRunner.ts +189 -0
  244. package/src/modules/billing/migrations/Migration20260519000000_billing.ts +175 -0
  245. package/src/modules/billing/setup.ts +252 -0
  246. package/src/modules/billing/subscribers/payment-captured-to-paid.ts +89 -0
  247. package/src/modules/billing/translations.ts +9 -0
  248. package/src/modules/billing/workers/reap-stale-bill-runs.ts +57 -0
@@ -0,0 +1,198 @@
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 { BillingAccount } from '../../data/entities'
6
+ import { billingEntityIds } from '../../data/entityIds'
7
+ import {
8
+ billingAccountCreateSchema,
9
+ billingAccountListQuerySchema,
10
+ billingAccountUpdateSchema,
11
+ } from '../../data/validators'
12
+ import {
13
+ createBillingCrudOpenApi,
14
+ createPagedListResponseSchema,
15
+ defaultCreateResponseSchema,
16
+ defaultOkResponseSchema,
17
+ } from '../openapi'
18
+
19
+ // ─── Auth / RBAC ─────────────────────────────────────────────────
20
+
21
+ const routeMetadata = {
22
+ GET: { requireAuth: true, requireFeatures: ['billing.account.view'] },
23
+ POST: { requireAuth: true, requireFeatures: ['billing.account.manage'] },
24
+ PUT: { requireAuth: true, requireFeatures: ['billing.account.manage'] },
25
+ DELETE: { requireAuth: true, requireFeatures: ['billing.account.manage'] },
26
+ }
27
+
28
+ export const metadata = routeMetadata
29
+
30
+ // ─── List response item shape (informational — used by OpenAPI) ──
31
+
32
+ // The list endpoint projects raw column names — snake_case.
33
+ const billingAccountListItemSchema = z.object({
34
+ id: z.string().uuid(),
35
+ customer_id: z.string(),
36
+ name: z.string(),
37
+ currency_code: z.string(),
38
+ bill_cycle: z.string(),
39
+ bill_cycle_anchor: z.string(),
40
+ invoice_email: z.string(),
41
+ invoice_language: z.string(),
42
+ tax_id: z.string().nullable(),
43
+ invoice_address: z.record(z.string(), z.unknown()),
44
+ next_bill_date: z.string(),
45
+ last_bill_date: z.string().nullable(),
46
+ is_active: z.boolean(),
47
+ organization_id: z.string().uuid(),
48
+ tenant_id: z.string().uuid(),
49
+ created_at: z.string(),
50
+ updated_at: z.string(),
51
+ })
52
+
53
+ const rawBodySchema = z.object({}).passthrough()
54
+
55
+ // ─── CRUD route ──────────────────────────────────────────────────
56
+
57
+ const crud = makeCrudRoute({
58
+ metadata: routeMetadata,
59
+ orm: {
60
+ entity: BillingAccount,
61
+ idField: 'id',
62
+ orgField: 'organizationId',
63
+ tenantField: 'tenantId',
64
+ softDeleteField: 'deletedAt',
65
+ },
66
+ list: {
67
+ schema: billingAccountListQuerySchema,
68
+ entityId: billingEntityIds.account,
69
+ fields: [
70
+ 'id',
71
+ 'customer_id',
72
+ 'name',
73
+ 'currency_code',
74
+ 'bill_cycle',
75
+ 'bill_cycle_anchor',
76
+ 'invoice_email',
77
+ 'invoice_language',
78
+ 'tax_id',
79
+ 'invoice_address',
80
+ 'next_bill_date',
81
+ 'last_bill_date',
82
+ 'is_active',
83
+ 'organization_id',
84
+ 'tenant_id',
85
+ 'created_at',
86
+ 'updated_at',
87
+ ],
88
+ sortFieldMap: {
89
+ name: 'name',
90
+ createdAt: 'created_at',
91
+ updatedAt: 'updated_at',
92
+ nextBillDate: 'next_bill_date',
93
+ },
94
+ buildFilters: async (query) => {
95
+ const filters: Record<string, unknown> = {}
96
+ // `?id=<uuid>` narrows the list to one row — the detail page
97
+ // reads a single account through this filter.
98
+ if (typeof query.id === 'string' && query.id) {
99
+ filters.id = { $eq: query.id }
100
+ }
101
+ if (typeof query.customerId === 'string' && query.customerId) {
102
+ filters.customer_id = { $eq: query.customerId }
103
+ }
104
+ if (typeof query.currencyCode === 'string' && query.currencyCode) {
105
+ filters.currency_code = { $eq: query.currencyCode.toUpperCase() }
106
+ }
107
+ if (typeof query.billCycle === 'string' && query.billCycle) {
108
+ filters.bill_cycle = { $eq: query.billCycle }
109
+ }
110
+ if (typeof query.isActive === 'boolean') {
111
+ filters.is_active = { $eq: query.isActive }
112
+ }
113
+ if (typeof query.search === 'string' && query.search.trim()) {
114
+ const pattern = `%${query.search.trim().replace(/[%_]/g, (m) => `\\${m}`)}%`
115
+ filters.name = { $ilike: pattern }
116
+ }
117
+ return filters
118
+ },
119
+ },
120
+ actions: {
121
+ create: {
122
+ commandId: 'billing.accounts.create',
123
+ schema: rawBodySchema,
124
+ mapInput: async ({ raw, ctx }) => {
125
+ const { translate } = await resolveTranslations()
126
+ const scoped = withScopedPayload(raw ?? {}, ctx, translate)
127
+ return billingAccountCreateSchema.parse(scoped)
128
+ },
129
+ response: ({ result }) => ({ id: result?.id ?? null }),
130
+ status: 201,
131
+ },
132
+ update: {
133
+ commandId: 'billing.accounts.update',
134
+ schema: rawBodySchema,
135
+ mapInput: async ({ raw, ctx }) => {
136
+ const { translate } = await resolveTranslations()
137
+ const scoped = withScopedPayload(raw ?? {}, ctx, translate)
138
+ return billingAccountUpdateSchema.parse(scoped)
139
+ },
140
+ response: () => ({ ok: true }),
141
+ },
142
+ delete: {
143
+ commandId: 'billing.accounts.delete',
144
+ schema: rawBodySchema,
145
+ mapInput: async ({ raw, ctx }) => {
146
+ const { translate } = await resolveTranslations()
147
+ // The factory hands delete a `{ body, query }` envelope — the
148
+ // id may arrive in either, depending on the caller.
149
+ const envelope = (raw ?? {}) as {
150
+ body?: Record<string, unknown>
151
+ query?: Record<string, unknown>
152
+ }
153
+ const body = envelope.body ?? {}
154
+ const query = envelope.query ?? {}
155
+ const scoped = withScopedPayload(body, ctx, translate)
156
+ const id =
157
+ (typeof body.id === 'string' && body.id) ||
158
+ (typeof query.id === 'string' && query.id) ||
159
+ null
160
+ if (!id) {
161
+ throw new Error('id is required')
162
+ }
163
+ return {
164
+ id,
165
+ tenantId: (scoped as { tenantId?: string }).tenantId,
166
+ organizationId: (scoped as { organizationId?: string }).organizationId,
167
+ }
168
+ },
169
+ response: () => ({ ok: true }),
170
+ },
171
+ },
172
+ })
173
+
174
+ const { POST, PUT, DELETE } = crud
175
+ export { POST, PUT, DELETE }
176
+ export const GET = crud.GET
177
+
178
+ export const openApi = createBillingCrudOpenApi({
179
+ resourceName: 'BillingAccount',
180
+ pluralName: 'BillingAccounts',
181
+ querySchema: billingAccountListQuerySchema,
182
+ listResponseSchema: createPagedListResponseSchema(billingAccountListItemSchema),
183
+ create: {
184
+ schema: billingAccountCreateSchema,
185
+ responseSchema: defaultCreateResponseSchema,
186
+ description: 'Creates a new billing account for a customer.',
187
+ },
188
+ update: {
189
+ schema: billingAccountUpdateSchema,
190
+ responseSchema: defaultOkResponseSchema,
191
+ description: 'Updates a billing account. `currencyCode` and `customerId` are immutable.',
192
+ },
193
+ del: {
194
+ schema: z.object({ id: z.string().uuid() }),
195
+ responseSchema: defaultOkResponseSchema,
196
+ description: 'Soft-deletes a billing account (sets `deleted_at`).',
197
+ },
198
+ })
@@ -0,0 +1,233 @@
1
+ import { NextResponse } from 'next/server'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
5
+ import {
6
+ BillingAccount,
7
+ BillingAccountUsage,
8
+ BillingItem,
9
+ BillRunOutcome,
10
+ DraftInvoiceEdit,
11
+ } from '../../../../data/entities'
12
+
13
+ /**
14
+ * `GET /api/billing/export/account/[id]` — GDPR portability dump.
15
+ *
16
+ * Returns a full JSON serialization of a single Billing Account
17
+ * including:
18
+ * - The account row itself.
19
+ * - Every active + soft-deleted `BillingItem` for the account.
20
+ * - Every `BillingAccountUsage` record.
21
+ * - Every `BillRunOutcome` row referencing the account.
22
+ * - Every `core/sales` invoice the engine emitted for the account
23
+ * (matched via `metadata->>'bill_account_id'`).
24
+ * - Every `DraftInvoiceEdit` audit row for those invoices.
25
+ *
26
+ * Scope: the caller's auth tenant/organization is the authority — a
27
+ * 404 is returned even for valid UUIDs that belong to a different
28
+ * tenant (no cross-tenant leak).
29
+ *
30
+ * Soft-deleted rows are included on purpose: GDPR portability is
31
+ * about the data we still hold, not just the data the UI shows.
32
+ */
33
+
34
+ export const metadata = {
35
+ GET: { requireAuth: true, requireFeatures: ['billing.account.view'] },
36
+ }
37
+
38
+ export async function GET(
39
+ req: Request,
40
+ { params }: { params: Promise<{ id: string }> },
41
+ ) {
42
+ let container: Awaited<ReturnType<typeof createRequestContainer>> | null = null
43
+ try {
44
+ container = await createRequestContainer()
45
+ const auth = await getAuthFromRequest(req)
46
+ if (!auth || !auth.tenantId) {
47
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
48
+ }
49
+ const tenantId = auth.tenantId
50
+ const organizationId = (auth as { orgId?: string }).orgId ?? null
51
+ if (!organizationId) {
52
+ return NextResponse.json(
53
+ { error: 'Organization scope required' },
54
+ { status: 403 },
55
+ )
56
+ }
57
+
58
+ const { id: accountId } = await params
59
+
60
+ const em = (container.resolve('em') as EntityManager).fork()
61
+ const account = await em.findOne(BillingAccount, {
62
+ id: accountId,
63
+ tenantId,
64
+ organizationId,
65
+ deletedAt: null,
66
+ })
67
+ if (!account) {
68
+ return NextResponse.json({ error: 'Account not found' }, { status: 404 })
69
+ }
70
+
71
+ // Time-window cap for usage records, run outcomes and invoices.
72
+ //
73
+ // Scale note: a telco account can carry millions of usage rows.
74
+ // Returning everything would OOM on the export path. We default
75
+ // to the last 12 months and let the caller override via `since` /
76
+ // `until` for full GDPR-compliance dumps (a regulator request that
77
+ // legitimately needs the lifetime export should set `since=1970-01-01`).
78
+ // Each query is independently capped by `EXPORT_HARD_LIMIT` so a
79
+ // single year of pathological volume still returns rather than
80
+ // hanging.
81
+ const url = new URL(req.url)
82
+ const since = parseDate(url.searchParams.get('since'))
83
+ ?? defaultSince()
84
+ const until = parseDate(url.searchParams.get('until')) ?? new Date()
85
+
86
+ const [items, usage, outcomes] = await Promise.all([
87
+ // Items are bounded by integration volume (typically <100 per
88
+ // account); no time-window needed — they often outlive a single
89
+ // export window and the operator needs to see active items
90
+ // regardless of when they were created.
91
+ em.find(
92
+ BillingItem,
93
+ { tenantId, organizationId, billAccountId: account.id } as never,
94
+ { limit: EXPORT_HARD_LIMIT },
95
+ ),
96
+ em.find(
97
+ BillingAccountUsage,
98
+ {
99
+ tenantId,
100
+ organizationId,
101
+ billAccountId: account.id,
102
+ periodEnd: { $gte: since, $lte: until },
103
+ } as never,
104
+ { limit: EXPORT_HARD_LIMIT, orderBy: { periodEnd: 'DESC' } },
105
+ ),
106
+ em.find(
107
+ BillRunOutcome,
108
+ {
109
+ tenantId,
110
+ organizationId,
111
+ billAccountId: account.id,
112
+ createdAt: { $gte: since, $lte: until },
113
+ } as never,
114
+ { limit: EXPORT_HARD_LIMIT, orderBy: { createdAt: 'DESC' } },
115
+ ),
116
+ ])
117
+
118
+ // Invoices created in the window. The `metadata->>'bill_account_id'`
119
+ // predicate keeps non-billing core/sales invoices out.
120
+ const invoiceRows = (await em.execute(
121
+ `SELECT id, invoice_number, status, status_entry_id, issue_date, due_date,
122
+ currency_code, subtotal_net_amount, grand_total_net_amount,
123
+ grand_total_gross_amount, paid_total_amount, outstanding_amount,
124
+ metadata, created_at, updated_at, deleted_at
125
+ FROM sales_invoices
126
+ WHERE tenant_id = ?
127
+ AND organization_id = ?
128
+ AND deleted_at IS NULL
129
+ AND metadata->>'bill_account_id' = ?
130
+ AND created_at >= ?
131
+ AND created_at <= ?
132
+ ORDER BY created_at DESC
133
+ LIMIT ?`,
134
+ [tenantId, organizationId, account.id, since, until, EXPORT_HARD_LIMIT],
135
+ )) as unknown as Array<{ id: string }>
136
+
137
+ const invoiceIds = invoiceRows.map((r) => r.id)
138
+ const draftEdits =
139
+ invoiceIds.length > 0
140
+ ? await em.find(
141
+ DraftInvoiceEdit,
142
+ {
143
+ tenantId,
144
+ organizationId,
145
+ invoiceId: { $in: invoiceIds },
146
+ } as never,
147
+ { limit: EXPORT_HARD_LIMIT, orderBy: { createdAt: 'DESC' } },
148
+ )
149
+ : []
150
+
151
+ return NextResponse.json({
152
+ exportedAt: new Date().toISOString(),
153
+ schemaVersion: 1,
154
+ tenantId,
155
+ organizationId,
156
+ window: {
157
+ since: since.toISOString(),
158
+ until: until.toISOString(),
159
+ hardLimit: EXPORT_HARD_LIMIT,
160
+ },
161
+ account,
162
+ items,
163
+ usage,
164
+ runOutcomes: outcomes,
165
+ invoices: invoiceRows,
166
+ draftInvoiceEdits: draftEdits,
167
+ })
168
+ } catch (err) {
169
+ console.error('[billing/export/account.GET]', err)
170
+ return NextResponse.json({ error: 'Internal error' }, { status: 500 })
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Per-collection cap to keep a single export bounded in size and time
176
+ * even when the caller asks for `since=1970-01-01`. The full GDPR
177
+ * lifetime dump for an outlier account requires a follow-up
178
+ * background-job approach (chunked NDJSON to object storage) — out of
179
+ * scope for the v1 admin export endpoint.
180
+ */
181
+ const EXPORT_HARD_LIMIT = 50_000
182
+
183
+ /**
184
+ * Default time window for the export — last 12 months. Operators
185
+ * pulling for routine GDPR requests almost always want a finite
186
+ * window; the regulator-compliance "lifetime" case overrides via
187
+ * `?since=1970-01-01`.
188
+ */
189
+ function defaultSince(): Date {
190
+ const d = new Date()
191
+ d.setUTCFullYear(d.getUTCFullYear() - 1)
192
+ return d
193
+ }
194
+
195
+ function parseDate(value: string | null | undefined): Date | null {
196
+ if (!value) return null
197
+ const d = new Date(value)
198
+ return Number.isNaN(d.getTime()) ? null : d
199
+ }
200
+
201
+ export const openApi = {
202
+ methods: {
203
+ GET: {
204
+ tags: ['Billing'],
205
+ summary: 'Export a billing account (GDPR portability)',
206
+ parameters: [
207
+ {
208
+ name: 'id',
209
+ in: 'path',
210
+ required: true,
211
+ schema: { type: 'string', format: 'uuid' },
212
+ },
213
+ {
214
+ name: 'since',
215
+ in: 'query',
216
+ required: false,
217
+ description:
218
+ 'ISO timestamp; lower bound for usage / outcomes / invoices windows. Defaults to today − 12 months.',
219
+ schema: { type: 'string', format: 'date-time' },
220
+ },
221
+ {
222
+ name: 'until',
223
+ in: 'query',
224
+ required: false,
225
+ description: 'ISO timestamp; upper bound. Defaults to now.',
226
+ schema: { type: 'string', format: 'date-time' },
227
+ },
228
+ ],
229
+ description:
230
+ "Returns a JSON dump of a single Billing Account: the account itself, every Billing Item (no time cap — items often outlive a single export window), and the Usage records / BillRunOutcomes / emitted core/sales invoices / DraftInvoiceEdit rows whose timestamps fall in the [since, until] window (defaults to the last 12 months). Each collection is independently capped by EXPORT_HARD_LIMIT = 50,000 rows. Lifetime dumps for outlier accounts need a follow-up chunked background-job approach — out of scope for this v1 endpoint.",
231
+ },
232
+ },
233
+ }
@@ -0,0 +1,143 @@
1
+ import { NextResponse } from 'next/server'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
5
+
6
+ /**
7
+ * `GET /api/billing/invoices/[id]` — billing-invoice detail.
8
+ *
9
+ * Returns the `core/sales` invoice row + its lines, but only if the
10
+ * invoice is billing-managed (carries `metadata.bill_run_id`). Other
11
+ * `core/sales` invoices return 404 here even though the same UUID
12
+ * resolves at `/api/sales/invoices/[id]` — this endpoint is the
13
+ * billing operator's surface, scoped to the billing universe.
14
+ */
15
+
16
+ export const metadata = {
17
+ GET: { requireAuth: true, requireFeatures: ['billing.invoice.view'] },
18
+ }
19
+
20
+ type InvoiceRow = {
21
+ id: string
22
+ invoice_number: string
23
+ status: string | null
24
+ status_entry_id: string | null
25
+ currency_code: string
26
+ issue_date: string | null
27
+ due_date: string | null
28
+ subtotal_net_amount: string
29
+ subtotal_gross_amount: string
30
+ discount_total_amount: string
31
+ tax_total_amount: string
32
+ grand_total_net_amount: string
33
+ grand_total_gross_amount: string
34
+ paid_total_amount: string
35
+ outstanding_amount: string
36
+ metadata: Record<string, unknown> | null
37
+ created_at: string
38
+ updated_at: string
39
+ }
40
+
41
+ type InvoiceLineRow = {
42
+ id: string
43
+ line_number: number
44
+ kind: string
45
+ description: string | null
46
+ quantity: string
47
+ currency_code: string
48
+ unit_price_net: string
49
+ unit_price_gross: string
50
+ tax_rate: string
51
+ tax_amount: string
52
+ total_net_amount: string
53
+ total_gross_amount: string
54
+ metadata: Record<string, unknown> | null
55
+ }
56
+
57
+ export async function GET(
58
+ req: Request,
59
+ { params }: { params: Promise<{ id: string }> },
60
+ ) {
61
+ try {
62
+ const container = await createRequestContainer()
63
+ const auth = await getAuthFromRequest(req)
64
+ if (!auth || !auth.tenantId) {
65
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
66
+ }
67
+ const organizationId = (auth as { orgId?: string }).orgId ?? null
68
+ if (!organizationId) {
69
+ return NextResponse.json(
70
+ { error: 'Organization scope required' },
71
+ { status: 403 },
72
+ )
73
+ }
74
+
75
+ const { id: invoiceId } = await params
76
+ const em = (container.resolve('em') as EntityManager).fork()
77
+
78
+ const invoiceRows = (await em.execute(
79
+ `SELECT id, invoice_number, status, status_entry_id, currency_code,
80
+ issue_date, due_date,
81
+ subtotal_net_amount, subtotal_gross_amount,
82
+ discount_total_amount, tax_total_amount,
83
+ grand_total_net_amount, grand_total_gross_amount,
84
+ paid_total_amount, outstanding_amount,
85
+ metadata, created_at, updated_at
86
+ FROM sales_invoices
87
+ WHERE id = ?
88
+ AND tenant_id = ?
89
+ AND organization_id = ?
90
+ AND deleted_at IS NULL
91
+ AND jsonb_exists(metadata, 'bill_run_id')`,
92
+ [invoiceId, auth.tenantId, organizationId],
93
+ )) as unknown as InvoiceRow[]
94
+
95
+ if (!invoiceRows || invoiceRows.length === 0) {
96
+ return NextResponse.json({ error: 'Invoice not found' }, { status: 404 })
97
+ }
98
+
99
+ const invoice = invoiceRows[0]
100
+
101
+ const lineRows = (await em.execute(
102
+ `SELECT id, line_number, kind, description, quantity, currency_code,
103
+ unit_price_net, unit_price_gross, tax_rate, tax_amount,
104
+ total_net_amount, total_gross_amount, metadata
105
+ FROM sales_invoice_lines
106
+ WHERE invoice_id = ?
107
+ AND tenant_id = ?
108
+ AND organization_id = ?
109
+ ORDER BY line_number ASC`,
110
+ [invoiceId, auth.tenantId, organizationId],
111
+ )) as unknown as InvoiceLineRow[]
112
+
113
+ return NextResponse.json({
114
+ invoice,
115
+ lines: lineRows,
116
+ })
117
+ } catch (err) {
118
+ console.error('[billing/invoices/[id].GET]', err)
119
+ return NextResponse.json({ error: 'Internal error' }, { status: 500 })
120
+ }
121
+ }
122
+
123
+ export const openApi = {
124
+ methods: {
125
+ GET: {
126
+ tags: ['Billing'],
127
+ summary: 'Get a billing-managed invoice (with lines)',
128
+ description:
129
+ 'Returns the `core/sales` invoice + every line, scoped to ' +
130
+ "billing-managed invoices (`metadata.bill_run_id` set). Non-" +
131
+ 'billing invoices return 404 even when the UUID resolves at ' +
132
+ '`/api/sales/invoices/[id]`.',
133
+ parameters: [
134
+ {
135
+ name: 'id',
136
+ in: 'path',
137
+ required: true,
138
+ schema: { type: 'string', format: 'uuid' },
139
+ },
140
+ ],
141
+ },
142
+ },
143
+ }
@@ -0,0 +1,74 @@
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 { SalesInvoice } from '@open-mercato/core/modules/sales/data/entities'
6
+ import { billingEntityIds } from '../../../data/entityIds'
7
+ import { billingInvoiceAddLineSchema } from '../../../data/validators'
8
+ import { createBillingCrudOpenApi } from '../../openapi'
9
+
10
+ const routeMetadata = {
11
+ POST: { requireAuth: true, requireFeatures: ['billing.invoice.edit_draft'] },
12
+ }
13
+
14
+ export const metadata = routeMetadata
15
+
16
+ const addLineResponseSchema = z.object({
17
+ invoiceId: z.string().uuid(),
18
+ invoiceLineId: z.string().uuid(),
19
+ auditId: z.string().uuid(),
20
+ })
21
+
22
+ const rawBodySchema = z.object({}).passthrough()
23
+
24
+ const crud = makeCrudRoute({
25
+ metadata: routeMetadata,
26
+ orm: {
27
+ entity: SalesInvoice,
28
+ idField: 'id',
29
+ orgField: 'organizationId',
30
+ tenantField: 'tenantId',
31
+ softDeleteField: 'deletedAt',
32
+ },
33
+ list: {
34
+ schema: z.object({}).passthrough(),
35
+ entityId: billingEntityIds.draftInvoiceEdit,
36
+ fields: ['id'],
37
+ buildFilters: async () => ({}),
38
+ },
39
+ actions: {
40
+ create: {
41
+ commandId: 'billing.invoices.add_draft_line',
42
+ schema: rawBodySchema,
43
+ mapInput: async ({ raw, ctx }) => {
44
+ const { translate } = await resolveTranslations()
45
+ const scoped = withScopedPayload(raw ?? {}, ctx, translate)
46
+ return billingInvoiceAddLineSchema.parse(scoped)
47
+ },
48
+ response: ({ result }) => ({
49
+ invoiceId: result?.invoiceId ?? null,
50
+ invoiceLineId: result?.invoiceLineId ?? null,
51
+ auditId: result?.auditId ?? null,
52
+ }),
53
+ status: 201,
54
+ },
55
+ },
56
+ })
57
+
58
+ export const POST = crud.POST
59
+
60
+ export const openApi = createBillingCrudOpenApi({
61
+ resourceName: 'BillingInvoiceLineAdd',
62
+ pluralName: 'BillingInvoiceLineAdds',
63
+ querySchema: z.object({}).passthrough(),
64
+ listResponseSchema: z.object({ items: z.array(z.unknown()) }),
65
+ create: {
66
+ schema: billingInvoiceAddLineSchema,
67
+ responseSchema: addLineResponseSchema,
68
+ description:
69
+ 'Appends a new line to a draft invoice (e.g. operator adds a ' +
70
+ 'one-off adjustment). Writes a `DraftInvoiceEdit` audit row ' +
71
+ '(action=`line_added`, `before_json=null`). Recomputes invoice ' +
72
+ 'totals. Refuses non-draft invoices.',
73
+ },
74
+ })