@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,317 @@
1
+ import { registerCommand } from "@open-mercato/shared/lib/commands";
2
+ import {
3
+ ensureOrganizationScope,
4
+ ensureTenantScope
5
+ } from "@open-mercato/shared/lib/commands/scope";
6
+ import {
7
+ emitCrudSideEffects,
8
+ requireId
9
+ } from "@open-mercato/shared/lib/commands/helpers";
10
+ import { CrudHttpError } from "@open-mercato/shared/lib/crud/errors";
11
+ import { BillingAccount, BillingItem } from "../data/entities.js";
12
+ import { billingEntityIds } from "../data/entityIds.js";
13
+ import { findBySourceRef } from "../lib/idempotency.js";
14
+ import {
15
+ billingItemBulkCreateSchema,
16
+ billingItemCreateSchema,
17
+ billingItemDeleteSchema,
18
+ billingItemUpdateSchema,
19
+ validateRateJson
20
+ } from "../data/validators.js";
21
+ const itemIndexer = { entityType: billingEntityIds.item };
22
+ const itemEvents = { module: "billing", entity: "item", persistent: true };
23
+ function getEm(ctx) {
24
+ return ctx.container.resolve("em").fork();
25
+ }
26
+ function getDataEngine(ctx) {
27
+ return ctx.container.resolve("dataEngine");
28
+ }
29
+ async function assertAccountInScope(em, params) {
30
+ const account = await em.findOne(BillingAccount, {
31
+ id: params.billAccountId,
32
+ tenantId: params.tenantId,
33
+ organizationId: params.organizationId,
34
+ deletedAt: null
35
+ });
36
+ if (!account) {
37
+ throw new CrudHttpError(404, { error: "Billing account not found" });
38
+ }
39
+ return account;
40
+ }
41
+ const createItemCommand = {
42
+ id: "billing.items.create",
43
+ async execute(rawInput, ctx) {
44
+ const parsed = billingItemCreateSchema.parse(rawInput);
45
+ ensureTenantScope(ctx, parsed.tenantId);
46
+ ensureOrganizationScope(ctx, parsed.organizationId);
47
+ const em = getEm(ctx);
48
+ const account = await assertAccountInScope(em, parsed);
49
+ if (parsed.sourceRef) {
50
+ const existing = await findBySourceRef(em, BillingItem, {
51
+ tenantId: parsed.tenantId,
52
+ billAccountId: parsed.billAccountId,
53
+ sourceRef: parsed.sourceRef
54
+ });
55
+ if (existing) {
56
+ return { id: existing.id, deduplicated: true };
57
+ }
58
+ }
59
+ validateRateJson(parsed.type, parsed.rateJson);
60
+ const now = /* @__PURE__ */ new Date();
61
+ const entity = em.create(BillingItem, {
62
+ organizationId: parsed.organizationId,
63
+ tenantId: parsed.tenantId,
64
+ billAccountId: account.id,
65
+ type: parsed.type,
66
+ billStartDate: parsed.billStartDate,
67
+ billEndDate: parsed.billEndDate ?? null,
68
+ description: parsed.description,
69
+ rateJson: parsed.rateJson,
70
+ uomCode: parsed.uomCode ?? null,
71
+ subscriptionId: parsed.subscriptionId ?? null,
72
+ subscriptionItemId: parsed.subscriptionItemId ?? null,
73
+ sourceRef: parsed.sourceRef ?? null,
74
+ // `currency_mismatch` is set by Phase 2 / connectors when the
75
+ // posted value's currency differs from the account's. Stays
76
+ // false on create (operator opts in to flagging).
77
+ currencyMismatch: false,
78
+ // `billed_to_date` is set by the Bill Run engine on the first
79
+ // real-run that emits this item. Always null on create.
80
+ billedToDate: null,
81
+ isActive: true,
82
+ createdAt: now,
83
+ updatedAt: now
84
+ });
85
+ em.persist(entity);
86
+ await em.flush();
87
+ await emitCrudSideEffects({
88
+ dataEngine: getDataEngine(ctx),
89
+ action: "created",
90
+ entity,
91
+ identifiers: {
92
+ id: entity.id,
93
+ tenantId: entity.tenantId,
94
+ organizationId: entity.organizationId
95
+ },
96
+ indexer: itemIndexer,
97
+ events: itemEvents
98
+ });
99
+ return { id: entity.id, deduplicated: false };
100
+ }
101
+ };
102
+ const updateItemCommand = {
103
+ id: "billing.items.update",
104
+ async execute(rawInput, ctx) {
105
+ const parsed = billingItemUpdateSchema.parse(rawInput);
106
+ ensureTenantScope(ctx, parsed.tenantId);
107
+ ensureOrganizationScope(ctx, parsed.organizationId);
108
+ const em = getEm(ctx);
109
+ const record = await em.findOne(BillingItem, {
110
+ id: parsed.id,
111
+ tenantId: parsed.tenantId,
112
+ organizationId: parsed.organizationId,
113
+ deletedAt: null
114
+ });
115
+ if (!record) {
116
+ throw new CrudHttpError(404, { error: "Billing item not found" });
117
+ }
118
+ if (parsed.billStartDate !== void 0) record.billStartDate = parsed.billStartDate;
119
+ if (parsed.billEndDate !== void 0) record.billEndDate = parsed.billEndDate;
120
+ if (parsed.description !== void 0) record.description = parsed.description;
121
+ if (parsed.rateJson !== void 0) {
122
+ validateRateJson(record.type, parsed.rateJson);
123
+ record.rateJson = parsed.rateJson;
124
+ }
125
+ if (parsed.uomCode !== void 0) record.uomCode = parsed.uomCode;
126
+ if (parsed.subscriptionId !== void 0) record.subscriptionId = parsed.subscriptionId;
127
+ if (parsed.subscriptionItemId !== void 0) {
128
+ record.subscriptionItemId = parsed.subscriptionItemId;
129
+ }
130
+ if (parsed.isActive !== void 0) record.isActive = parsed.isActive;
131
+ record.updatedAt = /* @__PURE__ */ new Date();
132
+ await em.flush();
133
+ await emitCrudSideEffects({
134
+ dataEngine: getDataEngine(ctx),
135
+ action: "updated",
136
+ entity: record,
137
+ identifiers: {
138
+ id: record.id,
139
+ tenantId: record.tenantId,
140
+ organizationId: record.organizationId
141
+ },
142
+ indexer: itemIndexer,
143
+ events: itemEvents
144
+ });
145
+ return { id: record.id };
146
+ }
147
+ };
148
+ const deleteItemCommand = {
149
+ id: "billing.items.delete",
150
+ async execute(rawInput, ctx) {
151
+ const parsed = billingItemDeleteSchema.parse(rawInput);
152
+ ensureTenantScope(ctx, parsed.tenantId);
153
+ ensureOrganizationScope(ctx, parsed.organizationId);
154
+ const id = requireId(parsed);
155
+ const em = getEm(ctx);
156
+ const record = await em.findOne(BillingItem, {
157
+ id,
158
+ tenantId: parsed.tenantId,
159
+ organizationId: parsed.organizationId,
160
+ deletedAt: null
161
+ });
162
+ if (!record) {
163
+ throw new CrudHttpError(404, { error: "Billing item not found" });
164
+ }
165
+ record.deletedAt = /* @__PURE__ */ new Date();
166
+ record.updatedAt = record.deletedAt;
167
+ await em.flush();
168
+ await emitCrudSideEffects({
169
+ dataEngine: getDataEngine(ctx),
170
+ action: "deleted",
171
+ entity: record,
172
+ identifiers: {
173
+ id: record.id,
174
+ tenantId: record.tenantId,
175
+ organizationId: record.organizationId
176
+ },
177
+ indexer: itemIndexer,
178
+ events: itemEvents
179
+ });
180
+ return { id: record.id };
181
+ }
182
+ };
183
+ const bulkCreateItemsCommand = {
184
+ id: "billing.items.bulk_create",
185
+ async execute(rawInput, ctx) {
186
+ const parsed = billingItemBulkCreateSchema.parse(rawInput);
187
+ ensureTenantScope(ctx, parsed.tenantId);
188
+ ensureOrganizationScope(ctx, parsed.organizationId);
189
+ const em = getEm(ctx);
190
+ const accountIds = Array.from(new Set(parsed.items.map((i) => i.billAccountId)));
191
+ const accounts = await em.find(BillingAccount, {
192
+ id: { $in: accountIds },
193
+ tenantId: parsed.tenantId,
194
+ organizationId: parsed.organizationId,
195
+ deletedAt: null
196
+ });
197
+ const knownAccountIds = new Set(accounts.map((a) => a.id));
198
+ const missingAccount = accountIds.find((id) => !knownAccountIds.has(id));
199
+ if (missingAccount) {
200
+ throw new CrudHttpError(404, {
201
+ error: "Billing account not found",
202
+ billAccountId: missingAccount
203
+ });
204
+ }
205
+ const sourceRefs = parsed.items.map((i) => i.sourceRef).filter((ref) => typeof ref === "string" && ref.length > 0);
206
+ const existingBySourceKey = /* @__PURE__ */ new Map();
207
+ if (sourceRefs.length > 0) {
208
+ const existing = await em.find(BillingItem, {
209
+ tenantId: parsed.tenantId,
210
+ organizationId: parsed.organizationId,
211
+ billAccountId: { $in: accountIds },
212
+ sourceRef: { $in: Array.from(new Set(sourceRefs)) },
213
+ deletedAt: null
214
+ });
215
+ for (const row of existing) {
216
+ if (row.sourceRef) {
217
+ existingBySourceKey.set(`${row.billAccountId}::${row.sourceRef}`, row.id);
218
+ }
219
+ }
220
+ }
221
+ const now = /* @__PURE__ */ new Date();
222
+ const results = [];
223
+ const createdEntities = [];
224
+ const deferredIdLinks = [];
225
+ const seenInBatch = /* @__PURE__ */ new Set();
226
+ for (const entry of parsed.items) {
227
+ const sourceKey = entry.sourceRef ? `${entry.billAccountId}::${entry.sourceRef}` : null;
228
+ if (sourceKey) {
229
+ const existingId = existingBySourceKey.get(sourceKey);
230
+ if (existingId) {
231
+ results.push({ sourceRef: entry.sourceRef ?? null, id: existingId, deduplicated: true });
232
+ continue;
233
+ }
234
+ if (seenInBatch.has(sourceKey)) {
235
+ const firstCreated = createdEntities.find(
236
+ (e) => e.billAccountId === entry.billAccountId && e.sourceRef === entry.sourceRef
237
+ );
238
+ const dedupResult = {
239
+ sourceRef: entry.sourceRef ?? null,
240
+ id: "",
241
+ deduplicated: true
242
+ };
243
+ results.push(dedupResult);
244
+ if (firstCreated) deferredIdLinks.push({ result: dedupResult, entity: firstCreated });
245
+ continue;
246
+ }
247
+ seenInBatch.add(sourceKey);
248
+ }
249
+ validateRateJson(entry.type, entry.rateJson);
250
+ const item = em.create(BillingItem, {
251
+ organizationId: parsed.organizationId,
252
+ tenantId: parsed.tenantId,
253
+ billAccountId: entry.billAccountId,
254
+ type: entry.type,
255
+ billStartDate: entry.billStartDate,
256
+ billEndDate: entry.billEndDate ?? null,
257
+ description: entry.description,
258
+ rateJson: entry.rateJson,
259
+ uomCode: entry.uomCode ?? null,
260
+ subscriptionId: entry.subscriptionId ?? null,
261
+ subscriptionItemId: entry.subscriptionItemId ?? null,
262
+ sourceRef: entry.sourceRef ?? null,
263
+ currencyMismatch: false,
264
+ billedToDate: null,
265
+ isActive: true,
266
+ createdAt: now,
267
+ updatedAt: now
268
+ });
269
+ em.persist(item);
270
+ createdEntities.push(item);
271
+ const createdResult = {
272
+ sourceRef: entry.sourceRef ?? null,
273
+ id: "",
274
+ deduplicated: false
275
+ };
276
+ results.push(createdResult);
277
+ deferredIdLinks.push({ result: createdResult, entity: item });
278
+ }
279
+ if (createdEntities.length > 0) {
280
+ await em.flush();
281
+ }
282
+ for (const link of deferredIdLinks) {
283
+ link.result.id = link.entity.id;
284
+ }
285
+ const dataEngine = getDataEngine(ctx);
286
+ for (const item of createdEntities) {
287
+ await emitCrudSideEffects({
288
+ dataEngine,
289
+ action: "created",
290
+ entity: item,
291
+ identifiers: {
292
+ id: item.id,
293
+ tenantId: item.tenantId,
294
+ organizationId: item.organizationId
295
+ },
296
+ indexer: itemIndexer,
297
+ events: itemEvents
298
+ });
299
+ }
300
+ return {
301
+ created: createdEntities.length,
302
+ deduplicated: results.filter((r) => r.deduplicated).length,
303
+ items: results
304
+ };
305
+ }
306
+ };
307
+ registerCommand(createItemCommand);
308
+ registerCommand(updateItemCommand);
309
+ registerCommand(deleteItemCommand);
310
+ registerCommand(bulkCreateItemsCommand);
311
+ export {
312
+ bulkCreateItemsCommand,
313
+ createItemCommand,
314
+ deleteItemCommand,
315
+ updateItemCommand
316
+ };
317
+ //# sourceMappingURL=items.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/billing/commands/items.ts"],
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands/types'\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport {\n ensureOrganizationScope,\n ensureTenantScope,\n} from '@open-mercato/shared/lib/commands/scope'\nimport {\n emitCrudSideEffects,\n requireId,\n} from '@open-mercato/shared/lib/commands/helpers'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport type { DataEngine } from '@open-mercato/shared/lib/data/engine'\nimport { BillingAccount, BillingItem } from '../data/entities'\nimport { billingEntityIds } from '../data/entityIds'\nimport { findBySourceRef } from '../lib/idempotency'\nimport {\n billingItemBulkCreateSchema,\n billingItemCreateSchema,\n billingItemDeleteSchema,\n billingItemUpdateSchema,\n validateRateJson,\n type BillingItemBulkCreateInput,\n type BillingItemCreateInput,\n type BillingItemDeleteInput,\n type BillingItemUpdateInput,\n} from '../data/validators'\n\n/**\n * BillingItem writes \u2014 create / update / soft-delete.\n *\n * Create is idempotent via `source_ref`: if the integrator POSTs the\n * same `(tenant_id, bill_account_id, source_ref)` twice, the second\n * call returns the existing item with `deduplicated: true`. The\n * underlying unique index is the correctness gate; this pre-check keeps\n * the happy path single-round-trip and the response body informative.\n */\n\nconst itemIndexer = { entityType: billingEntityIds.item } as const\nconst itemEvents = { module: 'billing', entity: 'item', persistent: true } as const\n\nfunction getEm(ctx: CommandRuntimeContext): EntityManager {\n return (ctx.container.resolve('em') as EntityManager).fork()\n}\n\nfunction getDataEngine(ctx: CommandRuntimeContext): DataEngine {\n return ctx.container.resolve('dataEngine') as DataEngine\n}\n\nasync function assertAccountInScope(\n em: EntityManager,\n params: { tenantId: string; organizationId: string; billAccountId: string },\n): Promise<BillingAccount> {\n const account = await em.findOne(BillingAccount, {\n id: params.billAccountId,\n tenantId: params.tenantId,\n organizationId: params.organizationId,\n deletedAt: null,\n })\n if (!account) {\n throw new CrudHttpError(404, { error: 'Billing account not found' })\n }\n return account\n}\n\n// \u2500\u2500\u2500 Create (idempotent) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst createItemCommand: CommandHandler<\n BillingItemCreateInput,\n { id: string; deduplicated: boolean }\n> = {\n id: 'billing.items.create',\n\n async execute(rawInput, ctx) {\n const parsed = billingItemCreateSchema.parse(rawInput)\n ensureTenantScope(ctx, parsed.tenantId)\n ensureOrganizationScope(ctx, parsed.organizationId)\n\n const em = getEm(ctx)\n const account = await assertAccountInScope(em, parsed)\n\n // Idempotency: if `source_ref` provided and matches an existing\n // (non-deleted) row, return that row. The unique index is the\n // final correctness gate \u2014 this is the happy-path early return.\n if (parsed.sourceRef) {\n const existing = await findBySourceRef(em, BillingItem, {\n tenantId: parsed.tenantId,\n billAccountId: parsed.billAccountId,\n sourceRef: parsed.sourceRef,\n })\n if (existing) {\n return { id: existing.id, deduplicated: true }\n }\n }\n\n // Re-validate the rate_json shape against the resolved type to\n // future-proof against schema drift between the API surface and\n // the persistence layer.\n validateRateJson(parsed.type, parsed.rateJson)\n\n const now = new Date()\n const entity = em.create(BillingItem, {\n organizationId: parsed.organizationId,\n tenantId: parsed.tenantId,\n billAccountId: account.id,\n type: parsed.type,\n billStartDate: parsed.billStartDate,\n billEndDate: parsed.billEndDate ?? null,\n description: parsed.description,\n rateJson: parsed.rateJson as Record<string, unknown>,\n uomCode: parsed.uomCode ?? null,\n subscriptionId: parsed.subscriptionId ?? null,\n subscriptionItemId: parsed.subscriptionItemId ?? null,\n sourceRef: parsed.sourceRef ?? null,\n // `currency_mismatch` is set by Phase 2 / connectors when the\n // posted value's currency differs from the account's. Stays\n // false on create (operator opts in to flagging).\n currencyMismatch: false,\n // `billed_to_date` is set by the Bill Run engine on the first\n // real-run that emits this item. Always null on create.\n billedToDate: null,\n isActive: true,\n createdAt: now,\n updatedAt: now,\n })\n em.persist(entity)\n await em.flush()\n\n await emitCrudSideEffects({\n dataEngine: getDataEngine(ctx),\n action: 'created',\n entity,\n identifiers: {\n id: entity.id,\n tenantId: entity.tenantId,\n organizationId: entity.organizationId,\n },\n indexer: itemIndexer,\n events: itemEvents,\n })\n\n return { id: entity.id, deduplicated: false }\n },\n}\n\n// \u2500\u2500\u2500 Update \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst updateItemCommand: CommandHandler<BillingItemUpdateInput, { id: string }> = {\n id: 'billing.items.update',\n\n async execute(rawInput, ctx) {\n const parsed = billingItemUpdateSchema.parse(rawInput)\n ensureTenantScope(ctx, parsed.tenantId)\n ensureOrganizationScope(ctx, parsed.organizationId)\n\n const em = getEm(ctx)\n const record = await em.findOne(BillingItem, {\n id: parsed.id,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n deletedAt: null,\n })\n if (!record) {\n throw new CrudHttpError(404, { error: 'Billing item not found' })\n }\n\n if (parsed.billStartDate !== undefined) record.billStartDate = parsed.billStartDate\n if (parsed.billEndDate !== undefined) record.billEndDate = parsed.billEndDate\n if (parsed.description !== undefined) record.description = parsed.description\n if (parsed.rateJson !== undefined) {\n // Re-validate against the current (immutable) type before persist.\n validateRateJson(record.type as 'one_time' | 'recurring' | 'usage', parsed.rateJson)\n record.rateJson = parsed.rateJson as Record<string, unknown>\n }\n if (parsed.uomCode !== undefined) record.uomCode = parsed.uomCode\n if (parsed.subscriptionId !== undefined) record.subscriptionId = parsed.subscriptionId\n if (parsed.subscriptionItemId !== undefined) {\n record.subscriptionItemId = parsed.subscriptionItemId\n }\n if (parsed.isActive !== undefined) record.isActive = parsed.isActive\n\n record.updatedAt = new Date()\n await em.flush()\n\n await emitCrudSideEffects({\n dataEngine: getDataEngine(ctx),\n action: 'updated',\n entity: record,\n identifiers: {\n id: record.id,\n tenantId: record.tenantId,\n organizationId: record.organizationId,\n },\n indexer: itemIndexer,\n events: itemEvents,\n })\n\n return { id: record.id }\n },\n}\n\n// \u2500\u2500\u2500 Delete (soft) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst deleteItemCommand: CommandHandler<BillingItemDeleteInput, { id: string }> = {\n id: 'billing.items.delete',\n\n async execute(rawInput, ctx) {\n const parsed = billingItemDeleteSchema.parse(rawInput)\n ensureTenantScope(ctx, parsed.tenantId)\n ensureOrganizationScope(ctx, parsed.organizationId)\n const id = requireId(parsed)\n\n const em = getEm(ctx)\n const record = await em.findOne(BillingItem, {\n id,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n deletedAt: null,\n })\n if (!record) {\n throw new CrudHttpError(404, { error: 'Billing item not found' })\n }\n\n record.deletedAt = new Date()\n record.updatedAt = record.deletedAt\n await em.flush()\n\n await emitCrudSideEffects({\n dataEngine: getDataEngine(ctx),\n action: 'deleted',\n entity: record,\n identifiers: {\n id: record.id,\n tenantId: record.tenantId,\n organizationId: record.organizationId,\n },\n indexer: itemIndexer,\n events: itemEvents,\n })\n\n return { id: record.id }\n },\n}\n\n// \u2500\u2500\u2500 Bulk create (idempotent, batched) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n//\n// Collapses N `billing.items.create` round-trips into one command.\n// The CPQ connector activating a large subscription is the primary\n// caller \u2014 it used to fire one create per charge.\n//\n// Idempotency is still per-`source_ref` but the existence check is a\n// SINGLE query over all source_refs in the batch, instead of one\n// `findOne` per item. Inserts are one `em.flush()` for the whole\n// batch. So a 200-charge activation goes from ~200 round-trips +\n// 200 flushes to ~3 queries + 1 flush.\n\ntype BulkCreateResultEntry = {\n sourceRef: string | null\n id: string\n deduplicated: boolean\n}\n\nconst bulkCreateItemsCommand: CommandHandler<\n BillingItemBulkCreateInput,\n { created: number; deduplicated: number; items: BulkCreateResultEntry[] }\n> = {\n id: 'billing.items.bulk_create',\n\n async execute(rawInput, ctx) {\n const parsed = billingItemBulkCreateSchema.parse(rawInput)\n ensureTenantScope(ctx, parsed.tenantId)\n ensureOrganizationScope(ctx, parsed.organizationId)\n\n const em = getEm(ctx)\n\n // Validate every referenced account exists + is in scope. One\n // `$in` query for the distinct account ids, not one per item.\n const accountIds = Array.from(new Set(parsed.items.map((i) => i.billAccountId)))\n const accounts = await em.find(BillingAccount, {\n id: { $in: accountIds },\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n deletedAt: null,\n } as never)\n const knownAccountIds = new Set(accounts.map((a) => a.id))\n const missingAccount = accountIds.find((id) => !knownAccountIds.has(id))\n if (missingAccount) {\n throw new CrudHttpError(404, {\n error: 'Billing account not found',\n billAccountId: missingAccount,\n })\n }\n\n // Batched idempotency check \u2014 one query over every source_ref in\n // the batch. `source_ref` is unique per `(tenant, bill_account,\n // source_ref)`, so we key the existing-set on that triple.\n const sourceRefs = parsed.items\n .map((i) => i.sourceRef)\n .filter((ref): ref is string => typeof ref === 'string' && ref.length > 0)\n const existingBySourceKey = new Map<string, string>()\n if (sourceRefs.length > 0) {\n const existing = await em.find(BillingItem, {\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n billAccountId: { $in: accountIds },\n sourceRef: { $in: Array.from(new Set(sourceRefs)) },\n deletedAt: null,\n } as never)\n for (const row of existing) {\n if (row.sourceRef) {\n existingBySourceKey.set(`${row.billAccountId}::${row.sourceRef}`, row.id)\n }\n }\n }\n\n const now = new Date()\n const results: BulkCreateResultEntry[] = []\n const createdEntities: BillingItem[] = []\n // Result entries whose `id` is only known after the flush \u2014 the\n // BillingItem PK is a DB-generated UUID, unassigned until then.\n // Covers freshly-created rows and in-batch dedup rows that point\n // at a sibling created earlier in the same payload.\n const deferredIdLinks: Array<{ result: BulkCreateResultEntry; entity: BillingItem }> = []\n\n // Track source_refs created within THIS batch so a payload that\n // repeats the same source_ref twice dedups against itself (the\n // unique index would otherwise blow up the whole flush).\n const seenInBatch = new Set<string>()\n\n for (const entry of parsed.items) {\n const sourceKey = entry.sourceRef\n ? `${entry.billAccountId}::${entry.sourceRef}`\n : null\n\n if (sourceKey) {\n const existingId = existingBySourceKey.get(sourceKey)\n if (existingId) {\n results.push({ sourceRef: entry.sourceRef ?? null, id: existingId, deduplicated: true })\n continue\n }\n if (seenInBatch.has(sourceKey)) {\n // Duplicate source_ref inside the same payload \u2014 first wins,\n // the repeat is reported deduplicated against the first.\n const firstCreated = createdEntities.find(\n (e) => e.billAccountId === entry.billAccountId && e.sourceRef === entry.sourceRef,\n )\n const dedupResult: BulkCreateResultEntry = {\n sourceRef: entry.sourceRef ?? null,\n id: '',\n deduplicated: true,\n }\n results.push(dedupResult)\n if (firstCreated) deferredIdLinks.push({ result: dedupResult, entity: firstCreated })\n continue\n }\n seenInBatch.add(sourceKey)\n }\n\n validateRateJson(entry.type, entry.rateJson)\n const item = em.create(BillingItem, {\n organizationId: parsed.organizationId,\n tenantId: parsed.tenantId,\n billAccountId: entry.billAccountId,\n type: entry.type,\n billStartDate: entry.billStartDate,\n billEndDate: entry.billEndDate ?? null,\n description: entry.description,\n rateJson: entry.rateJson as Record<string, unknown>,\n uomCode: entry.uomCode ?? null,\n subscriptionId: entry.subscriptionId ?? null,\n subscriptionItemId: entry.subscriptionItemId ?? null,\n sourceRef: entry.sourceRef ?? null,\n currencyMismatch: false,\n billedToDate: null,\n isActive: true,\n createdAt: now,\n updatedAt: now,\n })\n em.persist(item)\n createdEntities.push(item)\n const createdResult: BulkCreateResultEntry = {\n sourceRef: entry.sourceRef ?? null,\n id: '',\n deduplicated: false,\n }\n results.push(createdResult)\n deferredIdLinks.push({ result: createdResult, entity: item })\n }\n\n // Single flush for the whole batch.\n if (createdEntities.length > 0) {\n await em.flush()\n }\n\n // The BillingItem UUID PK is assigned by the flush \u2014 backfill it\n // into the result entries now that the value exists.\n for (const link of deferredIdLinks) {\n link.result.id = link.entity.id\n }\n\n // Side effects after the commit. Emitting per-entity keeps the\n // query-index + event contract identical to single-create \u2014 the\n // engine / search index can't tell a bulk insert from N singles.\n const dataEngine = getDataEngine(ctx)\n for (const item of createdEntities) {\n await emitCrudSideEffects({\n dataEngine,\n action: 'created',\n entity: item,\n identifiers: {\n id: item.id,\n tenantId: item.tenantId,\n organizationId: item.organizationId,\n },\n indexer: itemIndexer,\n events: itemEvents,\n })\n }\n\n return {\n created: createdEntities.length,\n deduplicated: results.filter((r) => r.deduplicated).length,\n items: results,\n }\n },\n}\n\nregisterCommand(createItemCommand)\nregisterCommand(updateItemCommand)\nregisterCommand(deleteItemCommand)\nregisterCommand(bulkCreateItemsCommand)\n\nexport {\n createItemCommand,\n updateItemCommand,\n deleteItemCommand,\n bulkCreateItemsCommand,\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1,123 @@
1
+ import { registerCommand } from "@open-mercato/shared/lib/commands";
2
+ import {
3
+ ensureOrganizationScope,
4
+ ensureTenantScope
5
+ } from "@open-mercato/shared/lib/commands/scope";
6
+ import { CrudHttpError } from "@open-mercato/shared/lib/crud/errors";
7
+ import { BillRun, BillRunOutcome } from "../data/entities.js";
8
+ import {
9
+ billingRunRetryFailedSchema,
10
+ billingRunTriggerSchema
11
+ } from "../data/validators.js";
12
+ import { runBillRun } from "../lib/billRunEngine.js";
13
+ import { TenantLockBusyError } from "../lib/tenantLock.js";
14
+ function getEm(ctx) {
15
+ return ctx.container.resolve("em").fork();
16
+ }
17
+ function getContainer(ctx) {
18
+ return ctx.container;
19
+ }
20
+ function asLockBusyError(err) {
21
+ return err instanceof TenantLockBusyError;
22
+ }
23
+ const triggerRunCommand = {
24
+ id: "billing.runs.trigger",
25
+ async execute(rawInput, ctx) {
26
+ const parsed = billingRunTriggerSchema.parse(rawInput);
27
+ ensureTenantScope(ctx, parsed.tenantId);
28
+ ensureOrganizationScope(ctx, parsed.organizationId);
29
+ const em = getEm(ctx);
30
+ const asOfDate = parsed.asOfDate ?? /* @__PURE__ */ new Date();
31
+ try {
32
+ const { billRun, outcomes } = await runBillRun(em, getContainer(ctx), {
33
+ tenantId: parsed.tenantId,
34
+ organizationId: parsed.organizationId,
35
+ asOfDate,
36
+ triggeredBy: "manual",
37
+ mode: parsed.mode,
38
+ scopedAccountIds: parsed.scopedAccountIds,
39
+ catchUp: parsed.catchUp
40
+ });
41
+ return {
42
+ billRunId: billRun.id,
43
+ status: billRun.status,
44
+ outcomesCount: outcomes.length
45
+ };
46
+ } catch (err) {
47
+ if (asLockBusyError(err)) {
48
+ throw new CrudHttpError(409, {
49
+ error: "Another Bill Run is already in progress for this tenant",
50
+ code: "billing.run.lock_busy",
51
+ tenantId: err.tenantId,
52
+ lockName: err.lockName
53
+ });
54
+ }
55
+ throw err;
56
+ }
57
+ }
58
+ };
59
+ const retryFailedCommand = {
60
+ id: "billing.runs.retry_failed",
61
+ async execute(rawInput, ctx) {
62
+ const parsed = billingRunRetryFailedSchema.parse(rawInput);
63
+ ensureTenantScope(ctx, parsed.tenantId);
64
+ ensureOrganizationScope(ctx, parsed.organizationId);
65
+ const em = getEm(ctx);
66
+ const parentRun = await em.findOne(BillRun, {
67
+ id: parsed.billRunId,
68
+ tenantId: parsed.tenantId,
69
+ organizationId: parsed.organizationId,
70
+ deletedAt: null
71
+ });
72
+ if (!parentRun) {
73
+ throw new CrudHttpError(404, { error: "Parent Bill Run not found" });
74
+ }
75
+ const failedOutcomes = await em.find(BillRunOutcome, {
76
+ billRunId: parentRun.id,
77
+ tenantId: parsed.tenantId,
78
+ organizationId: parsed.organizationId,
79
+ status: "failed"
80
+ });
81
+ if (failedOutcomes.length === 0) {
82
+ throw new CrudHttpError(409, {
83
+ error: "Parent run has no failed outcomes to retry",
84
+ code: "billing.run.nothing_to_retry"
85
+ });
86
+ }
87
+ const scopedAccountIds = Array.from(new Set(failedOutcomes.map((o) => o.billAccountId)));
88
+ try {
89
+ const { billRun, outcomes } = await runBillRun(em, getContainer(ctx), {
90
+ tenantId: parsed.tenantId,
91
+ organizationId: parsed.organizationId,
92
+ asOfDate: parentRun.asOfDate,
93
+ triggeredBy: "manual",
94
+ mode: parsed.mode,
95
+ scopedAccountIds,
96
+ parentRunId: parentRun.id,
97
+ catchUp: parentRun.catchUp
98
+ });
99
+ return {
100
+ billRunId: billRun.id,
101
+ status: billRun.status,
102
+ outcomesCount: outcomes.length
103
+ };
104
+ } catch (err) {
105
+ if (asLockBusyError(err)) {
106
+ throw new CrudHttpError(409, {
107
+ error: "Another Bill Run is already in progress for this tenant",
108
+ code: "billing.run.lock_busy",
109
+ tenantId: err.tenantId,
110
+ lockName: err.lockName
111
+ });
112
+ }
113
+ throw err;
114
+ }
115
+ }
116
+ };
117
+ registerCommand(triggerRunCommand);
118
+ registerCommand(retryFailedCommand);
119
+ export {
120
+ retryFailedCommand,
121
+ triggerRunCommand
122
+ };
123
+ //# sourceMappingURL=runs.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/billing/commands/runs.ts"],
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { AwilixContainer } from 'awilix'\nimport type { CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands/types'\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport {\n ensureOrganizationScope,\n ensureTenantScope,\n} from '@open-mercato/shared/lib/commands/scope'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { BillRun, BillRunOutcome } from '../data/entities'\nimport {\n billingRunRetryFailedSchema,\n billingRunTriggerSchema,\n type BillingRunRetryFailedInput,\n type BillingRunTriggerInput,\n} from '../data/validators'\nimport { runBillRun } from '../lib/billRunEngine'\nimport { TenantLockBusyError } from '../lib/tenantLock'\n\n/**\n * Bill Run trigger commands.\n *\n * Both commands wrap `runBillRun(...)` from `lib/billRunEngine`. The\n * lock-busy condition (concurrent run on the same tenant) is mapped to\n * HTTP 409 here so the API surface returns the canonical Conflict\n * response without each route knowing the lock primitive's\n * implementation.\n *\n * `triggeredBy` is intentionally NOT exposed on the input schema \u2014\n * client-driven triggers are always `'manual'`. The scheduler worker\n * (Phase 4) injects `'schedule'` directly through a separate\n * orchestrator path, not through this command.\n */\n\nfunction getEm(ctx: CommandRuntimeContext): EntityManager {\n return (ctx.container.resolve('em') as EntityManager).fork()\n}\n\nfunction getContainer(ctx: CommandRuntimeContext): AwilixContainer {\n return ctx.container as AwilixContainer\n}\n\nfunction asLockBusyError(err: unknown): err is TenantLockBusyError {\n return err instanceof TenantLockBusyError\n}\n\n// \u2500\u2500\u2500 Trigger \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\ntype TriggerResult = {\n billRunId: string\n status: string\n outcomesCount: number\n}\n\nconst triggerRunCommand: CommandHandler<BillingRunTriggerInput, TriggerResult> = {\n id: 'billing.runs.trigger',\n\n async execute(rawInput, ctx) {\n const parsed = billingRunTriggerSchema.parse(rawInput)\n ensureTenantScope(ctx, parsed.tenantId)\n ensureOrganizationScope(ctx, parsed.organizationId)\n\n const em = getEm(ctx)\n const asOfDate = parsed.asOfDate ?? new Date()\n\n try {\n const { billRun, outcomes } = await runBillRun(em, getContainer(ctx), {\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n asOfDate,\n triggeredBy: 'manual',\n mode: parsed.mode,\n scopedAccountIds: parsed.scopedAccountIds,\n catchUp: parsed.catchUp,\n })\n return {\n billRunId: billRun.id,\n status: billRun.status,\n outcomesCount: outcomes.length,\n }\n } catch (err) {\n if (asLockBusyError(err)) {\n // The route metadata maps `CrudHttpError(409)` to the canonical\n // 409 Conflict response. The error body carries the\n // `error_lock_busy` discriminator so clients can distinguish\n // \"lock busy\" from other 409 conditions.\n throw new CrudHttpError(409, {\n error: 'Another Bill Run is already in progress for this tenant',\n code: 'billing.run.lock_busy',\n tenantId: err.tenantId,\n lockName: err.lockName,\n })\n }\n throw err\n }\n },\n}\n\n// \u2500\u2500\u2500 Retry failed \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst retryFailedCommand: CommandHandler<BillingRunRetryFailedInput, TriggerResult> = {\n id: 'billing.runs.retry_failed',\n\n async execute(rawInput, ctx) {\n const parsed = billingRunRetryFailedSchema.parse(rawInput)\n ensureTenantScope(ctx, parsed.tenantId)\n ensureOrganizationScope(ctx, parsed.organizationId)\n\n const em = getEm(ctx)\n const parentRun = await em.findOne(BillRun, {\n id: parsed.billRunId,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n deletedAt: null,\n })\n if (!parentRun) {\n throw new CrudHttpError(404, { error: 'Parent Bill Run not found' })\n }\n const failedOutcomes = await em.find(BillRunOutcome, {\n billRunId: parentRun.id,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n status: 'failed',\n })\n if (failedOutcomes.length === 0) {\n throw new CrudHttpError(409, {\n error: 'Parent run has no failed outcomes to retry',\n code: 'billing.run.nothing_to_retry',\n })\n }\n const scopedAccountIds = Array.from(new Set(failedOutcomes.map((o) => o.billAccountId)))\n\n try {\n const { billRun, outcomes } = await runBillRun(em, getContainer(ctx), {\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n asOfDate: parentRun.asOfDate,\n triggeredBy: 'manual',\n mode: parsed.mode,\n scopedAccountIds,\n parentRunId: parentRun.id,\n catchUp: parentRun.catchUp,\n })\n return {\n billRunId: billRun.id,\n status: billRun.status,\n outcomesCount: outcomes.length,\n }\n } catch (err) {\n if (asLockBusyError(err)) {\n throw new CrudHttpError(409, {\n error: 'Another Bill Run is already in progress for this tenant',\n code: 'billing.run.lock_busy',\n tenantId: err.tenantId,\n lockName: err.lockName,\n })\n }\n throw err\n }\n },\n}\n\nregisterCommand(triggerRunCommand)\nregisterCommand(retryFailedCommand)\n\nexport { triggerRunCommand, retryFailedCommand }\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1,88 @@
1
+ import { registerCommand } from "@open-mercato/shared/lib/commands";
2
+ import {
3
+ ensureOrganizationScope,
4
+ ensureTenantScope
5
+ } from "@open-mercato/shared/lib/commands/scope";
6
+ import { emitCrudSideEffects } from "@open-mercato/shared/lib/commands/helpers";
7
+ import { CrudHttpError } from "@open-mercato/shared/lib/crud/errors";
8
+ import { BillingAccount, BillingAccountUsage } from "../data/entities.js";
9
+ import { billingEntityIds } from "../data/entityIds.js";
10
+ import { findBySourceRef } from "../lib/idempotency.js";
11
+ import {
12
+ billingUsageCreateSchema
13
+ } from "../data/validators.js";
14
+ const usageIndexer = { entityType: billingEntityIds.usage };
15
+ const usageEvents = { module: "billing", entity: "usage", persistent: true };
16
+ function getEm(ctx) {
17
+ return ctx.container.resolve("em").fork();
18
+ }
19
+ function getDataEngine(ctx) {
20
+ return ctx.container.resolve("dataEngine");
21
+ }
22
+ const createUsageCommand = {
23
+ id: "billing.usage.create",
24
+ async execute(rawInput, ctx) {
25
+ const parsed = billingUsageCreateSchema.parse(rawInput);
26
+ ensureTenantScope(ctx, parsed.tenantId);
27
+ ensureOrganizationScope(ctx, parsed.organizationId);
28
+ const em = getEm(ctx);
29
+ const account = await em.findOne(BillingAccount, {
30
+ id: parsed.billAccountId,
31
+ tenantId: parsed.tenantId,
32
+ organizationId: parsed.organizationId,
33
+ deletedAt: null
34
+ });
35
+ if (!account) {
36
+ throw new CrudHttpError(404, { error: "Billing account not found" });
37
+ }
38
+ if (parsed.sourceRef) {
39
+ const existing = await findBySourceRef(em, BillingAccountUsage, {
40
+ tenantId: parsed.tenantId,
41
+ billAccountId: parsed.billAccountId,
42
+ sourceRef: parsed.sourceRef
43
+ });
44
+ if (existing) {
45
+ return { id: existing.id, deduplicated: true };
46
+ }
47
+ }
48
+ const now = /* @__PURE__ */ new Date();
49
+ const entity = em.create(BillingAccountUsage, {
50
+ organizationId: parsed.organizationId,
51
+ tenantId: parsed.tenantId,
52
+ billAccountId: parsed.billAccountId,
53
+ uomCode: parsed.uomCode,
54
+ // numeric(18,4) — MikroORM stores numeric columns as string in app
55
+ // code. JSON arrives as number; coerce here so the persisted shape
56
+ // matches what later reads expect.
57
+ quantity: parsed.quantity.toFixed(4),
58
+ periodStart: parsed.periodStart,
59
+ periodEnd: parsed.periodEnd,
60
+ lineDescription: parsed.lineDescription ?? null,
61
+ sourceRef: parsed.sourceRef ?? null,
62
+ ratedInBillRunId: null,
63
+ isActive: true,
64
+ createdAt: now,
65
+ updatedAt: now
66
+ });
67
+ em.persist(entity);
68
+ await em.flush();
69
+ await emitCrudSideEffects({
70
+ dataEngine: getDataEngine(ctx),
71
+ action: "created",
72
+ entity,
73
+ identifiers: {
74
+ id: entity.id,
75
+ tenantId: entity.tenantId,
76
+ organizationId: entity.organizationId
77
+ },
78
+ indexer: usageIndexer,
79
+ events: usageEvents
80
+ });
81
+ return { id: entity.id, deduplicated: false };
82
+ }
83
+ };
84
+ registerCommand(createUsageCommand);
85
+ export {
86
+ createUsageCommand
87
+ };
88
+ //# sourceMappingURL=usage.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/billing/commands/usage.ts"],
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands/types'\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport {\n ensureOrganizationScope,\n ensureTenantScope,\n} from '@open-mercato/shared/lib/commands/scope'\nimport { emitCrudSideEffects } from '@open-mercato/shared/lib/commands/helpers'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport type { DataEngine } from '@open-mercato/shared/lib/data/engine'\nimport { BillingAccount, BillingAccountUsage } from '../data/entities'\nimport { billingEntityIds } from '../data/entityIds'\nimport { findBySourceRef } from '../lib/idempotency'\nimport {\n billingUsageCreateSchema,\n type BillingUsageCreateInput,\n} from '../data/validators'\n\n/**\n * Usage ingest \u2014 append-only.\n *\n * Usage records are immutable once persisted. The Bill Run engine\n * (Phase 2) writes `rated_in_bill_run_id` on them but the data itself\n * never changes. There is therefore no update or delete command \u2014 if\n * an external system mis-aggregates and needs to \"correct\" a record,\n * they POST a compensating record (negative quantity is not allowed\n * by the validator; the connector posts a `one_time` Billing Item\n * credit instead, which is the spec's discount/refund model).\n *\n * Create is idempotent via `source_ref` \u2014 same shape as items.\n */\n\nconst usageIndexer = { entityType: billingEntityIds.usage } as const\nconst usageEvents = { module: 'billing', entity: 'usage', persistent: true } as const\n\nfunction getEm(ctx: CommandRuntimeContext): EntityManager {\n return (ctx.container.resolve('em') as EntityManager).fork()\n}\n\nfunction getDataEngine(ctx: CommandRuntimeContext): DataEngine {\n return ctx.container.resolve('dataEngine') as DataEngine\n}\n\nconst createUsageCommand: CommandHandler<\n BillingUsageCreateInput,\n { id: string; deduplicated: boolean }\n> = {\n id: 'billing.usage.create',\n\n async execute(rawInput, ctx) {\n const parsed = billingUsageCreateSchema.parse(rawInput)\n ensureTenantScope(ctx, parsed.tenantId)\n ensureOrganizationScope(ctx, parsed.organizationId)\n\n const em = getEm(ctx)\n const account = await em.findOne(BillingAccount, {\n id: parsed.billAccountId,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n deletedAt: null,\n })\n if (!account) {\n throw new CrudHttpError(404, { error: 'Billing account not found' })\n }\n\n if (parsed.sourceRef) {\n const existing = await findBySourceRef(em, BillingAccountUsage, {\n tenantId: parsed.tenantId,\n billAccountId: parsed.billAccountId,\n sourceRef: parsed.sourceRef,\n })\n if (existing) {\n return { id: existing.id, deduplicated: true }\n }\n }\n\n const now = new Date()\n const entity = em.create(BillingAccountUsage, {\n organizationId: parsed.organizationId,\n tenantId: parsed.tenantId,\n billAccountId: parsed.billAccountId,\n uomCode: parsed.uomCode,\n // numeric(18,4) \u2014 MikroORM stores numeric columns as string in app\n // code. JSON arrives as number; coerce here so the persisted shape\n // matches what later reads expect.\n quantity: parsed.quantity.toFixed(4),\n periodStart: parsed.periodStart,\n periodEnd: parsed.periodEnd,\n lineDescription: parsed.lineDescription ?? null,\n sourceRef: parsed.sourceRef ?? null,\n ratedInBillRunId: null,\n isActive: true,\n createdAt: now,\n updatedAt: now,\n })\n em.persist(entity)\n await em.flush()\n\n await emitCrudSideEffects({\n dataEngine: getDataEngine(ctx),\n action: 'created',\n entity,\n identifiers: {\n id: entity.id,\n tenantId: entity.tenantId,\n organizationId: entity.organizationId,\n },\n indexer: usageIndexer,\n events: usageEvents,\n })\n\n return { id: entity.id, deduplicated: false }\n },\n}\n\nregisterCommand(createUsageCommand)\n\nexport { createUsageCommand }\n"],
5
+ "mappings": "AAEA,SAAS,uBAAuB;AAChC;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,2BAA2B;AACpC,SAAS,qBAAqB;AAE9B,SAAS,gBAAgB,2BAA2B;AACpD,SAAS,wBAAwB;AACjC,SAAS,uBAAuB;AAChC;AAAA,EACE;AAAA,OAEK;AAgBP,MAAM,eAAe,EAAE,YAAY,iBAAiB,MAAM;AAC1D,MAAM,cAAc,EAAE,QAAQ,WAAW,QAAQ,SAAS,YAAY,KAAK;AAE3E,SAAS,MAAM,KAA2C;AACxD,SAAQ,IAAI,UAAU,QAAQ,IAAI,EAAoB,KAAK;AAC7D;AAEA,SAAS,cAAc,KAAwC;AAC7D,SAAO,IAAI,UAAU,QAAQ,YAAY;AAC3C;AAEA,MAAM,qBAGF;AAAA,EACF,IAAI;AAAA,EAEJ,MAAM,QAAQ,UAAU,KAAK;AAC3B,UAAM,SAAS,yBAAyB,MAAM,QAAQ;AACtD,sBAAkB,KAAK,OAAO,QAAQ;AACtC,4BAAwB,KAAK,OAAO,cAAc;AAElD,UAAM,KAAK,MAAM,GAAG;AACpB,UAAM,UAAU,MAAM,GAAG,QAAQ,gBAAgB;AAAA,MAC/C,IAAI,OAAO;AAAA,MACX,UAAU,OAAO;AAAA,MACjB,gBAAgB,OAAO;AAAA,MACvB,WAAW;AAAA,IACb,CAAC;AACD,QAAI,CAAC,SAAS;AACZ,YAAM,IAAI,cAAc,KAAK,EAAE,OAAO,4BAA4B,CAAC;AAAA,IACrE;AAEA,QAAI,OAAO,WAAW;AACpB,YAAM,WAAW,MAAM,gBAAgB,IAAI,qBAAqB;AAAA,QAC9D,UAAU,OAAO;AAAA,QACjB,eAAe,OAAO;AAAA,QACtB,WAAW,OAAO;AAAA,MACpB,CAAC;AACD,UAAI,UAAU;AACZ,eAAO,EAAE,IAAI,SAAS,IAAI,cAAc,KAAK;AAAA,MAC/C;AAAA,IACF;AAEA,UAAM,MAAM,oBAAI,KAAK;AACrB,UAAM,SAAS,GAAG,OAAO,qBAAqB;AAAA,MAC5C,gBAAgB,OAAO;AAAA,MACvB,UAAU,OAAO;AAAA,MACjB,eAAe,OAAO;AAAA,MACtB,SAAS,OAAO;AAAA;AAAA;AAAA;AAAA,MAIhB,UAAU,OAAO,SAAS,QAAQ,CAAC;AAAA,MACnC,aAAa,OAAO;AAAA,MACpB,WAAW,OAAO;AAAA,MAClB,iBAAiB,OAAO,mBAAmB;AAAA,MAC3C,WAAW,OAAO,aAAa;AAAA,MAC/B,kBAAkB;AAAA,MAClB,UAAU;AAAA,MACV,WAAW;AAAA,MACX,WAAW;AAAA,IACb,CAAC;AACD,OAAG,QAAQ,MAAM;AACjB,UAAM,GAAG,MAAM;AAEf,UAAM,oBAAoB;AAAA,MACxB,YAAY,cAAc,GAAG;AAAA,MAC7B,QAAQ;AAAA,MACR;AAAA,MACA,aAAa;AAAA,QACX,IAAI,OAAO;AAAA,QACX,UAAU,OAAO;AAAA,QACjB,gBAAgB,OAAO;AAAA,MACzB;AAAA,MACA,SAAS;AAAA,MACT,QAAQ;AAAA,IACV,CAAC;AAED,WAAO,EAAE,IAAI,OAAO,IAAI,cAAc,MAAM;AAAA,EAC9C;AACF;AAEA,gBAAgB,kBAAkB;",
6
+ "names": []
7
+ }