@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,256 @@
1
+ import {
2
+ BillingAccount,
3
+ BillingAccountUsage,
4
+ BillingItem,
5
+ BillRun,
6
+ BillRunOutcome
7
+ } from "../data/entities.js";
8
+ import { advanceNextBillDate, deriveBillPeriod, isCycleDue } from "./billPeriod.js";
9
+ import {
10
+ selectItemsForPeriod
11
+ } from "./itemSelector.js";
12
+ import {
13
+ buildInvoiceLinesFromItems,
14
+ writeDraftInvoice
15
+ } from "./invoiceWriter.js";
16
+ import { withTenantLock } from "./tenantLock.js";
17
+ import { processUsageForAccount } from "./usageRunner.js";
18
+ const SCHEDULED_CYCLE_LIMIT = 1;
19
+ const CATCH_UP_HARD_CAP = 60;
20
+ async function runBillRun(em, container, params) {
21
+ return withTenantLock(
22
+ em,
23
+ { tenantId: params.tenantId, lockName: "billing-run" },
24
+ async (tem) => executeRunWithinLock(tem, container, params)
25
+ );
26
+ }
27
+ async function executeRunWithinLock(em, container, params) {
28
+ const now = /* @__PURE__ */ new Date();
29
+ const billRun = em.create(BillRun, {
30
+ organizationId: params.organizationId,
31
+ tenantId: params.tenantId,
32
+ triggeredBy: params.triggeredBy,
33
+ parentRunId: params.parentRunId ?? null,
34
+ dryRun: params.mode === "dry",
35
+ testMode: params.mode === "test",
36
+ catchUp: Boolean(params.catchUp),
37
+ scopedAccountIds: params.scopedAccountIds ?? null,
38
+ asOfDate: params.asOfDate,
39
+ startedAt: now,
40
+ status: "running",
41
+ summary: null,
42
+ isActive: true,
43
+ createdAt: now,
44
+ updatedAt: now
45
+ });
46
+ em.persist(billRun);
47
+ await em.flush();
48
+ const accounts = await selectTargetAccounts(em, params);
49
+ const outcomes = [];
50
+ let totalUsageRecordsRated = 0;
51
+ for (const account of accounts) {
52
+ const processResult = await processAccountSafely(em, container, billRun, account, params);
53
+ if (processResult.status !== "failed") {
54
+ totalUsageRecordsRated += processResult.usageRecordsRated;
55
+ }
56
+ const outcome = em.create(BillRunOutcome, {
57
+ organizationId: params.organizationId,
58
+ tenantId: params.tenantId,
59
+ billRunId: billRun.id,
60
+ billAccountId: account.id,
61
+ status: processResult.status,
62
+ errorMessage: processResult.status === "failed" ? processResult.errorMessage : null,
63
+ warnings: processResult.status === "failed" ? null : processResult.warnings,
64
+ draftInvoiceId: processResult.status === "failed" ? null : processResult.draftInvoiceId,
65
+ isActive: true,
66
+ createdAt: /* @__PURE__ */ new Date(),
67
+ updatedAt: /* @__PURE__ */ new Date()
68
+ });
69
+ em.persist(outcome);
70
+ await em.flush();
71
+ outcomes.push(outcome);
72
+ }
73
+ billRun.status = computeFinalRunStatus(outcomes);
74
+ billRun.finishedAt = /* @__PURE__ */ new Date();
75
+ billRun.updatedAt = billRun.finishedAt;
76
+ billRun.summary = buildRunSummary(outcomes, accounts.length, totalUsageRecordsRated);
77
+ await em.flush();
78
+ return { billRun, outcomes };
79
+ }
80
+ async function selectTargetAccounts(em, params) {
81
+ const where = {
82
+ tenantId: params.tenantId,
83
+ organizationId: params.organizationId,
84
+ deletedAt: null,
85
+ nextBillDate: { $lte: params.asOfDate }
86
+ };
87
+ if (params.scopedAccountIds && params.scopedAccountIds.length > 0) {
88
+ where.id = { $in: params.scopedAccountIds };
89
+ }
90
+ return em.find(BillingAccount, where);
91
+ }
92
+ async function processAccountSafely(em, container, billRun, account, params) {
93
+ try {
94
+ return await em.transactional(async (subEm) => {
95
+ return processAccount(subEm, container, billRun, account, params);
96
+ });
97
+ } catch (err) {
98
+ return {
99
+ status: "failed",
100
+ errorMessage: err instanceof Error ? err.message : String(err)
101
+ };
102
+ }
103
+ }
104
+ async function processAccount(em, container, billRun, account, params) {
105
+ const maxCycles = params.triggeredBy === "schedule" || !params.catchUp ? SCHEDULED_CYCLE_LIMIT : CATCH_UP_HARD_CAP;
106
+ let cyclesEmitted = 0;
107
+ let lastDraftInvoiceId = null;
108
+ let totalUsageRatedThisAccount = 0;
109
+ const aggregatedWarnings = {};
110
+ while (cyclesEmitted < maxCycles && isCycleDue(account.nextBillDate, params.asOfDate)) {
111
+ const period = deriveBillPeriod(account.nextBillDate, account.billCycle);
112
+ if (params.mode !== "dry") {
113
+ const existing = await findExistingDraftForPeriod(em, account, period);
114
+ if (existing) {
115
+ return {
116
+ status: "skipped_existing_draft",
117
+ warnings: null,
118
+ draftInvoiceId: existing,
119
+ cyclesEmitted,
120
+ usageRecordsRated: totalUsageRatedThisAccount
121
+ };
122
+ }
123
+ }
124
+ const items = await em.find(BillingItem, {
125
+ tenantId: account.tenantId,
126
+ organizationId: account.organizationId,
127
+ billAccountId: account.id,
128
+ deletedAt: null
129
+ });
130
+ const usageItems = items.filter((it) => it.type === "usage");
131
+ const { included } = selectItemsForPeriod(items, period);
132
+ const billableItems = included;
133
+ const currencyMismatchIds = billableItems.filter((it) => it.currencyMismatch).map((it) => it.id);
134
+ if (currencyMismatchIds.length > 0) {
135
+ aggregatedWarnings.currency_mismatch_items = [
136
+ ...aggregatedWarnings.currency_mismatch_items ?? [],
137
+ ...currencyMismatchIds
138
+ ];
139
+ }
140
+ const itemLines = buildInvoiceLinesFromItems(billableItems);
141
+ const usageResult = await processUsageForAccount(em, account, period, usageItems);
142
+ if (usageResult.unmatchedUoms.length > 0) {
143
+ aggregatedWarnings.unmatched_usage_uoms = [
144
+ ...aggregatedWarnings.unmatched_usage_uoms ?? [],
145
+ ...usageResult.unmatchedUoms
146
+ ];
147
+ }
148
+ const allLines = [...itemLines, ...usageResult.lines];
149
+ if (allLines.length === 0) {
150
+ if (params.mode !== "real") break;
151
+ account.nextBillDate = advanceNextBillDate(
152
+ account.nextBillDate,
153
+ account.billCycle
154
+ );
155
+ account.lastBillDate = period.periodEnd;
156
+ account.updatedAt = /* @__PURE__ */ new Date();
157
+ cyclesEmitted += 1;
158
+ continue;
159
+ }
160
+ const written = await writeDraftInvoice(em, container, {
161
+ mode: params.mode,
162
+ account,
163
+ period,
164
+ billRunId: billRun.id,
165
+ lines: allLines
166
+ });
167
+ lastDraftInvoiceId = written.invoiceId;
168
+ if (params.mode === "real") {
169
+ const now = /* @__PURE__ */ new Date();
170
+ for (const item of billableItems) {
171
+ item.billedToDate = period.periodEnd;
172
+ item.updatedAt = now;
173
+ }
174
+ if (usageResult.matchedUoms.length > 0) {
175
+ await em.nativeUpdate(
176
+ BillingAccountUsage,
177
+ {
178
+ tenantId: account.tenantId,
179
+ organizationId: account.organizationId,
180
+ billAccountId: account.id,
181
+ uomCode: { $in: usageResult.matchedUoms },
182
+ ratedInBillRunId: null,
183
+ periodEnd: { $lte: period.periodEnd },
184
+ deletedAt: null
185
+ },
186
+ { ratedInBillRunId: billRun.id, updatedAt: now }
187
+ );
188
+ }
189
+ totalUsageRatedThisAccount += usageResult.ratedCount;
190
+ account.nextBillDate = advanceNextBillDate(
191
+ account.nextBillDate,
192
+ account.billCycle
193
+ );
194
+ account.lastBillDate = period.periodEnd;
195
+ account.updatedAt = now;
196
+ await em.flush();
197
+ } else {
198
+ break;
199
+ }
200
+ cyclesEmitted += 1;
201
+ }
202
+ const hasCurrencyWarnings = (aggregatedWarnings.currency_mismatch_items ?? []).length > 0;
203
+ const hasUnmatchedUsage = (aggregatedWarnings.unmatched_usage_uoms ?? []).length > 0;
204
+ const hasWarnings = hasCurrencyWarnings || hasUnmatchedUsage;
205
+ return {
206
+ status: hasWarnings ? "success_with_warnings" : "success",
207
+ warnings: hasWarnings ? aggregatedWarnings : null,
208
+ draftInvoiceId: lastDraftInvoiceId,
209
+ cyclesEmitted,
210
+ usageRecordsRated: totalUsageRatedThisAccount
211
+ };
212
+ }
213
+ async function findExistingDraftForPeriod(em, account, period) {
214
+ const periodStartISO = period.periodStart.toISOString().slice(0, 10);
215
+ const periodEndISO = period.periodEnd.toISOString().slice(0, 10);
216
+ const rows = await em.execute(
217
+ `SELECT id FROM sales_invoices
218
+ WHERE tenant_id = ?
219
+ AND organization_id = ?
220
+ AND deleted_at IS NULL
221
+ AND status = 'draft'
222
+ AND metadata->>'bill_account_id' = ?
223
+ AND metadata->>'bill_period_start' = ?
224
+ AND metadata->>'bill_period_end' = ?
225
+ LIMIT 1`,
226
+ [account.tenantId, account.organizationId, account.id, periodStartISO, periodEndISO]
227
+ );
228
+ if (!rows || rows.length === 0) return null;
229
+ return rows[0].id ?? null;
230
+ }
231
+ function computeFinalRunStatus(outcomes) {
232
+ if (outcomes.length === 0) return "completed";
233
+ const anyFailed = outcomes.some((o) => o.status === "failed");
234
+ const anySuccess = outcomes.some(
235
+ (o) => o.status === "success" || o.status === "success_with_warnings"
236
+ );
237
+ if (anyFailed && anySuccess) return "partial_failure";
238
+ if (anyFailed) return "partial_failure";
239
+ return "completed";
240
+ }
241
+ function buildRunSummary(outcomes, totalAccounts, usageRecordsRated) {
242
+ return {
243
+ accounts_processed: totalAccounts,
244
+ drafts_created: outcomes.filter(
245
+ (o) => o.draftInvoiceId !== null && o.status !== "skipped_existing_draft"
246
+ ).length,
247
+ drafts_skipped_existing: outcomes.filter((o) => o.status === "skipped_existing_draft").length,
248
+ accounts_failed: outcomes.filter((o) => o.status === "failed").length,
249
+ accounts_with_warnings: outcomes.filter((o) => o.status === "success_with_warnings").length,
250
+ usage_records_rated: usageRecordsRated
251
+ };
252
+ }
253
+ export {
254
+ runBillRun
255
+ };
256
+ //# sourceMappingURL=billRunEngine.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/billing/lib/billRunEngine.ts"],
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { AwilixContainer } from 'awilix'\nimport {\n BillingAccount,\n BillingAccountUsage,\n BillingItem,\n BillRun,\n BillRunOutcome,\n} from '../data/entities'\nimport { advanceNextBillDate, deriveBillPeriod, isCycleDue } from './billPeriod'\nimport {\n selectItemsForPeriod,\n type SelectableItem,\n} from './itemSelector'\nimport {\n buildInvoiceLinesFromItems,\n writeDraftInvoice,\n type InvoiceLineDescriptor,\n type WriteMode,\n} from './invoiceWriter'\nimport { withTenantLock } from './tenantLock'\nimport { processUsageForAccount } from './usageRunner'\nimport type { BillCycle } from '../data/validators'\n\n/**\n * Bill Run engine \u2014 orchestrates one execution of the billing cycle for\n * a single tenant.\n *\n * Concurrency / locking:\n * - The whole run is wrapped in `withTenantLock(...)`, which holds a\n * transaction-scoped Postgres advisory lock keyed by\n * `hash(tenant_id || ':billing-run')`. A second trigger fired while a\n * run is in flight short-circuits with `TenantLockBusyError` (the\n * command layer maps that to HTTP 409).\n * - Within the outer transaction, every account is processed inside a\n * nested `em.transactional(...)` (PG SAVEPOINT). A per-account failure\n * rolls back only that account's work, leaving siblings unaffected.\n *\n * Run modes:\n * - `real` (default) \u2014 persist drafts, advance `next_bill_date`, mark\n * items `billed_to_date`. Production path.\n * - `test` \u2014 persist drafts flagged with `metadata.test_run=true`, but\n * do NOT advance state. Repeatable.\n * - `dry` \u2014 compute everything; persist nothing. Preview only.\n *\n * Catch-up:\n * - Manual triggers may set `catchUp=true`. The engine then loops cycles\n * per account until `next_bill_date > as_of_date`, emitting one draft\n * per missed cycle (never combined).\n * - Scheduled triggers always process exactly ONE cycle per account per\n * run, regardless of how far behind the account is.\n *\n * Anti-duplicate guards (both run in every mode except `dry`):\n * 1. Open-draft check on `core/sales` invoice (per period boundary).\n * 2. `billed_to_date` per BillingItem.\n */\n\nexport type RunMode = WriteMode\n\nexport type RunBillRunParams = {\n tenantId: string\n organizationId: string\n asOfDate: Date\n triggeredBy: 'schedule' | 'manual'\n mode: RunMode\n scopedAccountIds?: string[]\n parentRunId?: string | null\n catchUp?: boolean\n}\n\nexport type RunBillRunResult = {\n billRun: BillRun\n outcomes: BillRunOutcome[]\n}\n\ntype AccountWarnings = {\n currency_mismatch_items?: string[]\n unmatched_usage_uoms?: string[]\n}\n\ntype AccountProcessSuccess = {\n status: 'success' | 'success_with_warnings' | 'skipped_existing_draft'\n warnings: AccountWarnings | null\n draftInvoiceId: string | null\n cyclesEmitted: number\n usageRecordsRated: number\n}\n\ntype AccountProcessFailed = {\n status: 'failed'\n errorMessage: string\n}\n\ntype AccountProcessResult = AccountProcessSuccess | AccountProcessFailed\n\nconst SCHEDULED_CYCLE_LIMIT = 1\nconst CATCH_UP_HARD_CAP = 60 // safety net so a misconfigured account can't loop forever\n\nexport async function runBillRun(\n em: EntityManager,\n container: AwilixContainer,\n params: RunBillRunParams,\n): Promise<RunBillRunResult> {\n return withTenantLock(\n em,\n { tenantId: params.tenantId, lockName: 'billing-run' },\n async (tem) => executeRunWithinLock(tem, container, params),\n )\n}\n\nasync function executeRunWithinLock(\n em: EntityManager,\n container: AwilixContainer,\n params: RunBillRunParams,\n): Promise<RunBillRunResult> {\n const now = new Date()\n const billRun = em.create(BillRun, {\n organizationId: params.organizationId,\n tenantId: params.tenantId,\n triggeredBy: params.triggeredBy,\n parentRunId: params.parentRunId ?? null,\n dryRun: params.mode === 'dry',\n testMode: params.mode === 'test',\n catchUp: Boolean(params.catchUp),\n scopedAccountIds: params.scopedAccountIds ?? null,\n asOfDate: params.asOfDate,\n startedAt: now,\n status: 'running',\n summary: null,\n isActive: true,\n createdAt: now,\n updatedAt: now,\n })\n em.persist(billRun)\n await em.flush()\n\n const accounts = await selectTargetAccounts(em, params)\n const outcomes: BillRunOutcome[] = []\n let totalUsageRecordsRated = 0\n\n for (const account of accounts) {\n const processResult = await processAccountSafely(em, container, billRun, account, params)\n if (processResult.status !== 'failed') {\n totalUsageRecordsRated += processResult.usageRecordsRated\n }\n const outcome = em.create(BillRunOutcome, {\n organizationId: params.organizationId,\n tenantId: params.tenantId,\n billRunId: billRun.id,\n billAccountId: account.id,\n status: processResult.status,\n errorMessage: processResult.status === 'failed' ? processResult.errorMessage : null,\n warnings:\n processResult.status === 'failed'\n ? null\n : (processResult.warnings as Record<string, unknown> | null),\n draftInvoiceId: processResult.status === 'failed' ? null : processResult.draftInvoiceId,\n isActive: true,\n createdAt: new Date(),\n updatedAt: new Date(),\n })\n em.persist(outcome)\n await em.flush()\n outcomes.push(outcome)\n }\n\n billRun.status = computeFinalRunStatus(outcomes)\n billRun.finishedAt = new Date()\n billRun.updatedAt = billRun.finishedAt\n billRun.summary = buildRunSummary(outcomes, accounts.length, totalUsageRecordsRated)\n await em.flush()\n\n return { billRun, outcomes }\n}\n\nasync function selectTargetAccounts(\n em: EntityManager,\n params: RunBillRunParams,\n): Promise<BillingAccount[]> {\n const where: Record<string, unknown> = {\n tenantId: params.tenantId,\n organizationId: params.organizationId,\n deletedAt: null,\n nextBillDate: { $lte: params.asOfDate },\n }\n if (params.scopedAccountIds && params.scopedAccountIds.length > 0) {\n where.id = { $in: params.scopedAccountIds }\n }\n return em.find(BillingAccount, where as never)\n}\n\nasync function processAccountSafely(\n em: EntityManager,\n container: AwilixContainer,\n billRun: BillRun,\n account: BillingAccount,\n params: RunBillRunParams,\n): Promise<AccountProcessResult> {\n try {\n return await em.transactional(async (subEm) => {\n return processAccount(subEm, container, billRun, account, params)\n })\n } catch (err) {\n return {\n status: 'failed',\n errorMessage: err instanceof Error ? err.message : String(err),\n }\n }\n}\n\nasync function processAccount(\n em: EntityManager,\n container: AwilixContainer,\n billRun: BillRun,\n account: BillingAccount,\n params: RunBillRunParams,\n): Promise<AccountProcessResult> {\n const maxCycles =\n params.triggeredBy === 'schedule' || !params.catchUp\n ? SCHEDULED_CYCLE_LIMIT\n : CATCH_UP_HARD_CAP\n\n let cyclesEmitted = 0\n let lastDraftInvoiceId: string | null = null\n let totalUsageRatedThisAccount = 0\n const aggregatedWarnings: AccountWarnings = {}\n\n while (cyclesEmitted < maxCycles && isCycleDue(account.nextBillDate, params.asOfDate)) {\n const period = deriveBillPeriod(account.nextBillDate, account.billCycle as BillCycle)\n\n // Open-draft anti-duplicate \u2014 only applies when we'd actually persist.\n if (params.mode !== 'dry') {\n const existing = await findExistingDraftForPeriod(em, account, period)\n if (existing) {\n return {\n status: 'skipped_existing_draft',\n warnings: null,\n draftInvoiceId: existing,\n cyclesEmitted,\n usageRecordsRated: totalUsageRatedThisAccount,\n }\n }\n }\n\n const items = await em.find(BillingItem, {\n tenantId: account.tenantId,\n organizationId: account.organizationId,\n billAccountId: account.id,\n deletedAt: null,\n } as never)\n\n const usageItems = (items as BillingItem[]).filter((it) => it.type === 'usage')\n\n // Non-usage path: one_time + recurring filtered by the selector.\n const { included } = selectItemsForPeriod(items as SelectableItem[], period)\n const billableItems = included as unknown as BillingItem[]\n\n const currencyMismatchIds = billableItems\n .filter((it) => it.currencyMismatch)\n .map((it) => it.id)\n if (currencyMismatchIds.length > 0) {\n aggregatedWarnings.currency_mismatch_items = [\n ...(aggregatedWarnings.currency_mismatch_items ?? []),\n ...currencyMismatchIds,\n ]\n }\n const itemLines = buildInvoiceLinesFromItems(billableItems)\n\n // Usage path: aggregate matching usage records per uom_code, rate,\n // emit one line per matched usage item, warn about unmatched UoMs.\n const usageResult = await processUsageForAccount(em, account, period, usageItems)\n if (usageResult.unmatchedUoms.length > 0) {\n aggregatedWarnings.unmatched_usage_uoms = [\n ...(aggregatedWarnings.unmatched_usage_uoms ?? []),\n ...usageResult.unmatchedUoms,\n ]\n }\n\n const allLines: InvoiceLineDescriptor[] = [...itemLines, ...usageResult.lines]\n if (allLines.length === 0) {\n // Nothing to bill this cycle \u2014 still advance so the account moves\n // forward in real mode. In test / dry, no advance, so we'd loop\n // forever; break out.\n if (params.mode !== 'real') break\n account.nextBillDate = advanceNextBillDate(\n account.nextBillDate,\n account.billCycle as BillCycle,\n )\n account.lastBillDate = period.periodEnd\n account.updatedAt = new Date()\n cyclesEmitted += 1\n continue\n }\n\n const written = await writeDraftInvoice(em, container, {\n mode: params.mode,\n account,\n period,\n billRunId: billRun.id,\n lines: allLines,\n })\n lastDraftInvoiceId = written.invoiceId\n\n if (params.mode === 'real') {\n const now = new Date()\n for (const item of billableItems) {\n item.billedToDate = period.periodEnd\n item.updatedAt = now\n }\n // Mark consumed usage records \u2014 single bulk update so we don't\n // round-trip per record on accounts with thousands of usage rows.\n if (usageResult.matchedUoms.length > 0) {\n // Predicate-based bulk update: marks every still-unrated record\n // for the matched uoms in the account's period with a single\n // SQL UPDATE \u2014 no id list ever leaves Postgres. Stays\n // memory-bounded even for telco accounts with millions of\n // usage records per cycle (spec Performance Considerations\n // \"Shape B\").\n await em.nativeUpdate(\n BillingAccountUsage,\n {\n tenantId: account.tenantId,\n organizationId: account.organizationId,\n billAccountId: account.id,\n uomCode: { $in: usageResult.matchedUoms },\n ratedInBillRunId: null,\n periodEnd: { $lte: period.periodEnd },\n deletedAt: null,\n } as never,\n { ratedInBillRunId: billRun.id, updatedAt: now } as never,\n )\n }\n totalUsageRatedThisAccount += usageResult.ratedCount\n account.nextBillDate = advanceNextBillDate(\n account.nextBillDate,\n account.billCycle as BillCycle,\n )\n account.lastBillDate = period.periodEnd\n account.updatedAt = now\n await em.flush()\n } else {\n // Test / dry: do not advance state. One iteration only.\n break\n }\n\n cyclesEmitted += 1\n }\n\n const hasCurrencyWarnings =\n (aggregatedWarnings.currency_mismatch_items ?? []).length > 0\n const hasUnmatchedUsage =\n (aggregatedWarnings.unmatched_usage_uoms ?? []).length > 0\n const hasWarnings = hasCurrencyWarnings || hasUnmatchedUsage\n return {\n status: hasWarnings ? 'success_with_warnings' : 'success',\n warnings: hasWarnings ? aggregatedWarnings : null,\n draftInvoiceId: lastDraftInvoiceId,\n cyclesEmitted,\n usageRecordsRated: totalUsageRatedThisAccount,\n }\n}\n\nasync function findExistingDraftForPeriod(\n em: EntityManager,\n account: BillingAccount,\n period: { periodStart: Date; periodEnd: Date },\n): Promise<string | null> {\n const periodStartISO = period.periodStart.toISOString().slice(0, 10)\n const periodEndISO = period.periodEnd.toISOString().slice(0, 10)\n const rows = (await em.execute(\n `SELECT id FROM sales_invoices\n WHERE tenant_id = ?\n AND organization_id = ?\n AND deleted_at IS NULL\n AND status = 'draft'\n AND metadata->>'bill_account_id' = ?\n AND metadata->>'bill_period_start' = ?\n AND metadata->>'bill_period_end' = ?\n LIMIT 1`,\n [account.tenantId, account.organizationId, account.id, periodStartISO, periodEndISO],\n )) as unknown as Array<{ id: string }> | undefined\n if (!rows || rows.length === 0) return null\n return rows[0].id ?? null\n}\n\nfunction computeFinalRunStatus(\n outcomes: readonly BillRunOutcome[],\n): BillRun['status'] {\n if (outcomes.length === 0) return 'completed'\n const anyFailed = outcomes.some((o) => o.status === 'failed')\n const anySuccess = outcomes.some(\n (o) => o.status === 'success' || o.status === 'success_with_warnings',\n )\n if (anyFailed && anySuccess) return 'partial_failure'\n if (anyFailed) return 'partial_failure'\n return 'completed'\n}\n\nfunction buildRunSummary(\n outcomes: readonly BillRunOutcome[],\n totalAccounts: number,\n usageRecordsRated: number,\n): Record<string, unknown> {\n return {\n accounts_processed: totalAccounts,\n drafts_created: outcomes.filter(\n (o) => o.draftInvoiceId !== null && o.status !== 'skipped_existing_draft',\n ).length,\n drafts_skipped_existing: outcomes.filter((o) => o.status === 'skipped_existing_draft')\n .length,\n accounts_failed: outcomes.filter((o) => o.status === 'failed').length,\n accounts_with_warnings: outcomes.filter((o) => o.status === 'success_with_warnings')\n .length,\n usage_records_rated: usageRecordsRated,\n }\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1,8 @@
1
+ function billRunName(asOfDate) {
2
+ const date = typeof asOfDate === "string" && asOfDate.length >= 10 ? asOfDate.slice(0, 10) : "unknown";
3
+ return `BILL_RUN_${date}`;
4
+ }
5
+ export {
6
+ billRunName
7
+ };
8
+ //# sourceMappingURL=billRunLabel.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/billing/lib/billRunLabel.ts"],
4
+ "sourcesContent": ["/**\n * Human-readable label for a Bill Run.\n *\n * The BillRun entity has no `name` column \u2014 the engine keys runs by id.\n * The admin UI derives a stable, scannable label from the run's as-of\n * date so operators see `BILL_RUN_2026-05-20` instead of a raw UUID.\n */\nexport function billRunName(asOfDate: string | null | undefined): string {\n const date =\n typeof asOfDate === 'string' && asOfDate.length >= 10\n ? asOfDate.slice(0, 10)\n : 'unknown'\n return `BILL_RUN_${date}`\n}\n"],
5
+ "mappings": "AAOO,SAAS,YAAY,UAA6C;AACvE,QAAM,OACJ,OAAO,aAAa,YAAY,SAAS,UAAU,KAC/C,SAAS,MAAM,GAAG,EAAE,IACpB;AACN,SAAO,YAAY,IAAI;AACzB;",
6
+ "names": []
7
+ }
@@ -0,0 +1,13 @@
1
+ async function findBySourceRef(em, entity, scope) {
2
+ const where = {
3
+ tenantId: scope.tenantId,
4
+ billAccountId: scope.billAccountId,
5
+ sourceRef: scope.sourceRef,
6
+ deletedAt: null
7
+ };
8
+ return em.findOne(entity, where);
9
+ }
10
+ export {
11
+ findBySourceRef
12
+ };
13
+ //# sourceMappingURL=idempotency.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/billing/lib/idempotency.ts"],
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\n\n/**\n * `source_ref` idempotency helper.\n *\n * The unique index on `(tenant_id, bill_account_id, source_ref)` does the\n * heavy lifting at the database level \u2014 Postgres treats NULLs as distinct,\n * so multiple NULL `source_ref` values coexist for the same account. This\n * helper checks for an existing row *before* the insert so we can return\n * it (HTTP 200 with `deduplicated: true`) instead of taking the round-trip\n * to the DB and unwinding a unique-violation.\n *\n * Why not rely solely on the unique constraint and catch the conflict?\n * Two reasons:\n * 1. Catching the conflict requires a SAVEPOINT or a separate tx \u2014\n * complicates error handling and rolls back the surrounding work.\n * 2. Idempotency is also a read-side guarantee: if a connector POSTs\n * twice, the *second* call should observe what the first one\n * persisted, not just \"duplicate, sorry.\"\n *\n * The constraint stays in place as the *correctness* gate \u2014 if a race\n * sneaks past this check, the DB still refuses the duplicate.\n */\n\ntype SourceRefScope = {\n tenantId: string\n billAccountId: string\n sourceRef: string\n deletedAt?: null\n}\n\ntype EntityCtor<T> = new (...args: never[]) => T\n\nexport async function findBySourceRef<T extends { sourceRef?: string | null }>(\n em: EntityManager,\n entity: EntityCtor<T>,\n scope: { tenantId: string; billAccountId: string; sourceRef: string },\n): Promise<T | null> {\n const where: SourceRefScope = {\n tenantId: scope.tenantId,\n billAccountId: scope.billAccountId,\n sourceRef: scope.sourceRef,\n deletedAt: null,\n }\n return em.findOne(entity, where as never)\n}\n"],
5
+ "mappings": "AAiCA,eAAsB,gBACpB,IACA,QACA,OACmB;AACnB,QAAM,QAAwB;AAAA,IAC5B,UAAU,MAAM;AAAA,IAChB,eAAe,MAAM;AAAA,IACrB,WAAW,MAAM;AAAA,IACjB,WAAW;AAAA,EACb;AACA,SAAO,GAAG,QAAQ,QAAQ,KAAc;AAC1C;",
6
+ "names": []
7
+ }
@@ -0,0 +1,38 @@
1
+ import {
2
+ Dictionary,
3
+ DictionaryEntry
4
+ } from "@open-mercato/core/modules/dictionaries/data/entities";
5
+ const SALES_INVOICE_STATUS_DICTIONARY_KEY = "sales.invoice_status";
6
+ const cache = /* @__PURE__ */ new Map();
7
+ function key(tenantId, organizationId, status) {
8
+ return `${tenantId}:${organizationId}:${status}`;
9
+ }
10
+ async function resolveInvoiceStatusEntryId(em, tenantId, organizationId, status) {
11
+ const cacheKey = key(tenantId, organizationId, status);
12
+ const cached = cache.get(cacheKey);
13
+ if (cached) return cached;
14
+ const dictionary = await em.findOne(Dictionary, {
15
+ tenantId,
16
+ organizationId,
17
+ key: SALES_INVOICE_STATUS_DICTIONARY_KEY,
18
+ deletedAt: null
19
+ });
20
+ if (!dictionary) return null;
21
+ const entry = await em.findOne(DictionaryEntry, {
22
+ dictionary,
23
+ tenantId,
24
+ organizationId,
25
+ value: status
26
+ });
27
+ if (!entry) return null;
28
+ cache.set(cacheKey, entry.id);
29
+ return entry.id;
30
+ }
31
+ function __resetInvoiceStatusCacheForTests() {
32
+ cache.clear();
33
+ }
34
+ export {
35
+ __resetInvoiceStatusCacheForTests,
36
+ resolveInvoiceStatusEntryId
37
+ };
38
+ //# sourceMappingURL=invoiceStatus.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/billing/lib/invoiceStatus.ts"],
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport {\n Dictionary,\n DictionaryEntry,\n} from '@open-mercato/core/modules/dictionaries/data/entities'\nimport type { InvoiceStatus } from '../data/validators'\n\n/**\n * Resolve the `sales.invoice_status` dictionary entry id for a given\n * status value, scoped per `(tenantId, organizationId)`.\n *\n * Used by:\n * - `invoiceWriter` to set `statusEntryId` on the draft at create time.\n * - The \"post\" command to flip draft \u2192 posted.\n * - The payment-captured subscriber to flip posted \u2192 paid.\n *\n * Cached because the lookup is hot (every Bill Run draft hits it\n * once, every posted invoice hits it twice, every paid invoice once)\n * and the dictionary entry id never changes for the lifetime of a\n * tenant. Exposed `__resetInvoiceStatusCacheForTests` to keep tests\n * deterministic across runs.\n */\n\nconst SALES_INVOICE_STATUS_DICTIONARY_KEY = 'sales.invoice_status'\n\nconst cache = new Map<string, string>()\n\nfunction key(tenantId: string, organizationId: string, status: InvoiceStatus): string {\n return `${tenantId}:${organizationId}:${status}`\n}\n\nexport async function resolveInvoiceStatusEntryId(\n em: EntityManager,\n tenantId: string,\n organizationId: string,\n status: InvoiceStatus,\n): Promise<string | null> {\n const cacheKey = key(tenantId, organizationId, status)\n const cached = cache.get(cacheKey)\n if (cached) return cached\n const dictionary = await em.findOne(Dictionary, {\n tenantId,\n organizationId,\n key: SALES_INVOICE_STATUS_DICTIONARY_KEY,\n deletedAt: null,\n })\n if (!dictionary) return null\n const entry = await em.findOne(DictionaryEntry, {\n dictionary,\n tenantId,\n organizationId,\n value: status,\n })\n if (!entry) return null\n cache.set(cacheKey, entry.id)\n return entry.id\n}\n\nexport function __resetInvoiceStatusCacheForTests(): void {\n cache.clear()\n}\n"],
5
+ "mappings": "AACA;AAAA,EACE;AAAA,EACA;AAAA,OACK;AAmBP,MAAM,sCAAsC;AAE5C,MAAM,QAAQ,oBAAI,IAAoB;AAEtC,SAAS,IAAI,UAAkB,gBAAwB,QAA+B;AACpF,SAAO,GAAG,QAAQ,IAAI,cAAc,IAAI,MAAM;AAChD;AAEA,eAAsB,4BACpB,IACA,UACA,gBACA,QACwB;AACxB,QAAM,WAAW,IAAI,UAAU,gBAAgB,MAAM;AACrD,QAAM,SAAS,MAAM,IAAI,QAAQ;AACjC,MAAI,OAAQ,QAAO;AACnB,QAAM,aAAa,MAAM,GAAG,QAAQ,YAAY;AAAA,IAC9C;AAAA,IACA;AAAA,IACA,KAAK;AAAA,IACL,WAAW;AAAA,EACb,CAAC;AACD,MAAI,CAAC,WAAY,QAAO;AACxB,QAAM,QAAQ,MAAM,GAAG,QAAQ,iBAAiB;AAAA,IAC9C;AAAA,IACA;AAAA,IACA;AAAA,IACA,OAAO;AAAA,EACT,CAAC;AACD,MAAI,CAAC,MAAO,QAAO;AACnB,QAAM,IAAI,UAAU,MAAM,EAAE;AAC5B,SAAO,MAAM;AACf;AAEO,SAAS,oCAA0C;AACxD,QAAM,MAAM;AACd;",
6
+ "names": []
7
+ }
@@ -0,0 +1,153 @@
1
+ import {
2
+ SalesInvoice,
3
+ SalesInvoiceLine
4
+ } from "@open-mercato/core/modules/sales/data/entities";
5
+ import {
6
+ __resetInvoiceStatusCacheForTests,
7
+ resolveInvoiceStatusEntryId
8
+ } from "./invoiceStatus.js";
9
+ const DRAFT_INVOICE_STATUS_VALUE = "draft";
10
+ function __resetInvoiceWriterCacheForTests() {
11
+ __resetInvoiceStatusCacheForTests();
12
+ }
13
+ function generateTestInvoiceNumber() {
14
+ const random = Math.floor(Math.random() * 1e9).toString(16).padStart(8, "0");
15
+ return `TEST-${Date.now().toString(16)}-${random}`;
16
+ }
17
+ function roundHalfUp2dp(value) {
18
+ return Math.round((value + Number.EPSILON) * 100) / 100;
19
+ }
20
+ function format4dp(value) {
21
+ return roundHalfUp2dp(value).toFixed(4);
22
+ }
23
+ function sumNetTotal(lines) {
24
+ let total = 0;
25
+ for (const line of lines) {
26
+ total += Number.parseFloat(line.totalNetAmount);
27
+ }
28
+ return format4dp(total);
29
+ }
30
+ async function writeDraftInvoice(em, container, params) {
31
+ const totalNet = sumNetTotal(params.lines);
32
+ if (params.mode === "dry") {
33
+ return { invoiceId: null, invoiceNumber: null, totalNet };
34
+ }
35
+ const { account, period, billRunId, lines, mode } = params;
36
+ let invoiceNumber;
37
+ if (mode === "test") {
38
+ invoiceNumber = generateTestInvoiceNumber();
39
+ } else {
40
+ const numberGenerator = container.resolve(
41
+ "salesDocumentNumberGenerator"
42
+ );
43
+ const generated = await numberGenerator.generate({
44
+ organizationId: account.organizationId,
45
+ tenantId: account.tenantId,
46
+ kind: "invoice"
47
+ });
48
+ invoiceNumber = generated.number;
49
+ }
50
+ const statusEntryId = await resolveInvoiceStatusEntryId(
51
+ em,
52
+ account.tenantId,
53
+ account.organizationId,
54
+ "draft"
55
+ );
56
+ const now = /* @__PURE__ */ new Date();
57
+ const invoice = em.create(SalesInvoice, {
58
+ organizationId: account.organizationId,
59
+ tenantId: account.tenantId,
60
+ invoiceNumber,
61
+ statusEntryId,
62
+ status: DRAFT_INVOICE_STATUS_VALUE,
63
+ issueDate: now,
64
+ currencyCode: account.currencyCode,
65
+ subtotalNetAmount: totalNet,
66
+ subtotalGrossAmount: totalNet,
67
+ // gross == net for drafts (tax computed on post)
68
+ discountTotalAmount: "0",
69
+ taxTotalAmount: "0",
70
+ grandTotalNetAmount: totalNet,
71
+ grandTotalGrossAmount: totalNet,
72
+ paidTotalAmount: "0",
73
+ outstandingAmount: totalNet,
74
+ metadata: {
75
+ bill_run_id: billRunId,
76
+ bill_account_id: account.id,
77
+ bill_period_start: period.periodStart.toISOString().slice(0, 10),
78
+ bill_period_end: period.periodEnd.toISOString().slice(0, 10),
79
+ test_run: mode === "test" ? true : void 0
80
+ },
81
+ createdAt: now,
82
+ updatedAt: now
83
+ });
84
+ em.persist(invoice);
85
+ let lineNumber = 1;
86
+ for (const line of lines) {
87
+ const invoiceLine = em.create(SalesInvoiceLine, {
88
+ invoice,
89
+ organizationId: account.organizationId,
90
+ tenantId: account.tenantId,
91
+ lineNumber: lineNumber++,
92
+ kind: "product",
93
+ description: line.description,
94
+ quantity: line.quantity,
95
+ normalizedQuantity: line.quantity,
96
+ currencyCode: account.currencyCode,
97
+ unitPriceNet: line.unitPriceNet,
98
+ unitPriceGross: line.unitPriceNet,
99
+ // gross == net until tax computed on post
100
+ discountAmount: "0",
101
+ discountPercent: "0",
102
+ taxRate: "0",
103
+ taxAmount: "0",
104
+ totalNetAmount: line.totalNetAmount,
105
+ totalGrossAmount: line.totalNetAmount,
106
+ metadata: {
107
+ billing_item_id: line.billingItemId,
108
+ billing_type: line.billingType,
109
+ ...line.metadataExtra ?? {}
110
+ }
111
+ });
112
+ em.persist(invoiceLine);
113
+ }
114
+ await em.flush();
115
+ return { invoiceId: invoice.id, invoiceNumber, totalNet };
116
+ }
117
+ function buildInvoiceLinesFromItems(items) {
118
+ const lines = [];
119
+ for (const item of items) {
120
+ if (item.type === "one_time") {
121
+ const rate = item.rateJson;
122
+ if (!rate || typeof rate.amount !== "number") continue;
123
+ const amount = format4dp(rate.amount);
124
+ lines.push({
125
+ billingItemId: item.id,
126
+ billingType: "one_time",
127
+ description: item.description,
128
+ unitPriceNet: amount,
129
+ quantity: "1",
130
+ totalNetAmount: amount
131
+ });
132
+ } else if (item.type === "recurring") {
133
+ const rate = item.rateJson;
134
+ if (!rate || typeof rate.unit_price !== "number") continue;
135
+ const unitPrice = format4dp(rate.unit_price);
136
+ lines.push({
137
+ billingItemId: item.id,
138
+ billingType: "recurring",
139
+ description: item.description,
140
+ unitPriceNet: unitPrice,
141
+ quantity: "1",
142
+ totalNetAmount: unitPrice
143
+ });
144
+ }
145
+ }
146
+ return lines;
147
+ }
148
+ export {
149
+ __resetInvoiceWriterCacheForTests,
150
+ buildInvoiceLinesFromItems,
151
+ writeDraftInvoice
152
+ };
153
+ //# sourceMappingURL=invoiceWriter.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/billing/lib/invoiceWriter.ts"],
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { AwilixContainer } from 'awilix'\nimport {\n SalesInvoice,\n SalesInvoiceLine,\n} from '@open-mercato/core/modules/sales/data/entities'\nimport type { SalesDocumentNumberGenerator } from '@open-mercato/core/modules/sales/services/salesDocumentNumberGenerator'\nimport type { BillingAccount, BillingItem } from '../data/entities'\nimport type { BillPeriod } from './billPeriod'\nimport {\n __resetInvoiceStatusCacheForTests,\n resolveInvoiceStatusEntryId,\n} from './invoiceStatus'\n\n/**\n * Writes a `core/sales` draft invoice for a billing account's bill period.\n *\n * Two notable deviations from the spec, documented in CHANGELOG / spec:\n *\n * 1. `SalesInvoice.invoice_number` is `NOT NULL` in the upstream\n * schema, but the spec says \"sequence number assigned at Post.\"\n * Real-mode invoices get a real number via\n * `salesDocumentNumberGenerator.generate({ kind: 'invoice' })` at\n * *draft create time* \u2014 the spec's intent (gap-free numbering via\n * atomic SQL upsert) is preserved; the timing differs because the\n * upstream column is `NOT NULL`. \"Post\" then just transitions\n * status `draft \u2192 posted`.\n *\n * 2. Test-mode invoices use a `TEST-<uuid>` placeholder number (not\n * the real sequence) so test runs do not burn production numbers\n * and can be repeated idempotently.\n *\n * VAT is NOT computed here \u2014 `core/sales`'s tax service owns the\n * per-line `tax_rate` / `tax_amount` calculation, and runs on POST.\n * Drafts therefore carry zeroes in the tax columns; the totals reflect\n * net amounts only.\n */\n\nexport type WriteMode = 'real' | 'test' | 'dry'\n\nexport type InvoiceLineDescriptor = {\n /** Maps to billing_item_id on the resulting SalesInvoiceLine.metadata. */\n billingItemId: string\n /** From the spec's `metadata.billing_type` for traceability. */\n billingType: 'one_time' | 'recurring' | 'usage'\n description: string\n /** Net unit price (numeric string for SalesInvoiceLine.unit_price_net). */\n unitPriceNet: string\n /** Quantity (string for the numeric(18,4) column). */\n quantity: string\n /** Total net (string). */\n totalNetAmount: string\n /** Optional per-line metadata extension \u2014 e.g. usage tier breakdown. */\n metadataExtra?: Record<string, unknown>\n}\n\nexport type WriteInvoiceParams = {\n mode: WriteMode\n account: BillingAccount\n period: BillPeriod\n billRunId: string\n lines: InvoiceLineDescriptor[]\n}\n\nexport type WrittenInvoice = {\n invoiceId: string | null\n invoiceNumber: string | null\n totalNet: string\n}\n\nconst DRAFT_INVOICE_STATUS_VALUE = 'draft'\n\n/**\n * Test hook \u2014 clears the shared resolver's status cache so the engine\n * test suite stays deterministic across mocked-EM environments.\n */\nexport function __resetInvoiceWriterCacheForTests(): void {\n __resetInvoiceStatusCacheForTests()\n}\n\nfunction generateTestInvoiceNumber(): string {\n // Match the pattern operators search by: `TEST-` prefix + random\n // suffix so the test wipe-out endpoint can filter via `LIKE 'TEST-%'`\n // (defensive \u2014 primary filter is `metadata.test_run=true`).\n const random = Math.floor(Math.random() * 1e9)\n .toString(16)\n .padStart(8, '0')\n return `TEST-${Date.now().toString(16)}-${random}`\n}\n\n/**\n * 2dp half-up rounding that survives binary floating-point edge cases\n * (e.g. `1.005 * 100 === 100.49999...` would otherwise round DOWN to\n * `1.00` under naive `Math.round`). The `+ EPSILON` nudge pushes\n * representations like 100.4999...8 up to the true half-up boundary.\n * Matches the spec's \"2 decimal places, half-up\" rounding policy.\n */\nfunction roundHalfUp2dp(value: number): number {\n return Math.round((value + Number.EPSILON) * 100) / 100\n}\n\nfunction format4dp(value: number): string {\n return roundHalfUp2dp(value).toFixed(4)\n}\n\nfunction sumNetTotal(lines: InvoiceLineDescriptor[]): string {\n let total = 0\n for (const line of lines) {\n total += Number.parseFloat(line.totalNetAmount)\n }\n return format4dp(total)\n}\n\nexport async function writeDraftInvoice(\n em: EntityManager,\n container: AwilixContainer,\n params: WriteInvoiceParams,\n): Promise<WrittenInvoice> {\n const totalNet = sumNetTotal(params.lines)\n\n if (params.mode === 'dry') {\n // Compute totals; persist nothing.\n return { invoiceId: null, invoiceNumber: null, totalNet }\n }\n\n const { account, period, billRunId, lines, mode } = params\n\n let invoiceNumber: string\n if (mode === 'test') {\n invoiceNumber = generateTestInvoiceNumber()\n } else {\n const numberGenerator = container.resolve(\n 'salesDocumentNumberGenerator',\n ) as SalesDocumentNumberGenerator\n const generated = await numberGenerator.generate({\n organizationId: account.organizationId,\n tenantId: account.tenantId,\n kind: 'invoice',\n })\n invoiceNumber = generated.number\n }\n\n const statusEntryId = await resolveInvoiceStatusEntryId(\n em,\n account.tenantId,\n account.organizationId,\n 'draft',\n )\n\n const now = new Date()\n const invoice = em.create(SalesInvoice, {\n organizationId: account.organizationId,\n tenantId: account.tenantId,\n invoiceNumber,\n statusEntryId,\n status: DRAFT_INVOICE_STATUS_VALUE,\n issueDate: now,\n currencyCode: account.currencyCode,\n subtotalNetAmount: totalNet,\n subtotalGrossAmount: totalNet, // gross == net for drafts (tax computed on post)\n discountTotalAmount: '0',\n taxTotalAmount: '0',\n grandTotalNetAmount: totalNet,\n grandTotalGrossAmount: totalNet,\n paidTotalAmount: '0',\n outstandingAmount: totalNet,\n metadata: {\n bill_run_id: billRunId,\n bill_account_id: account.id,\n bill_period_start: period.periodStart.toISOString().slice(0, 10),\n bill_period_end: period.periodEnd.toISOString().slice(0, 10),\n test_run: mode === 'test' ? true : undefined,\n },\n createdAt: now,\n updatedAt: now,\n })\n em.persist(invoice)\n\n let lineNumber = 1\n for (const line of lines) {\n const invoiceLine = em.create(SalesInvoiceLine, {\n invoice,\n organizationId: account.organizationId,\n tenantId: account.tenantId,\n lineNumber: lineNumber++,\n kind: 'product',\n description: line.description,\n quantity: line.quantity,\n normalizedQuantity: line.quantity,\n currencyCode: account.currencyCode,\n unitPriceNet: line.unitPriceNet,\n unitPriceGross: line.unitPriceNet, // gross == net until tax computed on post\n discountAmount: '0',\n discountPercent: '0',\n taxRate: '0',\n taxAmount: '0',\n totalNetAmount: line.totalNetAmount,\n totalGrossAmount: line.totalNetAmount,\n metadata: {\n billing_item_id: line.billingItemId,\n billing_type: line.billingType,\n ...(line.metadataExtra ?? {}),\n },\n })\n em.persist(invoiceLine)\n }\n\n await em.flush()\n\n return { invoiceId: invoice.id, invoiceNumber, totalNet }\n}\n\n/**\n * Build the InvoiceLineDescriptor list for a single account's eligible\n * items (the result of `itemSelector.selectItemsForPeriod`). One line\n * per item; `usage` items are excluded earlier by the selector.\n *\n * Per-type pricing per the spec's `BillingItem \u2192 InvoiceLine mapping`:\n * - `one_time`: unit_price = amount, quantity = 1\n * - `recurring`: unit_price = unit_price, quantity = 1\n * (one full cycle per bill period; catch-up emits separate drafts\n * per missed cycle, never combined)\n */\nexport function buildInvoiceLinesFromItems(\n items: readonly BillingItem[],\n): InvoiceLineDescriptor[] {\n const lines: InvoiceLineDescriptor[] = []\n for (const item of items) {\n if (item.type === 'one_time') {\n const rate = item.rateJson as { amount?: number } | null\n if (!rate || typeof rate.amount !== 'number') continue\n // For one_time the per-unit price IS the total \u2014 quantity is 1.\n // Both fields therefore carry the same value, with the 2dp\n // half-up policy applied (unit price + line total are the same\n // numeric column at numeric(18,4)).\n const amount = format4dp(rate.amount)\n lines.push({\n billingItemId: item.id,\n billingType: 'one_time',\n description: item.description,\n unitPriceNet: amount,\n quantity: '1',\n totalNetAmount: amount,\n })\n } else if (item.type === 'recurring') {\n const rate = item.rateJson as { unit_price?: number } | null\n if (!rate || typeof rate.unit_price !== 'number') continue\n const unitPrice = format4dp(rate.unit_price)\n lines.push({\n billingItemId: item.id,\n billingType: 'recurring',\n description: item.description,\n unitPriceNet: unitPrice,\n quantity: '1',\n totalNetAmount: unitPrice,\n })\n }\n }\n return lines\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1,77 @@
1
+ function asTime(date) {
2
+ if (!date) return null;
3
+ return new Date(date).getTime();
4
+ }
5
+ function isWithin(windowStart, windowEnd, point) {
6
+ const pointTime = asTime(point);
7
+ const startTime = asTime(windowStart);
8
+ if (pointTime < startTime) return false;
9
+ const endTime = asTime(windowEnd);
10
+ if (endTime === null) return true;
11
+ return pointTime <= endTime;
12
+ }
13
+ function overlapsPeriod(itemStart, itemEnd, period) {
14
+ const itemStartTime = asTime(itemStart);
15
+ const itemEndTime = asTime(itemEnd);
16
+ const periodStartTime = asTime(period.periodStart);
17
+ const periodEndTime = asTime(period.periodEnd);
18
+ if (itemStartTime > periodEndTime) return false;
19
+ if (itemEndTime !== null && itemEndTime < periodStartTime) return false;
20
+ return true;
21
+ }
22
+ function shouldIncludeItem(item, period) {
23
+ if (!item.isActive) return { include: false, reason: "item-inactive" };
24
+ if (item.deletedAt) return { include: false, reason: "item-soft-deleted" };
25
+ switch (item.type) {
26
+ case "one_time": {
27
+ if (item.billedToDate) {
28
+ return { include: false, reason: "one_time-already-billed" };
29
+ }
30
+ if (!overlapsPeriod(item.billStartDate, item.billEndDate, period)) {
31
+ return { include: false, reason: "one_time-window-outside-period" };
32
+ }
33
+ return { include: true };
34
+ }
35
+ case "recurring": {
36
+ const billedToTime = asTime(item.billedToDate);
37
+ const periodEndTime = asTime(period.periodEnd);
38
+ if (billedToTime !== null && billedToTime >= periodEndTime) {
39
+ return { include: false, reason: "recurring-already-billed-through-period" };
40
+ }
41
+ const periodStartTime = asTime(period.periodStart);
42
+ const itemStartTime = asTime(item.billStartDate);
43
+ if (itemStartTime > periodStartTime) {
44
+ return { include: false, reason: "recurring-starts-mid-cycle" };
45
+ }
46
+ const itemEndTime = asTime(item.billEndDate);
47
+ if (itemEndTime !== null && itemEndTime < periodEndTime) {
48
+ return { include: false, reason: "recurring-ends-mid-cycle" };
49
+ }
50
+ return { include: true };
51
+ }
52
+ case "usage":
53
+ return { include: false, reason: "usage-handled-by-rater" };
54
+ default:
55
+ return { include: false, reason: `unknown-type:${item.type}` };
56
+ }
57
+ }
58
+ function selectItemsForPeriod(items, period) {
59
+ const included = [];
60
+ const skipped = [];
61
+ for (const item of items) {
62
+ const decision = shouldIncludeItem(item, period);
63
+ if (decision.include) {
64
+ included.push(item);
65
+ } else {
66
+ skipped.push({ item, reason: decision.reason });
67
+ }
68
+ }
69
+ return { included, skipped };
70
+ }
71
+ export {
72
+ isWithin,
73
+ overlapsPeriod,
74
+ selectItemsForPeriod,
75
+ shouldIncludeItem
76
+ };
77
+ //# sourceMappingURL=itemSelector.js.map