@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,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure functions to rate usage quantity against a `rate_json` payload.
|
|
3
|
+
*
|
|
4
|
+
* Three tier models per the spec (`volume` / `graduated` / `flat`) plus
|
|
5
|
+
* the "simple" flat-rate shape (`{ unit_price }` without a `model`).
|
|
6
|
+
* The validator at `data/validators.ts` ensures the payload structure
|
|
7
|
+
* before it reaches this rater — this module trusts the shape but
|
|
8
|
+
* defends against numeric edge cases (NaN, negative quantity).
|
|
9
|
+
*
|
|
10
|
+
* Output:
|
|
11
|
+
* - `amount` — numeric string, rounded 2dp half-up, formatted to 4
|
|
12
|
+
* decimals to match the `core/sales` numeric(18,4) column shape.
|
|
13
|
+
* - `breakdown` — per-tier rated quantity / amount, persisted into
|
|
14
|
+
* `SalesInvoiceLine.metadata.usage_tier_breakdown` so operators
|
|
15
|
+
* can see how the line was built without re-running the math.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { BillingItem } from '../data/entities'
|
|
19
|
+
|
|
20
|
+
export type SimpleFlatRate = { unit_price: number }
|
|
21
|
+
export type VolumeTier = { up_to: number | null; unit_price: number }
|
|
22
|
+
export type GraduatedTier = { up_to: number | null; unit_price: number }
|
|
23
|
+
export type FlatTier = { up_to: number | null; flat_amount: number }
|
|
24
|
+
|
|
25
|
+
export type VolumeRate = { model: 'volume'; tiers: VolumeTier[] }
|
|
26
|
+
export type GraduatedRate = { model: 'graduated'; tiers: GraduatedTier[] }
|
|
27
|
+
export type FlatRate = { model: 'flat'; tiers: FlatTier[] }
|
|
28
|
+
export type TieredRate = VolumeRate | GraduatedRate | FlatRate
|
|
29
|
+
export type UsageRate = SimpleFlatRate | TieredRate
|
|
30
|
+
|
|
31
|
+
export type TierBreakdownEntry = {
|
|
32
|
+
tierIndex: number
|
|
33
|
+
upTo: number | null
|
|
34
|
+
/** Quantity rated at this tier — 0 for tiers above the consumed range. */
|
|
35
|
+
rangeQuantity: number
|
|
36
|
+
/** `unit_price` for volume/graduated; `flat_amount` for flat. */
|
|
37
|
+
rate: number
|
|
38
|
+
/** Per-tier amount contribution, rounded 4dp. */
|
|
39
|
+
amount: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type UsageRateResult = {
|
|
43
|
+
/** Total rated amount, numeric(18,4) string. */
|
|
44
|
+
amount: string
|
|
45
|
+
/** Per-tier or single-rate breakdown for invoice-line metadata. */
|
|
46
|
+
breakdown: TierBreakdownEntry[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Half-up rounding to 2dp survives the JS floating-point edge case
|
|
51
|
+
* (`1.005 → 1.01`, `100 × 0.001 + 100 × 0.001 + … → exactly N×0.001`).
|
|
52
|
+
* Match `invoiceWriter`'s rounding policy so totals are consistent
|
|
53
|
+
* across line types.
|
|
54
|
+
*/
|
|
55
|
+
function roundHalfUp2dp(value: number): number {
|
|
56
|
+
return Math.round((value + Number.EPSILON) * 100) / 100
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function format4dp(value: number): string {
|
|
60
|
+
return roundHalfUp2dp(value).toFixed(4)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Format a unit price to 4dp WITHOUT 2dp rounding — sub-cent rates
|
|
65
|
+
* like `0.001` (per-API-request pricing) are legitimate. The spec's
|
|
66
|
+
* 2dp half-up rule applies only to line *amounts*, not per-unit
|
|
67
|
+
* rates.
|
|
68
|
+
*/
|
|
69
|
+
function formatUnitPrice(value: number): string {
|
|
70
|
+
if (!Number.isFinite(value)) return '0.0000'
|
|
71
|
+
return value.toFixed(4)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isTieredRate(rate: UsageRate): rate is TieredRate {
|
|
75
|
+
return typeof (rate as { model?: unknown }).model === 'string'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function safeQuantity(quantity: number): number {
|
|
79
|
+
if (!Number.isFinite(quantity) || quantity < 0) return 0
|
|
80
|
+
return quantity
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─── Simple flat-rate ────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
function rateSimpleFlat(quantity: number, rate: SimpleFlatRate): UsageRateResult {
|
|
86
|
+
const q = safeQuantity(quantity)
|
|
87
|
+
const total = q * rate.unit_price
|
|
88
|
+
return {
|
|
89
|
+
amount: format4dp(total),
|
|
90
|
+
breakdown: [
|
|
91
|
+
{
|
|
92
|
+
tierIndex: 0,
|
|
93
|
+
upTo: null,
|
|
94
|
+
rangeQuantity: q,
|
|
95
|
+
rate: rate.unit_price,
|
|
96
|
+
amount: format4dp(total),
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─── Volume tier ─────────────────────────────────────────────────
|
|
103
|
+
//
|
|
104
|
+
// "Entire quantity priced at the unit_price of the tier its total falls
|
|
105
|
+
// into." So we walk tiers in order, find the first tier whose `up_to`
|
|
106
|
+
// is `null` (open-ended) or `>= quantity`, and charge `quantity ×
|
|
107
|
+
// that tier's unit_price`. The unconsumed tiers report 0.
|
|
108
|
+
|
|
109
|
+
function rateVolume(quantity: number, rate: VolumeRate): UsageRateResult {
|
|
110
|
+
const q = safeQuantity(quantity)
|
|
111
|
+
const breakdown: TierBreakdownEntry[] = rate.tiers.map((tier, idx) => ({
|
|
112
|
+
tierIndex: idx,
|
|
113
|
+
upTo: tier.up_to,
|
|
114
|
+
rangeQuantity: 0,
|
|
115
|
+
rate: tier.unit_price,
|
|
116
|
+
amount: format4dp(0),
|
|
117
|
+
}))
|
|
118
|
+
for (let i = 0; i < rate.tiers.length; i++) {
|
|
119
|
+
const tier = rate.tiers[i]
|
|
120
|
+
if (tier.up_to === null || q <= tier.up_to) {
|
|
121
|
+
const total = q * tier.unit_price
|
|
122
|
+
breakdown[i] = {
|
|
123
|
+
tierIndex: i,
|
|
124
|
+
upTo: tier.up_to,
|
|
125
|
+
rangeQuantity: q,
|
|
126
|
+
rate: tier.unit_price,
|
|
127
|
+
amount: format4dp(total),
|
|
128
|
+
}
|
|
129
|
+
return { amount: format4dp(total), breakdown }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Should be unreachable: validator enforces an open-ended top tier.
|
|
133
|
+
return { amount: format4dp(0), breakdown }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── Graduated tier ──────────────────────────────────────────────
|
|
137
|
+
//
|
|
138
|
+
// "Each unit priced at its tier." Walk tiers in order, charging
|
|
139
|
+
// `min(q_remaining, tier_capacity) × unit_price` for each tier the
|
|
140
|
+
// quantity touches.
|
|
141
|
+
|
|
142
|
+
function rateGraduated(quantity: number, rate: GraduatedRate): UsageRateResult {
|
|
143
|
+
const q = safeQuantity(quantity)
|
|
144
|
+
let remaining = q
|
|
145
|
+
let previousUpTo = 0
|
|
146
|
+
let total = 0
|
|
147
|
+
const breakdown: TierBreakdownEntry[] = []
|
|
148
|
+
for (let i = 0; i < rate.tiers.length; i++) {
|
|
149
|
+
const tier = rate.tiers[i]
|
|
150
|
+
const tierCapacity =
|
|
151
|
+
tier.up_to === null ? Infinity : Math.max(0, tier.up_to - previousUpTo)
|
|
152
|
+
const consumed = Math.min(remaining, tierCapacity)
|
|
153
|
+
const tierAmount = consumed * tier.unit_price
|
|
154
|
+
total += tierAmount
|
|
155
|
+
breakdown.push({
|
|
156
|
+
tierIndex: i,
|
|
157
|
+
upTo: tier.up_to,
|
|
158
|
+
rangeQuantity: consumed,
|
|
159
|
+
rate: tier.unit_price,
|
|
160
|
+
amount: format4dp(tierAmount),
|
|
161
|
+
})
|
|
162
|
+
remaining -= consumed
|
|
163
|
+
previousUpTo = tier.up_to ?? previousUpTo
|
|
164
|
+
if (remaining <= 0) {
|
|
165
|
+
// Pad remaining tiers with zeros for breakdown completeness.
|
|
166
|
+
for (let j = i + 1; j < rate.tiers.length; j++) {
|
|
167
|
+
breakdown.push({
|
|
168
|
+
tierIndex: j,
|
|
169
|
+
upTo: rate.tiers[j].up_to,
|
|
170
|
+
rangeQuantity: 0,
|
|
171
|
+
rate: rate.tiers[j].unit_price,
|
|
172
|
+
amount: format4dp(0),
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
break
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return { amount: format4dp(total), breakdown }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ─── Flat tier ───────────────────────────────────────────────────
|
|
182
|
+
//
|
|
183
|
+
// "One flat fee for the highest tier reached." Find the first tier
|
|
184
|
+
// whose `up_to` is `null` or `>= quantity`. Charge that tier's
|
|
185
|
+
// `flat_amount`. Other tiers report 0.
|
|
186
|
+
|
|
187
|
+
function rateFlat(quantity: number, rate: FlatRate): UsageRateResult {
|
|
188
|
+
const q = safeQuantity(quantity)
|
|
189
|
+
const breakdown: TierBreakdownEntry[] = rate.tiers.map((tier, idx) => ({
|
|
190
|
+
tierIndex: idx,
|
|
191
|
+
upTo: tier.up_to,
|
|
192
|
+
rangeQuantity: 0,
|
|
193
|
+
rate: tier.flat_amount,
|
|
194
|
+
amount: format4dp(0),
|
|
195
|
+
}))
|
|
196
|
+
for (let i = 0; i < rate.tiers.length; i++) {
|
|
197
|
+
const tier = rate.tiers[i]
|
|
198
|
+
if (tier.up_to === null || q <= tier.up_to) {
|
|
199
|
+
breakdown[i] = {
|
|
200
|
+
tierIndex: i,
|
|
201
|
+
upTo: tier.up_to,
|
|
202
|
+
rangeQuantity: q,
|
|
203
|
+
rate: tier.flat_amount,
|
|
204
|
+
amount: format4dp(tier.flat_amount),
|
|
205
|
+
}
|
|
206
|
+
return { amount: format4dp(tier.flat_amount), breakdown }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return { amount: format4dp(0), breakdown }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ─── Public dispatcher ───────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
export function rateUsageQuantity(rate: UsageRate, totalQuantity: number): UsageRateResult {
|
|
215
|
+
if (!isTieredRate(rate)) {
|
|
216
|
+
return rateSimpleFlat(totalQuantity, rate)
|
|
217
|
+
}
|
|
218
|
+
switch (rate.model) {
|
|
219
|
+
case 'volume':
|
|
220
|
+
return rateVolume(totalQuantity, rate)
|
|
221
|
+
case 'graduated':
|
|
222
|
+
return rateGraduated(totalQuantity, rate)
|
|
223
|
+
case 'flat':
|
|
224
|
+
return rateFlat(totalQuantity, rate)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Convenience wrapper: takes a BillingItem's `rate_json` (typed as
|
|
230
|
+
* `unknown` from JSONB) and the total quantity, returns the rated
|
|
231
|
+
* amount + breakdown. Returns `null` when the rate shape is invalid —
|
|
232
|
+
* caller falls back to "no charge" + warns instead of crashing the
|
|
233
|
+
* whole account.
|
|
234
|
+
*/
|
|
235
|
+
export function rateUsageForItem(
|
|
236
|
+
item: Pick<BillingItem, 'rateJson' | 'type'>,
|
|
237
|
+
totalQuantity: number,
|
|
238
|
+
): UsageRateResult | null {
|
|
239
|
+
if (item.type !== 'usage') return null
|
|
240
|
+
const rate = item.rateJson as UsageRate | null | undefined
|
|
241
|
+
if (!rate || typeof rate !== 'object') return null
|
|
242
|
+
if (isTieredRate(rate)) {
|
|
243
|
+
if (!Array.isArray(rate.tiers) || rate.tiers.length === 0) return null
|
|
244
|
+
} else if (typeof (rate as SimpleFlatRate).unit_price !== 'number') {
|
|
245
|
+
return null
|
|
246
|
+
}
|
|
247
|
+
return rateUsageQuantity(rate, totalQuantity)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export { roundHalfUp2dp, format4dp, formatUnitPrice }
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { type BillingAccount, type BillingItem } from '../data/entities'
|
|
3
|
+
import type { BillPeriod } from './billPeriod'
|
|
4
|
+
import type { InvoiceLineDescriptor } from './invoiceWriter'
|
|
5
|
+
import { format4dp, formatUnitPrice, rateUsageForItem, type UsageRate } from './usageRater'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Per-account usage processing — Phase 3.
|
|
9
|
+
*
|
|
10
|
+
* ## Scale design
|
|
11
|
+
*
|
|
12
|
+
* This module's hot path runs once per account per Bill Run. For
|
|
13
|
+
* telco / infra customers the spec's "Shape B" scenario projects
|
|
14
|
+
* **millions of usage records per account per cycle**. A naive
|
|
15
|
+
* `em.find(BillingAccountUsage, …)` materialises every row in JS
|
|
16
|
+
* memory before we can sum, which OOMs on those tenants.
|
|
17
|
+
*
|
|
18
|
+
* Phase 4f refactor: replace the load-all-then-aggregate path with a
|
|
19
|
+
* single Postgres `GROUP BY uom_code` aggregate. Memory is now bounded
|
|
20
|
+
* by `O(distinct uom_codes per account)` (a small number, typically
|
|
21
|
+
* 1-20) rather than `O(records)`. The corresponding "mark as rated"
|
|
22
|
+
* UPDATE happens engine-side via a `uom_code IN (…)` predicate so we
|
|
23
|
+
* never enumerate ids into memory either.
|
|
24
|
+
*
|
|
25
|
+
* ## Spec behaviour
|
|
26
|
+
*
|
|
27
|
+
* Selects all unrated usage records whose `period_end <= bill_period_end`,
|
|
28
|
+
* groups them by `uom_code`, matches each group against the account's
|
|
29
|
+
* `type=usage` Billing Items (exact `uom_code` match per spec — NO
|
|
30
|
+
* conversion), rates the aggregated quantity, and emits ONE invoice
|
|
31
|
+
* line per matched item.
|
|
32
|
+
*
|
|
33
|
+
* Per-record `line_description` itemization is deferred to a follow-up
|
|
34
|
+
* (would require a non-aggregate path; the engine keeps the invoice
|
|
35
|
+
* clean with one aggregated line per item for now).
|
|
36
|
+
*
|
|
37
|
+
* Unmatched UoMs:
|
|
38
|
+
* - When usage records exist for a `uom_code` that does NOT match
|
|
39
|
+
* any active `type=usage` Billing Item, those records are LEFT
|
|
40
|
+
* un-rated (`rated_in_bill_run_id` stays NULL) and the uom is
|
|
41
|
+
* surfaced in `unmatchedUoms` so the account outcome carries it
|
|
42
|
+
* as `success_with_warnings`. Operator's fix: add the missing
|
|
43
|
+
* Billing Item, then trigger retry-failed.
|
|
44
|
+
*
|
|
45
|
+
* Mode handling:
|
|
46
|
+
* - real: the engine marks `rated_in_bill_run_id` on records matching
|
|
47
|
+
* the returned `matchedUoms` via a single bulk UPDATE.
|
|
48
|
+
* - test / dry: the engine skips the UPDATE; same usage stays
|
|
49
|
+
* consumable by a follow-up run.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
export type UsageRunResult = {
|
|
53
|
+
/** Invoice line descriptors ready for `writeDraftInvoice`. */
|
|
54
|
+
lines: InvoiceLineDescriptor[]
|
|
55
|
+
/**
|
|
56
|
+
* `uom_code`s the engine should mark as rated. The engine issues a
|
|
57
|
+
* single `UPDATE ... WHERE uom_code IN (matchedUoms) AND <scope>`
|
|
58
|
+
* after this returns — no id list materialisation.
|
|
59
|
+
*/
|
|
60
|
+
matchedUoms: string[]
|
|
61
|
+
/** UoMs present in usage records but missing from `type=usage` items. */
|
|
62
|
+
unmatchedUoms: string[]
|
|
63
|
+
/** Count of usage records rated this cycle (for the run summary). */
|
|
64
|
+
ratedCount: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const EMPTY_RESULT: UsageRunResult = {
|
|
68
|
+
lines: [],
|
|
69
|
+
matchedUoms: [],
|
|
70
|
+
unmatchedUoms: [],
|
|
71
|
+
ratedCount: 0,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function nonNullUomItems(items: readonly BillingItem[]): Map<string, BillingItem> {
|
|
75
|
+
const out = new Map<string, BillingItem>()
|
|
76
|
+
for (const item of items) {
|
|
77
|
+
if (item.type !== 'usage') continue
|
|
78
|
+
if (!item.isActive || item.deletedAt) continue
|
|
79
|
+
if (!item.uomCode) continue
|
|
80
|
+
out.set(item.uomCode, item)
|
|
81
|
+
}
|
|
82
|
+
return out
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type UsageAggregateRow = {
|
|
86
|
+
uom_code: string
|
|
87
|
+
total_quantity: string
|
|
88
|
+
record_count: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Single Postgres aggregate: bucket unrated usage records by `uom_code`,
|
|
93
|
+
* sum the quantities, count the records. Returns one row per uom — a
|
|
94
|
+
* small, bounded result set regardless of how many millions of records
|
|
95
|
+
* the account actually has.
|
|
96
|
+
*
|
|
97
|
+
* The matching index is the partial index on
|
|
98
|
+
* `(bill_account_id, uom_code, rated_in_bill_run_id, period_end)` shipped
|
|
99
|
+
* in Phase 0's migration — both the filter and the GROUP BY land on
|
|
100
|
+
* the same index.
|
|
101
|
+
*/
|
|
102
|
+
async function fetchUsageAggregate(
|
|
103
|
+
em: EntityManager,
|
|
104
|
+
account: BillingAccount,
|
|
105
|
+
period: BillPeriod,
|
|
106
|
+
): Promise<UsageAggregateRow[]> {
|
|
107
|
+
const rows = (await em.execute(
|
|
108
|
+
`SELECT uom_code,
|
|
109
|
+
SUM(quantity)::text AS total_quantity,
|
|
110
|
+
COUNT(*)::text AS record_count
|
|
111
|
+
FROM billing_account_usage
|
|
112
|
+
WHERE tenant_id = ?
|
|
113
|
+
AND organization_id = ?
|
|
114
|
+
AND bill_account_id = ?
|
|
115
|
+
AND deleted_at IS NULL
|
|
116
|
+
AND rated_in_bill_run_id IS NULL
|
|
117
|
+
AND period_end <= ?
|
|
118
|
+
GROUP BY uom_code`,
|
|
119
|
+
[account.tenantId, account.organizationId, account.id, period.periodEnd],
|
|
120
|
+
)) as unknown as UsageAggregateRow[] | undefined
|
|
121
|
+
return rows ?? []
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function processUsageForAccount(
|
|
125
|
+
em: EntityManager,
|
|
126
|
+
account: BillingAccount,
|
|
127
|
+
period: BillPeriod,
|
|
128
|
+
usageItems: readonly BillingItem[],
|
|
129
|
+
): Promise<UsageRunResult> {
|
|
130
|
+
const aggregateRows = await fetchUsageAggregate(em, account, period)
|
|
131
|
+
if (aggregateRows.length === 0) return EMPTY_RESULT
|
|
132
|
+
|
|
133
|
+
const itemByUom = nonNullUomItems(usageItems)
|
|
134
|
+
|
|
135
|
+
const lines: InvoiceLineDescriptor[] = []
|
|
136
|
+
const matchedUoms: string[] = []
|
|
137
|
+
const unmatchedUoms: string[] = []
|
|
138
|
+
let ratedCount = 0
|
|
139
|
+
|
|
140
|
+
for (const row of aggregateRows) {
|
|
141
|
+
const uom = row.uom_code
|
|
142
|
+
const totalQuantity = Number.parseFloat(row.total_quantity)
|
|
143
|
+
const recordCount = Number.parseInt(row.record_count, 10)
|
|
144
|
+
|
|
145
|
+
const item = itemByUom.get(uom)
|
|
146
|
+
if (!item) {
|
|
147
|
+
unmatchedUoms.push(uom)
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
if (!Number.isFinite(totalQuantity) || totalQuantity === 0) continue
|
|
151
|
+
|
|
152
|
+
const rated = rateUsageForItem(item, totalQuantity)
|
|
153
|
+
if (!rated) {
|
|
154
|
+
// Malformed rate_json on a passing-validation item is rare but
|
|
155
|
+
// possible if an operator hand-edited it. Surface as unmatched
|
|
156
|
+
// so the operator knows to look rather than silently dropping
|
|
157
|
+
// revenue.
|
|
158
|
+
unmatchedUoms.push(uom)
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const isSimpleFlat = !('model' in (item.rateJson as Record<string, unknown>))
|
|
163
|
+
const unitPriceNet = isSimpleFlat
|
|
164
|
+
? formatUnitPrice((item.rateJson as { unit_price: number }).unit_price)
|
|
165
|
+
: '0'
|
|
166
|
+
lines.push({
|
|
167
|
+
billingItemId: item.id,
|
|
168
|
+
billingType: 'usage',
|
|
169
|
+
description: item.description,
|
|
170
|
+
unitPriceNet,
|
|
171
|
+
quantity: format4dp(totalQuantity),
|
|
172
|
+
totalNetAmount: rated.amount,
|
|
173
|
+
metadataExtra: {
|
|
174
|
+
uom_code: uom,
|
|
175
|
+
usage_record_count: recordCount,
|
|
176
|
+
usage_tier_breakdown: rated.breakdown,
|
|
177
|
+
},
|
|
178
|
+
})
|
|
179
|
+
matchedUoms.push(uom)
|
|
180
|
+
ratedCount += Number.isFinite(recordCount) ? recordCount : 0
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return { lines, matchedUoms, unmatchedUoms, ratedCount }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export { EMPTY_RESULT as __EMPTY_USAGE_RESULT }
|
|
187
|
+
|
|
188
|
+
// Suppress unused-export warning for the type alias.
|
|
189
|
+
export type _UsageRateAlias = UsageRate
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { Migration } from '@mikro-orm/migrations'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Initial Billing module schema (XD-249 Phase 0).
|
|
5
|
+
*
|
|
6
|
+
* Frozen as hand-written. Why this is OK despite the "no hand-written
|
|
7
|
+
* migrations" rule in AGENTS.md:
|
|
8
|
+
*
|
|
9
|
+
* - At Phase 0 (May 2026) the standalone-app build of
|
|
10
|
+
* `@open-mercato/cli@0.6.0` did not auto-discover entities from
|
|
11
|
+
* `@dainamite/*` workspaces, so `yarn mercato db generate` produced
|
|
12
|
+
* an empty diff. The CLI patch shipped in this PR fixes the
|
|
13
|
+
* resolver going forward (see `packages/cpq/scripts/cli-resolver.patch.mjs`
|
|
14
|
+
* usage), but **regenerating THIS initial migration would mean
|
|
15
|
+
* wiping every existing billing_* table on every tenant** to let
|
|
16
|
+
* the CLI re-emit the same `create table` statements. That's
|
|
17
|
+
* never safe on a live system.
|
|
18
|
+
* - The 1:1 mapping against `packages/billing/src/modules/billing/data/entities.ts`
|
|
19
|
+
* has been validated against production-shape data (CPQ → billing
|
|
20
|
+
* end-to-end flow). Any subsequent schema change MUST go through
|
|
21
|
+
* `yarn mercato db generate` — the CLI now correctly walks
|
|
22
|
+
* workspace `@dainamite/*` packages, so future migration files
|
|
23
|
+
* in this folder will be generated, not hand-written.
|
|
24
|
+
*/
|
|
25
|
+
export class Migration20260519000000_billing extends Migration {
|
|
26
|
+
async up(): Promise<void> {
|
|
27
|
+
// ─── billing_accounts ────────────────────────────────────
|
|
28
|
+
this.addSql(`create table "billing_accounts" (
|
|
29
|
+
"id" uuid not null default gen_random_uuid(),
|
|
30
|
+
"organization_id" uuid not null,
|
|
31
|
+
"tenant_id" uuid not null,
|
|
32
|
+
"customer_id" text not null,
|
|
33
|
+
"name" text not null,
|
|
34
|
+
"currency_code" text not null,
|
|
35
|
+
"bill_cycle" text not null,
|
|
36
|
+
"bill_cycle_anchor" text not null,
|
|
37
|
+
"invoice_email" text not null,
|
|
38
|
+
"invoice_language" text not null,
|
|
39
|
+
"tax_id" text null,
|
|
40
|
+
"invoice_address" jsonb not null,
|
|
41
|
+
"next_bill_date" date not null,
|
|
42
|
+
"last_bill_date" date null,
|
|
43
|
+
"is_active" boolean not null default true,
|
|
44
|
+
"created_at" timestamptz not null,
|
|
45
|
+
"updated_at" timestamptz not null,
|
|
46
|
+
"deleted_at" timestamptz null,
|
|
47
|
+
constraint "billing_accounts_pkey" primary key ("id")
|
|
48
|
+
);`)
|
|
49
|
+
this.addSql(`create index "billing_accounts_next_bill_date_idx" on "billing_accounts" ("organization_id", "tenant_id", "next_bill_date") where "deleted_at" is null;`)
|
|
50
|
+
this.addSql(`create index "billing_accounts_customer_idx" on "billing_accounts" ("organization_id", "tenant_id", "customer_id");`)
|
|
51
|
+
this.addSql(`create index "billing_accounts_sort_idx" on "billing_accounts" ("organization_id", "tenant_id", "deleted_at", "created_at");`)
|
|
52
|
+
|
|
53
|
+
// ─── billing_items ───────────────────────────────────────
|
|
54
|
+
this.addSql(`create table "billing_items" (
|
|
55
|
+
"id" uuid not null default gen_random_uuid(),
|
|
56
|
+
"organization_id" uuid not null,
|
|
57
|
+
"tenant_id" uuid not null,
|
|
58
|
+
"bill_account_id" uuid not null,
|
|
59
|
+
"type" text not null,
|
|
60
|
+
"bill_start_date" date not null,
|
|
61
|
+
"bill_end_date" date null,
|
|
62
|
+
"description" text not null,
|
|
63
|
+
"rate_json" jsonb not null,
|
|
64
|
+
"uom_code" text null,
|
|
65
|
+
"subscription_id" text null,
|
|
66
|
+
"subscription_item_id" text null,
|
|
67
|
+
"source_ref" text null,
|
|
68
|
+
"currency_mismatch" boolean not null default false,
|
|
69
|
+
"billed_to_date" date null,
|
|
70
|
+
"is_active" boolean not null default true,
|
|
71
|
+
"created_at" timestamptz not null,
|
|
72
|
+
"updated_at" timestamptz not null,
|
|
73
|
+
"deleted_at" timestamptz null,
|
|
74
|
+
constraint "billing_items_pkey" primary key ("id")
|
|
75
|
+
);`)
|
|
76
|
+
this.addSql(`alter table "billing_items" add constraint "billing_items_source_ref_unique" unique ("tenant_id", "bill_account_id", "source_ref");`)
|
|
77
|
+
this.addSql(`create index "billing_items_account_type_idx" on "billing_items" ("bill_account_id", "type");`)
|
|
78
|
+
this.addSql(`create index "billing_items_account_active_idx" on "billing_items" ("bill_account_id", "type", "is_active") where "deleted_at" is null;`)
|
|
79
|
+
this.addSql(`create index "billing_items_subscription_idx" on "billing_items" ("organization_id", "tenant_id", "subscription_id");`)
|
|
80
|
+
|
|
81
|
+
// ─── billing_account_usage ───────────────────────────────
|
|
82
|
+
this.addSql(`create table "billing_account_usage" (
|
|
83
|
+
"id" uuid not null default gen_random_uuid(),
|
|
84
|
+
"organization_id" uuid not null,
|
|
85
|
+
"tenant_id" uuid not null,
|
|
86
|
+
"bill_account_id" uuid not null,
|
|
87
|
+
"uom_code" text not null,
|
|
88
|
+
"quantity" numeric(18, 4) not null,
|
|
89
|
+
"period_start" timestamptz not null,
|
|
90
|
+
"period_end" timestamptz not null,
|
|
91
|
+
"line_description" text null,
|
|
92
|
+
"source_ref" text null,
|
|
93
|
+
"rated_in_bill_run_id" uuid null,
|
|
94
|
+
"is_active" boolean not null default true,
|
|
95
|
+
"created_at" timestamptz not null,
|
|
96
|
+
"updated_at" timestamptz not null,
|
|
97
|
+
"deleted_at" timestamptz null,
|
|
98
|
+
constraint "billing_account_usage_pkey" primary key ("id")
|
|
99
|
+
);`)
|
|
100
|
+
this.addSql(`alter table "billing_account_usage" add constraint "billing_account_usage_source_ref_unique" unique ("tenant_id", "bill_account_id", "source_ref");`)
|
|
101
|
+
this.addSql(`create index "billing_account_usage_rating_idx" on "billing_account_usage" ("bill_account_id", "uom_code", "rated_in_bill_run_id", "period_end");`)
|
|
102
|
+
|
|
103
|
+
// ─── billing_runs ────────────────────────────────────────
|
|
104
|
+
this.addSql(`create table "billing_runs" (
|
|
105
|
+
"id" uuid not null default gen_random_uuid(),
|
|
106
|
+
"organization_id" uuid not null,
|
|
107
|
+
"tenant_id" uuid not null,
|
|
108
|
+
"triggered_by" text not null,
|
|
109
|
+
"parent_run_id" uuid null,
|
|
110
|
+
"dry_run" boolean not null default false,
|
|
111
|
+
"test_mode" boolean not null default false,
|
|
112
|
+
"catch_up" boolean not null default false,
|
|
113
|
+
"scoped_account_ids" jsonb null,
|
|
114
|
+
"as_of_date" date not null,
|
|
115
|
+
"started_at" timestamptz null,
|
|
116
|
+
"finished_at" timestamptz null,
|
|
117
|
+
"status" text not null,
|
|
118
|
+
"summary" jsonb null,
|
|
119
|
+
"is_active" boolean not null default true,
|
|
120
|
+
"created_at" timestamptz not null,
|
|
121
|
+
"updated_at" timestamptz not null,
|
|
122
|
+
"deleted_at" timestamptz null,
|
|
123
|
+
constraint "billing_runs_pkey" primary key ("id")
|
|
124
|
+
);`)
|
|
125
|
+
this.addSql(`create index "billing_runs_tenant_status_idx" on "billing_runs" ("tenant_id", "status", "started_at");`)
|
|
126
|
+
this.addSql(`create index "billing_runs_parent_idx" on "billing_runs" ("parent_run_id");`)
|
|
127
|
+
|
|
128
|
+
// ─── billing_run_outcomes ────────────────────────────────
|
|
129
|
+
this.addSql(`create table "billing_run_outcomes" (
|
|
130
|
+
"id" uuid not null default gen_random_uuid(),
|
|
131
|
+
"organization_id" uuid not null,
|
|
132
|
+
"tenant_id" uuid not null,
|
|
133
|
+
"bill_run_id" uuid not null,
|
|
134
|
+
"bill_account_id" uuid not null,
|
|
135
|
+
"status" text not null,
|
|
136
|
+
"error_message" text null,
|
|
137
|
+
"warnings" jsonb null,
|
|
138
|
+
"draft_invoice_id" uuid null,
|
|
139
|
+
"is_active" boolean not null default true,
|
|
140
|
+
"created_at" timestamptz not null,
|
|
141
|
+
"updated_at" timestamptz not null,
|
|
142
|
+
"deleted_at" timestamptz null,
|
|
143
|
+
constraint "billing_run_outcomes_pkey" primary key ("id")
|
|
144
|
+
);`)
|
|
145
|
+
this.addSql(`create index "billing_run_outcomes_run_status_idx" on "billing_run_outcomes" ("bill_run_id", "status");`)
|
|
146
|
+
this.addSql(`create index "billing_run_outcomes_account_history_idx" on "billing_run_outcomes" ("organization_id", "tenant_id", "bill_account_id", "created_at");`)
|
|
147
|
+
|
|
148
|
+
// ─── billing_draft_invoice_edits ─────────────────────────
|
|
149
|
+
this.addSql(`create table "billing_draft_invoice_edits" (
|
|
150
|
+
"id" uuid not null default gen_random_uuid(),
|
|
151
|
+
"organization_id" uuid not null,
|
|
152
|
+
"tenant_id" uuid not null,
|
|
153
|
+
"invoice_id" uuid not null,
|
|
154
|
+
"invoice_line_id" uuid null,
|
|
155
|
+
"user_id" uuid not null,
|
|
156
|
+
"action" text not null,
|
|
157
|
+
"before_json" jsonb null,
|
|
158
|
+
"after_json" jsonb null,
|
|
159
|
+
"created_at" timestamptz not null,
|
|
160
|
+
"deleted_at" timestamptz null,
|
|
161
|
+
constraint "billing_draft_invoice_edits_pkey" primary key ("id")
|
|
162
|
+
);`)
|
|
163
|
+
this.addSql(`create index "billing_draft_invoice_edits_invoice_idx" on "billing_draft_invoice_edits" ("organization_id", "tenant_id", "invoice_id", "created_at");`)
|
|
164
|
+
this.addSql(`create index "billing_draft_invoice_edits_user_idx" on "billing_draft_invoice_edits" ("organization_id", "tenant_id", "user_id", "created_at");`)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async down(): Promise<void> {
|
|
168
|
+
this.addSql(`drop table if exists "billing_draft_invoice_edits" cascade;`)
|
|
169
|
+
this.addSql(`drop table if exists "billing_run_outcomes" cascade;`)
|
|
170
|
+
this.addSql(`drop table if exists "billing_runs" cascade;`)
|
|
171
|
+
this.addSql(`drop table if exists "billing_account_usage" cascade;`)
|
|
172
|
+
this.addSql(`drop table if exists "billing_items" cascade;`)
|
|
173
|
+
this.addSql(`drop table if exists "billing_accounts" cascade;`)
|
|
174
|
+
}
|
|
175
|
+
}
|