@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,416 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import type { AwilixContainer } from 'awilix'
|
|
3
|
+
import {
|
|
4
|
+
BillingAccount,
|
|
5
|
+
BillingAccountUsage,
|
|
6
|
+
BillingItem,
|
|
7
|
+
BillRun,
|
|
8
|
+
BillRunOutcome,
|
|
9
|
+
} from '../data/entities'
|
|
10
|
+
import { advanceNextBillDate, deriveBillPeriod, isCycleDue } from './billPeriod'
|
|
11
|
+
import {
|
|
12
|
+
selectItemsForPeriod,
|
|
13
|
+
type SelectableItem,
|
|
14
|
+
} from './itemSelector'
|
|
15
|
+
import {
|
|
16
|
+
buildInvoiceLinesFromItems,
|
|
17
|
+
writeDraftInvoice,
|
|
18
|
+
type InvoiceLineDescriptor,
|
|
19
|
+
type WriteMode,
|
|
20
|
+
} from './invoiceWriter'
|
|
21
|
+
import { withTenantLock } from './tenantLock'
|
|
22
|
+
import { processUsageForAccount } from './usageRunner'
|
|
23
|
+
import type { BillCycle } from '../data/validators'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Bill Run engine — orchestrates one execution of the billing cycle for
|
|
27
|
+
* a single tenant.
|
|
28
|
+
*
|
|
29
|
+
* Concurrency / locking:
|
|
30
|
+
* - The whole run is wrapped in `withTenantLock(...)`, which holds a
|
|
31
|
+
* transaction-scoped Postgres advisory lock keyed by
|
|
32
|
+
* `hash(tenant_id || ':billing-run')`. A second trigger fired while a
|
|
33
|
+
* run is in flight short-circuits with `TenantLockBusyError` (the
|
|
34
|
+
* command layer maps that to HTTP 409).
|
|
35
|
+
* - Within the outer transaction, every account is processed inside a
|
|
36
|
+
* nested `em.transactional(...)` (PG SAVEPOINT). A per-account failure
|
|
37
|
+
* rolls back only that account's work, leaving siblings unaffected.
|
|
38
|
+
*
|
|
39
|
+
* Run modes:
|
|
40
|
+
* - `real` (default) — persist drafts, advance `next_bill_date`, mark
|
|
41
|
+
* items `billed_to_date`. Production path.
|
|
42
|
+
* - `test` — persist drafts flagged with `metadata.test_run=true`, but
|
|
43
|
+
* do NOT advance state. Repeatable.
|
|
44
|
+
* - `dry` — compute everything; persist nothing. Preview only.
|
|
45
|
+
*
|
|
46
|
+
* Catch-up:
|
|
47
|
+
* - Manual triggers may set `catchUp=true`. The engine then loops cycles
|
|
48
|
+
* per account until `next_bill_date > as_of_date`, emitting one draft
|
|
49
|
+
* per missed cycle (never combined).
|
|
50
|
+
* - Scheduled triggers always process exactly ONE cycle per account per
|
|
51
|
+
* run, regardless of how far behind the account is.
|
|
52
|
+
*
|
|
53
|
+
* Anti-duplicate guards (both run in every mode except `dry`):
|
|
54
|
+
* 1. Open-draft check on `core/sales` invoice (per period boundary).
|
|
55
|
+
* 2. `billed_to_date` per BillingItem.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
export type RunMode = WriteMode
|
|
59
|
+
|
|
60
|
+
export type RunBillRunParams = {
|
|
61
|
+
tenantId: string
|
|
62
|
+
organizationId: string
|
|
63
|
+
asOfDate: Date
|
|
64
|
+
triggeredBy: 'schedule' | 'manual'
|
|
65
|
+
mode: RunMode
|
|
66
|
+
scopedAccountIds?: string[]
|
|
67
|
+
parentRunId?: string | null
|
|
68
|
+
catchUp?: boolean
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type RunBillRunResult = {
|
|
72
|
+
billRun: BillRun
|
|
73
|
+
outcomes: BillRunOutcome[]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type AccountWarnings = {
|
|
77
|
+
currency_mismatch_items?: string[]
|
|
78
|
+
unmatched_usage_uoms?: string[]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type AccountProcessSuccess = {
|
|
82
|
+
status: 'success' | 'success_with_warnings' | 'skipped_existing_draft'
|
|
83
|
+
warnings: AccountWarnings | null
|
|
84
|
+
draftInvoiceId: string | null
|
|
85
|
+
cyclesEmitted: number
|
|
86
|
+
usageRecordsRated: number
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type AccountProcessFailed = {
|
|
90
|
+
status: 'failed'
|
|
91
|
+
errorMessage: string
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
type AccountProcessResult = AccountProcessSuccess | AccountProcessFailed
|
|
95
|
+
|
|
96
|
+
const SCHEDULED_CYCLE_LIMIT = 1
|
|
97
|
+
const CATCH_UP_HARD_CAP = 60 // safety net so a misconfigured account can't loop forever
|
|
98
|
+
|
|
99
|
+
export async function runBillRun(
|
|
100
|
+
em: EntityManager,
|
|
101
|
+
container: AwilixContainer,
|
|
102
|
+
params: RunBillRunParams,
|
|
103
|
+
): Promise<RunBillRunResult> {
|
|
104
|
+
return withTenantLock(
|
|
105
|
+
em,
|
|
106
|
+
{ tenantId: params.tenantId, lockName: 'billing-run' },
|
|
107
|
+
async (tem) => executeRunWithinLock(tem, container, params),
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function executeRunWithinLock(
|
|
112
|
+
em: EntityManager,
|
|
113
|
+
container: AwilixContainer,
|
|
114
|
+
params: RunBillRunParams,
|
|
115
|
+
): Promise<RunBillRunResult> {
|
|
116
|
+
const now = new Date()
|
|
117
|
+
const billRun = em.create(BillRun, {
|
|
118
|
+
organizationId: params.organizationId,
|
|
119
|
+
tenantId: params.tenantId,
|
|
120
|
+
triggeredBy: params.triggeredBy,
|
|
121
|
+
parentRunId: params.parentRunId ?? null,
|
|
122
|
+
dryRun: params.mode === 'dry',
|
|
123
|
+
testMode: params.mode === 'test',
|
|
124
|
+
catchUp: Boolean(params.catchUp),
|
|
125
|
+
scopedAccountIds: params.scopedAccountIds ?? null,
|
|
126
|
+
asOfDate: params.asOfDate,
|
|
127
|
+
startedAt: now,
|
|
128
|
+
status: 'running',
|
|
129
|
+
summary: null,
|
|
130
|
+
isActive: true,
|
|
131
|
+
createdAt: now,
|
|
132
|
+
updatedAt: now,
|
|
133
|
+
})
|
|
134
|
+
em.persist(billRun)
|
|
135
|
+
await em.flush()
|
|
136
|
+
|
|
137
|
+
const accounts = await selectTargetAccounts(em, params)
|
|
138
|
+
const outcomes: BillRunOutcome[] = []
|
|
139
|
+
let totalUsageRecordsRated = 0
|
|
140
|
+
|
|
141
|
+
for (const account of accounts) {
|
|
142
|
+
const processResult = await processAccountSafely(em, container, billRun, account, params)
|
|
143
|
+
if (processResult.status !== 'failed') {
|
|
144
|
+
totalUsageRecordsRated += processResult.usageRecordsRated
|
|
145
|
+
}
|
|
146
|
+
const outcome = em.create(BillRunOutcome, {
|
|
147
|
+
organizationId: params.organizationId,
|
|
148
|
+
tenantId: params.tenantId,
|
|
149
|
+
billRunId: billRun.id,
|
|
150
|
+
billAccountId: account.id,
|
|
151
|
+
status: processResult.status,
|
|
152
|
+
errorMessage: processResult.status === 'failed' ? processResult.errorMessage : null,
|
|
153
|
+
warnings:
|
|
154
|
+
processResult.status === 'failed'
|
|
155
|
+
? null
|
|
156
|
+
: (processResult.warnings as Record<string, unknown> | null),
|
|
157
|
+
draftInvoiceId: processResult.status === 'failed' ? null : processResult.draftInvoiceId,
|
|
158
|
+
isActive: true,
|
|
159
|
+
createdAt: new Date(),
|
|
160
|
+
updatedAt: new Date(),
|
|
161
|
+
})
|
|
162
|
+
em.persist(outcome)
|
|
163
|
+
await em.flush()
|
|
164
|
+
outcomes.push(outcome)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
billRun.status = computeFinalRunStatus(outcomes)
|
|
168
|
+
billRun.finishedAt = new Date()
|
|
169
|
+
billRun.updatedAt = billRun.finishedAt
|
|
170
|
+
billRun.summary = buildRunSummary(outcomes, accounts.length, totalUsageRecordsRated)
|
|
171
|
+
await em.flush()
|
|
172
|
+
|
|
173
|
+
return { billRun, outcomes }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function selectTargetAccounts(
|
|
177
|
+
em: EntityManager,
|
|
178
|
+
params: RunBillRunParams,
|
|
179
|
+
): Promise<BillingAccount[]> {
|
|
180
|
+
const where: Record<string, unknown> = {
|
|
181
|
+
tenantId: params.tenantId,
|
|
182
|
+
organizationId: params.organizationId,
|
|
183
|
+
deletedAt: null,
|
|
184
|
+
nextBillDate: { $lte: params.asOfDate },
|
|
185
|
+
}
|
|
186
|
+
if (params.scopedAccountIds && params.scopedAccountIds.length > 0) {
|
|
187
|
+
where.id = { $in: params.scopedAccountIds }
|
|
188
|
+
}
|
|
189
|
+
return em.find(BillingAccount, where as never)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function processAccountSafely(
|
|
193
|
+
em: EntityManager,
|
|
194
|
+
container: AwilixContainer,
|
|
195
|
+
billRun: BillRun,
|
|
196
|
+
account: BillingAccount,
|
|
197
|
+
params: RunBillRunParams,
|
|
198
|
+
): Promise<AccountProcessResult> {
|
|
199
|
+
try {
|
|
200
|
+
return await em.transactional(async (subEm) => {
|
|
201
|
+
return processAccount(subEm, container, billRun, account, params)
|
|
202
|
+
})
|
|
203
|
+
} catch (err) {
|
|
204
|
+
return {
|
|
205
|
+
status: 'failed',
|
|
206
|
+
errorMessage: err instanceof Error ? err.message : String(err),
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function processAccount(
|
|
212
|
+
em: EntityManager,
|
|
213
|
+
container: AwilixContainer,
|
|
214
|
+
billRun: BillRun,
|
|
215
|
+
account: BillingAccount,
|
|
216
|
+
params: RunBillRunParams,
|
|
217
|
+
): Promise<AccountProcessResult> {
|
|
218
|
+
const maxCycles =
|
|
219
|
+
params.triggeredBy === 'schedule' || !params.catchUp
|
|
220
|
+
? SCHEDULED_CYCLE_LIMIT
|
|
221
|
+
: CATCH_UP_HARD_CAP
|
|
222
|
+
|
|
223
|
+
let cyclesEmitted = 0
|
|
224
|
+
let lastDraftInvoiceId: string | null = null
|
|
225
|
+
let totalUsageRatedThisAccount = 0
|
|
226
|
+
const aggregatedWarnings: AccountWarnings = {}
|
|
227
|
+
|
|
228
|
+
while (cyclesEmitted < maxCycles && isCycleDue(account.nextBillDate, params.asOfDate)) {
|
|
229
|
+
const period = deriveBillPeriod(account.nextBillDate, account.billCycle as BillCycle)
|
|
230
|
+
|
|
231
|
+
// Open-draft anti-duplicate — only applies when we'd actually persist.
|
|
232
|
+
if (params.mode !== 'dry') {
|
|
233
|
+
const existing = await findExistingDraftForPeriod(em, account, period)
|
|
234
|
+
if (existing) {
|
|
235
|
+
return {
|
|
236
|
+
status: 'skipped_existing_draft',
|
|
237
|
+
warnings: null,
|
|
238
|
+
draftInvoiceId: existing,
|
|
239
|
+
cyclesEmitted,
|
|
240
|
+
usageRecordsRated: totalUsageRatedThisAccount,
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const items = await em.find(BillingItem, {
|
|
246
|
+
tenantId: account.tenantId,
|
|
247
|
+
organizationId: account.organizationId,
|
|
248
|
+
billAccountId: account.id,
|
|
249
|
+
deletedAt: null,
|
|
250
|
+
} as never)
|
|
251
|
+
|
|
252
|
+
const usageItems = (items as BillingItem[]).filter((it) => it.type === 'usage')
|
|
253
|
+
|
|
254
|
+
// Non-usage path: one_time + recurring filtered by the selector.
|
|
255
|
+
const { included } = selectItemsForPeriod(items as SelectableItem[], period)
|
|
256
|
+
const billableItems = included as unknown as BillingItem[]
|
|
257
|
+
|
|
258
|
+
const currencyMismatchIds = billableItems
|
|
259
|
+
.filter((it) => it.currencyMismatch)
|
|
260
|
+
.map((it) => it.id)
|
|
261
|
+
if (currencyMismatchIds.length > 0) {
|
|
262
|
+
aggregatedWarnings.currency_mismatch_items = [
|
|
263
|
+
...(aggregatedWarnings.currency_mismatch_items ?? []),
|
|
264
|
+
...currencyMismatchIds,
|
|
265
|
+
]
|
|
266
|
+
}
|
|
267
|
+
const itemLines = buildInvoiceLinesFromItems(billableItems)
|
|
268
|
+
|
|
269
|
+
// Usage path: aggregate matching usage records per uom_code, rate,
|
|
270
|
+
// emit one line per matched usage item, warn about unmatched UoMs.
|
|
271
|
+
const usageResult = await processUsageForAccount(em, account, period, usageItems)
|
|
272
|
+
if (usageResult.unmatchedUoms.length > 0) {
|
|
273
|
+
aggregatedWarnings.unmatched_usage_uoms = [
|
|
274
|
+
...(aggregatedWarnings.unmatched_usage_uoms ?? []),
|
|
275
|
+
...usageResult.unmatchedUoms,
|
|
276
|
+
]
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const allLines: InvoiceLineDescriptor[] = [...itemLines, ...usageResult.lines]
|
|
280
|
+
if (allLines.length === 0) {
|
|
281
|
+
// Nothing to bill this cycle — still advance so the account moves
|
|
282
|
+
// forward in real mode. In test / dry, no advance, so we'd loop
|
|
283
|
+
// forever; break out.
|
|
284
|
+
if (params.mode !== 'real') break
|
|
285
|
+
account.nextBillDate = advanceNextBillDate(
|
|
286
|
+
account.nextBillDate,
|
|
287
|
+
account.billCycle as BillCycle,
|
|
288
|
+
)
|
|
289
|
+
account.lastBillDate = period.periodEnd
|
|
290
|
+
account.updatedAt = new Date()
|
|
291
|
+
cyclesEmitted += 1
|
|
292
|
+
continue
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const written = await writeDraftInvoice(em, container, {
|
|
296
|
+
mode: params.mode,
|
|
297
|
+
account,
|
|
298
|
+
period,
|
|
299
|
+
billRunId: billRun.id,
|
|
300
|
+
lines: allLines,
|
|
301
|
+
})
|
|
302
|
+
lastDraftInvoiceId = written.invoiceId
|
|
303
|
+
|
|
304
|
+
if (params.mode === 'real') {
|
|
305
|
+
const now = new Date()
|
|
306
|
+
for (const item of billableItems) {
|
|
307
|
+
item.billedToDate = period.periodEnd
|
|
308
|
+
item.updatedAt = now
|
|
309
|
+
}
|
|
310
|
+
// Mark consumed usage records — single bulk update so we don't
|
|
311
|
+
// round-trip per record on accounts with thousands of usage rows.
|
|
312
|
+
if (usageResult.matchedUoms.length > 0) {
|
|
313
|
+
// Predicate-based bulk update: marks every still-unrated record
|
|
314
|
+
// for the matched uoms in the account's period with a single
|
|
315
|
+
// SQL UPDATE — no id list ever leaves Postgres. Stays
|
|
316
|
+
// memory-bounded even for telco accounts with millions of
|
|
317
|
+
// usage records per cycle (spec Performance Considerations
|
|
318
|
+
// "Shape B").
|
|
319
|
+
await em.nativeUpdate(
|
|
320
|
+
BillingAccountUsage,
|
|
321
|
+
{
|
|
322
|
+
tenantId: account.tenantId,
|
|
323
|
+
organizationId: account.organizationId,
|
|
324
|
+
billAccountId: account.id,
|
|
325
|
+
uomCode: { $in: usageResult.matchedUoms },
|
|
326
|
+
ratedInBillRunId: null,
|
|
327
|
+
periodEnd: { $lte: period.periodEnd },
|
|
328
|
+
deletedAt: null,
|
|
329
|
+
} as never,
|
|
330
|
+
{ ratedInBillRunId: billRun.id, updatedAt: now } as never,
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
totalUsageRatedThisAccount += usageResult.ratedCount
|
|
334
|
+
account.nextBillDate = advanceNextBillDate(
|
|
335
|
+
account.nextBillDate,
|
|
336
|
+
account.billCycle as BillCycle,
|
|
337
|
+
)
|
|
338
|
+
account.lastBillDate = period.periodEnd
|
|
339
|
+
account.updatedAt = now
|
|
340
|
+
await em.flush()
|
|
341
|
+
} else {
|
|
342
|
+
// Test / dry: do not advance state. One iteration only.
|
|
343
|
+
break
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
cyclesEmitted += 1
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const hasCurrencyWarnings =
|
|
350
|
+
(aggregatedWarnings.currency_mismatch_items ?? []).length > 0
|
|
351
|
+
const hasUnmatchedUsage =
|
|
352
|
+
(aggregatedWarnings.unmatched_usage_uoms ?? []).length > 0
|
|
353
|
+
const hasWarnings = hasCurrencyWarnings || hasUnmatchedUsage
|
|
354
|
+
return {
|
|
355
|
+
status: hasWarnings ? 'success_with_warnings' : 'success',
|
|
356
|
+
warnings: hasWarnings ? aggregatedWarnings : null,
|
|
357
|
+
draftInvoiceId: lastDraftInvoiceId,
|
|
358
|
+
cyclesEmitted,
|
|
359
|
+
usageRecordsRated: totalUsageRatedThisAccount,
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function findExistingDraftForPeriod(
|
|
364
|
+
em: EntityManager,
|
|
365
|
+
account: BillingAccount,
|
|
366
|
+
period: { periodStart: Date; periodEnd: Date },
|
|
367
|
+
): Promise<string | null> {
|
|
368
|
+
const periodStartISO = period.periodStart.toISOString().slice(0, 10)
|
|
369
|
+
const periodEndISO = period.periodEnd.toISOString().slice(0, 10)
|
|
370
|
+
const rows = (await em.execute(
|
|
371
|
+
`SELECT id FROM sales_invoices
|
|
372
|
+
WHERE tenant_id = ?
|
|
373
|
+
AND organization_id = ?
|
|
374
|
+
AND deleted_at IS NULL
|
|
375
|
+
AND status = 'draft'
|
|
376
|
+
AND metadata->>'bill_account_id' = ?
|
|
377
|
+
AND metadata->>'bill_period_start' = ?
|
|
378
|
+
AND metadata->>'bill_period_end' = ?
|
|
379
|
+
LIMIT 1`,
|
|
380
|
+
[account.tenantId, account.organizationId, account.id, periodStartISO, periodEndISO],
|
|
381
|
+
)) as unknown as Array<{ id: string }> | undefined
|
|
382
|
+
if (!rows || rows.length === 0) return null
|
|
383
|
+
return rows[0].id ?? null
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function computeFinalRunStatus(
|
|
387
|
+
outcomes: readonly BillRunOutcome[],
|
|
388
|
+
): BillRun['status'] {
|
|
389
|
+
if (outcomes.length === 0) return 'completed'
|
|
390
|
+
const anyFailed = outcomes.some((o) => o.status === 'failed')
|
|
391
|
+
const anySuccess = outcomes.some(
|
|
392
|
+
(o) => o.status === 'success' || o.status === 'success_with_warnings',
|
|
393
|
+
)
|
|
394
|
+
if (anyFailed && anySuccess) return 'partial_failure'
|
|
395
|
+
if (anyFailed) return 'partial_failure'
|
|
396
|
+
return 'completed'
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function buildRunSummary(
|
|
400
|
+
outcomes: readonly BillRunOutcome[],
|
|
401
|
+
totalAccounts: number,
|
|
402
|
+
usageRecordsRated: number,
|
|
403
|
+
): Record<string, unknown> {
|
|
404
|
+
return {
|
|
405
|
+
accounts_processed: totalAccounts,
|
|
406
|
+
drafts_created: outcomes.filter(
|
|
407
|
+
(o) => o.draftInvoiceId !== null && o.status !== 'skipped_existing_draft',
|
|
408
|
+
).length,
|
|
409
|
+
drafts_skipped_existing: outcomes.filter((o) => o.status === 'skipped_existing_draft')
|
|
410
|
+
.length,
|
|
411
|
+
accounts_failed: outcomes.filter((o) => o.status === 'failed').length,
|
|
412
|
+
accounts_with_warnings: outcomes.filter((o) => o.status === 'success_with_warnings')
|
|
413
|
+
.length,
|
|
414
|
+
usage_records_rated: usageRecordsRated,
|
|
415
|
+
}
|
|
416
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-readable label for a Bill Run.
|
|
3
|
+
*
|
|
4
|
+
* The BillRun entity has no `name` column — the engine keys runs by id.
|
|
5
|
+
* The admin UI derives a stable, scannable label from the run's as-of
|
|
6
|
+
* date so operators see `BILL_RUN_2026-05-20` instead of a raw UUID.
|
|
7
|
+
*/
|
|
8
|
+
export function billRunName(asOfDate: string | null | undefined): string {
|
|
9
|
+
const date =
|
|
10
|
+
typeof asOfDate === 'string' && asOfDate.length >= 10
|
|
11
|
+
? asOfDate.slice(0, 10)
|
|
12
|
+
: 'unknown'
|
|
13
|
+
return `BILL_RUN_${date}`
|
|
14
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `source_ref` idempotency helper.
|
|
5
|
+
*
|
|
6
|
+
* The unique index on `(tenant_id, bill_account_id, source_ref)` does the
|
|
7
|
+
* heavy lifting at the database level — Postgres treats NULLs as distinct,
|
|
8
|
+
* so multiple NULL `source_ref` values coexist for the same account. This
|
|
9
|
+
* helper checks for an existing row *before* the insert so we can return
|
|
10
|
+
* it (HTTP 200 with `deduplicated: true`) instead of taking the round-trip
|
|
11
|
+
* to the DB and unwinding a unique-violation.
|
|
12
|
+
*
|
|
13
|
+
* Why not rely solely on the unique constraint and catch the conflict?
|
|
14
|
+
* Two reasons:
|
|
15
|
+
* 1. Catching the conflict requires a SAVEPOINT or a separate tx —
|
|
16
|
+
* complicates error handling and rolls back the surrounding work.
|
|
17
|
+
* 2. Idempotency is also a read-side guarantee: if a connector POSTs
|
|
18
|
+
* twice, the *second* call should observe what the first one
|
|
19
|
+
* persisted, not just "duplicate, sorry."
|
|
20
|
+
*
|
|
21
|
+
* The constraint stays in place as the *correctness* gate — if a race
|
|
22
|
+
* sneaks past this check, the DB still refuses the duplicate.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
type SourceRefScope = {
|
|
26
|
+
tenantId: string
|
|
27
|
+
billAccountId: string
|
|
28
|
+
sourceRef: string
|
|
29
|
+
deletedAt?: null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type EntityCtor<T> = new (...args: never[]) => T
|
|
33
|
+
|
|
34
|
+
export async function findBySourceRef<T extends { sourceRef?: string | null }>(
|
|
35
|
+
em: EntityManager,
|
|
36
|
+
entity: EntityCtor<T>,
|
|
37
|
+
scope: { tenantId: string; billAccountId: string; sourceRef: string },
|
|
38
|
+
): Promise<T | null> {
|
|
39
|
+
const where: SourceRefScope = {
|
|
40
|
+
tenantId: scope.tenantId,
|
|
41
|
+
billAccountId: scope.billAccountId,
|
|
42
|
+
sourceRef: scope.sourceRef,
|
|
43
|
+
deletedAt: null,
|
|
44
|
+
}
|
|
45
|
+
return em.findOne(entity, where as never)
|
|
46
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import {
|
|
3
|
+
Dictionary,
|
|
4
|
+
DictionaryEntry,
|
|
5
|
+
} from '@open-mercato/core/modules/dictionaries/data/entities'
|
|
6
|
+
import type { InvoiceStatus } from '../data/validators'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolve the `sales.invoice_status` dictionary entry id for a given
|
|
10
|
+
* status value, scoped per `(tenantId, organizationId)`.
|
|
11
|
+
*
|
|
12
|
+
* Used by:
|
|
13
|
+
* - `invoiceWriter` to set `statusEntryId` on the draft at create time.
|
|
14
|
+
* - The "post" command to flip draft → posted.
|
|
15
|
+
* - The payment-captured subscriber to flip posted → paid.
|
|
16
|
+
*
|
|
17
|
+
* Cached because the lookup is hot (every Bill Run draft hits it
|
|
18
|
+
* once, every posted invoice hits it twice, every paid invoice once)
|
|
19
|
+
* and the dictionary entry id never changes for the lifetime of a
|
|
20
|
+
* tenant. Exposed `__resetInvoiceStatusCacheForTests` to keep tests
|
|
21
|
+
* deterministic across runs.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const SALES_INVOICE_STATUS_DICTIONARY_KEY = 'sales.invoice_status'
|
|
25
|
+
|
|
26
|
+
const cache = new Map<string, string>()
|
|
27
|
+
|
|
28
|
+
function key(tenantId: string, organizationId: string, status: InvoiceStatus): string {
|
|
29
|
+
return `${tenantId}:${organizationId}:${status}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function resolveInvoiceStatusEntryId(
|
|
33
|
+
em: EntityManager,
|
|
34
|
+
tenantId: string,
|
|
35
|
+
organizationId: string,
|
|
36
|
+
status: InvoiceStatus,
|
|
37
|
+
): Promise<string | null> {
|
|
38
|
+
const cacheKey = key(tenantId, organizationId, status)
|
|
39
|
+
const cached = cache.get(cacheKey)
|
|
40
|
+
if (cached) return cached
|
|
41
|
+
const dictionary = await em.findOne(Dictionary, {
|
|
42
|
+
tenantId,
|
|
43
|
+
organizationId,
|
|
44
|
+
key: SALES_INVOICE_STATUS_DICTIONARY_KEY,
|
|
45
|
+
deletedAt: null,
|
|
46
|
+
})
|
|
47
|
+
if (!dictionary) return null
|
|
48
|
+
const entry = await em.findOne(DictionaryEntry, {
|
|
49
|
+
dictionary,
|
|
50
|
+
tenantId,
|
|
51
|
+
organizationId,
|
|
52
|
+
value: status,
|
|
53
|
+
})
|
|
54
|
+
if (!entry) return null
|
|
55
|
+
cache.set(cacheKey, entry.id)
|
|
56
|
+
return entry.id
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function __resetInvoiceStatusCacheForTests(): void {
|
|
60
|
+
cache.clear()
|
|
61
|
+
}
|