@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,438 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands/types'
3
+ import { registerCommand } from '@open-mercato/shared/lib/commands'
4
+ import {
5
+ ensureOrganizationScope,
6
+ ensureTenantScope,
7
+ } from '@open-mercato/shared/lib/commands/scope'
8
+ import {
9
+ emitCrudSideEffects,
10
+ requireId,
11
+ } from '@open-mercato/shared/lib/commands/helpers'
12
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
13
+ import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
14
+ import { BillingAccount, BillingItem } from '../data/entities'
15
+ import { billingEntityIds } from '../data/entityIds'
16
+ import { findBySourceRef } from '../lib/idempotency'
17
+ import {
18
+ billingItemBulkCreateSchema,
19
+ billingItemCreateSchema,
20
+ billingItemDeleteSchema,
21
+ billingItemUpdateSchema,
22
+ validateRateJson,
23
+ type BillingItemBulkCreateInput,
24
+ type BillingItemCreateInput,
25
+ type BillingItemDeleteInput,
26
+ type BillingItemUpdateInput,
27
+ } from '../data/validators'
28
+
29
+ /**
30
+ * BillingItem writes — create / update / soft-delete.
31
+ *
32
+ * Create is idempotent via `source_ref`: if the integrator POSTs the
33
+ * same `(tenant_id, bill_account_id, source_ref)` twice, the second
34
+ * call returns the existing item with `deduplicated: true`. The
35
+ * underlying unique index is the correctness gate; this pre-check keeps
36
+ * the happy path single-round-trip and the response body informative.
37
+ */
38
+
39
+ const itemIndexer = { entityType: billingEntityIds.item } as const
40
+ const itemEvents = { module: 'billing', entity: 'item', persistent: true } as const
41
+
42
+ function getEm(ctx: CommandRuntimeContext): EntityManager {
43
+ return (ctx.container.resolve('em') as EntityManager).fork()
44
+ }
45
+
46
+ function getDataEngine(ctx: CommandRuntimeContext): DataEngine {
47
+ return ctx.container.resolve('dataEngine') as DataEngine
48
+ }
49
+
50
+ async function assertAccountInScope(
51
+ em: EntityManager,
52
+ params: { tenantId: string; organizationId: string; billAccountId: string },
53
+ ): Promise<BillingAccount> {
54
+ const account = await em.findOne(BillingAccount, {
55
+ id: params.billAccountId,
56
+ tenantId: params.tenantId,
57
+ organizationId: params.organizationId,
58
+ deletedAt: null,
59
+ })
60
+ if (!account) {
61
+ throw new CrudHttpError(404, { error: 'Billing account not found' })
62
+ }
63
+ return account
64
+ }
65
+
66
+ // ─── Create (idempotent) ─────────────────────────────────────────
67
+
68
+ const createItemCommand: CommandHandler<
69
+ BillingItemCreateInput,
70
+ { id: string; deduplicated: boolean }
71
+ > = {
72
+ id: 'billing.items.create',
73
+
74
+ async execute(rawInput, ctx) {
75
+ const parsed = billingItemCreateSchema.parse(rawInput)
76
+ ensureTenantScope(ctx, parsed.tenantId)
77
+ ensureOrganizationScope(ctx, parsed.organizationId)
78
+
79
+ const em = getEm(ctx)
80
+ const account = await assertAccountInScope(em, parsed)
81
+
82
+ // Idempotency: if `source_ref` provided and matches an existing
83
+ // (non-deleted) row, return that row. The unique index is the
84
+ // final correctness gate — this is the happy-path early return.
85
+ if (parsed.sourceRef) {
86
+ const existing = await findBySourceRef(em, BillingItem, {
87
+ tenantId: parsed.tenantId,
88
+ billAccountId: parsed.billAccountId,
89
+ sourceRef: parsed.sourceRef,
90
+ })
91
+ if (existing) {
92
+ return { id: existing.id, deduplicated: true }
93
+ }
94
+ }
95
+
96
+ // Re-validate the rate_json shape against the resolved type to
97
+ // future-proof against schema drift between the API surface and
98
+ // the persistence layer.
99
+ validateRateJson(parsed.type, parsed.rateJson)
100
+
101
+ const now = new Date()
102
+ const entity = em.create(BillingItem, {
103
+ organizationId: parsed.organizationId,
104
+ tenantId: parsed.tenantId,
105
+ billAccountId: account.id,
106
+ type: parsed.type,
107
+ billStartDate: parsed.billStartDate,
108
+ billEndDate: parsed.billEndDate ?? null,
109
+ description: parsed.description,
110
+ rateJson: parsed.rateJson as Record<string, unknown>,
111
+ uomCode: parsed.uomCode ?? null,
112
+ subscriptionId: parsed.subscriptionId ?? null,
113
+ subscriptionItemId: parsed.subscriptionItemId ?? null,
114
+ sourceRef: parsed.sourceRef ?? null,
115
+ // `currency_mismatch` is set by Phase 2 / connectors when the
116
+ // posted value's currency differs from the account's. Stays
117
+ // false on create (operator opts in to flagging).
118
+ currencyMismatch: false,
119
+ // `billed_to_date` is set by the Bill Run engine on the first
120
+ // real-run that emits this item. Always null on create.
121
+ billedToDate: null,
122
+ isActive: true,
123
+ createdAt: now,
124
+ updatedAt: now,
125
+ })
126
+ em.persist(entity)
127
+ await em.flush()
128
+
129
+ await emitCrudSideEffects({
130
+ dataEngine: getDataEngine(ctx),
131
+ action: 'created',
132
+ entity,
133
+ identifiers: {
134
+ id: entity.id,
135
+ tenantId: entity.tenantId,
136
+ organizationId: entity.organizationId,
137
+ },
138
+ indexer: itemIndexer,
139
+ events: itemEvents,
140
+ })
141
+
142
+ return { id: entity.id, deduplicated: false }
143
+ },
144
+ }
145
+
146
+ // ─── Update ──────────────────────────────────────────────────────
147
+
148
+ const updateItemCommand: CommandHandler<BillingItemUpdateInput, { id: string }> = {
149
+ id: 'billing.items.update',
150
+
151
+ async execute(rawInput, ctx) {
152
+ const parsed = billingItemUpdateSchema.parse(rawInput)
153
+ ensureTenantScope(ctx, parsed.tenantId)
154
+ ensureOrganizationScope(ctx, parsed.organizationId)
155
+
156
+ const em = getEm(ctx)
157
+ const record = await em.findOne(BillingItem, {
158
+ id: parsed.id,
159
+ tenantId: parsed.tenantId,
160
+ organizationId: parsed.organizationId,
161
+ deletedAt: null,
162
+ })
163
+ if (!record) {
164
+ throw new CrudHttpError(404, { error: 'Billing item not found' })
165
+ }
166
+
167
+ if (parsed.billStartDate !== undefined) record.billStartDate = parsed.billStartDate
168
+ if (parsed.billEndDate !== undefined) record.billEndDate = parsed.billEndDate
169
+ if (parsed.description !== undefined) record.description = parsed.description
170
+ if (parsed.rateJson !== undefined) {
171
+ // Re-validate against the current (immutable) type before persist.
172
+ validateRateJson(record.type as 'one_time' | 'recurring' | 'usage', parsed.rateJson)
173
+ record.rateJson = parsed.rateJson as Record<string, unknown>
174
+ }
175
+ if (parsed.uomCode !== undefined) record.uomCode = parsed.uomCode
176
+ if (parsed.subscriptionId !== undefined) record.subscriptionId = parsed.subscriptionId
177
+ if (parsed.subscriptionItemId !== undefined) {
178
+ record.subscriptionItemId = parsed.subscriptionItemId
179
+ }
180
+ if (parsed.isActive !== undefined) record.isActive = parsed.isActive
181
+
182
+ record.updatedAt = new Date()
183
+ await em.flush()
184
+
185
+ await emitCrudSideEffects({
186
+ dataEngine: getDataEngine(ctx),
187
+ action: 'updated',
188
+ entity: record,
189
+ identifiers: {
190
+ id: record.id,
191
+ tenantId: record.tenantId,
192
+ organizationId: record.organizationId,
193
+ },
194
+ indexer: itemIndexer,
195
+ events: itemEvents,
196
+ })
197
+
198
+ return { id: record.id }
199
+ },
200
+ }
201
+
202
+ // ─── Delete (soft) ───────────────────────────────────────────────
203
+
204
+ const deleteItemCommand: CommandHandler<BillingItemDeleteInput, { id: string }> = {
205
+ id: 'billing.items.delete',
206
+
207
+ async execute(rawInput, ctx) {
208
+ const parsed = billingItemDeleteSchema.parse(rawInput)
209
+ ensureTenantScope(ctx, parsed.tenantId)
210
+ ensureOrganizationScope(ctx, parsed.organizationId)
211
+ const id = requireId(parsed)
212
+
213
+ const em = getEm(ctx)
214
+ const record = await em.findOne(BillingItem, {
215
+ id,
216
+ tenantId: parsed.tenantId,
217
+ organizationId: parsed.organizationId,
218
+ deletedAt: null,
219
+ })
220
+ if (!record) {
221
+ throw new CrudHttpError(404, { error: 'Billing item not found' })
222
+ }
223
+
224
+ record.deletedAt = new Date()
225
+ record.updatedAt = record.deletedAt
226
+ await em.flush()
227
+
228
+ await emitCrudSideEffects({
229
+ dataEngine: getDataEngine(ctx),
230
+ action: 'deleted',
231
+ entity: record,
232
+ identifiers: {
233
+ id: record.id,
234
+ tenantId: record.tenantId,
235
+ organizationId: record.organizationId,
236
+ },
237
+ indexer: itemIndexer,
238
+ events: itemEvents,
239
+ })
240
+
241
+ return { id: record.id }
242
+ },
243
+ }
244
+
245
+ // ─── Bulk create (idempotent, batched) ───────────────────────────
246
+ //
247
+ // Collapses N `billing.items.create` round-trips into one command.
248
+ // The CPQ connector activating a large subscription is the primary
249
+ // caller — it used to fire one create per charge.
250
+ //
251
+ // Idempotency is still per-`source_ref` but the existence check is a
252
+ // SINGLE query over all source_refs in the batch, instead of one
253
+ // `findOne` per item. Inserts are one `em.flush()` for the whole
254
+ // batch. So a 200-charge activation goes from ~200 round-trips +
255
+ // 200 flushes to ~3 queries + 1 flush.
256
+
257
+ type BulkCreateResultEntry = {
258
+ sourceRef: string | null
259
+ id: string
260
+ deduplicated: boolean
261
+ }
262
+
263
+ const bulkCreateItemsCommand: CommandHandler<
264
+ BillingItemBulkCreateInput,
265
+ { created: number; deduplicated: number; items: BulkCreateResultEntry[] }
266
+ > = {
267
+ id: 'billing.items.bulk_create',
268
+
269
+ async execute(rawInput, ctx) {
270
+ const parsed = billingItemBulkCreateSchema.parse(rawInput)
271
+ ensureTenantScope(ctx, parsed.tenantId)
272
+ ensureOrganizationScope(ctx, parsed.organizationId)
273
+
274
+ const em = getEm(ctx)
275
+
276
+ // Validate every referenced account exists + is in scope. One
277
+ // `$in` query for the distinct account ids, not one per item.
278
+ const accountIds = Array.from(new Set(parsed.items.map((i) => i.billAccountId)))
279
+ const accounts = await em.find(BillingAccount, {
280
+ id: { $in: accountIds },
281
+ tenantId: parsed.tenantId,
282
+ organizationId: parsed.organizationId,
283
+ deletedAt: null,
284
+ } as never)
285
+ const knownAccountIds = new Set(accounts.map((a) => a.id))
286
+ const missingAccount = accountIds.find((id) => !knownAccountIds.has(id))
287
+ if (missingAccount) {
288
+ throw new CrudHttpError(404, {
289
+ error: 'Billing account not found',
290
+ billAccountId: missingAccount,
291
+ })
292
+ }
293
+
294
+ // Batched idempotency check — one query over every source_ref in
295
+ // the batch. `source_ref` is unique per `(tenant, bill_account,
296
+ // source_ref)`, so we key the existing-set on that triple.
297
+ const sourceRefs = parsed.items
298
+ .map((i) => i.sourceRef)
299
+ .filter((ref): ref is string => typeof ref === 'string' && ref.length > 0)
300
+ const existingBySourceKey = new Map<string, string>()
301
+ if (sourceRefs.length > 0) {
302
+ const existing = await em.find(BillingItem, {
303
+ tenantId: parsed.tenantId,
304
+ organizationId: parsed.organizationId,
305
+ billAccountId: { $in: accountIds },
306
+ sourceRef: { $in: Array.from(new Set(sourceRefs)) },
307
+ deletedAt: null,
308
+ } as never)
309
+ for (const row of existing) {
310
+ if (row.sourceRef) {
311
+ existingBySourceKey.set(`${row.billAccountId}::${row.sourceRef}`, row.id)
312
+ }
313
+ }
314
+ }
315
+
316
+ const now = new Date()
317
+ const results: BulkCreateResultEntry[] = []
318
+ const createdEntities: BillingItem[] = []
319
+ // Result entries whose `id` is only known after the flush — the
320
+ // BillingItem PK is a DB-generated UUID, unassigned until then.
321
+ // Covers freshly-created rows and in-batch dedup rows that point
322
+ // at a sibling created earlier in the same payload.
323
+ const deferredIdLinks: Array<{ result: BulkCreateResultEntry; entity: BillingItem }> = []
324
+
325
+ // Track source_refs created within THIS batch so a payload that
326
+ // repeats the same source_ref twice dedups against itself (the
327
+ // unique index would otherwise blow up the whole flush).
328
+ const seenInBatch = new Set<string>()
329
+
330
+ for (const entry of parsed.items) {
331
+ const sourceKey = entry.sourceRef
332
+ ? `${entry.billAccountId}::${entry.sourceRef}`
333
+ : null
334
+
335
+ if (sourceKey) {
336
+ const existingId = existingBySourceKey.get(sourceKey)
337
+ if (existingId) {
338
+ results.push({ sourceRef: entry.sourceRef ?? null, id: existingId, deduplicated: true })
339
+ continue
340
+ }
341
+ if (seenInBatch.has(sourceKey)) {
342
+ // Duplicate source_ref inside the same payload — first wins,
343
+ // the repeat is reported deduplicated against the first.
344
+ const firstCreated = createdEntities.find(
345
+ (e) => e.billAccountId === entry.billAccountId && e.sourceRef === entry.sourceRef,
346
+ )
347
+ const dedupResult: BulkCreateResultEntry = {
348
+ sourceRef: entry.sourceRef ?? null,
349
+ id: '',
350
+ deduplicated: true,
351
+ }
352
+ results.push(dedupResult)
353
+ if (firstCreated) deferredIdLinks.push({ result: dedupResult, entity: firstCreated })
354
+ continue
355
+ }
356
+ seenInBatch.add(sourceKey)
357
+ }
358
+
359
+ validateRateJson(entry.type, entry.rateJson)
360
+ const item = em.create(BillingItem, {
361
+ organizationId: parsed.organizationId,
362
+ tenantId: parsed.tenantId,
363
+ billAccountId: entry.billAccountId,
364
+ type: entry.type,
365
+ billStartDate: entry.billStartDate,
366
+ billEndDate: entry.billEndDate ?? null,
367
+ description: entry.description,
368
+ rateJson: entry.rateJson as Record<string, unknown>,
369
+ uomCode: entry.uomCode ?? null,
370
+ subscriptionId: entry.subscriptionId ?? null,
371
+ subscriptionItemId: entry.subscriptionItemId ?? null,
372
+ sourceRef: entry.sourceRef ?? null,
373
+ currencyMismatch: false,
374
+ billedToDate: null,
375
+ isActive: true,
376
+ createdAt: now,
377
+ updatedAt: now,
378
+ })
379
+ em.persist(item)
380
+ createdEntities.push(item)
381
+ const createdResult: BulkCreateResultEntry = {
382
+ sourceRef: entry.sourceRef ?? null,
383
+ id: '',
384
+ deduplicated: false,
385
+ }
386
+ results.push(createdResult)
387
+ deferredIdLinks.push({ result: createdResult, entity: item })
388
+ }
389
+
390
+ // Single flush for the whole batch.
391
+ if (createdEntities.length > 0) {
392
+ await em.flush()
393
+ }
394
+
395
+ // The BillingItem UUID PK is assigned by the flush — backfill it
396
+ // into the result entries now that the value exists.
397
+ for (const link of deferredIdLinks) {
398
+ link.result.id = link.entity.id
399
+ }
400
+
401
+ // Side effects after the commit. Emitting per-entity keeps the
402
+ // query-index + event contract identical to single-create — the
403
+ // engine / search index can't tell a bulk insert from N singles.
404
+ const dataEngine = getDataEngine(ctx)
405
+ for (const item of createdEntities) {
406
+ await emitCrudSideEffects({
407
+ dataEngine,
408
+ action: 'created',
409
+ entity: item,
410
+ identifiers: {
411
+ id: item.id,
412
+ tenantId: item.tenantId,
413
+ organizationId: item.organizationId,
414
+ },
415
+ indexer: itemIndexer,
416
+ events: itemEvents,
417
+ })
418
+ }
419
+
420
+ return {
421
+ created: createdEntities.length,
422
+ deduplicated: results.filter((r) => r.deduplicated).length,
423
+ items: results,
424
+ }
425
+ },
426
+ }
427
+
428
+ registerCommand(createItemCommand)
429
+ registerCommand(updateItemCommand)
430
+ registerCommand(deleteItemCommand)
431
+ registerCommand(bulkCreateItemsCommand)
432
+
433
+ export {
434
+ createItemCommand,
435
+ updateItemCommand,
436
+ deleteItemCommand,
437
+ bulkCreateItemsCommand,
438
+ }
@@ -0,0 +1,166 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { AwilixContainer } from 'awilix'
3
+ import type { CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands/types'
4
+ import { registerCommand } from '@open-mercato/shared/lib/commands'
5
+ import {
6
+ ensureOrganizationScope,
7
+ ensureTenantScope,
8
+ } from '@open-mercato/shared/lib/commands/scope'
9
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
10
+ import { BillRun, BillRunOutcome } from '../data/entities'
11
+ import {
12
+ billingRunRetryFailedSchema,
13
+ billingRunTriggerSchema,
14
+ type BillingRunRetryFailedInput,
15
+ type BillingRunTriggerInput,
16
+ } from '../data/validators'
17
+ import { runBillRun } from '../lib/billRunEngine'
18
+ import { TenantLockBusyError } from '../lib/tenantLock'
19
+
20
+ /**
21
+ * Bill Run trigger commands.
22
+ *
23
+ * Both commands wrap `runBillRun(...)` from `lib/billRunEngine`. The
24
+ * lock-busy condition (concurrent run on the same tenant) is mapped to
25
+ * HTTP 409 here so the API surface returns the canonical Conflict
26
+ * response without each route knowing the lock primitive's
27
+ * implementation.
28
+ *
29
+ * `triggeredBy` is intentionally NOT exposed on the input schema —
30
+ * client-driven triggers are always `'manual'`. The scheduler worker
31
+ * (Phase 4) injects `'schedule'` directly through a separate
32
+ * orchestrator path, not through this command.
33
+ */
34
+
35
+ function getEm(ctx: CommandRuntimeContext): EntityManager {
36
+ return (ctx.container.resolve('em') as EntityManager).fork()
37
+ }
38
+
39
+ function getContainer(ctx: CommandRuntimeContext): AwilixContainer {
40
+ return ctx.container as AwilixContainer
41
+ }
42
+
43
+ function asLockBusyError(err: unknown): err is TenantLockBusyError {
44
+ return err instanceof TenantLockBusyError
45
+ }
46
+
47
+ // ─── Trigger ─────────────────────────────────────────────────────
48
+
49
+ type TriggerResult = {
50
+ billRunId: string
51
+ status: string
52
+ outcomesCount: number
53
+ }
54
+
55
+ const triggerRunCommand: CommandHandler<BillingRunTriggerInput, TriggerResult> = {
56
+ id: 'billing.runs.trigger',
57
+
58
+ async execute(rawInput, ctx) {
59
+ const parsed = billingRunTriggerSchema.parse(rawInput)
60
+ ensureTenantScope(ctx, parsed.tenantId)
61
+ ensureOrganizationScope(ctx, parsed.organizationId)
62
+
63
+ const em = getEm(ctx)
64
+ const asOfDate = parsed.asOfDate ?? new Date()
65
+
66
+ try {
67
+ const { billRun, outcomes } = await runBillRun(em, getContainer(ctx), {
68
+ tenantId: parsed.tenantId,
69
+ organizationId: parsed.organizationId,
70
+ asOfDate,
71
+ triggeredBy: 'manual',
72
+ mode: parsed.mode,
73
+ scopedAccountIds: parsed.scopedAccountIds,
74
+ catchUp: parsed.catchUp,
75
+ })
76
+ return {
77
+ billRunId: billRun.id,
78
+ status: billRun.status,
79
+ outcomesCount: outcomes.length,
80
+ }
81
+ } catch (err) {
82
+ if (asLockBusyError(err)) {
83
+ // The route metadata maps `CrudHttpError(409)` to the canonical
84
+ // 409 Conflict response. The error body carries the
85
+ // `error_lock_busy` discriminator so clients can distinguish
86
+ // "lock busy" from other 409 conditions.
87
+ throw new CrudHttpError(409, {
88
+ error: 'Another Bill Run is already in progress for this tenant',
89
+ code: 'billing.run.lock_busy',
90
+ tenantId: err.tenantId,
91
+ lockName: err.lockName,
92
+ })
93
+ }
94
+ throw err
95
+ }
96
+ },
97
+ }
98
+
99
+ // ─── Retry failed ────────────────────────────────────────────────
100
+
101
+ const retryFailedCommand: CommandHandler<BillingRunRetryFailedInput, TriggerResult> = {
102
+ id: 'billing.runs.retry_failed',
103
+
104
+ async execute(rawInput, ctx) {
105
+ const parsed = billingRunRetryFailedSchema.parse(rawInput)
106
+ ensureTenantScope(ctx, parsed.tenantId)
107
+ ensureOrganizationScope(ctx, parsed.organizationId)
108
+
109
+ const em = getEm(ctx)
110
+ const parentRun = await em.findOne(BillRun, {
111
+ id: parsed.billRunId,
112
+ tenantId: parsed.tenantId,
113
+ organizationId: parsed.organizationId,
114
+ deletedAt: null,
115
+ })
116
+ if (!parentRun) {
117
+ throw new CrudHttpError(404, { error: 'Parent Bill Run not found' })
118
+ }
119
+ const failedOutcomes = await em.find(BillRunOutcome, {
120
+ billRunId: parentRun.id,
121
+ tenantId: parsed.tenantId,
122
+ organizationId: parsed.organizationId,
123
+ status: 'failed',
124
+ })
125
+ if (failedOutcomes.length === 0) {
126
+ throw new CrudHttpError(409, {
127
+ error: 'Parent run has no failed outcomes to retry',
128
+ code: 'billing.run.nothing_to_retry',
129
+ })
130
+ }
131
+ const scopedAccountIds = Array.from(new Set(failedOutcomes.map((o) => o.billAccountId)))
132
+
133
+ try {
134
+ const { billRun, outcomes } = await runBillRun(em, getContainer(ctx), {
135
+ tenantId: parsed.tenantId,
136
+ organizationId: parsed.organizationId,
137
+ asOfDate: parentRun.asOfDate,
138
+ triggeredBy: 'manual',
139
+ mode: parsed.mode,
140
+ scopedAccountIds,
141
+ parentRunId: parentRun.id,
142
+ catchUp: parentRun.catchUp,
143
+ })
144
+ return {
145
+ billRunId: billRun.id,
146
+ status: billRun.status,
147
+ outcomesCount: outcomes.length,
148
+ }
149
+ } catch (err) {
150
+ if (asLockBusyError(err)) {
151
+ throw new CrudHttpError(409, {
152
+ error: 'Another Bill Run is already in progress for this tenant',
153
+ code: 'billing.run.lock_busy',
154
+ tenantId: err.tenantId,
155
+ lockName: err.lockName,
156
+ })
157
+ }
158
+ throw err
159
+ }
160
+ },
161
+ }
162
+
163
+ registerCommand(triggerRunCommand)
164
+ registerCommand(retryFailedCommand)
165
+
166
+ export { triggerRunCommand, retryFailedCommand }