@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.
- package/CHANGELOG.md +658 -0
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/dist/modules/billing/acl.js +20 -0
- package/dist/modules/billing/acl.js.map +7 -0
- package/dist/modules/billing/api/accounts/route.js +182 -0
- package/dist/modules/billing/api/accounts/route.js.map +7 -0
- package/dist/modules/billing/api/export/account/[id]/route.js +170 -0
- package/dist/modules/billing/api/export/account/[id]/route.js.map +7 -0
- package/dist/modules/billing/api/invoices/[id]/route.js +85 -0
- package/dist/modules/billing/api/invoices/[id]/route.js.map +7 -0
- package/dist/modules/billing/api/invoices/add-line/route.js +69 -0
- package/dist/modules/billing/api/invoices/add-line/route.js.map +7 -0
- package/dist/modules/billing/api/invoices/edit-line/route.js +78 -0
- package/dist/modules/billing/api/invoices/edit-line/route.js.map +7 -0
- package/dist/modules/billing/api/invoices/post/route.js +74 -0
- package/dist/modules/billing/api/invoices/post/route.js.map +7 -0
- package/dist/modules/billing/api/invoices/remove-line/route.js +69 -0
- package/dist/modules/billing/api/invoices/remove-line/route.js.map +7 -0
- package/dist/modules/billing/api/invoices/route.js +109 -0
- package/dist/modules/billing/api/invoices/route.js.map +7 -0
- package/dist/modules/billing/api/items/bulk/route.js +74 -0
- package/dist/modules/billing/api/items/bulk/route.js.map +7 -0
- package/dist/modules/billing/api/items/route.js +224 -0
- package/dist/modules/billing/api/items/route.js.map +7 -0
- package/dist/modules/billing/api/openapi.js +25 -0
- package/dist/modules/billing/api/openapi.js.map +7 -0
- package/dist/modules/billing/api/run-outcomes/route.js +122 -0
- package/dist/modules/billing/api/run-outcomes/route.js.map +7 -0
- package/dist/modules/billing/api/runs/retry-failed/route.js +70 -0
- package/dist/modules/billing/api/runs/retry-failed/route.js.map +7 -0
- package/dist/modules/billing/api/runs/route.js +143 -0
- package/dist/modules/billing/api/runs/route.js.map +7 -0
- package/dist/modules/billing/api/test-invoices/wipe/route.js +65 -0
- package/dist/modules/billing/api/test-invoices/wipe/route.js.map +7 -0
- package/dist/modules/billing/api/usage/route.js +138 -0
- package/dist/modules/billing/api/usage/route.js.map +7 -0
- package/dist/modules/billing/backend/billing/accounts/[id]/page.js +402 -0
- package/dist/modules/billing/backend/billing/accounts/[id]/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/accounts/[id]/page.meta.js +25 -0
- package/dist/modules/billing/backend/billing/accounts/[id]/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/accounts/create/page.js +82 -0
- package/dist/modules/billing/backend/billing/accounts/create/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/accounts/create/page.meta.js +25 -0
- package/dist/modules/billing/backend/billing/accounts/create/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/accounts/page.js +173 -0
- package/dist/modules/billing/backend/billing/accounts/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/accounts/page.meta.js +18 -0
- package/dist/modules/billing/backend/billing/accounts/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/invoices/[id]/page.js +500 -0
- package/dist/modules/billing/backend/billing/invoices/[id]/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/invoices/[id]/page.meta.js +25 -0
- package/dist/modules/billing/backend/billing/invoices/[id]/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/invoices/page.js +187 -0
- package/dist/modules/billing/backend/billing/invoices/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/invoices/page.meta.js +18 -0
- package/dist/modules/billing/backend/billing/invoices/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/items/[id]/page.js +265 -0
- package/dist/modules/billing/backend/billing/items/[id]/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/items/[id]/page.meta.js +25 -0
- package/dist/modules/billing/backend/billing/items/[id]/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/items/create/page.js +90 -0
- package/dist/modules/billing/backend/billing/items/create/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/items/create/page.meta.js +25 -0
- package/dist/modules/billing/backend/billing/items/create/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/items/page.js +208 -0
- package/dist/modules/billing/backend/billing/items/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/items/page.meta.js +18 -0
- package/dist/modules/billing/backend/billing/items/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/runs/[id]/page.js +293 -0
- package/dist/modules/billing/backend/billing/runs/[id]/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/runs/[id]/page.meta.js +21 -0
- package/dist/modules/billing/backend/billing/runs/[id]/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/runs/page.js +341 -0
- package/dist/modules/billing/backend/billing/runs/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/runs/page.meta.js +18 -0
- package/dist/modules/billing/backend/billing/runs/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/usage/create/page.js +100 -0
- package/dist/modules/billing/backend/billing/usage/create/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/usage/create/page.meta.js +25 -0
- package/dist/modules/billing/backend/billing/usage/create/page.meta.js.map +7 -0
- package/dist/modules/billing/ce.js +56 -0
- package/dist/modules/billing/ce.js.map +7 -0
- package/dist/modules/billing/cli.js +67 -0
- package/dist/modules/billing/cli.js.map +7 -0
- package/dist/modules/billing/commands/accounts.js +157 -0
- package/dist/modules/billing/commands/accounts.js.map +7 -0
- package/dist/modules/billing/commands/index.js +6 -0
- package/dist/modules/billing/commands/index.js.map +7 -0
- package/dist/modules/billing/commands/invoices.js +401 -0
- package/dist/modules/billing/commands/invoices.js.map +7 -0
- package/dist/modules/billing/commands/items.js +317 -0
- package/dist/modules/billing/commands/items.js.map +7 -0
- package/dist/modules/billing/commands/runs.js +123 -0
- package/dist/modules/billing/commands/runs.js.map +7 -0
- package/dist/modules/billing/commands/usage.js +88 -0
- package/dist/modules/billing/commands/usage.js.map +7 -0
- package/dist/modules/billing/components/AccountForm.js +420 -0
- package/dist/modules/billing/components/AccountForm.js.map +7 -0
- package/dist/modules/billing/components/DetailFields.js +37 -0
- package/dist/modules/billing/components/DetailFields.js.map +7 -0
- package/dist/modules/billing/components/ItemForm.js +440 -0
- package/dist/modules/billing/components/ItemForm.js.map +7 -0
- package/dist/modules/billing/components/LineFormDialog.js +195 -0
- package/dist/modules/billing/components/LineFormDialog.js.map +7 -0
- package/dist/modules/billing/components/UsageForm.js +262 -0
- package/dist/modules/billing/components/UsageForm.js.map +7 -0
- package/dist/modules/billing/data/entities.js +433 -0
- package/dist/modules/billing/data/entities.js.map +7 -0
- package/dist/modules/billing/data/entityIds.js +12 -0
- package/dist/modules/billing/data/entityIds.js.map +7 -0
- package/dist/modules/billing/data/validators.js +396 -0
- package/dist/modules/billing/data/validators.js.map +7 -0
- package/dist/modules/billing/di.js +6 -0
- package/dist/modules/billing/di.js.map +7 -0
- package/dist/modules/billing/events.js +35 -0
- package/dist/modules/billing/events.js.map +7 -0
- package/dist/modules/billing/i18n/en.json +336 -0
- package/dist/modules/billing/i18n/pl.json +336 -0
- package/dist/modules/billing/index.js +17 -0
- package/dist/modules/billing/index.js.map +7 -0
- package/dist/modules/billing/lib/billPeriod.js +101 -0
- package/dist/modules/billing/lib/billPeriod.js.map +7 -0
- package/dist/modules/billing/lib/billRunEngine.js +256 -0
- package/dist/modules/billing/lib/billRunEngine.js.map +7 -0
- package/dist/modules/billing/lib/billRunLabel.js +8 -0
- package/dist/modules/billing/lib/billRunLabel.js.map +7 -0
- package/dist/modules/billing/lib/idempotency.js +13 -0
- package/dist/modules/billing/lib/idempotency.js.map +7 -0
- package/dist/modules/billing/lib/invoiceStatus.js +38 -0
- package/dist/modules/billing/lib/invoiceStatus.js.map +7 -0
- package/dist/modules/billing/lib/invoiceWriter.js +153 -0
- package/dist/modules/billing/lib/invoiceWriter.js.map +7 -0
- package/dist/modules/billing/lib/itemSelector.js +77 -0
- package/dist/modules/billing/lib/itemSelector.js.map +7 -0
- package/dist/modules/billing/lib/seeds/demo.js +130 -0
- package/dist/modules/billing/lib/seeds/demo.js.map +7 -0
- package/dist/modules/billing/lib/tenantLock.js +32 -0
- package/dist/modules/billing/lib/tenantLock.js.map +7 -0
- package/dist/modules/billing/lib/usageRater.js +150 -0
- package/dist/modules/billing/lib/usageRater.js.map +7 -0
- package/dist/modules/billing/lib/usageRunner.js +82 -0
- package/dist/modules/billing/lib/usageRunner.js.map +7 -0
- package/dist/modules/billing/migrations/Migration20260519000000_billing.js +144 -0
- package/dist/modules/billing/migrations/Migration20260519000000_billing.js.map +7 -0
- package/dist/modules/billing/setup.js +190 -0
- package/dist/modules/billing/setup.js.map +7 -0
- package/dist/modules/billing/subscribers/payment-captured-to-paid.js +47 -0
- package/dist/modules/billing/subscribers/payment-captured-to-paid.js.map +7 -0
- package/dist/modules/billing/translations.js +5 -0
- package/dist/modules/billing/translations.js.map +7 -0
- package/dist/modules/billing/workers/reap-stale-bill-runs.js +31 -0
- package/dist/modules/billing/workers/reap-stale-bill-runs.js.map +7 -0
- package/package.json +148 -0
- package/src/modules/billing/__integration__/TC-BILL-001.spec.ts +116 -0
- package/src/modules/billing/__integration__/TC-BILL-002.spec.ts +169 -0
- package/src/modules/billing/__integration__/TC-BILL-003.spec.ts +28 -0
- package/src/modules/billing/__integration__/meta.ts +4 -0
- package/src/modules/billing/__tests__/commands/invoices.test.ts +464 -0
- package/src/modules/billing/__tests__/commands/itemsBulk.test.ts +284 -0
- package/src/modules/billing/__tests__/lib/billPeriod.test.ts +131 -0
- package/src/modules/billing/__tests__/lib/billRunEngine.test.ts +561 -0
- package/src/modules/billing/__tests__/lib/idempotency.test.ts +81 -0
- package/src/modules/billing/__tests__/lib/invoiceStatus.test.ts +97 -0
- package/src/modules/billing/__tests__/lib/invoiceWriter.test.ts +119 -0
- package/src/modules/billing/__tests__/lib/itemSelector.test.ts +192 -0
- package/src/modules/billing/__tests__/lib/seedsDemo.test.ts +86 -0
- package/src/modules/billing/__tests__/lib/tenantLock.test.ts +126 -0
- package/src/modules/billing/__tests__/lib/usageRater.test.ts +190 -0
- package/src/modules/billing/__tests__/package-isolation.test.ts +223 -0
- package/src/modules/billing/__tests__/setup.test.ts +107 -0
- package/src/modules/billing/__tests__/validators.test.ts +594 -0
- package/src/modules/billing/acl.ts +16 -0
- package/src/modules/billing/api/accounts/route.ts +198 -0
- package/src/modules/billing/api/export/account/[id]/route.ts +233 -0
- package/src/modules/billing/api/invoices/[id]/route.ts +143 -0
- package/src/modules/billing/api/invoices/add-line/route.ts +74 -0
- package/src/modules/billing/api/invoices/edit-line/route.ts +97 -0
- package/src/modules/billing/api/invoices/post/route.ts +97 -0
- package/src/modules/billing/api/invoices/remove-line/route.ts +75 -0
- package/src/modules/billing/api/invoices/route.ts +167 -0
- package/src/modules/billing/api/items/bulk/route.ts +92 -0
- package/src/modules/billing/api/items/route.ts +255 -0
- package/src/modules/billing/api/openapi.ts +33 -0
- package/src/modules/billing/api/run-outcomes/route.ts +149 -0
- package/src/modules/billing/api/runs/retry-failed/route.ts +88 -0
- package/src/modules/billing/api/runs/route.ts +170 -0
- package/src/modules/billing/api/test-invoices/wipe/route.ts +87 -0
- package/src/modules/billing/api/usage/route.ts +148 -0
- package/src/modules/billing/backend/billing/accounts/[id]/page.meta.ts +23 -0
- package/src/modules/billing/backend/billing/accounts/[id]/page.tsx +574 -0
- package/src/modules/billing/backend/billing/accounts/create/page.meta.ts +23 -0
- package/src/modules/billing/backend/billing/accounts/create/page.tsx +101 -0
- package/src/modules/billing/backend/billing/accounts/page.meta.ts +16 -0
- package/src/modules/billing/backend/billing/accounts/page.tsx +222 -0
- package/src/modules/billing/backend/billing/invoices/[id]/page.meta.ts +23 -0
- package/src/modules/billing/backend/billing/invoices/[id]/page.tsx +613 -0
- package/src/modules/billing/backend/billing/invoices/page.meta.ts +16 -0
- package/src/modules/billing/backend/billing/invoices/page.tsx +245 -0
- package/src/modules/billing/backend/billing/items/[id]/page.meta.ts +23 -0
- package/src/modules/billing/backend/billing/items/[id]/page.tsx +366 -0
- package/src/modules/billing/backend/billing/items/create/page.meta.ts +23 -0
- package/src/modules/billing/backend/billing/items/create/page.tsx +110 -0
- package/src/modules/billing/backend/billing/items/page.meta.ts +16 -0
- package/src/modules/billing/backend/billing/items/page.tsx +272 -0
- package/src/modules/billing/backend/billing/runs/[id]/page.meta.ts +19 -0
- package/src/modules/billing/backend/billing/runs/[id]/page.tsx +404 -0
- package/src/modules/billing/backend/billing/runs/page.meta.ts +16 -0
- package/src/modules/billing/backend/billing/runs/page.tsx +426 -0
- package/src/modules/billing/backend/billing/usage/create/page.meta.ts +23 -0
- package/src/modules/billing/backend/billing/usage/create/page.tsx +120 -0
- package/src/modules/billing/ce.ts +74 -0
- package/src/modules/billing/cli.ts +89 -0
- package/src/modules/billing/commands/accounts.ts +205 -0
- package/src/modules/billing/commands/index.ts +13 -0
- package/src/modules/billing/commands/invoices.ts +542 -0
- package/src/modules/billing/commands/items.ts +438 -0
- package/src/modules/billing/commands/runs.ts +166 -0
- package/src/modules/billing/commands/usage.ts +118 -0
- package/src/modules/billing/components/AccountForm.tsx +517 -0
- package/src/modules/billing/components/DetailFields.tsx +60 -0
- package/src/modules/billing/components/ItemForm.tsx +558 -0
- package/src/modules/billing/components/LineFormDialog.tsx +253 -0
- package/src/modules/billing/components/UsageForm.tsx +326 -0
- package/src/modules/billing/data/entities.ts +533 -0
- package/src/modules/billing/data/entityIds.ts +25 -0
- package/src/modules/billing/data/validators.ts +562 -0
- package/src/modules/billing/di.ts +14 -0
- package/src/modules/billing/events.ts +47 -0
- package/src/modules/billing/i18n/en.json +336 -0
- package/src/modules/billing/i18n/pl.json +336 -0
- package/src/modules/billing/index.ts +22 -0
- package/src/modules/billing/lib/billPeriod.ts +181 -0
- package/src/modules/billing/lib/billRunEngine.ts +416 -0
- package/src/modules/billing/lib/billRunLabel.ts +14 -0
- package/src/modules/billing/lib/idempotency.ts +46 -0
- package/src/modules/billing/lib/invoiceStatus.ts +61 -0
- package/src/modules/billing/lib/invoiceWriter.ts +260 -0
- package/src/modules/billing/lib/itemSelector.ts +154 -0
- package/src/modules/billing/lib/seeds/demo.ts +199 -0
- package/src/modules/billing/lib/tenantLock.ts +87 -0
- package/src/modules/billing/lib/usageRater.ts +250 -0
- package/src/modules/billing/lib/usageRunner.ts +189 -0
- package/src/modules/billing/migrations/Migration20260519000000_billing.ts +175 -0
- package/src/modules/billing/setup.ts +252 -0
- package/src/modules/billing/subscribers/payment-captured-to-paid.ts +89 -0
- package/src/modules/billing/translations.ts +9 -0
- package/src/modules/billing/workers/reap-stale-bill-runs.ts +57 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/billing/lib/itemSelector.ts"],
|
|
4
|
+
"sourcesContent": ["import type { BillingItem } from '../data/entities'\nimport type { BillPeriod } from './billPeriod'\n\n/**\n * Decide whether a Billing Item contributes to a given bill period.\n *\n * Two anti-duplicate guards working together (spec \"Why two anti-duplicate\n * guards\"):\n *\n * 1. `billed_to_date` \u2014 per-item watermark. Set after a successful\n * real-mode run that emitted this item; prevents re-billing the\n * same item even if the period boundaries shift.\n * 2. Open-draft check \u2014 per-account, handled by the engine before it\n * even invokes this selector (see `billRunEngine`). Not this\n * module's concern.\n *\n * Per-type semantics from the spec:\n *\n * `one_time` \u2014 one shot per item, ever. Eligible only if\n * `billed_to_date IS NULL` AND the item's `[bill_start_date,\n * bill_end_date]` window overlaps the bill period.\n *\n * `recurring` \u2014 one line per full cycle. Eligible only when:\n * - `bill_start_date <= bill_period_start`\n * - AND (`bill_end_date IS NULL` OR `bill_end_date >= bill_period_end`)\n * - AND (`billed_to_date IS NULL` OR `billed_to_date < bill_period_end`)\n *\n * Mid-cycle starts / cancels are SKIPPED \u2014 the integrator is\n * responsible for posting a `one_time` proration line if the\n * partial period should be charged (spec \"Mid-cycle recurring\n * items\").\n *\n * `usage` \u2014 not handled here. Phase 3 owns usage rating end-to-end.\n *\n * All comparisons treat dates as midnight (consistent with `billPeriod`).\n */\n\nexport type SelectableItem = Pick<\n BillingItem,\n | 'id'\n | 'type'\n | 'billStartDate'\n | 'billEndDate'\n | 'billedToDate'\n | 'description'\n | 'rateJson'\n | 'isActive'\n | 'deletedAt'\n>\n\nexport type SelectionDecision =\n | { include: true }\n | { include: false; reason: string }\n\nfunction asTime(date: Date | null | undefined): number | null {\n if (!date) return null\n return new Date(date).getTime()\n}\n\nfunction isWithin(\n windowStart: Date,\n windowEnd: Date | null | undefined,\n point: Date,\n): boolean {\n const pointTime = asTime(point)!\n const startTime = asTime(windowStart)!\n if (pointTime < startTime) return false\n const endTime = asTime(windowEnd)\n if (endTime === null) return true\n return pointTime <= endTime\n}\n\nfunction overlapsPeriod(\n itemStart: Date,\n itemEnd: Date | null | undefined,\n period: BillPeriod,\n): boolean {\n const itemStartTime = asTime(itemStart)!\n const itemEndTime = asTime(itemEnd)\n const periodStartTime = asTime(period.periodStart)!\n const periodEndTime = asTime(period.periodEnd)!\n if (itemStartTime > periodEndTime) return false\n if (itemEndTime !== null && itemEndTime < periodStartTime) return false\n return true\n}\n\nexport function shouldIncludeItem(\n item: SelectableItem,\n period: BillPeriod,\n): SelectionDecision {\n if (!item.isActive) return { include: false, reason: 'item-inactive' }\n if (item.deletedAt) return { include: false, reason: 'item-soft-deleted' }\n\n switch (item.type) {\n case 'one_time': {\n if (item.billedToDate) {\n return { include: false, reason: 'one_time-already-billed' }\n }\n if (!overlapsPeriod(item.billStartDate, item.billEndDate, period)) {\n return { include: false, reason: 'one_time-window-outside-period' }\n }\n return { include: true }\n }\n case 'recurring': {\n const billedToTime = asTime(item.billedToDate)\n const periodEndTime = asTime(period.periodEnd)!\n if (billedToTime !== null && billedToTime >= periodEndTime) {\n return { include: false, reason: 'recurring-already-billed-through-period' }\n }\n const periodStartTime = asTime(period.periodStart)!\n const itemStartTime = asTime(item.billStartDate)!\n if (itemStartTime > periodStartTime) {\n return { include: false, reason: 'recurring-starts-mid-cycle' }\n }\n const itemEndTime = asTime(item.billEndDate)\n if (itemEndTime !== null && itemEndTime < periodEndTime) {\n return { include: false, reason: 'recurring-ends-mid-cycle' }\n }\n return { include: true }\n }\n case 'usage':\n // Phase 3 routes usage items through the dedicated usage rater.\n // The selector returns \"skip\" so the engine's recurring/one_time\n // path is unaffected.\n return { include: false, reason: 'usage-handled-by-rater' }\n default:\n return { include: false, reason: `unknown-type:${item.type}` }\n }\n}\n\n/**\n * Pure filter \u2014 given the items already loaded for an account, returns\n * those eligible for the bill period together with the decision reason\n * for diagnostics (the engine logs skipped items in the run outcome's\n * warnings on demand).\n */\nexport function selectItemsForPeriod<T extends SelectableItem>(\n items: readonly T[],\n period: BillPeriod,\n): { included: T[]; skipped: Array<{ item: T; reason: string }> } {\n const included: T[] = []\n const skipped: Array<{ item: T; reason: string }> = []\n for (const item of items) {\n const decision = shouldIncludeItem(item, period)\n if (decision.include) {\n included.push(item)\n } else {\n skipped.push({ item, reason: decision.reason })\n }\n }\n return { included, skipped }\n}\n\nexport { isWithin, overlapsPeriod }\n"],
|
|
5
|
+
"mappings": "AAsDA,SAAS,OAAO,MAA8C;AAC5D,MAAI,CAAC,KAAM,QAAO;AAClB,SAAO,IAAI,KAAK,IAAI,EAAE,QAAQ;AAChC;AAEA,SAAS,SACP,aACA,WACA,OACS;AACT,QAAM,YAAY,OAAO,KAAK;AAC9B,QAAM,YAAY,OAAO,WAAW;AACpC,MAAI,YAAY,UAAW,QAAO;AAClC,QAAM,UAAU,OAAO,SAAS;AAChC,MAAI,YAAY,KAAM,QAAO;AAC7B,SAAO,aAAa;AACtB;AAEA,SAAS,eACP,WACA,SACA,QACS;AACT,QAAM,gBAAgB,OAAO,SAAS;AACtC,QAAM,cAAc,OAAO,OAAO;AAClC,QAAM,kBAAkB,OAAO,OAAO,WAAW;AACjD,QAAM,gBAAgB,OAAO,OAAO,SAAS;AAC7C,MAAI,gBAAgB,cAAe,QAAO;AAC1C,MAAI,gBAAgB,QAAQ,cAAc,gBAAiB,QAAO;AAClE,SAAO;AACT;AAEO,SAAS,kBACd,MACA,QACmB;AACnB,MAAI,CAAC,KAAK,SAAU,QAAO,EAAE,SAAS,OAAO,QAAQ,gBAAgB;AACrE,MAAI,KAAK,UAAW,QAAO,EAAE,SAAS,OAAO,QAAQ,oBAAoB;AAEzE,UAAQ,KAAK,MAAM;AAAA,IACjB,KAAK,YAAY;AACf,UAAI,KAAK,cAAc;AACrB,eAAO,EAAE,SAAS,OAAO,QAAQ,0BAA0B;AAAA,MAC7D;AACA,UAAI,CAAC,eAAe,KAAK,eAAe,KAAK,aAAa,MAAM,GAAG;AACjE,eAAO,EAAE,SAAS,OAAO,QAAQ,iCAAiC;AAAA,MACpE;AACA,aAAO,EAAE,SAAS,KAAK;AAAA,IACzB;AAAA,IACA,KAAK,aAAa;AAChB,YAAM,eAAe,OAAO,KAAK,YAAY;AAC7C,YAAM,gBAAgB,OAAO,OAAO,SAAS;AAC7C,UAAI,iBAAiB,QAAQ,gBAAgB,eAAe;AAC1D,eAAO,EAAE,SAAS,OAAO,QAAQ,0CAA0C;AAAA,MAC7E;AACA,YAAM,kBAAkB,OAAO,OAAO,WAAW;AACjD,YAAM,gBAAgB,OAAO,KAAK,aAAa;AAC/C,UAAI,gBAAgB,iBAAiB;AACnC,eAAO,EAAE,SAAS,OAAO,QAAQ,6BAA6B;AAAA,MAChE;AACA,YAAM,cAAc,OAAO,KAAK,WAAW;AAC3C,UAAI,gBAAgB,QAAQ,cAAc,eAAe;AACvD,eAAO,EAAE,SAAS,OAAO,QAAQ,2BAA2B;AAAA,MAC9D;AACA,aAAO,EAAE,SAAS,KAAK;AAAA,IACzB;AAAA,IACA,KAAK;AAIH,aAAO,EAAE,SAAS,OAAO,QAAQ,yBAAyB;AAAA,IAC5D;AACE,aAAO,EAAE,SAAS,OAAO,QAAQ,gBAAgB,KAAK,IAAI,GAAG;AAAA,EACjE;AACF;AAQO,SAAS,qBACd,OACA,QACgE;AAChE,QAAM,WAAgB,CAAC;AACvB,QAAM,UAA8C,CAAC;AACrD,aAAW,QAAQ,OAAO;AACxB,UAAM,WAAW,kBAAkB,MAAM,MAAM;AAC/C,QAAI,SAAS,SAAS;AACpB,eAAS,KAAK,IAAI;AAAA,IACpB,OAAO;AACL,cAAQ,KAAK,EAAE,MAAM,QAAQ,SAAS,OAAO,CAAC;AAAA,IAChD;AAAA,EACF;AACA,SAAO,EAAE,UAAU,QAAQ;AAC7B;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { BillingAccount, BillingItem, BillingAccountUsage } from "../../data/entities.js";
|
|
2
|
+
const SRC = "demo-billing";
|
|
3
|
+
const ACCOUNT_NAME_PREFIX = "Demo \u2014 ";
|
|
4
|
+
const ACCOUNTS = [
|
|
5
|
+
{
|
|
6
|
+
account: {
|
|
7
|
+
name: `${ACCOUNT_NAME_PREFIX}Northwind Telecom`,
|
|
8
|
+
customerId: "demo-cust-northwind",
|
|
9
|
+
currencyCode: "PLN",
|
|
10
|
+
invoiceLanguage: "en"
|
|
11
|
+
},
|
|
12
|
+
items: [
|
|
13
|
+
{ ref: `${SRC}-nw-rec`, type: "recurring", description: "Platform subscription (monthly)", unitPrice: 199 },
|
|
14
|
+
{ ref: `${SRC}-nw-usg`, type: "usage", description: "Data transfer (per GB)", unitPrice: 0.5, uomCode: "gb" }
|
|
15
|
+
],
|
|
16
|
+
usage: [
|
|
17
|
+
{ ref: `${SRC}-nw-usage`, uomCode: "gb", quantity: 120, lineDescription: "Data transfer (prev. month)" }
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
account: {
|
|
22
|
+
name: `${ACCOUNT_NAME_PREFIX}Globex Cloud`,
|
|
23
|
+
customerId: "demo-cust-globex",
|
|
24
|
+
currencyCode: "EUR",
|
|
25
|
+
invoiceLanguage: "en"
|
|
26
|
+
},
|
|
27
|
+
items: [
|
|
28
|
+
{ ref: `${SRC}-gx-rec`, type: "recurring", description: "Cloud plan (monthly)", unitPrice: 49 }
|
|
29
|
+
],
|
|
30
|
+
usage: []
|
|
31
|
+
}
|
|
32
|
+
];
|
|
33
|
+
async function resetBillingDemo(em, scope) {
|
|
34
|
+
await em.nativeDelete(BillingAccountUsage, {
|
|
35
|
+
...scope,
|
|
36
|
+
sourceRef: { $like: `${SRC}-%` }
|
|
37
|
+
});
|
|
38
|
+
await em.nativeDelete(BillingItem, {
|
|
39
|
+
...scope,
|
|
40
|
+
sourceRef: { $like: `${SRC}-%` }
|
|
41
|
+
});
|
|
42
|
+
await em.nativeDelete(BillingAccount, {
|
|
43
|
+
...scope,
|
|
44
|
+
name: { $like: `${ACCOUNT_NAME_PREFIX}%` }
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
async function seedBillingDemo(em, scope, options = {}) {
|
|
48
|
+
if (options.reset) await resetBillingDemo(em, scope);
|
|
49
|
+
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
50
|
+
const nextBillDate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
|
51
|
+
const prevMonthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
|
|
52
|
+
const prevMonthEnd = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 0));
|
|
53
|
+
const result = { accountsCreated: 0, itemsCreated: 0, usageCreated: 0 };
|
|
54
|
+
for (const spec of ACCOUNTS) {
|
|
55
|
+
let account = await em.findOne(BillingAccount, {
|
|
56
|
+
...scope,
|
|
57
|
+
name: spec.account.name,
|
|
58
|
+
deletedAt: null
|
|
59
|
+
});
|
|
60
|
+
if (!account) {
|
|
61
|
+
account = em.create(BillingAccount, {
|
|
62
|
+
...scope,
|
|
63
|
+
customerId: spec.account.customerId,
|
|
64
|
+
name: spec.account.name,
|
|
65
|
+
currencyCode: spec.account.currencyCode,
|
|
66
|
+
billCycle: "monthly",
|
|
67
|
+
billCycleAnchor: "1",
|
|
68
|
+
invoiceEmail: `billing@${spec.account.customerId}.demo`,
|
|
69
|
+
invoiceLanguage: spec.account.invoiceLanguage,
|
|
70
|
+
taxId: null,
|
|
71
|
+
invoiceAddress: { line1: "ul. Demo 1", city: "Warszawa", postal_code: "00-001", country: "PL" },
|
|
72
|
+
nextBillDate,
|
|
73
|
+
lastBillDate: null,
|
|
74
|
+
isActive: true
|
|
75
|
+
});
|
|
76
|
+
em.persist(account);
|
|
77
|
+
await em.flush();
|
|
78
|
+
result.accountsCreated += 1;
|
|
79
|
+
}
|
|
80
|
+
for (const item of spec.items) {
|
|
81
|
+
const existing = await em.findOne(BillingItem, { ...scope, sourceRef: item.ref });
|
|
82
|
+
if (existing) continue;
|
|
83
|
+
em.persist(
|
|
84
|
+
em.create(BillingItem, {
|
|
85
|
+
...scope,
|
|
86
|
+
billAccountId: account.id,
|
|
87
|
+
type: item.type,
|
|
88
|
+
billStartDate: prevMonthStart,
|
|
89
|
+
billEndDate: null,
|
|
90
|
+
description: item.description,
|
|
91
|
+
rateJson: { unit_price: item.unitPrice },
|
|
92
|
+
uomCode: item.uomCode ?? null,
|
|
93
|
+
subscriptionId: null,
|
|
94
|
+
subscriptionItemId: null,
|
|
95
|
+
sourceRef: item.ref,
|
|
96
|
+
currencyMismatch: false,
|
|
97
|
+
billedToDate: null,
|
|
98
|
+
isActive: true
|
|
99
|
+
})
|
|
100
|
+
);
|
|
101
|
+
result.itemsCreated += 1;
|
|
102
|
+
}
|
|
103
|
+
for (const u of spec.usage) {
|
|
104
|
+
const existing = await em.findOne(BillingAccountUsage, { ...scope, sourceRef: u.ref });
|
|
105
|
+
if (existing) continue;
|
|
106
|
+
em.persist(
|
|
107
|
+
em.create(BillingAccountUsage, {
|
|
108
|
+
...scope,
|
|
109
|
+
billAccountId: account.id,
|
|
110
|
+
uomCode: u.uomCode,
|
|
111
|
+
quantity: u.quantity.toFixed(4),
|
|
112
|
+
periodStart: prevMonthStart,
|
|
113
|
+
periodEnd: prevMonthEnd,
|
|
114
|
+
lineDescription: u.lineDescription,
|
|
115
|
+
sourceRef: u.ref,
|
|
116
|
+
ratedInBillRunId: null,
|
|
117
|
+
isActive: true
|
|
118
|
+
})
|
|
119
|
+
);
|
|
120
|
+
result.usageCreated += 1;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
await em.flush();
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
export {
|
|
127
|
+
resetBillingDemo,
|
|
128
|
+
seedBillingDemo
|
|
129
|
+
};
|
|
130
|
+
//# sourceMappingURL=demo.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../../src/modules/billing/lib/seeds/demo.ts"],
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { BillingAccount, BillingItem, BillingAccountUsage } from '../../data/entities'\n\n/**\n * Self-contained billing test-data seed.\n *\n * Creates a couple of demo billing accounts with recurring + usage items\n * and one unrated usage record, scoped to a single tenant. Everything is\n * idempotent: accounts are matched by name, items/usage by `source_ref`,\n * so re-running is a no-op rather than a duplicate.\n *\n * The accounts are dated so they are immediately billable: `next_bill_date`\n * is the 1st of the current month, which makes a Bill Run with the default\n * (today's) as-of date produce an invoice for the previous month \u2014 the usage\n * record is seeded into exactly that period.\n *\n * Drives `mercato billing seed`. Kept free of any customer-specific data so\n * it ships with `@dainamite/billing`.\n */\n\nexport type BillingDemoScope = { tenantId: string; organizationId: string }\nexport type BillingDemoResult = {\n accountsCreated: number\n itemsCreated: number\n usageCreated: number\n}\n\n// Shared prefix for every row this seed owns \u2014 the reset path keys off it.\nconst SRC = 'demo-billing'\nconst ACCOUNT_NAME_PREFIX = 'Demo \u2014 '\n\ntype DemoAccountSpec = {\n name: string\n customerId: string\n currencyCode: string\n invoiceLanguage: string\n}\n\ntype DemoItemSpec = {\n ref: string\n type: 'recurring' | 'usage'\n description: string\n unitPrice: number\n uomCode?: string\n}\n\ntype DemoUsageSpec = {\n ref: string\n uomCode: string\n quantity: number\n lineDescription: string\n}\n\nconst ACCOUNTS: Array<{\n account: DemoAccountSpec\n items: DemoItemSpec[]\n usage: DemoUsageSpec[]\n}> = [\n {\n account: {\n name: `${ACCOUNT_NAME_PREFIX}Northwind Telecom`,\n customerId: 'demo-cust-northwind',\n currencyCode: 'PLN',\n invoiceLanguage: 'en',\n },\n items: [\n { ref: `${SRC}-nw-rec`, type: 'recurring', description: 'Platform subscription (monthly)', unitPrice: 199 },\n { ref: `${SRC}-nw-usg`, type: 'usage', description: 'Data transfer (per GB)', unitPrice: 0.5, uomCode: 'gb' },\n ],\n usage: [\n { ref: `${SRC}-nw-usage`, uomCode: 'gb', quantity: 120, lineDescription: 'Data transfer (prev. month)' },\n ],\n },\n {\n account: {\n name: `${ACCOUNT_NAME_PREFIX}Globex Cloud`,\n customerId: 'demo-cust-globex',\n currencyCode: 'EUR',\n invoiceLanguage: 'en',\n },\n items: [\n { ref: `${SRC}-gx-rec`, type: 'recurring', description: 'Cloud plan (monthly)', unitPrice: 49 },\n ],\n usage: [],\n },\n]\n\n/** Delete every row this seed owns within the scope (reset before re-seed). */\nexport async function resetBillingDemo(em: EntityManager, scope: BillingDemoScope): Promise<void> {\n await em.nativeDelete(BillingAccountUsage, {\n ...scope,\n sourceRef: { $like: `${SRC}-%` },\n } as never)\n await em.nativeDelete(BillingItem, {\n ...scope,\n sourceRef: { $like: `${SRC}-%` },\n } as never)\n await em.nativeDelete(BillingAccount, {\n ...scope,\n name: { $like: `${ACCOUNT_NAME_PREFIX}%` },\n } as never)\n}\n\nexport async function seedBillingDemo(\n em: EntityManager,\n scope: BillingDemoScope,\n options: { reset?: boolean; now?: Date } = {},\n): Promise<BillingDemoResult> {\n if (options.reset) await resetBillingDemo(em, scope)\n\n const now = options.now ?? new Date()\n // Bill period the next run will cover, given next_bill = 1st of this month:\n // periodStart = 1st of previous month, periodEnd = last day of previous month.\n // Build at UTC midnight so `date` columns don't drift a day under a positive\n // local UTC offset (local `new Date(y, m, 1)` would persist as the prev day).\n const nextBillDate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1))\n const prevMonthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1))\n const prevMonthEnd = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 0))\n\n const result: BillingDemoResult = { accountsCreated: 0, itemsCreated: 0, usageCreated: 0 }\n\n for (const spec of ACCOUNTS) {\n // \u2500\u2500 Account (idempotent by name within scope) \u2500\u2500\n let account = await em.findOne(BillingAccount, {\n ...scope,\n name: spec.account.name,\n deletedAt: null,\n } as never)\n if (!account) {\n account = em.create(BillingAccount, {\n ...scope,\n customerId: spec.account.customerId,\n name: spec.account.name,\n currencyCode: spec.account.currencyCode,\n billCycle: 'monthly',\n billCycleAnchor: '1',\n invoiceEmail: `billing@${spec.account.customerId}.demo`,\n invoiceLanguage: spec.account.invoiceLanguage,\n taxId: null,\n invoiceAddress: { line1: 'ul. Demo 1', city: 'Warszawa', postal_code: '00-001', country: 'PL' },\n nextBillDate,\n lastBillDate: null,\n isActive: true,\n })\n em.persist(account)\n await em.flush() // need the id for child rows\n result.accountsCreated += 1\n }\n\n // \u2500\u2500 Items (idempotent by source_ref) \u2500\u2500\n for (const item of spec.items) {\n const existing = await em.findOne(BillingItem, { ...scope, sourceRef: item.ref } as never)\n if (existing) continue\n em.persist(\n em.create(BillingItem, {\n ...scope,\n billAccountId: account.id,\n type: item.type,\n billStartDate: prevMonthStart,\n billEndDate: null,\n description: item.description,\n rateJson: { unit_price: item.unitPrice },\n uomCode: item.uomCode ?? null,\n subscriptionId: null,\n subscriptionItemId: null,\n sourceRef: item.ref,\n currencyMismatch: false,\n billedToDate: null,\n isActive: true,\n }),\n )\n result.itemsCreated += 1\n }\n\n // \u2500\u2500 Usage records (idempotent by source_ref) \u2500\u2500\n for (const u of spec.usage) {\n const existing = await em.findOne(BillingAccountUsage, { ...scope, sourceRef: u.ref } as never)\n if (existing) continue\n em.persist(\n em.create(BillingAccountUsage, {\n ...scope,\n billAccountId: account.id,\n uomCode: u.uomCode,\n quantity: u.quantity.toFixed(4),\n periodStart: prevMonthStart,\n periodEnd: prevMonthEnd,\n lineDescription: u.lineDescription,\n sourceRef: u.ref,\n ratedInBillRunId: null,\n isActive: true,\n }),\n )\n result.usageCreated += 1\n }\n }\n\n await em.flush()\n return result\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
class TenantLockBusyError extends Error {
|
|
2
|
+
constructor(tenantId, lockName) {
|
|
3
|
+
super(`Tenant ${tenantId} already holds the billing lock "${lockName}"`);
|
|
4
|
+
this.name = "TenantLockBusyError";
|
|
5
|
+
this.tenantId = tenantId;
|
|
6
|
+
this.lockName = lockName;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
const LOCK_KEY_PREFIX = "dainamite-billing";
|
|
10
|
+
function buildLockKey(tenantId, lockName) {
|
|
11
|
+
return `${LOCK_KEY_PREFIX}:${tenantId}:${lockName}`;
|
|
12
|
+
}
|
|
13
|
+
async function withTenantLock(em, params, fn) {
|
|
14
|
+
return em.transactional(async (tem) => {
|
|
15
|
+
const key = buildLockKey(params.tenantId, params.lockName);
|
|
16
|
+
const rows = await tem.execute(
|
|
17
|
+
"SELECT pg_try_advisory_xact_lock(hashtext(?)::bigint) AS acquired",
|
|
18
|
+
[key]
|
|
19
|
+
);
|
|
20
|
+
const first = Array.isArray(rows) ? rows[0] : rows;
|
|
21
|
+
if (!first?.acquired) {
|
|
22
|
+
throw new TenantLockBusyError(params.tenantId, params.lockName);
|
|
23
|
+
}
|
|
24
|
+
return fn(tem);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
export {
|
|
28
|
+
TenantLockBusyError,
|
|
29
|
+
buildLockKey,
|
|
30
|
+
withTenantLock
|
|
31
|
+
};
|
|
32
|
+
//# sourceMappingURL=tenantLock.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/billing/lib/tenantLock.ts"],
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\n\n/**\n * Per-tenant Postgres transaction-scoped advisory lock primitive.\n *\n * The Bill Run engine (Phase 2) uses this to guarantee at most one\n * concurrently-running run per tenant \u2014 the spec requires this so two\n * cron triggers or a \"schedule\" + \"manual\" overlap cannot produce\n * duplicate drafts.\n *\n * Why advisory locks (not row locks):\n * - `pg_try_advisory_xact_lock` is non-blocking \u2014 we get a clean \"busy\"\n * signal instead of waiting on a row.\n * - It's released automatically when the transaction ends (commit,\n * rollback, or process death) \u2014 so there is no lock-leak path even\n * on `kill -9`. The `BillRun` row's `status='running'` may be left\n * hanging, but the lock itself vanishes; the reaper worker\n * (workers/reap-stale-bill-runs.ts) handles the orphaned row.\n * - It works without a sentinel table \u2014 we hash a string key into the\n * bigint key space the advisory-lock API requires.\n *\n * Usage:\n *\n * ```ts\n * import { withTenantLock, TenantLockBusyError } from '../lib/tenantLock'\n *\n * try {\n * await withTenantLock(em, { tenantId, lockName: 'billing-run' }, async (tem) => {\n * // tem is the transactional EntityManager; lock is held here.\n * // Returns to caller after function resolves and tx commits.\n * })\n * } catch (err) {\n * if (err instanceof TenantLockBusyError) {\n * // HTTP 409 \u2014 another run is in progress.\n * }\n * throw err\n * }\n * ```\n *\n * Keep work inside the callback short. The advisory lock is released only\n * when the transaction ends; long-running work blocks other triggers for\n * the whole tenant.\n */\n\nexport class TenantLockBusyError extends Error {\n readonly tenantId: string\n readonly lockName: string\n\n constructor(tenantId: string, lockName: string) {\n super(`Tenant ${tenantId} already holds the billing lock \"${lockName}\"`)\n this.name = 'TenantLockBusyError'\n this.tenantId = tenantId\n this.lockName = lockName\n }\n}\n\nconst LOCK_KEY_PREFIX = 'dainamite-billing'\n\n/**\n * Build the deterministic string key fed into `hashtext()`. Exposed for\n * tests so they can assert lock-key independence across tenants /\n * lockNames without scraping SQL strings.\n */\nexport function buildLockKey(tenantId: string, lockName: string): string {\n return `${LOCK_KEY_PREFIX}:${tenantId}:${lockName}`\n}\n\ntype AdvisoryLockRow = { acquired: boolean }\n\nexport async function withTenantLock<T>(\n em: EntityManager,\n params: { tenantId: string; lockName: string },\n fn: (tem: EntityManager) => Promise<T>,\n): Promise<T> {\n return em.transactional(async (tem) => {\n const key = buildLockKey(params.tenantId, params.lockName)\n const rows = (await tem.execute(\n 'SELECT pg_try_advisory_xact_lock(hashtext(?)::bigint) AS acquired',\n [key],\n )) as unknown as AdvisoryLockRow[] | AdvisoryLockRow | undefined\n const first: AdvisoryLockRow | undefined = Array.isArray(rows) ? rows[0] : rows\n if (!first?.acquired) {\n throw new TenantLockBusyError(params.tenantId, params.lockName)\n }\n return fn(tem)\n })\n}\n"],
|
|
5
|
+
"mappings": "AA4CO,MAAM,4BAA4B,MAAM;AAAA,EAI7C,YAAY,UAAkB,UAAkB;AAC9C,UAAM,UAAU,QAAQ,oCAAoC,QAAQ,GAAG;AACvE,SAAK,OAAO;AACZ,SAAK,WAAW;AAChB,SAAK,WAAW;AAAA,EAClB;AACF;AAEA,MAAM,kBAAkB;AAOjB,SAAS,aAAa,UAAkB,UAA0B;AACvE,SAAO,GAAG,eAAe,IAAI,QAAQ,IAAI,QAAQ;AACnD;AAIA,eAAsB,eACpB,IACA,QACA,IACY;AACZ,SAAO,GAAG,cAAc,OAAO,QAAQ;AACrC,UAAM,MAAM,aAAa,OAAO,UAAU,OAAO,QAAQ;AACzD,UAAM,OAAQ,MAAM,IAAI;AAAA,MACtB;AAAA,MACA,CAAC,GAAG;AAAA,IACN;AACA,UAAM,QAAqC,MAAM,QAAQ,IAAI,IAAI,KAAK,CAAC,IAAI;AAC3E,QAAI,CAAC,OAAO,UAAU;AACpB,YAAM,IAAI,oBAAoB,OAAO,UAAU,OAAO,QAAQ;AAAA,IAChE;AACA,WAAO,GAAG,GAAG;AAAA,EACf,CAAC;AACH;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
function roundHalfUp2dp(value) {
|
|
2
|
+
return Math.round((value + Number.EPSILON) * 100) / 100;
|
|
3
|
+
}
|
|
4
|
+
function format4dp(value) {
|
|
5
|
+
return roundHalfUp2dp(value).toFixed(4);
|
|
6
|
+
}
|
|
7
|
+
function formatUnitPrice(value) {
|
|
8
|
+
if (!Number.isFinite(value)) return "0.0000";
|
|
9
|
+
return value.toFixed(4);
|
|
10
|
+
}
|
|
11
|
+
function isTieredRate(rate) {
|
|
12
|
+
return typeof rate.model === "string";
|
|
13
|
+
}
|
|
14
|
+
function safeQuantity(quantity) {
|
|
15
|
+
if (!Number.isFinite(quantity) || quantity < 0) return 0;
|
|
16
|
+
return quantity;
|
|
17
|
+
}
|
|
18
|
+
function rateSimpleFlat(quantity, rate) {
|
|
19
|
+
const q = safeQuantity(quantity);
|
|
20
|
+
const total = q * rate.unit_price;
|
|
21
|
+
return {
|
|
22
|
+
amount: format4dp(total),
|
|
23
|
+
breakdown: [
|
|
24
|
+
{
|
|
25
|
+
tierIndex: 0,
|
|
26
|
+
upTo: null,
|
|
27
|
+
rangeQuantity: q,
|
|
28
|
+
rate: rate.unit_price,
|
|
29
|
+
amount: format4dp(total)
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function rateVolume(quantity, rate) {
|
|
35
|
+
const q = safeQuantity(quantity);
|
|
36
|
+
const breakdown = rate.tiers.map((tier, idx) => ({
|
|
37
|
+
tierIndex: idx,
|
|
38
|
+
upTo: tier.up_to,
|
|
39
|
+
rangeQuantity: 0,
|
|
40
|
+
rate: tier.unit_price,
|
|
41
|
+
amount: format4dp(0)
|
|
42
|
+
}));
|
|
43
|
+
for (let i = 0; i < rate.tiers.length; i++) {
|
|
44
|
+
const tier = rate.tiers[i];
|
|
45
|
+
if (tier.up_to === null || q <= tier.up_to) {
|
|
46
|
+
const total = q * tier.unit_price;
|
|
47
|
+
breakdown[i] = {
|
|
48
|
+
tierIndex: i,
|
|
49
|
+
upTo: tier.up_to,
|
|
50
|
+
rangeQuantity: q,
|
|
51
|
+
rate: tier.unit_price,
|
|
52
|
+
amount: format4dp(total)
|
|
53
|
+
};
|
|
54
|
+
return { amount: format4dp(total), breakdown };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { amount: format4dp(0), breakdown };
|
|
58
|
+
}
|
|
59
|
+
function rateGraduated(quantity, rate) {
|
|
60
|
+
const q = safeQuantity(quantity);
|
|
61
|
+
let remaining = q;
|
|
62
|
+
let previousUpTo = 0;
|
|
63
|
+
let total = 0;
|
|
64
|
+
const breakdown = [];
|
|
65
|
+
for (let i = 0; i < rate.tiers.length; i++) {
|
|
66
|
+
const tier = rate.tiers[i];
|
|
67
|
+
const tierCapacity = tier.up_to === null ? Infinity : Math.max(0, tier.up_to - previousUpTo);
|
|
68
|
+
const consumed = Math.min(remaining, tierCapacity);
|
|
69
|
+
const tierAmount = consumed * tier.unit_price;
|
|
70
|
+
total += tierAmount;
|
|
71
|
+
breakdown.push({
|
|
72
|
+
tierIndex: i,
|
|
73
|
+
upTo: tier.up_to,
|
|
74
|
+
rangeQuantity: consumed,
|
|
75
|
+
rate: tier.unit_price,
|
|
76
|
+
amount: format4dp(tierAmount)
|
|
77
|
+
});
|
|
78
|
+
remaining -= consumed;
|
|
79
|
+
previousUpTo = tier.up_to ?? previousUpTo;
|
|
80
|
+
if (remaining <= 0) {
|
|
81
|
+
for (let j = i + 1; j < rate.tiers.length; j++) {
|
|
82
|
+
breakdown.push({
|
|
83
|
+
tierIndex: j,
|
|
84
|
+
upTo: rate.tiers[j].up_to,
|
|
85
|
+
rangeQuantity: 0,
|
|
86
|
+
rate: rate.tiers[j].unit_price,
|
|
87
|
+
amount: format4dp(0)
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { amount: format4dp(total), breakdown };
|
|
94
|
+
}
|
|
95
|
+
function rateFlat(quantity, rate) {
|
|
96
|
+
const q = safeQuantity(quantity);
|
|
97
|
+
const breakdown = rate.tiers.map((tier, idx) => ({
|
|
98
|
+
tierIndex: idx,
|
|
99
|
+
upTo: tier.up_to,
|
|
100
|
+
rangeQuantity: 0,
|
|
101
|
+
rate: tier.flat_amount,
|
|
102
|
+
amount: format4dp(0)
|
|
103
|
+
}));
|
|
104
|
+
for (let i = 0; i < rate.tiers.length; i++) {
|
|
105
|
+
const tier = rate.tiers[i];
|
|
106
|
+
if (tier.up_to === null || q <= tier.up_to) {
|
|
107
|
+
breakdown[i] = {
|
|
108
|
+
tierIndex: i,
|
|
109
|
+
upTo: tier.up_to,
|
|
110
|
+
rangeQuantity: q,
|
|
111
|
+
rate: tier.flat_amount,
|
|
112
|
+
amount: format4dp(tier.flat_amount)
|
|
113
|
+
};
|
|
114
|
+
return { amount: format4dp(tier.flat_amount), breakdown };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { amount: format4dp(0), breakdown };
|
|
118
|
+
}
|
|
119
|
+
function rateUsageQuantity(rate, totalQuantity) {
|
|
120
|
+
if (!isTieredRate(rate)) {
|
|
121
|
+
return rateSimpleFlat(totalQuantity, rate);
|
|
122
|
+
}
|
|
123
|
+
switch (rate.model) {
|
|
124
|
+
case "volume":
|
|
125
|
+
return rateVolume(totalQuantity, rate);
|
|
126
|
+
case "graduated":
|
|
127
|
+
return rateGraduated(totalQuantity, rate);
|
|
128
|
+
case "flat":
|
|
129
|
+
return rateFlat(totalQuantity, rate);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function rateUsageForItem(item, totalQuantity) {
|
|
133
|
+
if (item.type !== "usage") return null;
|
|
134
|
+
const rate = item.rateJson;
|
|
135
|
+
if (!rate || typeof rate !== "object") return null;
|
|
136
|
+
if (isTieredRate(rate)) {
|
|
137
|
+
if (!Array.isArray(rate.tiers) || rate.tiers.length === 0) return null;
|
|
138
|
+
} else if (typeof rate.unit_price !== "number") {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
return rateUsageQuantity(rate, totalQuantity);
|
|
142
|
+
}
|
|
143
|
+
export {
|
|
144
|
+
format4dp,
|
|
145
|
+
formatUnitPrice,
|
|
146
|
+
rateUsageForItem,
|
|
147
|
+
rateUsageQuantity,
|
|
148
|
+
roundHalfUp2dp
|
|
149
|
+
};
|
|
150
|
+
//# sourceMappingURL=usageRater.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/billing/lib/usageRater.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Pure functions to rate usage quantity against a `rate_json` payload.\n *\n * Three tier models per the spec (`volume` / `graduated` / `flat`) plus\n * the \"simple\" flat-rate shape (`{ unit_price }` without a `model`).\n * The validator at `data/validators.ts` ensures the payload structure\n * before it reaches this rater \u2014 this module trusts the shape but\n * defends against numeric edge cases (NaN, negative quantity).\n *\n * Output:\n * - `amount` \u2014 numeric string, rounded 2dp half-up, formatted to 4\n * decimals to match the `core/sales` numeric(18,4) column shape.\n * - `breakdown` \u2014 per-tier rated quantity / amount, persisted into\n * `SalesInvoiceLine.metadata.usage_tier_breakdown` so operators\n * can see how the line was built without re-running the math.\n */\n\nimport type { BillingItem } from '../data/entities'\n\nexport type SimpleFlatRate = { unit_price: number }\nexport type VolumeTier = { up_to: number | null; unit_price: number }\nexport type GraduatedTier = { up_to: number | null; unit_price: number }\nexport type FlatTier = { up_to: number | null; flat_amount: number }\n\nexport type VolumeRate = { model: 'volume'; tiers: VolumeTier[] }\nexport type GraduatedRate = { model: 'graduated'; tiers: GraduatedTier[] }\nexport type FlatRate = { model: 'flat'; tiers: FlatTier[] }\nexport type TieredRate = VolumeRate | GraduatedRate | FlatRate\nexport type UsageRate = SimpleFlatRate | TieredRate\n\nexport type TierBreakdownEntry = {\n tierIndex: number\n upTo: number | null\n /** Quantity rated at this tier \u2014 0 for tiers above the consumed range. */\n rangeQuantity: number\n /** `unit_price` for volume/graduated; `flat_amount` for flat. */\n rate: number\n /** Per-tier amount contribution, rounded 4dp. */\n amount: string\n}\n\nexport type UsageRateResult = {\n /** Total rated amount, numeric(18,4) string. */\n amount: string\n /** Per-tier or single-rate breakdown for invoice-line metadata. */\n breakdown: TierBreakdownEntry[]\n}\n\n/**\n * Half-up rounding to 2dp survives the JS floating-point edge case\n * (`1.005 \u2192 1.01`, `100 \u00D7 0.001 + 100 \u00D7 0.001 + \u2026 \u2192 exactly N\u00D70.001`).\n * Match `invoiceWriter`'s rounding policy so totals are consistent\n * across line types.\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\n/**\n * Format a unit price to 4dp WITHOUT 2dp rounding \u2014 sub-cent rates\n * like `0.001` (per-API-request pricing) are legitimate. The spec's\n * 2dp half-up rule applies only to line *amounts*, not per-unit\n * rates.\n */\nfunction formatUnitPrice(value: number): string {\n if (!Number.isFinite(value)) return '0.0000'\n return value.toFixed(4)\n}\n\nfunction isTieredRate(rate: UsageRate): rate is TieredRate {\n return typeof (rate as { model?: unknown }).model === 'string'\n}\n\nfunction safeQuantity(quantity: number): number {\n if (!Number.isFinite(quantity) || quantity < 0) return 0\n return quantity\n}\n\n// \u2500\u2500\u2500 Simple flat-rate \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\nfunction rateSimpleFlat(quantity: number, rate: SimpleFlatRate): UsageRateResult {\n const q = safeQuantity(quantity)\n const total = q * rate.unit_price\n return {\n amount: format4dp(total),\n breakdown: [\n {\n tierIndex: 0,\n upTo: null,\n rangeQuantity: q,\n rate: rate.unit_price,\n amount: format4dp(total),\n },\n ],\n }\n}\n\n// \u2500\u2500\u2500 Volume tier \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//\n// \"Entire quantity priced at the unit_price of the tier its total falls\n// into.\" So we walk tiers in order, find the first tier whose `up_to`\n// is `null` (open-ended) or `>= quantity`, and charge `quantity \u00D7\n// that tier's unit_price`. The unconsumed tiers report 0.\n\nfunction rateVolume(quantity: number, rate: VolumeRate): UsageRateResult {\n const q = safeQuantity(quantity)\n const breakdown: TierBreakdownEntry[] = rate.tiers.map((tier, idx) => ({\n tierIndex: idx,\n upTo: tier.up_to,\n rangeQuantity: 0,\n rate: tier.unit_price,\n amount: format4dp(0),\n }))\n for (let i = 0; i < rate.tiers.length; i++) {\n const tier = rate.tiers[i]\n if (tier.up_to === null || q <= tier.up_to) {\n const total = q * tier.unit_price\n breakdown[i] = {\n tierIndex: i,\n upTo: tier.up_to,\n rangeQuantity: q,\n rate: tier.unit_price,\n amount: format4dp(total),\n }\n return { amount: format4dp(total), breakdown }\n }\n }\n // Should be unreachable: validator enforces an open-ended top tier.\n return { amount: format4dp(0), breakdown }\n}\n\n// \u2500\u2500\u2500 Graduated tier \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//\n// \"Each unit priced at its tier.\" Walk tiers in order, charging\n// `min(q_remaining, tier_capacity) \u00D7 unit_price` for each tier the\n// quantity touches.\n\nfunction rateGraduated(quantity: number, rate: GraduatedRate): UsageRateResult {\n const q = safeQuantity(quantity)\n let remaining = q\n let previousUpTo = 0\n let total = 0\n const breakdown: TierBreakdownEntry[] = []\n for (let i = 0; i < rate.tiers.length; i++) {\n const tier = rate.tiers[i]\n const tierCapacity =\n tier.up_to === null ? Infinity : Math.max(0, tier.up_to - previousUpTo)\n const consumed = Math.min(remaining, tierCapacity)\n const tierAmount = consumed * tier.unit_price\n total += tierAmount\n breakdown.push({\n tierIndex: i,\n upTo: tier.up_to,\n rangeQuantity: consumed,\n rate: tier.unit_price,\n amount: format4dp(tierAmount),\n })\n remaining -= consumed\n previousUpTo = tier.up_to ?? previousUpTo\n if (remaining <= 0) {\n // Pad remaining tiers with zeros for breakdown completeness.\n for (let j = i + 1; j < rate.tiers.length; j++) {\n breakdown.push({\n tierIndex: j,\n upTo: rate.tiers[j].up_to,\n rangeQuantity: 0,\n rate: rate.tiers[j].unit_price,\n amount: format4dp(0),\n })\n }\n break\n }\n }\n return { amount: format4dp(total), breakdown }\n}\n\n// \u2500\u2500\u2500 Flat tier \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//\n// \"One flat fee for the highest tier reached.\" Find the first tier\n// whose `up_to` is `null` or `>= quantity`. Charge that tier's\n// `flat_amount`. Other tiers report 0.\n\nfunction rateFlat(quantity: number, rate: FlatRate): UsageRateResult {\n const q = safeQuantity(quantity)\n const breakdown: TierBreakdownEntry[] = rate.tiers.map((tier, idx) => ({\n tierIndex: idx,\n upTo: tier.up_to,\n rangeQuantity: 0,\n rate: tier.flat_amount,\n amount: format4dp(0),\n }))\n for (let i = 0; i < rate.tiers.length; i++) {\n const tier = rate.tiers[i]\n if (tier.up_to === null || q <= tier.up_to) {\n breakdown[i] = {\n tierIndex: i,\n upTo: tier.up_to,\n rangeQuantity: q,\n rate: tier.flat_amount,\n amount: format4dp(tier.flat_amount),\n }\n return { amount: format4dp(tier.flat_amount), breakdown }\n }\n }\n return { amount: format4dp(0), breakdown }\n}\n\n// \u2500\u2500\u2500 Public dispatcher \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\nexport function rateUsageQuantity(rate: UsageRate, totalQuantity: number): UsageRateResult {\n if (!isTieredRate(rate)) {\n return rateSimpleFlat(totalQuantity, rate)\n }\n switch (rate.model) {\n case 'volume':\n return rateVolume(totalQuantity, rate)\n case 'graduated':\n return rateGraduated(totalQuantity, rate)\n case 'flat':\n return rateFlat(totalQuantity, rate)\n }\n}\n\n/**\n * Convenience wrapper: takes a BillingItem's `rate_json` (typed as\n * `unknown` from JSONB) and the total quantity, returns the rated\n * amount + breakdown. Returns `null` when the rate shape is invalid \u2014\n * caller falls back to \"no charge\" + warns instead of crashing the\n * whole account.\n */\nexport function rateUsageForItem(\n item: Pick<BillingItem, 'rateJson' | 'type'>,\n totalQuantity: number,\n): UsageRateResult | null {\n if (item.type !== 'usage') return null\n const rate = item.rateJson as UsageRate | null | undefined\n if (!rate || typeof rate !== 'object') return null\n if (isTieredRate(rate)) {\n if (!Array.isArray(rate.tiers) || rate.tiers.length === 0) return null\n } else if (typeof (rate as SimpleFlatRate).unit_price !== 'number') {\n return null\n }\n return rateUsageQuantity(rate, totalQuantity)\n}\n\nexport { roundHalfUp2dp, format4dp, formatUnitPrice }\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { format4dp, formatUnitPrice, rateUsageForItem } from "./usageRater.js";
|
|
2
|
+
const EMPTY_RESULT = {
|
|
3
|
+
lines: [],
|
|
4
|
+
matchedUoms: [],
|
|
5
|
+
unmatchedUoms: [],
|
|
6
|
+
ratedCount: 0
|
|
7
|
+
};
|
|
8
|
+
function nonNullUomItems(items) {
|
|
9
|
+
const out = /* @__PURE__ */ new Map();
|
|
10
|
+
for (const item of items) {
|
|
11
|
+
if (item.type !== "usage") continue;
|
|
12
|
+
if (!item.isActive || item.deletedAt) continue;
|
|
13
|
+
if (!item.uomCode) continue;
|
|
14
|
+
out.set(item.uomCode, item);
|
|
15
|
+
}
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
async function fetchUsageAggregate(em, account, period) {
|
|
19
|
+
const rows = await em.execute(
|
|
20
|
+
`SELECT uom_code,
|
|
21
|
+
SUM(quantity)::text AS total_quantity,
|
|
22
|
+
COUNT(*)::text AS record_count
|
|
23
|
+
FROM billing_account_usage
|
|
24
|
+
WHERE tenant_id = ?
|
|
25
|
+
AND organization_id = ?
|
|
26
|
+
AND bill_account_id = ?
|
|
27
|
+
AND deleted_at IS NULL
|
|
28
|
+
AND rated_in_bill_run_id IS NULL
|
|
29
|
+
AND period_end <= ?
|
|
30
|
+
GROUP BY uom_code`,
|
|
31
|
+
[account.tenantId, account.organizationId, account.id, period.periodEnd]
|
|
32
|
+
);
|
|
33
|
+
return rows ?? [];
|
|
34
|
+
}
|
|
35
|
+
async function processUsageForAccount(em, account, period, usageItems) {
|
|
36
|
+
const aggregateRows = await fetchUsageAggregate(em, account, period);
|
|
37
|
+
if (aggregateRows.length === 0) return EMPTY_RESULT;
|
|
38
|
+
const itemByUom = nonNullUomItems(usageItems);
|
|
39
|
+
const lines = [];
|
|
40
|
+
const matchedUoms = [];
|
|
41
|
+
const unmatchedUoms = [];
|
|
42
|
+
let ratedCount = 0;
|
|
43
|
+
for (const row of aggregateRows) {
|
|
44
|
+
const uom = row.uom_code;
|
|
45
|
+
const totalQuantity = Number.parseFloat(row.total_quantity);
|
|
46
|
+
const recordCount = Number.parseInt(row.record_count, 10);
|
|
47
|
+
const item = itemByUom.get(uom);
|
|
48
|
+
if (!item) {
|
|
49
|
+
unmatchedUoms.push(uom);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (!Number.isFinite(totalQuantity) || totalQuantity === 0) continue;
|
|
53
|
+
const rated = rateUsageForItem(item, totalQuantity);
|
|
54
|
+
if (!rated) {
|
|
55
|
+
unmatchedUoms.push(uom);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const isSimpleFlat = !("model" in item.rateJson);
|
|
59
|
+
const unitPriceNet = isSimpleFlat ? formatUnitPrice(item.rateJson.unit_price) : "0";
|
|
60
|
+
lines.push({
|
|
61
|
+
billingItemId: item.id,
|
|
62
|
+
billingType: "usage",
|
|
63
|
+
description: item.description,
|
|
64
|
+
unitPriceNet,
|
|
65
|
+
quantity: format4dp(totalQuantity),
|
|
66
|
+
totalNetAmount: rated.amount,
|
|
67
|
+
metadataExtra: {
|
|
68
|
+
uom_code: uom,
|
|
69
|
+
usage_record_count: recordCount,
|
|
70
|
+
usage_tier_breakdown: rated.breakdown
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
matchedUoms.push(uom);
|
|
74
|
+
ratedCount += Number.isFinite(recordCount) ? recordCount : 0;
|
|
75
|
+
}
|
|
76
|
+
return { lines, matchedUoms, unmatchedUoms, ratedCount };
|
|
77
|
+
}
|
|
78
|
+
export {
|
|
79
|
+
EMPTY_RESULT as __EMPTY_USAGE_RESULT,
|
|
80
|
+
processUsageForAccount
|
|
81
|
+
};
|
|
82
|
+
//# sourceMappingURL=usageRunner.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/billing/lib/usageRunner.ts"],
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { type BillingAccount, type BillingItem } from '../data/entities'\nimport type { BillPeriod } from './billPeriod'\nimport type { InvoiceLineDescriptor } from './invoiceWriter'\nimport { format4dp, formatUnitPrice, rateUsageForItem, type UsageRate } from './usageRater'\n\n/**\n * Per-account usage processing \u2014 Phase 3.\n *\n * ## Scale design\n *\n * This module's hot path runs once per account per Bill Run. For\n * telco / infra customers the spec's \"Shape B\" scenario projects\n * **millions of usage records per account per cycle**. A naive\n * `em.find(BillingAccountUsage, \u2026)` materialises every row in JS\n * memory before we can sum, which OOMs on those tenants.\n *\n * Phase 4f refactor: replace the load-all-then-aggregate path with a\n * single Postgres `GROUP BY uom_code` aggregate. Memory is now bounded\n * by `O(distinct uom_codes per account)` (a small number, typically\n * 1-20) rather than `O(records)`. The corresponding \"mark as rated\"\n * UPDATE happens engine-side via a `uom_code IN (\u2026)` predicate so we\n * never enumerate ids into memory either.\n *\n * ## Spec behaviour\n *\n * Selects all unrated usage records whose `period_end <= bill_period_end`,\n * groups them by `uom_code`, matches each group against the account's\n * `type=usage` Billing Items (exact `uom_code` match per spec \u2014 NO\n * conversion), rates the aggregated quantity, and emits ONE invoice\n * line per matched item.\n *\n * Per-record `line_description` itemization is deferred to a follow-up\n * (would require a non-aggregate path; the engine keeps the invoice\n * clean with one aggregated line per item for now).\n *\n * Unmatched UoMs:\n * - When usage records exist for a `uom_code` that does NOT match\n * any active `type=usage` Billing Item, those records are LEFT\n * un-rated (`rated_in_bill_run_id` stays NULL) and the uom is\n * surfaced in `unmatchedUoms` so the account outcome carries it\n * as `success_with_warnings`. Operator's fix: add the missing\n * Billing Item, then trigger retry-failed.\n *\n * Mode handling:\n * - real: the engine marks `rated_in_bill_run_id` on records matching\n * the returned `matchedUoms` via a single bulk UPDATE.\n * - test / dry: the engine skips the UPDATE; same usage stays\n * consumable by a follow-up run.\n */\n\nexport type UsageRunResult = {\n /** Invoice line descriptors ready for `writeDraftInvoice`. */\n lines: InvoiceLineDescriptor[]\n /**\n * `uom_code`s the engine should mark as rated. The engine issues a\n * single `UPDATE ... WHERE uom_code IN (matchedUoms) AND <scope>`\n * after this returns \u2014 no id list materialisation.\n */\n matchedUoms: string[]\n /** UoMs present in usage records but missing from `type=usage` items. */\n unmatchedUoms: string[]\n /** Count of usage records rated this cycle (for the run summary). */\n ratedCount: number\n}\n\nconst EMPTY_RESULT: UsageRunResult = {\n lines: [],\n matchedUoms: [],\n unmatchedUoms: [],\n ratedCount: 0,\n}\n\nfunction nonNullUomItems(items: readonly BillingItem[]): Map<string, BillingItem> {\n const out = new Map<string, BillingItem>()\n for (const item of items) {\n if (item.type !== 'usage') continue\n if (!item.isActive || item.deletedAt) continue\n if (!item.uomCode) continue\n out.set(item.uomCode, item)\n }\n return out\n}\n\ntype UsageAggregateRow = {\n uom_code: string\n total_quantity: string\n record_count: string\n}\n\n/**\n * Single Postgres aggregate: bucket unrated usage records by `uom_code`,\n * sum the quantities, count the records. Returns one row per uom \u2014 a\n * small, bounded result set regardless of how many millions of records\n * the account actually has.\n *\n * The matching index is the partial index on\n * `(bill_account_id, uom_code, rated_in_bill_run_id, period_end)` shipped\n * in Phase 0's migration \u2014 both the filter and the GROUP BY land on\n * the same index.\n */\nasync function fetchUsageAggregate(\n em: EntityManager,\n account: BillingAccount,\n period: BillPeriod,\n): Promise<UsageAggregateRow[]> {\n const rows = (await em.execute(\n `SELECT uom_code,\n SUM(quantity)::text AS total_quantity,\n COUNT(*)::text AS record_count\n FROM billing_account_usage\n WHERE tenant_id = ?\n AND organization_id = ?\n AND bill_account_id = ?\n AND deleted_at IS NULL\n AND rated_in_bill_run_id IS NULL\n AND period_end <= ?\n GROUP BY uom_code`,\n [account.tenantId, account.organizationId, account.id, period.periodEnd],\n )) as unknown as UsageAggregateRow[] | undefined\n return rows ?? []\n}\n\nexport async function processUsageForAccount(\n em: EntityManager,\n account: BillingAccount,\n period: BillPeriod,\n usageItems: readonly BillingItem[],\n): Promise<UsageRunResult> {\n const aggregateRows = await fetchUsageAggregate(em, account, period)\n if (aggregateRows.length === 0) return EMPTY_RESULT\n\n const itemByUom = nonNullUomItems(usageItems)\n\n const lines: InvoiceLineDescriptor[] = []\n const matchedUoms: string[] = []\n const unmatchedUoms: string[] = []\n let ratedCount = 0\n\n for (const row of aggregateRows) {\n const uom = row.uom_code\n const totalQuantity = Number.parseFloat(row.total_quantity)\n const recordCount = Number.parseInt(row.record_count, 10)\n\n const item = itemByUom.get(uom)\n if (!item) {\n unmatchedUoms.push(uom)\n continue\n }\n if (!Number.isFinite(totalQuantity) || totalQuantity === 0) continue\n\n const rated = rateUsageForItem(item, totalQuantity)\n if (!rated) {\n // Malformed rate_json on a passing-validation item is rare but\n // possible if an operator hand-edited it. Surface as unmatched\n // so the operator knows to look rather than silently dropping\n // revenue.\n unmatchedUoms.push(uom)\n continue\n }\n\n const isSimpleFlat = !('model' in (item.rateJson as Record<string, unknown>))\n const unitPriceNet = isSimpleFlat\n ? formatUnitPrice((item.rateJson as { unit_price: number }).unit_price)\n : '0'\n lines.push({\n billingItemId: item.id,\n billingType: 'usage',\n description: item.description,\n unitPriceNet,\n quantity: format4dp(totalQuantity),\n totalNetAmount: rated.amount,\n metadataExtra: {\n uom_code: uom,\n usage_record_count: recordCount,\n usage_tier_breakdown: rated.breakdown,\n },\n })\n matchedUoms.push(uom)\n ratedCount += Number.isFinite(recordCount) ? recordCount : 0\n }\n\n return { lines, matchedUoms, unmatchedUoms, ratedCount }\n}\n\nexport { EMPTY_RESULT as __EMPTY_USAGE_RESULT }\n\n// Suppress unused-export warning for the type alias.\nexport type _UsageRateAlias = UsageRate\n"],
|
|
5
|
+
"mappings": "AAIA,SAAS,WAAW,iBAAiB,wBAAwC;AA8D7E,MAAM,eAA+B;AAAA,EACnC,OAAO,CAAC;AAAA,EACR,aAAa,CAAC;AAAA,EACd,eAAe,CAAC;AAAA,EAChB,YAAY;AACd;AAEA,SAAS,gBAAgB,OAAyD;AAChF,QAAM,MAAM,oBAAI,IAAyB;AACzC,aAAW,QAAQ,OAAO;AACxB,QAAI,KAAK,SAAS,QAAS;AAC3B,QAAI,CAAC,KAAK,YAAY,KAAK,UAAW;AACtC,QAAI,CAAC,KAAK,QAAS;AACnB,QAAI,IAAI,KAAK,SAAS,IAAI;AAAA,EAC5B;AACA,SAAO;AACT;AAmBA,eAAe,oBACb,IACA,SACA,QAC8B;AAC9B,QAAM,OAAQ,MAAM,GAAG;AAAA,IACrB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAWA,CAAC,QAAQ,UAAU,QAAQ,gBAAgB,QAAQ,IAAI,OAAO,SAAS;AAAA,EACzE;AACA,SAAO,QAAQ,CAAC;AAClB;AAEA,eAAsB,uBACpB,IACA,SACA,QACA,YACyB;AACzB,QAAM,gBAAgB,MAAM,oBAAoB,IAAI,SAAS,MAAM;AACnE,MAAI,cAAc,WAAW,EAAG,QAAO;AAEvC,QAAM,YAAY,gBAAgB,UAAU;AAE5C,QAAM,QAAiC,CAAC;AACxC,QAAM,cAAwB,CAAC;AAC/B,QAAM,gBAA0B,CAAC;AACjC,MAAI,aAAa;AAEjB,aAAW,OAAO,eAAe;AAC/B,UAAM,MAAM,IAAI;AAChB,UAAM,gBAAgB,OAAO,WAAW,IAAI,cAAc;AAC1D,UAAM,cAAc,OAAO,SAAS,IAAI,cAAc,EAAE;AAExD,UAAM,OAAO,UAAU,IAAI,GAAG;AAC9B,QAAI,CAAC,MAAM;AACT,oBAAc,KAAK,GAAG;AACtB;AAAA,IACF;AACA,QAAI,CAAC,OAAO,SAAS,aAAa,KAAK,kBAAkB,EAAG;AAE5D,UAAM,QAAQ,iBAAiB,MAAM,aAAa;AAClD,QAAI,CAAC,OAAO;AAKV,oBAAc,KAAK,GAAG;AACtB;AAAA,IACF;AAEA,UAAM,eAAe,EAAE,WAAY,KAAK;AACxC,UAAM,eAAe,eACjB,gBAAiB,KAAK,SAAoC,UAAU,IACpE;AACJ,UAAM,KAAK;AAAA,MACT,eAAe,KAAK;AAAA,MACpB,aAAa;AAAA,MACb,aAAa,KAAK;AAAA,MAClB;AAAA,MACA,UAAU,UAAU,aAAa;AAAA,MACjC,gBAAgB,MAAM;AAAA,MACtB,eAAe;AAAA,QACb,UAAU;AAAA,QACV,oBAAoB;AAAA,QACpB,sBAAsB,MAAM;AAAA,MAC9B;AAAA,IACF,CAAC;AACD,gBAAY,KAAK,GAAG;AACpB,kBAAc,OAAO,SAAS,WAAW,IAAI,cAAc;AAAA,EAC7D;AAEA,SAAO,EAAE,OAAO,aAAa,eAAe,WAAW;AACzD;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|