@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,255 @@
1
+ import { z } from 'zod'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import type { AwilixContainer } from 'awilix'
4
+ import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
5
+ import { withScopedPayload } from '@open-mercato/shared/lib/api/scoped'
6
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
7
+ import { BillingAccount, BillingItem } from '../../data/entities'
8
+ import { billingEntityIds } from '../../data/entityIds'
9
+ import {
10
+ billingItemCreateSchema,
11
+ billingItemListQuerySchema,
12
+ billingItemUpdateSchema,
13
+ } from '../../data/validators'
14
+ import {
15
+ createBillingCrudOpenApi,
16
+ createPagedListResponseSchema,
17
+ defaultOkResponseSchema,
18
+ } from '../openapi'
19
+
20
+ // ─── Auth / RBAC ─────────────────────────────────────────────────
21
+
22
+ const routeMetadata = {
23
+ GET: { requireAuth: true, requireFeatures: ['billing.item.view'] },
24
+ POST: { requireAuth: true, requireFeatures: ['billing.item.manage'] },
25
+ PUT: { requireAuth: true, requireFeatures: ['billing.item.manage'] },
26
+ DELETE: { requireAuth: true, requireFeatures: ['billing.item.manage'] },
27
+ }
28
+
29
+ export const metadata = routeMetadata
30
+
31
+ // The list endpoint projects raw column names — snake_case.
32
+ const billingItemListItemSchema = z.object({
33
+ id: z.string().uuid(),
34
+ bill_account_id: z.string().uuid(),
35
+ bill_account_name: z.string().nullable(),
36
+ type: z.string(),
37
+ bill_start_date: z.string(),
38
+ bill_end_date: z.string().nullable(),
39
+ description: z.string(),
40
+ rate_json: z.record(z.string(), z.unknown()),
41
+ uom_code: z.string().nullable(),
42
+ subscription_id: z.string().nullable(),
43
+ subscription_item_id: z.string().nullable(),
44
+ source_ref: z.string().nullable(),
45
+ currency_mismatch: z.boolean(),
46
+ billed_to_date: z.string().nullable(),
47
+ is_active: z.boolean(),
48
+ organization_id: z.string().uuid(),
49
+ tenant_id: z.string().uuid(),
50
+ created_at: z.string(),
51
+ updated_at: z.string(),
52
+ })
53
+
54
+ // Response for create includes `deduplicated` so integrators can tell
55
+ // whether their POST resulted in a new row or hit the idempotency
56
+ // short-circuit on `source_ref`.
57
+ const itemCreateResponseSchema = z.object({
58
+ id: z.string().uuid().nullable(),
59
+ deduplicated: z.boolean(),
60
+ })
61
+
62
+ const rawBodySchema = z.object({}).passthrough()
63
+
64
+ const crud = makeCrudRoute({
65
+ metadata: routeMetadata,
66
+ orm: {
67
+ entity: BillingItem,
68
+ idField: 'id',
69
+ orgField: 'organizationId',
70
+ tenantField: 'tenantId',
71
+ softDeleteField: 'deletedAt',
72
+ },
73
+ list: {
74
+ schema: billingItemListQuerySchema,
75
+ entityId: billingEntityIds.item,
76
+ fields: [
77
+ 'id',
78
+ 'bill_account_id',
79
+ 'type',
80
+ 'bill_start_date',
81
+ 'bill_end_date',
82
+ 'description',
83
+ 'rate_json',
84
+ 'uom_code',
85
+ 'subscription_id',
86
+ 'subscription_item_id',
87
+ 'source_ref',
88
+ 'currency_mismatch',
89
+ 'billed_to_date',
90
+ 'is_active',
91
+ 'organization_id',
92
+ 'tenant_id',
93
+ 'created_at',
94
+ 'updated_at',
95
+ ],
96
+ sortFieldMap: {
97
+ createdAt: 'created_at',
98
+ updatedAt: 'updated_at',
99
+ billStartDate: 'bill_start_date',
100
+ billEndDate: 'bill_end_date',
101
+ },
102
+ buildFilters: async (query) => {
103
+ const filters: Record<string, unknown> = {}
104
+ // `?id=<uuid>` narrows the list to one row — the detail page
105
+ // reads a single item through this filter.
106
+ if (typeof query.id === 'string' && query.id) {
107
+ filters.id = { $eq: query.id }
108
+ }
109
+ if (typeof query.search === 'string' && query.search.trim()) {
110
+ const pattern = `%${query.search.trim().replace(/[%_]/g, (m) => `\\${m}`)}%`
111
+ filters.description = { $ilike: pattern }
112
+ }
113
+ if (typeof query.billAccountId === 'string' && query.billAccountId) {
114
+ filters.bill_account_id = { $eq: query.billAccountId }
115
+ }
116
+ if (typeof query.type === 'string' && query.type) {
117
+ filters.type = { $eq: query.type }
118
+ }
119
+ if (typeof query.subscriptionId === 'string' && query.subscriptionId) {
120
+ filters.subscription_id = { $eq: query.subscriptionId }
121
+ }
122
+ if (typeof query.isActive === 'boolean') {
123
+ filters.is_active = { $eq: query.isActive }
124
+ }
125
+ // `activeAt=<date>` returns items whose validity window covers
126
+ // the supplied date (Bill Run engine uses the same predicate
127
+ // shape internally — see spec "Mid-cycle recurring items").
128
+ if (query.activeAt instanceof Date) {
129
+ filters.bill_start_date = { $lte: query.activeAt }
130
+ filters.$or = [
131
+ { bill_end_date: null },
132
+ { bill_end_date: { $gte: query.activeAt } },
133
+ ]
134
+ }
135
+ return filters
136
+ },
137
+ },
138
+ actions: {
139
+ create: {
140
+ commandId: 'billing.items.create',
141
+ schema: rawBodySchema,
142
+ mapInput: async ({ raw, ctx }) => {
143
+ const { translate } = await resolveTranslations()
144
+ const scoped = withScopedPayload(raw ?? {}, ctx, translate)
145
+ return billingItemCreateSchema.parse(scoped)
146
+ },
147
+ response: ({ result }) => ({
148
+ id: result?.id ?? null,
149
+ deduplicated: Boolean(result?.deduplicated),
150
+ }),
151
+ // Idempotent create returns 200 (vs 201). The spec mandates
152
+ // duplicate POST returns the existing row, never creates a
153
+ // duplicate — keeping the success status consistent makes
154
+ // client-side handling trivial.
155
+ status: 200,
156
+ },
157
+ update: {
158
+ commandId: 'billing.items.update',
159
+ schema: rawBodySchema,
160
+ mapInput: async ({ raw, ctx }) => {
161
+ const { translate } = await resolveTranslations()
162
+ const scoped = withScopedPayload(raw ?? {}, ctx, translate)
163
+ return billingItemUpdateSchema.parse(scoped)
164
+ },
165
+ response: () => ({ ok: true }),
166
+ },
167
+ delete: {
168
+ commandId: 'billing.items.delete',
169
+ schema: rawBodySchema,
170
+ mapInput: async ({ raw, ctx }) => {
171
+ const { translate } = await resolveTranslations()
172
+ // The factory hands delete a `{ body, query }` envelope — the
173
+ // id may arrive in either, depending on the caller.
174
+ const envelope = (raw ?? {}) as {
175
+ body?: Record<string, unknown>
176
+ query?: Record<string, unknown>
177
+ }
178
+ const body = envelope.body ?? {}
179
+ const query = envelope.query ?? {}
180
+ const scoped = withScopedPayload(body, ctx, translate)
181
+ const id =
182
+ (typeof body.id === 'string' && body.id) ||
183
+ (typeof query.id === 'string' && query.id) ||
184
+ null
185
+ if (!id) {
186
+ throw new Error('id is required')
187
+ }
188
+ return {
189
+ id,
190
+ tenantId: (scoped as { tenantId?: string }).tenantId,
191
+ organizationId: (scoped as { organizationId?: string }).organizationId,
192
+ }
193
+ },
194
+ response: () => ({ ok: true }),
195
+ },
196
+ },
197
+ hooks: {
198
+ // Items reference their account by FK id only; the list + detail UI
199
+ // wants the human-readable account name. One bounded query
200
+ // (≤ pageSize ids) resolves it after the page is fetched.
201
+ afterList: async (payload: unknown, ctx: unknown) => {
202
+ const items = ((payload as { items?: unknown[] } | null)?.items ?? []) as Array<
203
+ Record<string, unknown>
204
+ >
205
+ const accountIds = Array.from(
206
+ new Set(
207
+ items
208
+ .map((row) => row.bill_account_id)
209
+ .filter((v): v is string => typeof v === 'string' && v.length > 0),
210
+ ),
211
+ )
212
+ if (accountIds.length === 0) return
213
+ const em = (ctx as { container: AwilixContainer }).container.resolve<EntityManager>('em')
214
+ const accounts = await em.find(BillingAccount, { id: { $in: accountIds } })
215
+ const nameById = new Map(
216
+ accounts.map((a) => [String(a.id), a.name] as [string, unknown]),
217
+ )
218
+ for (const row of items) {
219
+ row.bill_account_name = nameById.get(String(row.bill_account_id)) ?? null
220
+ }
221
+ },
222
+ },
223
+ })
224
+
225
+ const { POST, PUT, DELETE } = crud
226
+ export { POST, PUT, DELETE }
227
+ export const GET = crud.GET
228
+
229
+ export const openApi = createBillingCrudOpenApi({
230
+ resourceName: 'BillingItem',
231
+ pluralName: 'BillingItems',
232
+ querySchema: billingItemListQuerySchema,
233
+ listResponseSchema: createPagedListResponseSchema(billingItemListItemSchema),
234
+ create: {
235
+ schema: billingItemCreateSchema,
236
+ responseSchema: itemCreateResponseSchema,
237
+ description:
238
+ 'Creates a billing item. Idempotent via `sourceRef`: re-POSTing the same ' +
239
+ '`(tenantId, billAccountId, sourceRef)` returns the existing row with ' +
240
+ '`deduplicated: true` instead of creating a duplicate. The unique index ' +
241
+ 'on `(tenant_id, bill_account_id, source_ref)` is the correctness gate.',
242
+ },
243
+ update: {
244
+ schema: billingItemUpdateSchema,
245
+ responseSchema: defaultOkResponseSchema,
246
+ description:
247
+ 'Updates a billing item. `type` and `sourceRef` are immutable — to change ' +
248
+ 'either, soft-delete the item and create a new one.',
249
+ },
250
+ del: {
251
+ schema: z.object({ id: z.string().uuid() }),
252
+ responseSchema: defaultOkResponseSchema,
253
+ description: 'Soft-deletes a billing item.',
254
+ },
255
+ })
@@ -0,0 +1,33 @@
1
+ import {
2
+ createCrudOpenApiFactory,
3
+ createPagedListResponseSchema as createSharedPagedListResponseSchema,
4
+ defaultCreateResponseSchema,
5
+ defaultOkResponseSchema,
6
+ type CrudOpenApiOptions,
7
+ } from '@open-mercato/shared/lib/openapi/crud'
8
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi/types'
9
+ import type { ZodTypeAny } from 'zod'
10
+
11
+ /**
12
+ * Billing CRUD OpenAPI factory. Every billing API route imports this
13
+ * and calls it with the resource's `create` / `update` / `del` schemas
14
+ * — the framework picks up the resulting `openApi` export and merges
15
+ * it into the global API documentation bundle.
16
+ */
17
+ const buildBillingCrudOpenApi = createCrudOpenApiFactory({
18
+ defaultTag: 'Billing',
19
+ defaultCreateResponseSchema,
20
+ defaultOkResponseSchema,
21
+ makeListDescription: ({ pluralLower }) =>
22
+ `Returns a paginated collection of ${pluralLower} scoped to the authenticated organization.`,
23
+ })
24
+
25
+ export function createBillingCrudOpenApi(options: CrudOpenApiOptions): OpenApiRouteDoc {
26
+ return buildBillingCrudOpenApi(options)
27
+ }
28
+
29
+ export function createPagedListResponseSchema(itemSchema: ZodTypeAny) {
30
+ return createSharedPagedListResponseSchema(itemSchema, { paginationMetaOptional: true })
31
+ }
32
+
33
+ export { defaultCreateResponseSchema, defaultOkResponseSchema }
@@ -0,0 +1,149 @@
1
+ import { z } from 'zod'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import type { AwilixContainer } from 'awilix'
4
+ import { SalesInvoice } from '@open-mercato/core/modules/sales/data/entities'
5
+ import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
6
+ import { BillingAccount, BillRunOutcome } from '../../data/entities'
7
+ import { billingEntityIds } from '../../data/entityIds'
8
+ import {
9
+ BILL_RUN_OUTCOME_STATUSES,
10
+ } from '../../data/validators'
11
+ import { createBillingCrudOpenApi, createPagedListResponseSchema } from '../openapi'
12
+
13
+ /**
14
+ * `GET /api/billing/run-outcomes` — list per-account outcomes,
15
+ * typically filtered by `billRunId` to populate the "show me what
16
+ * happened in this run" detail panel.
17
+ *
18
+ * Read-only; outcomes are written by the engine itself, never by API
19
+ * clients.
20
+ */
21
+
22
+ const routeMetadata = {
23
+ GET: { requireAuth: true, requireFeatures: ['billing.run.view'] },
24
+ }
25
+
26
+ export const metadata = routeMetadata
27
+
28
+ const listQuerySchema = z
29
+ .object({
30
+ page: z.coerce.number().int().min(1).default(1),
31
+ pageSize: z.coerce.number().int().min(1).max(100).default(50),
32
+ sortField: z.string().optional(),
33
+ sortDir: z.enum(['asc', 'desc']).optional(),
34
+ billRunId: z.string().uuid().optional(),
35
+ billAccountId: z.string().uuid().optional(),
36
+ status: z.enum(BILL_RUN_OUTCOME_STATUSES).optional(),
37
+ })
38
+ .passthrough()
39
+
40
+ const billRunOutcomeListItemSchema = z.object({
41
+ id: z.string().uuid(),
42
+ billRunId: z.string().uuid(),
43
+ billAccountId: z.string().uuid(),
44
+ status: z.string(),
45
+ errorMessage: z.string().nullable(),
46
+ warnings: z.record(z.string(), z.unknown()).nullable(),
47
+ draftInvoiceId: z.string().uuid().nullable(),
48
+ bill_account_name: z.string().nullable(),
49
+ invoice_number: z.string().nullable(),
50
+ organizationId: z.string().uuid(),
51
+ tenantId: z.string().uuid(),
52
+ createdAt: z.string(),
53
+ updatedAt: z.string(),
54
+ })
55
+
56
+ const crud = makeCrudRoute({
57
+ metadata: routeMetadata,
58
+ orm: {
59
+ entity: BillRunOutcome,
60
+ idField: 'id',
61
+ orgField: 'organizationId',
62
+ tenantField: 'tenantId',
63
+ softDeleteField: 'deletedAt',
64
+ },
65
+ list: {
66
+ schema: listQuerySchema,
67
+ entityId: billingEntityIds.runOutcome,
68
+ fields: [
69
+ 'id',
70
+ 'bill_run_id',
71
+ 'bill_account_id',
72
+ 'status',
73
+ 'error_message',
74
+ 'warnings',
75
+ 'draft_invoice_id',
76
+ 'organization_id',
77
+ 'tenant_id',
78
+ 'created_at',
79
+ 'updated_at',
80
+ ],
81
+ sortFieldMap: {
82
+ createdAt: 'created_at',
83
+ },
84
+ buildFilters: async (query) => {
85
+ const filters: Record<string, unknown> = {}
86
+ if (typeof query.billRunId === 'string') filters.bill_run_id = { $eq: query.billRunId }
87
+ if (typeof query.billAccountId === 'string') {
88
+ filters.bill_account_id = { $eq: query.billAccountId }
89
+ }
90
+ if (typeof query.status === 'string') filters.status = { $eq: query.status }
91
+ return filters
92
+ },
93
+ },
94
+ hooks: {
95
+ // Outcomes reference account + draft invoice by FK id only. The
96
+ // run detail UI wants names — resolve both in two bounded queries.
97
+ afterList: async (payload: unknown, ctx: unknown) => {
98
+ const items = ((payload as { items?: unknown[] } | null)?.items ?? []) as Array<
99
+ Record<string, unknown>
100
+ >
101
+ if (items.length === 0) return
102
+ const em = (ctx as { container: AwilixContainer }).container.resolve<EntityManager>('em')
103
+ const accountIds = Array.from(
104
+ new Set(
105
+ items
106
+ .map((row) => row.bill_account_id)
107
+ .filter((v): v is string => typeof v === 'string' && v.length > 0),
108
+ ),
109
+ )
110
+ const invoiceIds = Array.from(
111
+ new Set(
112
+ items
113
+ .map((row) => row.draft_invoice_id)
114
+ .filter((v): v is string => typeof v === 'string' && v.length > 0),
115
+ ),
116
+ )
117
+ const [accounts, invoices] = await Promise.all([
118
+ accountIds.length
119
+ ? em.find(BillingAccount, { id: { $in: accountIds } })
120
+ : [],
121
+ invoiceIds.length
122
+ ? em.find(SalesInvoice, { id: { $in: invoiceIds } })
123
+ : [],
124
+ ])
125
+ const accountName = new Map(
126
+ accounts.map((a) => [String(a.id), a.name] as [string, unknown]),
127
+ )
128
+ const invoiceNumber = new Map(
129
+ invoices.map((i) => [String(i.id), i.invoiceNumber] as [string, unknown]),
130
+ )
131
+ for (const row of items) {
132
+ row.bill_account_name = accountName.get(String(row.bill_account_id)) ?? null
133
+ row.invoice_number =
134
+ typeof row.draft_invoice_id === 'string'
135
+ ? invoiceNumber.get(row.draft_invoice_id) ?? null
136
+ : null
137
+ }
138
+ },
139
+ },
140
+ })
141
+
142
+ export const GET = crud.GET
143
+
144
+ export const openApi = createBillingCrudOpenApi({
145
+ resourceName: 'BillRunOutcome',
146
+ pluralName: 'BillRunOutcomes',
147
+ querySchema: listQuerySchema,
148
+ listResponseSchema: createPagedListResponseSchema(billRunOutcomeListItemSchema),
149
+ })
@@ -0,0 +1,88 @@
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 { BillRun } from '../../../data/entities'
6
+ import { billingEntityIds } from '../../../data/entityIds'
7
+ import { billingRunRetryFailedSchema } from '../../../data/validators'
8
+ import { createBillingCrudOpenApi } from '../../openapi'
9
+
10
+ /**
11
+ * `POST /api/billing/runs/retry-failed`
12
+ *
13
+ * Creates a new Bill Run scoped to the failed-account ids from a parent
14
+ * run, with `parent_run_id` set on the new run so the retry chain is
15
+ * traceable. Returns the new run's id + status + outcomes count.
16
+ *
17
+ * Implemented as a "create-only" `makeCrudRoute` so the framework's
18
+ * RBAC + OpenAPI + command-bus plumbing stays consistent with the rest
19
+ * of the billing surface. The list/get half of the factory is never
20
+ * exercised on this URL — only POST is exported.
21
+ */
22
+
23
+ const routeMetadata = {
24
+ POST: { requireAuth: true, requireFeatures: ['billing.run.trigger'] },
25
+ }
26
+
27
+ export const metadata = routeMetadata
28
+
29
+ const retryResponseSchema = z.object({
30
+ billRunId: z.string().uuid(),
31
+ status: z.string(),
32
+ outcomesCount: z.number().int().nonnegative(),
33
+ })
34
+
35
+ const rawBodySchema = z.object({}).passthrough()
36
+
37
+ const crud = makeCrudRoute({
38
+ metadata: routeMetadata,
39
+ orm: {
40
+ entity: BillRun,
41
+ idField: 'id',
42
+ orgField: 'organizationId',
43
+ tenantField: 'tenantId',
44
+ softDeleteField: 'deletedAt',
45
+ },
46
+ // No `list` — POST-only route. Keep a stub so the factory type-checks.
47
+ list: {
48
+ schema: z.object({}).passthrough(),
49
+ entityId: billingEntityIds.run,
50
+ fields: ['id'],
51
+ buildFilters: async () => ({}),
52
+ },
53
+ actions: {
54
+ create: {
55
+ commandId: 'billing.runs.retry_failed',
56
+ schema: rawBodySchema,
57
+ mapInput: async ({ raw, ctx }) => {
58
+ const { translate } = await resolveTranslations()
59
+ const scoped = withScopedPayload(raw ?? {}, ctx, translate)
60
+ return billingRunRetryFailedSchema.parse(scoped)
61
+ },
62
+ response: ({ result }) => ({
63
+ billRunId: result?.billRunId ?? null,
64
+ status: result?.status ?? null,
65
+ outcomesCount: result?.outcomesCount ?? 0,
66
+ }),
67
+ status: 201,
68
+ },
69
+ },
70
+ })
71
+
72
+ export const POST = crud.POST
73
+
74
+ export const openApi = createBillingCrudOpenApi({
75
+ resourceName: 'BillRunRetry',
76
+ pluralName: 'BillRunRetries',
77
+ querySchema: z.object({}).passthrough(),
78
+ listResponseSchema: z.object({ items: z.array(z.unknown()) }),
79
+ create: {
80
+ schema: billingRunRetryFailedSchema,
81
+ responseSchema: retryResponseSchema,
82
+ description:
83
+ 'Re-runs the failed accounts of an earlier Bill Run. The new run ' +
84
+ 'inherits the parent run\'s `asOfDate` and `catchUp` flag; its ' +
85
+ '`parent_run_id` points back at the source. Returns 409 if there ' +
86
+ 'are no failed outcomes or if another run is in flight.',
87
+ },
88
+ })
@@ -0,0 +1,170 @@
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 { BillRun } from '../../data/entities'
6
+ import { billingEntityIds } from '../../data/entityIds'
7
+ import {
8
+ billingRunListQuerySchema,
9
+ billingRunTriggerSchema,
10
+ } from '../../data/validators'
11
+ import { createBillingCrudOpenApi, createPagedListResponseSchema } from '../openapi'
12
+
13
+ /**
14
+ * `/api/billing/runs` — GET (history list) + POST (manual trigger).
15
+ *
16
+ * The triggered-by enum gate:
17
+ * - `trigger` feature is required for `real` mode (which mutates
18
+ * production state).
19
+ * - `dry_run` feature alone is enough for `mode=dry` (finance auditor
20
+ * persona). The route-level RBAC check on POST requires `trigger`,
21
+ * so dry-run callers must hold both `dry_run` AND `trigger` here —
22
+ * a deliberate tightening over what the spec persona table suggests.
23
+ *
24
+ * If we ever want auditors to drive dry-runs without holding `trigger`,
25
+ * we split into two routes (POST `/runs` for real, POST `/runs/dry` for
26
+ * dry). Postponed until an operator actually asks.
27
+ */
28
+
29
+ const routeMetadata = {
30
+ GET: { requireAuth: true, requireFeatures: ['billing.run.view'] },
31
+ POST: { requireAuth: true, requireFeatures: ['billing.run.trigger'] },
32
+ }
33
+
34
+ export const metadata = routeMetadata
35
+
36
+ // The list endpoint projects raw column names — snake_case.
37
+ const billRunListItemSchema = z.object({
38
+ id: z.string().uuid(),
39
+ triggered_by: z.string(),
40
+ parent_run_id: z.string().uuid().nullable(),
41
+ dry_run: z.boolean(),
42
+ test_mode: z.boolean(),
43
+ catch_up: z.boolean(),
44
+ scoped_account_ids: z.array(z.string().uuid()).nullable(),
45
+ as_of_date: z.string(),
46
+ started_at: z.string().nullable(),
47
+ finished_at: z.string().nullable(),
48
+ status: z.string(),
49
+ summary: z.record(z.string(), z.unknown()).nullable(),
50
+ organization_id: z.string().uuid(),
51
+ tenant_id: z.string().uuid(),
52
+ created_at: z.string(),
53
+ updated_at: z.string(),
54
+ })
55
+
56
+ const triggerResponseSchema = z.object({
57
+ billRunId: z.string().uuid(),
58
+ status: z.string(),
59
+ outcomesCount: z.number().int().nonnegative(),
60
+ })
61
+
62
+ const rawBodySchema = z.object({}).passthrough()
63
+
64
+ const crud = makeCrudRoute({
65
+ metadata: routeMetadata,
66
+ orm: {
67
+ entity: BillRun,
68
+ idField: 'id',
69
+ orgField: 'organizationId',
70
+ tenantField: 'tenantId',
71
+ softDeleteField: 'deletedAt',
72
+ },
73
+ list: {
74
+ schema: billingRunListQuerySchema,
75
+ entityId: billingEntityIds.run,
76
+ fields: [
77
+ 'id',
78
+ 'triggered_by',
79
+ 'parent_run_id',
80
+ 'dry_run',
81
+ 'test_mode',
82
+ 'catch_up',
83
+ 'scoped_account_ids',
84
+ 'as_of_date',
85
+ 'started_at',
86
+ 'finished_at',
87
+ 'status',
88
+ 'summary',
89
+ 'organization_id',
90
+ 'tenant_id',
91
+ 'created_at',
92
+ 'updated_at',
93
+ ],
94
+ sortFieldMap: {
95
+ startedAt: 'started_at',
96
+ finishedAt: 'finished_at',
97
+ asOfDate: 'as_of_date',
98
+ createdAt: 'created_at',
99
+ },
100
+ buildFilters: async (query) => {
101
+ const filters: Record<string, unknown> = {}
102
+ // `?id=<uuid>` narrows the list to one row — the detail page
103
+ // reads a single Bill Run through this filter.
104
+ if (typeof query.id === 'string' && query.id) {
105
+ filters.id = { $eq: query.id }
106
+ }
107
+ // Run ids are UUIDs — search matches a pasted id exactly.
108
+ if (typeof query.search === 'string' && /^[0-9a-fA-F-]{36}$/.test(query.search.trim())) {
109
+ filters.id = { $eq: query.search.trim() }
110
+ }
111
+ if (typeof query.status === 'string') filters.status = { $eq: query.status }
112
+ if (typeof query.triggeredBy === 'string') filters.triggered_by = { $eq: query.triggeredBy }
113
+ if (typeof query.parentRunId === 'string') filters.parent_run_id = { $eq: query.parentRunId }
114
+ if (query.asOfDateFrom instanceof Date) {
115
+ filters.as_of_date = Object.assign(
116
+ (filters.as_of_date as Record<string, unknown>) ?? {},
117
+ { $gte: query.asOfDateFrom },
118
+ )
119
+ }
120
+ if (query.asOfDateTo instanceof Date) {
121
+ filters.as_of_date = Object.assign(
122
+ (filters.as_of_date as Record<string, unknown>) ?? {},
123
+ { $lte: query.asOfDateTo },
124
+ )
125
+ }
126
+ return filters
127
+ },
128
+ },
129
+ actions: {
130
+ create: {
131
+ commandId: 'billing.runs.trigger',
132
+ schema: rawBodySchema,
133
+ mapInput: async ({ raw, ctx }) => {
134
+ const { translate } = await resolveTranslations()
135
+ const scoped = withScopedPayload(raw ?? {}, ctx, translate)
136
+ return billingRunTriggerSchema.parse(scoped)
137
+ },
138
+ response: ({ result }) => ({
139
+ billRunId: result?.billRunId ?? null,
140
+ status: result?.status ?? null,
141
+ outcomesCount: result?.outcomesCount ?? 0,
142
+ }),
143
+ status: 201,
144
+ },
145
+ // Runs are append-only — no update / delete on this surface. A
146
+ // failed run is corrected by triggering a `retry-failed` (separate
147
+ // route).
148
+ },
149
+ })
150
+
151
+ const { POST } = crud
152
+ export { POST }
153
+ export const GET = crud.GET
154
+
155
+ export const openApi = createBillingCrudOpenApi({
156
+ resourceName: 'BillRun',
157
+ pluralName: 'BillRuns',
158
+ querySchema: billingRunListQuerySchema,
159
+ listResponseSchema: createPagedListResponseSchema(billRunListItemSchema),
160
+ create: {
161
+ schema: billingRunTriggerSchema,
162
+ responseSchema: triggerResponseSchema,
163
+ description:
164
+ 'Triggers a Bill Run. `mode=real` (default) persists drafts and ' +
165
+ 'advances state; `mode=test` persists test-flagged drafts without ' +
166
+ 'advancing state; `mode=dry` computes everything in memory and ' +
167
+ 'persists nothing. Returns HTTP 409 (`billing.run.lock_busy`) when ' +
168
+ 'another run is in flight for the same tenant.',
169
+ },
170
+ })