@dainamite/billing 0.17.0 → 0.19.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 +99 -19
- package/dist/modules/billing/acl.js +9 -1
- package/dist/modules/billing/acl.js.map +2 -2
- package/dist/modules/billing/api/accounts/[id]/balance/route.js +62 -0
- package/dist/modules/billing/api/accounts/[id]/balance/route.js.map +7 -0
- package/dist/modules/billing/api/accounts/[id]/credit-status/route.js +56 -0
- package/dist/modules/billing/api/accounts/[id]/credit-status/route.js.map +7 -0
- package/dist/modules/billing/api/accounts/[id]/topups/route.js +36 -0
- package/dist/modules/billing/api/accounts/[id]/topups/route.js.map +7 -0
- package/dist/modules/billing/api/accounts/route.js +7 -0
- package/dist/modules/billing/api/accounts/route.js.map +2 -2
- package/dist/modules/billing/api/export/account/[id]/route.js +48 -3
- package/dist/modules/billing/api/export/account/[id]/route.js.map +2 -2
- package/dist/modules/billing/api/statements/generate/route.js +68 -0
- package/dist/modules/billing/api/statements/generate/route.js.map +7 -0
- package/dist/modules/billing/api/statements/route.js +110 -0
- package/dist/modules/billing/api/statements/route.js.map +7 -0
- package/dist/modules/billing/api/topups/route.js +139 -0
- package/dist/modules/billing/api/topups/route.js.map +7 -0
- package/dist/modules/billing/api/transactions/adjust/route.js +71 -0
- package/dist/modules/billing/api/transactions/adjust/route.js.map +7 -0
- package/dist/modules/billing/api/transactions/route.js +109 -0
- package/dist/modules/billing/api/transactions/route.js.map +7 -0
- package/dist/modules/billing/api/usage/route.js +30 -6
- package/dist/modules/billing/api/usage/route.js.map +2 -2
- package/dist/modules/billing/backend/billing/accounts/[id]/page.js +16 -1
- package/dist/modules/billing/backend/billing/accounts/[id]/page.js.map +2 -2
- package/dist/modules/billing/backend/billing/accounts/create/page.js +8 -1
- package/dist/modules/billing/backend/billing/accounts/create/page.js.map +2 -2
- package/dist/modules/billing/backend/billing/accounts/page.js +26 -1
- package/dist/modules/billing/backend/billing/accounts/page.js.map +2 -2
- package/dist/modules/billing/backend/billing/statements/[id]/page.js +129 -0
- package/dist/modules/billing/backend/billing/statements/[id]/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/statements/[id]/page.meta.js +21 -0
- package/dist/modules/billing/backend/billing/statements/[id]/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/statements/page.js +152 -0
- package/dist/modules/billing/backend/billing/statements/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/statements/page.meta.js +18 -0
- package/dist/modules/billing/backend/billing/statements/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/topups/[id]/page.js +90 -0
- package/dist/modules/billing/backend/billing/topups/[id]/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/topups/[id]/page.meta.js +21 -0
- package/dist/modules/billing/backend/billing/topups/[id]/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/topups/page.js +147 -0
- package/dist/modules/billing/backend/billing/topups/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/topups/page.meta.js +18 -0
- package/dist/modules/billing/backend/billing/topups/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/transactions/[id]/page.js +114 -0
- package/dist/modules/billing/backend/billing/transactions/[id]/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/transactions/[id]/page.meta.js +21 -0
- package/dist/modules/billing/backend/billing/transactions/[id]/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/transactions/page.js +149 -0
- package/dist/modules/billing/backend/billing/transactions/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/transactions/page.meta.js +18 -0
- package/dist/modules/billing/backend/billing/transactions/page.meta.js.map +7 -0
- package/dist/modules/billing/commands/accounts.js +26 -1
- package/dist/modules/billing/commands/accounts.js.map +2 -2
- package/dist/modules/billing/commands/index.js +3 -0
- package/dist/modules/billing/commands/index.js.map +2 -2
- package/dist/modules/billing/commands/invoices.js +21 -1
- package/dist/modules/billing/commands/invoices.js.map +2 -2
- package/dist/modules/billing/commands/statements.js +74 -0
- package/dist/modules/billing/commands/statements.js.map +7 -0
- package/dist/modules/billing/commands/topups.js +139 -0
- package/dist/modules/billing/commands/topups.js.map +7 -0
- package/dist/modules/billing/commands/transactions.js +119 -0
- package/dist/modules/billing/commands/transactions.js.map +7 -0
- package/dist/modules/billing/commands/usage.js +72 -0
- package/dist/modules/billing/commands/usage.js.map +2 -2
- package/dist/modules/billing/components/AccountForm.js +53 -1
- package/dist/modules/billing/components/AccountForm.js.map +2 -2
- package/dist/modules/billing/components/PrepaidPanel.js +314 -0
- package/dist/modules/billing/components/PrepaidPanel.js.map +7 -0
- package/dist/modules/billing/components/accountNames.js +20 -0
- package/dist/modules/billing/components/accountNames.js.map +7 -0
- package/dist/modules/billing/data/entities.js +290 -0
- package/dist/modules/billing/data/entities.js.map +2 -2
- package/dist/modules/billing/data/entityIds.js +6 -1
- package/dist/modules/billing/data/entityIds.js.map +2 -2
- package/dist/modules/billing/data/validators.js +107 -2
- package/dist/modules/billing/data/validators.js.map +2 -2
- package/dist/modules/billing/events.js +52 -0
- package/dist/modules/billing/events.js.map +2 -2
- package/dist/modules/billing/i18n/en.json +112 -4
- package/dist/modules/billing/i18n/pl.json +112 -4
- package/dist/modules/billing/index.js +1 -1
- package/dist/modules/billing/index.js.map +2 -2
- package/dist/modules/billing/lib/accountStatus.js +106 -0
- package/dist/modules/billing/lib/accountStatus.js.map +7 -0
- package/dist/modules/billing/lib/balanceEvents.js +48 -0
- package/dist/modules/billing/lib/balanceEvents.js.map +7 -0
- package/dist/modules/billing/lib/balanceLedger.js +136 -0
- package/dist/modules/billing/lib/balanceLedger.js.map +7 -0
- package/dist/modules/billing/lib/balanceStatus.js +29 -0
- package/dist/modules/billing/lib/balanceStatus.js.map +7 -0
- package/dist/modules/billing/lib/billRunEngine.js +40 -2
- package/dist/modules/billing/lib/billRunEngine.js.map +2 -2
- package/dist/modules/billing/lib/creditStatus.js +65 -0
- package/dist/modules/billing/lib/creditStatus.js.map +7 -0
- package/dist/modules/billing/lib/money.js +47 -0
- package/dist/modules/billing/lib/money.js.map +7 -0
- package/dist/modules/billing/lib/prepaidConfig.js +45 -0
- package/dist/modules/billing/lib/prepaidConfig.js.map +7 -0
- package/dist/modules/billing/lib/prepaidConsume.js +214 -0
- package/dist/modules/billing/lib/prepaidConsume.js.map +7 -0
- package/dist/modules/billing/lib/prepaidPeriodClose.js +285 -0
- package/dist/modules/billing/lib/prepaidPeriodClose.js.map +7 -0
- package/dist/modules/billing/lib/topupCapture.js +62 -0
- package/dist/modules/billing/lib/topupCapture.js.map +7 -0
- package/dist/modules/billing/lib/topupReceipt.js +97 -0
- package/dist/modules/billing/lib/topupReceipt.js.map +7 -0
- package/dist/modules/billing/lib/topupTransition.js +19 -0
- package/dist/modules/billing/lib/topupTransition.js.map +7 -0
- package/dist/modules/billing/migrations/Migration20260619000000_billing_prepaid.js +110 -0
- package/dist/modules/billing/migrations/Migration20260619000000_billing_prepaid.js.map +7 -0
- package/dist/modules/billing/setup.js +32 -1
- package/dist/modules/billing/setup.js.map +2 -2
- package/dist/modules/billing/subscribers/prepaid-topup-cancelled.js +26 -0
- package/dist/modules/billing/subscribers/prepaid-topup-cancelled.js.map +7 -0
- package/dist/modules/billing/subscribers/prepaid-topup-captured.js +87 -0
- package/dist/modules/billing/subscribers/prepaid-topup-captured.js.map +7 -0
- package/dist/modules/billing/subscribers/prepaid-topup-expired.js +26 -0
- package/dist/modules/billing/subscribers/prepaid-topup-expired.js.map +7 -0
- package/dist/modules/billing/subscribers/prepaid-topup-failed.js +26 -0
- package/dist/modules/billing/subscribers/prepaid-topup-failed.js.map +7 -0
- package/dist/modules/billing/workers/reconcile-prepaid-balances.js +50 -0
- package/dist/modules/billing/workers/reconcile-prepaid-balances.js.map +7 -0
- package/package.json +9 -9
- package/src/modules/billing/__tests__/commands/invoices.test.ts +3 -3
- package/src/modules/billing/__tests__/commands/topups.test.ts +104 -0
- package/src/modules/billing/__tests__/commands/transactions.test.ts +107 -0
- package/src/modules/billing/__tests__/helpers/prepaidMockEm.ts +235 -0
- package/src/modules/billing/__tests__/lib/accountStatus.test.ts +93 -0
- package/src/modules/billing/__tests__/lib/balanceLedger.test.ts +125 -0
- package/src/modules/billing/__tests__/lib/balanceStatus.test.ts +65 -0
- package/src/modules/billing/__tests__/lib/creditStatus.test.ts +111 -0
- package/src/modules/billing/__tests__/lib/money.test.ts +56 -0
- package/src/modules/billing/__tests__/lib/prepaidConsume.test.ts +179 -0
- package/src/modules/billing/__tests__/lib/prepaidPeriodClose.test.ts +232 -0
- package/src/modules/billing/__tests__/lib/topupCapture.test.ts +123 -0
- package/src/modules/billing/__tests__/lib/topupReceipt.test.ts +84 -0
- package/src/modules/billing/__tests__/setup.test.ts +28 -2
- package/src/modules/billing/acl.ts +8 -0
- package/src/modules/billing/api/accounts/[id]/balance/route.ts +74 -0
- package/src/modules/billing/api/accounts/[id]/credit-status/route.ts +71 -0
- package/src/modules/billing/api/accounts/[id]/topups/route.ts +49 -0
- package/src/modules/billing/api/accounts/route.ts +7 -0
- package/src/modules/billing/api/export/account/[id]/route.ts +47 -2
- package/src/modules/billing/api/statements/generate/route.ts +78 -0
- package/src/modules/billing/api/statements/route.ts +117 -0
- package/src/modules/billing/api/topups/route.ts +154 -0
- package/src/modules/billing/api/transactions/adjust/route.ts +80 -0
- package/src/modules/billing/api/transactions/route.ts +116 -0
- package/src/modules/billing/api/usage/route.ts +36 -5
- package/src/modules/billing/backend/billing/accounts/[id]/page.tsx +28 -0
- package/src/modules/billing/backend/billing/accounts/create/page.tsx +7 -0
- package/src/modules/billing/backend/billing/accounts/page.tsx +32 -1
- package/src/modules/billing/backend/billing/statements/[id]/page.meta.ts +19 -0
- package/src/modules/billing/backend/billing/statements/[id]/page.tsx +198 -0
- package/src/modules/billing/backend/billing/statements/page.meta.ts +16 -0
- package/src/modules/billing/backend/billing/statements/page.tsx +190 -0
- package/src/modules/billing/backend/billing/topups/[id]/page.meta.ts +19 -0
- package/src/modules/billing/backend/billing/topups/[id]/page.tsx +151 -0
- package/src/modules/billing/backend/billing/topups/page.meta.ts +16 -0
- package/src/modules/billing/backend/billing/topups/page.tsx +182 -0
- package/src/modules/billing/backend/billing/transactions/[id]/page.meta.ts +19 -0
- package/src/modules/billing/backend/billing/transactions/[id]/page.tsx +179 -0
- package/src/modules/billing/backend/billing/transactions/page.meta.ts +16 -0
- package/src/modules/billing/backend/billing/transactions/page.tsx +187 -0
- package/src/modules/billing/commands/accounts.ts +42 -1
- package/src/modules/billing/commands/index.ts +3 -0
- package/src/modules/billing/commands/invoices.ts +26 -2
- package/src/modules/billing/commands/statements.ts +101 -0
- package/src/modules/billing/commands/topups.ts +181 -0
- package/src/modules/billing/commands/transactions.ts +152 -0
- package/src/modules/billing/commands/usage.ts +114 -4
- package/src/modules/billing/components/AccountForm.tsx +66 -0
- package/src/modules/billing/components/PrepaidPanel.tsx +382 -0
- package/src/modules/billing/components/accountNames.ts +23 -0
- package/src/modules/billing/data/entities.ts +383 -0
- package/src/modules/billing/data/entityIds.ts +5 -0
- package/src/modules/billing/data/validators.ts +144 -0
- package/src/modules/billing/events.ts +52 -0
- package/src/modules/billing/i18n/en.json +112 -4
- package/src/modules/billing/i18n/pl.json +112 -4
- package/src/modules/billing/index.ts +1 -1
- package/src/modules/billing/lib/accountStatus.ts +155 -0
- package/src/modules/billing/lib/balanceEvents.ts +74 -0
- package/src/modules/billing/lib/balanceLedger.ts +258 -0
- package/src/modules/billing/lib/balanceStatus.ts +78 -0
- package/src/modules/billing/lib/billRunEngine.ts +66 -5
- package/src/modules/billing/lib/creditStatus.ts +153 -0
- package/src/modules/billing/lib/money.ts +73 -0
- package/src/modules/billing/lib/prepaidConfig.ts +58 -0
- package/src/modules/billing/lib/prepaidConsume.ts +299 -0
- package/src/modules/billing/lib/prepaidPeriodClose.ts +453 -0
- package/src/modules/billing/lib/topupCapture.ts +117 -0
- package/src/modules/billing/lib/topupReceipt.ts +153 -0
- package/src/modules/billing/lib/topupTransition.ts +31 -0
- package/src/modules/billing/migrations/Migration20260619000000_billing_prepaid.ts +131 -0
- package/src/modules/billing/setup.ts +45 -0
- package/src/modules/billing/subscribers/prepaid-topup-cancelled.ts +31 -0
- package/src/modules/billing/subscribers/prepaid-topup-captured.ts +120 -0
- package/src/modules/billing/subscribers/prepaid-topup-expired.ts +31 -0
- package/src/modules/billing/subscribers/prepaid-topup-failed.ts +32 -0
- package/src/modules/billing/workers/reconcile-prepaid-balances.ts +68 -0
|
@@ -15,14 +15,20 @@ import {
|
|
|
15
15
|
} from "./invoiceWriter.js";
|
|
16
16
|
import { withTenantLock } from "./tenantLock.js";
|
|
17
17
|
import { processUsageForAccount } from "./usageRunner.js";
|
|
18
|
+
import { processPrepaidAccount } from "./prepaidPeriodClose.js";
|
|
19
|
+
import { emitBillingEvent } from "../events.js";
|
|
18
20
|
const SCHEDULED_CYCLE_LIMIT = 1;
|
|
19
21
|
const CATCH_UP_HARD_CAP = 60;
|
|
20
22
|
async function runBillRun(em, container, params) {
|
|
21
|
-
|
|
23
|
+
const output = await withTenantLock(
|
|
22
24
|
em,
|
|
23
25
|
{ tenantId: params.tenantId, lockName: "billing-run" },
|
|
24
26
|
async (tem) => executeRunWithinLock(tem, container, params)
|
|
25
27
|
);
|
|
28
|
+
for (const ev of output.pendingEvents) {
|
|
29
|
+
await emitBillingEvent(ev.id, ev.payload, { persistent: true });
|
|
30
|
+
}
|
|
31
|
+
return { billRun: output.billRun, outcomes: output.outcomes };
|
|
26
32
|
}
|
|
27
33
|
async function executeRunWithinLock(em, container, params) {
|
|
28
34
|
const now = /* @__PURE__ */ new Date();
|
|
@@ -47,6 +53,7 @@ async function executeRunWithinLock(em, container, params) {
|
|
|
47
53
|
await em.flush();
|
|
48
54
|
const accounts = await selectTargetAccounts(em, params);
|
|
49
55
|
const outcomes = [];
|
|
56
|
+
const pendingEvents = [];
|
|
50
57
|
let totalUsageRecordsRated = 0;
|
|
51
58
|
for (const account of accounts) {
|
|
52
59
|
const processResult = await processAccountSafely(em, container, billRun, account, params);
|
|
@@ -62,6 +69,7 @@ async function executeRunWithinLock(em, container, params) {
|
|
|
62
69
|
errorMessage: processResult.status === "failed" ? processResult.errorMessage : null,
|
|
63
70
|
warnings: processResult.status === "failed" ? null : processResult.warnings,
|
|
64
71
|
draftInvoiceId: processResult.status === "failed" ? null : processResult.draftInvoiceId,
|
|
72
|
+
statementId: processResult.status === "failed" ? null : processResult.statementId ?? null,
|
|
65
73
|
isActive: true,
|
|
66
74
|
createdAt: /* @__PURE__ */ new Date(),
|
|
67
75
|
updatedAt: /* @__PURE__ */ new Date()
|
|
@@ -69,13 +77,16 @@ async function executeRunWithinLock(em, container, params) {
|
|
|
69
77
|
em.persist(outcome);
|
|
70
78
|
await em.flush();
|
|
71
79
|
outcomes.push(outcome);
|
|
80
|
+
if (processResult.status !== "failed" && processResult.statementEvents) {
|
|
81
|
+
pendingEvents.push(...processResult.statementEvents);
|
|
82
|
+
}
|
|
72
83
|
}
|
|
73
84
|
billRun.status = computeFinalRunStatus(outcomes);
|
|
74
85
|
billRun.finishedAt = /* @__PURE__ */ new Date();
|
|
75
86
|
billRun.updatedAt = billRun.finishedAt;
|
|
76
87
|
billRun.summary = buildRunSummary(outcomes, accounts.length, totalUsageRecordsRated);
|
|
77
88
|
await em.flush();
|
|
78
|
-
return { billRun, outcomes };
|
|
89
|
+
return { billRun, outcomes, pendingEvents };
|
|
79
90
|
}
|
|
80
91
|
async function selectTargetAccounts(em, params) {
|
|
81
92
|
const where = {
|
|
@@ -102,6 +113,26 @@ async function processAccountSafely(em, container, billRun, account, params) {
|
|
|
102
113
|
}
|
|
103
114
|
}
|
|
104
115
|
async function processAccount(em, container, billRun, account, params) {
|
|
116
|
+
if (account.billingMode === "prepaid") {
|
|
117
|
+
const result = await processPrepaidAccount(em, account, billRun, {
|
|
118
|
+
asOfDate: params.asOfDate,
|
|
119
|
+
mode: params.mode,
|
|
120
|
+
triggeredBy: params.triggeredBy,
|
|
121
|
+
catchUp: params.catchUp
|
|
122
|
+
});
|
|
123
|
+
if (result.status === "failed") {
|
|
124
|
+
return { status: "failed", errorMessage: result.errorMessage };
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
status: result.status,
|
|
128
|
+
warnings: result.warnings,
|
|
129
|
+
draftInvoiceId: null,
|
|
130
|
+
statementId: result.statementId,
|
|
131
|
+
statementEvents: result.statementEvents,
|
|
132
|
+
cyclesEmitted: result.cyclesEmitted,
|
|
133
|
+
usageRecordsRated: 0
|
|
134
|
+
};
|
|
135
|
+
}
|
|
105
136
|
const maxCycles = params.triggeredBy === "schedule" || !params.catchUp ? SCHEDULED_CYCLE_LIMIT : CATCH_UP_HARD_CAP;
|
|
106
137
|
let cyclesEmitted = 0;
|
|
107
138
|
let lastDraftInvoiceId = null;
|
|
@@ -245,6 +276,13 @@ function buildRunSummary(outcomes, totalAccounts, usageRecordsRated) {
|
|
|
245
276
|
(o) => o.draftInvoiceId !== null && o.status !== "skipped_existing_draft"
|
|
246
277
|
).length,
|
|
247
278
|
drafts_skipped_existing: outcomes.filter((o) => o.status === "skipped_existing_draft").length,
|
|
279
|
+
// SPEC-002 prepaid period close.
|
|
280
|
+
statements_created: outcomes.filter(
|
|
281
|
+
(o) => o.statementId !== null && o.status !== "skipped_existing_statement"
|
|
282
|
+
).length,
|
|
283
|
+
statements_skipped_existing: outcomes.filter(
|
|
284
|
+
(o) => o.status === "skipped_existing_statement"
|
|
285
|
+
).length,
|
|
248
286
|
accounts_failed: outcomes.filter((o) => o.status === "failed").length,
|
|
249
287
|
accounts_with_warnings: outcomes.filter((o) => o.status === "success_with_warnings").length,
|
|
250
288
|
usage_records_rated: usageRecordsRated
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/billing/lib/billRunEngine.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { AwilixContainer } from 'awilix'\nimport {\n BillingAccount,\n BillingAccountUsage,\n BillingItem,\n BillRun,\n BillRunOutcome,\n} from '../data/entities'\nimport { advanceNextBillDate, deriveBillPeriod, isCycleDue } from './billPeriod'\nimport {\n selectItemsForPeriod,\n type SelectableItem,\n} from './itemSelector'\nimport {\n buildInvoiceLinesFromItems,\n writeDraftInvoice,\n type InvoiceLineDescriptor,\n type WriteMode,\n} from './invoiceWriter'\nimport { withTenantLock } from './tenantLock'\nimport { processUsageForAccount } from './usageRunner'\nimport type { BillCycle } from '../data/validators'\n\n/**\n * Bill Run engine \u2014 orchestrates one execution of the billing cycle for\n * a single tenant.\n *\n * Concurrency / locking:\n * - The whole run is wrapped in `withTenantLock(...)`, which holds a\n * transaction-scoped Postgres advisory lock keyed by\n * `hash(tenant_id || ':billing-run')`. A second trigger fired while a\n * run is in flight short-circuits with `TenantLockBusyError` (the\n * command layer maps that to HTTP 409).\n * - Within the outer transaction, every account is processed inside a\n * nested `em.transactional(...)` (PG SAVEPOINT). A per-account failure\n * rolls back only that account's work, leaving siblings unaffected.\n *\n * Run modes:\n * - `real` (default) \u2014 persist drafts, advance `next_bill_date`, mark\n * items `billed_to_date`. Production path.\n * - `test` \u2014 persist drafts flagged with `metadata.test_run=true`, but\n * do NOT advance state. Repeatable.\n * - `dry` \u2014 compute everything; persist nothing. Preview only.\n *\n * Catch-up:\n * - Manual triggers may set `catchUp=true`. The engine then loops cycles\n * per account until `next_bill_date > as_of_date`, emitting one draft\n * per missed cycle (never combined).\n * - Scheduled triggers always process exactly ONE cycle per account per\n * run, regardless of how far behind the account is.\n *\n * Anti-duplicate guards (both run in every mode except `dry`):\n * 1. Open-draft check on `core/sales` invoice (per period boundary).\n * 2. `billed_to_date` per BillingItem.\n */\n\nexport type RunMode = WriteMode\n\nexport type RunBillRunParams = {\n tenantId: string\n organizationId: string\n asOfDate: Date\n triggeredBy: 'schedule' | 'manual'\n mode: RunMode\n scopedAccountIds?: string[]\n parentRunId?: string | null\n catchUp?: boolean\n}\n\nexport type RunBillRunResult = {\n billRun: BillRun\n outcomes: BillRunOutcome[]\n}\n\ntype AccountWarnings = {\n currency_mismatch_items?: string[]\n unmatched_usage_uoms?: string[]\n}\n\ntype AccountProcessSuccess = {\n status: 'success' | 'success_with_warnings' | 'skipped_existing_draft'\n warnings: AccountWarnings | null\n draftInvoiceId: string | null\n cyclesEmitted: number\n usageRecordsRated: number\n}\n\ntype AccountProcessFailed = {\n status: 'failed'\n errorMessage: string\n}\n\ntype AccountProcessResult = AccountProcessSuccess | AccountProcessFailed\n\nconst SCHEDULED_CYCLE_LIMIT = 1\nconst CATCH_UP_HARD_CAP = 60 // safety net so a misconfigured account can't loop forever\n\nexport async function runBillRun(\n em: EntityManager,\n container: AwilixContainer,\n params: RunBillRunParams,\n): Promise<RunBillRunResult> {\n return withTenantLock(\n em,\n { tenantId: params.tenantId, lockName: 'billing-run' },\n async (tem) => executeRunWithinLock(tem, container, params),\n )\n}\n\nasync function executeRunWithinLock(\n em: EntityManager,\n container: AwilixContainer,\n params: RunBillRunParams,\n): Promise<RunBillRunResult> {\n const now = new Date()\n const billRun = em.create(BillRun, {\n organizationId: params.organizationId,\n tenantId: params.tenantId,\n triggeredBy: params.triggeredBy,\n parentRunId: params.parentRunId ?? null,\n dryRun: params.mode === 'dry',\n testMode: params.mode === 'test',\n catchUp: Boolean(params.catchUp),\n scopedAccountIds: params.scopedAccountIds ?? null,\n asOfDate: params.asOfDate,\n startedAt: now,\n status: 'running',\n summary: null,\n isActive: true,\n createdAt: now,\n updatedAt: now,\n })\n em.persist(billRun)\n await em.flush()\n\n const accounts = await selectTargetAccounts(em, params)\n const outcomes: BillRunOutcome[] = []\n let totalUsageRecordsRated = 0\n\n for (const account of accounts) {\n const processResult = await processAccountSafely(em, container, billRun, account, params)\n if (processResult.status !== 'failed') {\n totalUsageRecordsRated += processResult.usageRecordsRated\n }\n const outcome = em.create(BillRunOutcome, {\n organizationId: params.organizationId,\n tenantId: params.tenantId,\n billRunId: billRun.id,\n billAccountId: account.id,\n status: processResult.status,\n errorMessage: processResult.status === 'failed' ? processResult.errorMessage : null,\n warnings:\n processResult.status === 'failed'\n ? null\n : (processResult.warnings as Record<string, unknown> | null),\n draftInvoiceId: processResult.status === 'failed' ? null : processResult.draftInvoiceId,\n isActive: true,\n createdAt: new Date(),\n updatedAt: new Date(),\n })\n em.persist(outcome)\n await em.flush()\n outcomes.push(outcome)\n }\n\n billRun.status = computeFinalRunStatus(outcomes)\n billRun.finishedAt = new Date()\n billRun.updatedAt = billRun.finishedAt\n billRun.summary = buildRunSummary(outcomes, accounts.length, totalUsageRecordsRated)\n await em.flush()\n\n return { billRun, outcomes }\n}\n\nasync function selectTargetAccounts(\n em: EntityManager,\n params: RunBillRunParams,\n): Promise<BillingAccount[]> {\n const where: Record<string, unknown> = {\n tenantId: params.tenantId,\n organizationId: params.organizationId,\n deletedAt: null,\n nextBillDate: { $lte: params.asOfDate },\n }\n if (params.scopedAccountIds && params.scopedAccountIds.length > 0) {\n where.id = { $in: params.scopedAccountIds }\n }\n return em.find(BillingAccount, where as never)\n}\n\nasync function processAccountSafely(\n em: EntityManager,\n container: AwilixContainer,\n billRun: BillRun,\n account: BillingAccount,\n params: RunBillRunParams,\n): Promise<AccountProcessResult> {\n try {\n return await em.transactional(async (subEm) => {\n return processAccount(subEm, container, billRun, account, params)\n })\n } catch (err) {\n return {\n status: 'failed',\n errorMessage: err instanceof Error ? err.message : String(err),\n }\n }\n}\n\nasync function processAccount(\n em: EntityManager,\n container: AwilixContainer,\n billRun: BillRun,\n account: BillingAccount,\n params: RunBillRunParams,\n): Promise<AccountProcessResult> {\n const maxCycles =\n params.triggeredBy === 'schedule' || !params.catchUp\n ? SCHEDULED_CYCLE_LIMIT\n : CATCH_UP_HARD_CAP\n\n let cyclesEmitted = 0\n let lastDraftInvoiceId: string | null = null\n let totalUsageRatedThisAccount = 0\n const aggregatedWarnings: AccountWarnings = {}\n\n while (cyclesEmitted < maxCycles && isCycleDue(account.nextBillDate, params.asOfDate)) {\n const period = deriveBillPeriod(account.nextBillDate, account.billCycle as BillCycle)\n\n // Open-draft anti-duplicate \u2014 only applies when we'd actually persist.\n if (params.mode !== 'dry') {\n const existing = await findExistingDraftForPeriod(em, account, period)\n if (existing) {\n return {\n status: 'skipped_existing_draft',\n warnings: null,\n draftInvoiceId: existing,\n cyclesEmitted,\n usageRecordsRated: totalUsageRatedThisAccount,\n }\n }\n }\n\n const items = await em.find(BillingItem, {\n tenantId: account.tenantId,\n organizationId: account.organizationId,\n billAccountId: account.id,\n deletedAt: null,\n } as never)\n\n const usageItems = (items as BillingItem[]).filter((it) => it.type === 'usage')\n\n // Non-usage path: one_time + recurring filtered by the selector.\n const { included } = selectItemsForPeriod(items as SelectableItem[], period)\n const billableItems = included as unknown as BillingItem[]\n\n const currencyMismatchIds = billableItems\n .filter((it) => it.currencyMismatch)\n .map((it) => it.id)\n if (currencyMismatchIds.length > 0) {\n aggregatedWarnings.currency_mismatch_items = [\n ...(aggregatedWarnings.currency_mismatch_items ?? []),\n ...currencyMismatchIds,\n ]\n }\n const itemLines = buildInvoiceLinesFromItems(billableItems)\n\n // Usage path: aggregate matching usage records per uom_code, rate,\n // emit one line per matched usage item, warn about unmatched UoMs.\n const usageResult = await processUsageForAccount(em, account, period, usageItems)\n if (usageResult.unmatchedUoms.length > 0) {\n aggregatedWarnings.unmatched_usage_uoms = [\n ...(aggregatedWarnings.unmatched_usage_uoms ?? []),\n ...usageResult.unmatchedUoms,\n ]\n }\n\n const allLines: InvoiceLineDescriptor[] = [...itemLines, ...usageResult.lines]\n if (allLines.length === 0) {\n // Nothing to bill this cycle \u2014 still advance so the account moves\n // forward in real mode. In test / dry, no advance, so we'd loop\n // forever; break out.\n if (params.mode !== 'real') break\n account.nextBillDate = advanceNextBillDate(\n account.nextBillDate,\n account.billCycle as BillCycle,\n )\n account.lastBillDate = period.periodEnd\n account.updatedAt = new Date()\n cyclesEmitted += 1\n continue\n }\n\n const written = await writeDraftInvoice(em, container, {\n mode: params.mode,\n account,\n period,\n billRunId: billRun.id,\n lines: allLines,\n })\n lastDraftInvoiceId = written.invoiceId\n\n if (params.mode === 'real') {\n const now = new Date()\n for (const item of billableItems) {\n item.billedToDate = period.periodEnd\n item.updatedAt = now\n }\n // Mark consumed usage records \u2014 single bulk update so we don't\n // round-trip per record on accounts with thousands of usage rows.\n if (usageResult.matchedUoms.length > 0) {\n // Predicate-based bulk update: marks every still-unrated record\n // for the matched uoms in the account's period with a single\n // SQL UPDATE \u2014 no id list ever leaves Postgres. Stays\n // memory-bounded even for telco accounts with millions of\n // usage records per cycle (spec Performance Considerations\n // \"Shape B\").\n await em.nativeUpdate(\n BillingAccountUsage,\n {\n tenantId: account.tenantId,\n organizationId: account.organizationId,\n billAccountId: account.id,\n uomCode: { $in: usageResult.matchedUoms },\n ratedInBillRunId: null,\n periodEnd: { $lte: period.periodEnd },\n deletedAt: null,\n } as never,\n { ratedInBillRunId: billRun.id, updatedAt: now } as never,\n )\n }\n totalUsageRatedThisAccount += usageResult.ratedCount\n account.nextBillDate = advanceNextBillDate(\n account.nextBillDate,\n account.billCycle as BillCycle,\n )\n account.lastBillDate = period.periodEnd\n account.updatedAt = now\n await em.flush()\n } else {\n // Test / dry: do not advance state. One iteration only.\n break\n }\n\n cyclesEmitted += 1\n }\n\n const hasCurrencyWarnings =\n (aggregatedWarnings.currency_mismatch_items ?? []).length > 0\n const hasUnmatchedUsage =\n (aggregatedWarnings.unmatched_usage_uoms ?? []).length > 0\n const hasWarnings = hasCurrencyWarnings || hasUnmatchedUsage\n return {\n status: hasWarnings ? 'success_with_warnings' : 'success',\n warnings: hasWarnings ? aggregatedWarnings : null,\n draftInvoiceId: lastDraftInvoiceId,\n cyclesEmitted,\n usageRecordsRated: totalUsageRatedThisAccount,\n }\n}\n\nasync function findExistingDraftForPeriod(\n em: EntityManager,\n account: BillingAccount,\n period: { periodStart: Date; periodEnd: Date },\n): Promise<string | null> {\n const periodStartISO = period.periodStart.toISOString().slice(0, 10)\n const periodEndISO = period.periodEnd.toISOString().slice(0, 10)\n const rows = (await em.execute(\n `SELECT id FROM sales_invoices\n WHERE tenant_id = ?\n AND organization_id = ?\n AND deleted_at IS NULL\n AND status = 'draft'\n AND metadata->>'bill_account_id' = ?\n AND metadata->>'bill_period_start' = ?\n AND metadata->>'bill_period_end' = ?\n LIMIT 1`,\n [account.tenantId, account.organizationId, account.id, periodStartISO, periodEndISO],\n )) as unknown as Array<{ id: string }> | undefined\n if (!rows || rows.length === 0) return null\n return rows[0].id ?? null\n}\n\nfunction computeFinalRunStatus(\n outcomes: readonly BillRunOutcome[],\n): BillRun['status'] {\n if (outcomes.length === 0) return 'completed'\n const anyFailed = outcomes.some((o) => o.status === 'failed')\n const anySuccess = outcomes.some(\n (o) => o.status === 'success' || o.status === 'success_with_warnings',\n )\n if (anyFailed && anySuccess) return 'partial_failure'\n if (anyFailed) return 'partial_failure'\n return 'completed'\n}\n\nfunction buildRunSummary(\n outcomes: readonly BillRunOutcome[],\n totalAccounts: number,\n usageRecordsRated: number,\n): Record<string, unknown> {\n return {\n accounts_processed: totalAccounts,\n drafts_created: outcomes.filter(\n (o) => o.draftInvoiceId !== null && o.status !== 'skipped_existing_draft',\n ).length,\n drafts_skipped_existing: outcomes.filter((o) => o.status === 'skipped_existing_draft')\n .length,\n accounts_failed: outcomes.filter((o) => o.status === 'failed').length,\n accounts_with_warnings: outcomes.filter((o) => o.status === 'success_with_warnings')\n .length,\n usage_records_rated: usageRecordsRated,\n }\n}\n"],
|
|
5
|
-
"mappings": "AAEA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,qBAAqB,kBAAkB,kBAAkB;AAClE;AAAA,EACE;AAAA,OAEK;AACP;AAAA,EACE;AAAA,EACA;AAAA,OAGK;AACP,SAAS,sBAAsB;AAC/B,SAAS,8BAA8B;
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { AwilixContainer } from 'awilix'\nimport {\n BillingAccount,\n BillingAccountUsage,\n BillingItem,\n BillRun,\n BillRunOutcome,\n} from '../data/entities'\nimport { advanceNextBillDate, deriveBillPeriod, isCycleDue } from './billPeriod'\nimport {\n selectItemsForPeriod,\n type SelectableItem,\n} from './itemSelector'\nimport {\n buildInvoiceLinesFromItems,\n writeDraftInvoice,\n type InvoiceLineDescriptor,\n type WriteMode,\n} from './invoiceWriter'\nimport { withTenantLock } from './tenantLock'\nimport { processUsageForAccount } from './usageRunner'\nimport { processPrepaidAccount } from './prepaidPeriodClose'\nimport type { DomainEvent } from './balanceEvents'\nimport { emitBillingEvent, type BillingEventId } from '../events'\nimport type { BillCycle } from '../data/validators'\n\n/**\n * Bill Run engine \u2014 orchestrates one execution of the billing cycle for\n * a single tenant.\n *\n * Concurrency / locking:\n * - The whole run is wrapped in `withTenantLock(...)`, which holds a\n * transaction-scoped Postgres advisory lock keyed by\n * `hash(tenant_id || ':billing-run')`. A second trigger fired while a\n * run is in flight short-circuits with `TenantLockBusyError` (the\n * command layer maps that to HTTP 409).\n * - Within the outer transaction, every account is processed inside a\n * nested `em.transactional(...)` (PG SAVEPOINT). A per-account failure\n * rolls back only that account's work, leaving siblings unaffected.\n *\n * Run modes:\n * - `real` (default) \u2014 persist drafts, advance `next_bill_date`, mark\n * items `billed_to_date`. Production path.\n * - `test` \u2014 persist drafts flagged with `metadata.test_run=true`, but\n * do NOT advance state. Repeatable.\n * - `dry` \u2014 compute everything; persist nothing. Preview only.\n *\n * Catch-up:\n * - Manual triggers may set `catchUp=true`. The engine then loops cycles\n * per account until `next_bill_date > as_of_date`, emitting one draft\n * per missed cycle (never combined).\n * - Scheduled triggers always process exactly ONE cycle per account per\n * run, regardless of how far behind the account is.\n *\n * Anti-duplicate guards (both run in every mode except `dry`):\n * 1. Open-draft check on `core/sales` invoice (per period boundary).\n * 2. `billed_to_date` per BillingItem.\n */\n\nexport type RunMode = WriteMode\n\nexport type RunBillRunParams = {\n tenantId: string\n organizationId: string\n asOfDate: Date\n triggeredBy: 'schedule' | 'manual'\n mode: RunMode\n scopedAccountIds?: string[]\n parentRunId?: string | null\n catchUp?: boolean\n}\n\nexport type RunBillRunResult = {\n billRun: BillRun\n outcomes: BillRunOutcome[]\n}\n\ntype RunOutput = RunBillRunResult & {\n /** Prepaid statement / crossing events to emit AFTER the outer tx commits. */\n pendingEvents: DomainEvent[]\n}\n\ntype AccountWarnings = {\n currency_mismatch_items?: string[]\n unmatched_usage_uoms?: string[]\n}\n\ntype AccountProcessSuccess = {\n status:\n | 'success'\n | 'success_with_warnings'\n | 'skipped_existing_draft'\n | 'skipped_existing_statement'\n warnings: AccountWarnings | Record<string, unknown> | null\n draftInvoiceId: string | null\n // SPEC-002: prepaid accounts produce a statement instead of a draft invoice.\n statementId?: string | null\n statementEvents?: DomainEvent[]\n cyclesEmitted: number\n usageRecordsRated: number\n}\n\ntype AccountProcessFailed = {\n status: 'failed'\n errorMessage: string\n}\n\ntype AccountProcessResult = AccountProcessSuccess | AccountProcessFailed\n\nconst SCHEDULED_CYCLE_LIMIT = 1\nconst CATCH_UP_HARD_CAP = 60 // safety net so a misconfigured account can't loop forever\n\nexport async function runBillRun(\n em: EntityManager,\n container: AwilixContainer,\n params: RunBillRunParams,\n): Promise<RunBillRunResult> {\n const output = await withTenantLock(\n em,\n { tenantId: params.tenantId, lockName: 'billing-run' },\n async (tem) => executeRunWithinLock(tem, container, params),\n )\n // Emit prepaid statement / crossing events ONLY after the outer transaction\n // (held by withTenantLock) has committed \u2014 emitting inside the lock would let\n // a consumer observe an uncommitted statement or a phantom event on rollback.\n for (const ev of output.pendingEvents) {\n await emitBillingEvent(ev.id as BillingEventId, ev.payload, { persistent: true })\n }\n return { billRun: output.billRun, outcomes: output.outcomes }\n}\n\nasync function executeRunWithinLock(\n em: EntityManager,\n container: AwilixContainer,\n params: RunBillRunParams,\n): Promise<RunOutput> {\n const now = new Date()\n const billRun = em.create(BillRun, {\n organizationId: params.organizationId,\n tenantId: params.tenantId,\n triggeredBy: params.triggeredBy,\n parentRunId: params.parentRunId ?? null,\n dryRun: params.mode === 'dry',\n testMode: params.mode === 'test',\n catchUp: Boolean(params.catchUp),\n scopedAccountIds: params.scopedAccountIds ?? null,\n asOfDate: params.asOfDate,\n startedAt: now,\n status: 'running',\n summary: null,\n isActive: true,\n createdAt: now,\n updatedAt: now,\n })\n em.persist(billRun)\n await em.flush()\n\n const accounts = await selectTargetAccounts(em, params)\n const outcomes: BillRunOutcome[] = []\n const pendingEvents: DomainEvent[] = []\n let totalUsageRecordsRated = 0\n\n for (const account of accounts) {\n const processResult = await processAccountSafely(em, container, billRun, account, params)\n if (processResult.status !== 'failed') {\n totalUsageRecordsRated += processResult.usageRecordsRated\n }\n const outcome = em.create(BillRunOutcome, {\n organizationId: params.organizationId,\n tenantId: params.tenantId,\n billRunId: billRun.id,\n billAccountId: account.id,\n status: processResult.status,\n errorMessage: processResult.status === 'failed' ? processResult.errorMessage : null,\n warnings:\n processResult.status === 'failed'\n ? null\n : (processResult.warnings as Record<string, unknown> | null),\n draftInvoiceId: processResult.status === 'failed' ? null : processResult.draftInvoiceId,\n statementId: processResult.status === 'failed' ? null : (processResult.statementId ?? null),\n isActive: true,\n createdAt: new Date(),\n updatedAt: new Date(),\n })\n em.persist(outcome)\n await em.flush()\n outcomes.push(outcome)\n\n // Collect statement / crossing events \u2014 runBillRun emits them AFTER the\n // outer tx commits (see runBillRun), never from inside the lock.\n if (processResult.status !== 'failed' && processResult.statementEvents) {\n pendingEvents.push(...processResult.statementEvents)\n }\n }\n\n billRun.status = computeFinalRunStatus(outcomes)\n billRun.finishedAt = new Date()\n billRun.updatedAt = billRun.finishedAt\n billRun.summary = buildRunSummary(outcomes, accounts.length, totalUsageRecordsRated)\n await em.flush()\n\n return { billRun, outcomes, pendingEvents }\n}\n\nasync function selectTargetAccounts(\n em: EntityManager,\n params: RunBillRunParams,\n): Promise<BillingAccount[]> {\n const where: Record<string, unknown> = {\n tenantId: params.tenantId,\n organizationId: params.organizationId,\n deletedAt: null,\n nextBillDate: { $lte: params.asOfDate },\n }\n if (params.scopedAccountIds && params.scopedAccountIds.length > 0) {\n where.id = { $in: params.scopedAccountIds }\n }\n return em.find(BillingAccount, where as never)\n}\n\nasync function processAccountSafely(\n em: EntityManager,\n container: AwilixContainer,\n billRun: BillRun,\n account: BillingAccount,\n params: RunBillRunParams,\n): Promise<AccountProcessResult> {\n try {\n return await em.transactional(async (subEm) => {\n return processAccount(subEm, container, billRun, account, params)\n })\n } catch (err) {\n return {\n status: 'failed',\n errorMessage: err instanceof Error ? err.message : String(err),\n }\n }\n}\n\nasync function processAccount(\n em: EntityManager,\n container: AwilixContainer,\n billRun: BillRun,\n account: BillingAccount,\n params: RunBillRunParams,\n): Promise<AccountProcessResult> {\n // SPEC-002: a prepaid account never gets a payable draft invoice. It still\n // runs in the Bill Run, but charges recurring / one_time items to the\n // balance and produces a non-fiscal consumption statement.\n if (account.billingMode === 'prepaid') {\n const result = await processPrepaidAccount(em, account, billRun, {\n asOfDate: params.asOfDate,\n mode: params.mode,\n triggeredBy: params.triggeredBy,\n catchUp: params.catchUp,\n })\n if (result.status === 'failed') {\n return { status: 'failed', errorMessage: result.errorMessage }\n }\n return {\n status: result.status,\n warnings: result.warnings,\n draftInvoiceId: null,\n statementId: result.statementId,\n statementEvents: result.statementEvents,\n cyclesEmitted: result.cyclesEmitted,\n usageRecordsRated: 0,\n }\n }\n\n const maxCycles =\n params.triggeredBy === 'schedule' || !params.catchUp\n ? SCHEDULED_CYCLE_LIMIT\n : CATCH_UP_HARD_CAP\n\n let cyclesEmitted = 0\n let lastDraftInvoiceId: string | null = null\n let totalUsageRatedThisAccount = 0\n const aggregatedWarnings: AccountWarnings = {}\n\n while (cyclesEmitted < maxCycles && isCycleDue(account.nextBillDate, params.asOfDate)) {\n const period = deriveBillPeriod(account.nextBillDate, account.billCycle as BillCycle)\n\n // Open-draft anti-duplicate \u2014 only applies when we'd actually persist.\n if (params.mode !== 'dry') {\n const existing = await findExistingDraftForPeriod(em, account, period)\n if (existing) {\n return {\n status: 'skipped_existing_draft',\n warnings: null,\n draftInvoiceId: existing,\n cyclesEmitted,\n usageRecordsRated: totalUsageRatedThisAccount,\n }\n }\n }\n\n const items = await em.find(BillingItem, {\n tenantId: account.tenantId,\n organizationId: account.organizationId,\n billAccountId: account.id,\n deletedAt: null,\n } as never)\n\n const usageItems = (items as BillingItem[]).filter((it) => it.type === 'usage')\n\n // Non-usage path: one_time + recurring filtered by the selector.\n const { included } = selectItemsForPeriod(items as SelectableItem[], period)\n const billableItems = included as unknown as BillingItem[]\n\n const currencyMismatchIds = billableItems\n .filter((it) => it.currencyMismatch)\n .map((it) => it.id)\n if (currencyMismatchIds.length > 0) {\n aggregatedWarnings.currency_mismatch_items = [\n ...(aggregatedWarnings.currency_mismatch_items ?? []),\n ...currencyMismatchIds,\n ]\n }\n const itemLines = buildInvoiceLinesFromItems(billableItems)\n\n // Usage path: aggregate matching usage records per uom_code, rate,\n // emit one line per matched usage item, warn about unmatched UoMs.\n const usageResult = await processUsageForAccount(em, account, period, usageItems)\n if (usageResult.unmatchedUoms.length > 0) {\n aggregatedWarnings.unmatched_usage_uoms = [\n ...(aggregatedWarnings.unmatched_usage_uoms ?? []),\n ...usageResult.unmatchedUoms,\n ]\n }\n\n const allLines: InvoiceLineDescriptor[] = [...itemLines, ...usageResult.lines]\n if (allLines.length === 0) {\n // Nothing to bill this cycle \u2014 still advance so the account moves\n // forward in real mode. In test / dry, no advance, so we'd loop\n // forever; break out.\n if (params.mode !== 'real') break\n account.nextBillDate = advanceNextBillDate(\n account.nextBillDate,\n account.billCycle as BillCycle,\n )\n account.lastBillDate = period.periodEnd\n account.updatedAt = new Date()\n cyclesEmitted += 1\n continue\n }\n\n const written = await writeDraftInvoice(em, container, {\n mode: params.mode,\n account,\n period,\n billRunId: billRun.id,\n lines: allLines,\n })\n lastDraftInvoiceId = written.invoiceId\n\n if (params.mode === 'real') {\n const now = new Date()\n for (const item of billableItems) {\n item.billedToDate = period.periodEnd\n item.updatedAt = now\n }\n // Mark consumed usage records \u2014 single bulk update so we don't\n // round-trip per record on accounts with thousands of usage rows.\n if (usageResult.matchedUoms.length > 0) {\n // Predicate-based bulk update: marks every still-unrated record\n // for the matched uoms in the account's period with a single\n // SQL UPDATE \u2014 no id list ever leaves Postgres. Stays\n // memory-bounded even for telco accounts with millions of\n // usage records per cycle (spec Performance Considerations\n // \"Shape B\").\n await em.nativeUpdate(\n BillingAccountUsage,\n {\n tenantId: account.tenantId,\n organizationId: account.organizationId,\n billAccountId: account.id,\n uomCode: { $in: usageResult.matchedUoms },\n ratedInBillRunId: null,\n periodEnd: { $lte: period.periodEnd },\n deletedAt: null,\n } as never,\n { ratedInBillRunId: billRun.id, updatedAt: now } as never,\n )\n }\n totalUsageRatedThisAccount += usageResult.ratedCount\n account.nextBillDate = advanceNextBillDate(\n account.nextBillDate,\n account.billCycle as BillCycle,\n )\n account.lastBillDate = period.periodEnd\n account.updatedAt = now\n await em.flush()\n } else {\n // Test / dry: do not advance state. One iteration only.\n break\n }\n\n cyclesEmitted += 1\n }\n\n const hasCurrencyWarnings =\n (aggregatedWarnings.currency_mismatch_items ?? []).length > 0\n const hasUnmatchedUsage =\n (aggregatedWarnings.unmatched_usage_uoms ?? []).length > 0\n const hasWarnings = hasCurrencyWarnings || hasUnmatchedUsage\n return {\n status: hasWarnings ? 'success_with_warnings' : 'success',\n warnings: hasWarnings ? aggregatedWarnings : null,\n draftInvoiceId: lastDraftInvoiceId,\n cyclesEmitted,\n usageRecordsRated: totalUsageRatedThisAccount,\n }\n}\n\nasync function findExistingDraftForPeriod(\n em: EntityManager,\n account: BillingAccount,\n period: { periodStart: Date; periodEnd: Date },\n): Promise<string | null> {\n const periodStartISO = period.periodStart.toISOString().slice(0, 10)\n const periodEndISO = period.periodEnd.toISOString().slice(0, 10)\n const rows = (await em.execute(\n `SELECT id FROM sales_invoices\n WHERE tenant_id = ?\n AND organization_id = ?\n AND deleted_at IS NULL\n AND status = 'draft'\n AND metadata->>'bill_account_id' = ?\n AND metadata->>'bill_period_start' = ?\n AND metadata->>'bill_period_end' = ?\n LIMIT 1`,\n [account.tenantId, account.organizationId, account.id, periodStartISO, periodEndISO],\n )) as unknown as Array<{ id: string }> | undefined\n if (!rows || rows.length === 0) return null\n return rows[0].id ?? null\n}\n\nfunction computeFinalRunStatus(\n outcomes: readonly BillRunOutcome[],\n): BillRun['status'] {\n if (outcomes.length === 0) return 'completed'\n const anyFailed = outcomes.some((o) => o.status === 'failed')\n const anySuccess = outcomes.some(\n (o) => o.status === 'success' || o.status === 'success_with_warnings',\n )\n if (anyFailed && anySuccess) return 'partial_failure'\n if (anyFailed) return 'partial_failure'\n return 'completed'\n}\n\nfunction buildRunSummary(\n outcomes: readonly BillRunOutcome[],\n totalAccounts: number,\n usageRecordsRated: number,\n): Record<string, unknown> {\n return {\n accounts_processed: totalAccounts,\n drafts_created: outcomes.filter(\n (o) => o.draftInvoiceId !== null && o.status !== 'skipped_existing_draft',\n ).length,\n drafts_skipped_existing: outcomes.filter((o) => o.status === 'skipped_existing_draft')\n .length,\n // SPEC-002 prepaid period close.\n statements_created: outcomes.filter(\n (o) => o.statementId !== null && o.status !== 'skipped_existing_statement',\n ).length,\n statements_skipped_existing: outcomes.filter(\n (o) => o.status === 'skipped_existing_statement',\n ).length,\n accounts_failed: outcomes.filter((o) => o.status === 'failed').length,\n accounts_with_warnings: outcomes.filter((o) => o.status === 'success_with_warnings')\n .length,\n usage_records_rated: usageRecordsRated,\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { compareMoney, fromUnits, subMoney, toUnits } from "./money.js";
|
|
2
|
+
function prepaidCreditUsed(balance) {
|
|
3
|
+
if (compareMoney(balance, 0) >= 0) return "0.0000";
|
|
4
|
+
return fromUnits(-toUnits(balance));
|
|
5
|
+
}
|
|
6
|
+
function computeCreditUsed(mode, opts) {
|
|
7
|
+
if (mode === "prepaid") {
|
|
8
|
+
return prepaidCreditUsed(opts.balance ?? "0");
|
|
9
|
+
}
|
|
10
|
+
return fromUnits(toUnits(opts.outstanding ?? "0"));
|
|
11
|
+
}
|
|
12
|
+
function computeAvailableCredit(creditLimit, creditUsed) {
|
|
13
|
+
return subMoney(creditLimit, creditUsed);
|
|
14
|
+
}
|
|
15
|
+
function computeCreditStatus(availableCredit, nearLimitBuffer, creditUsed) {
|
|
16
|
+
const hasDraw = creditUsed === void 0 ? true : compareMoney(creditUsed, 0) > 0;
|
|
17
|
+
if (compareMoney(availableCredit, 0) <= 0) {
|
|
18
|
+
return hasDraw ? "over_limit" : "within_limit";
|
|
19
|
+
}
|
|
20
|
+
if (compareMoney(availableCredit, nearLimitBuffer) <= 0) return "near_limit";
|
|
21
|
+
return "within_limit";
|
|
22
|
+
}
|
|
23
|
+
function resolveNearLimitBuffer(configDefault) {
|
|
24
|
+
if (configDefault !== null && configDefault !== void 0) return String(configDefault);
|
|
25
|
+
return "0";
|
|
26
|
+
}
|
|
27
|
+
function computeCreditSnapshot(params) {
|
|
28
|
+
const creditLimit = fromUnits(toUnits(params.creditLimit));
|
|
29
|
+
const creditUsed = computeCreditUsed(params.mode, {
|
|
30
|
+
balance: params.balance,
|
|
31
|
+
outstanding: params.outstanding
|
|
32
|
+
});
|
|
33
|
+
const availableCredit = computeAvailableCredit(creditLimit, creditUsed);
|
|
34
|
+
return {
|
|
35
|
+
mode: params.mode,
|
|
36
|
+
creditLimit,
|
|
37
|
+
creditUsed,
|
|
38
|
+
availableCredit,
|
|
39
|
+
creditStatus: computeCreditStatus(availableCredit, params.nearLimitBuffer, creditUsed)
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function detectCreditCrossing(params) {
|
|
43
|
+
const limitUnits = toUnits(params.creditLimit);
|
|
44
|
+
if (limitUnits <= 0) {
|
|
45
|
+
return { overLimitCrossedUp: false, nearLimitCrossedUp: false };
|
|
46
|
+
}
|
|
47
|
+
const usedBefore = toUnits(params.usedBefore);
|
|
48
|
+
const usedAfter = toUnits(params.usedAfter);
|
|
49
|
+
const bufferUnits = toUnits(params.nearLimitBuffer);
|
|
50
|
+
const overLimitCrossedUp = usedBefore < limitUnits && usedAfter >= limitUnits;
|
|
51
|
+
const availBefore = limitUnits - usedBefore;
|
|
52
|
+
const availAfter = limitUnits - usedAfter;
|
|
53
|
+
const nearLimitCrossedUp = bufferUnits > 0 && availBefore > bufferUnits && availAfter <= bufferUnits && availAfter > 0;
|
|
54
|
+
return { overLimitCrossedUp, nearLimitCrossedUp };
|
|
55
|
+
}
|
|
56
|
+
export {
|
|
57
|
+
computeAvailableCredit,
|
|
58
|
+
computeCreditSnapshot,
|
|
59
|
+
computeCreditStatus,
|
|
60
|
+
computeCreditUsed,
|
|
61
|
+
detectCreditCrossing,
|
|
62
|
+
prepaidCreditUsed,
|
|
63
|
+
resolveNearLimitBuffer
|
|
64
|
+
};
|
|
65
|
+
//# sourceMappingURL=creditStatus.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/billing/lib/creditStatus.ts"],
|
|
4
|
+
"sourcesContent": ["import type { BillingMode, CreditStatus } from '../data/validators'\nimport { compareMoney, fromUnits, subMoney, toUnits } from './money'\n\n/**\n * Credit-line status (both modes, SPEC-002). Billing **computes and reports**\n * where an account stands against its `credit_limit` \u2014 it NEVER blocks\n * (assumption #11). A consumer (CPQ order conversion, a dunning job, an\n * operator) decides what to do with an over-limit signal.\n *\n * `credit_used` per mode:\n * - prepaid: max(0, \u2212balance) \u2014 the portion of the balance gone negative\n * (drawing on the credit line).\n * - postpaid: \u03A3 outstanding (posted, not-yet-paid) invoice `outstanding`\n * amounts from core/sales.\n *\n * `available_credit = credit_limit \u2212 credit_used` (unified across both modes;\n * for prepaid this equals `balance + credit_limit` when balance < 0, else\n * `credit_limit`).\n */\n\n/** Prepaid credit drawn = max(0, \u2212balance). */\nexport function prepaidCreditUsed(balance: string | number): string {\n if (compareMoney(balance, 0) >= 0) return '0.0000'\n return fromUnits(-toUnits(balance))\n}\n\nexport function computeCreditUsed(\n mode: BillingMode,\n opts: { balance?: string | number | null; outstanding?: string | number | null },\n): string {\n if (mode === 'prepaid') {\n return prepaidCreditUsed(opts.balance ?? '0')\n }\n // postpaid\n return fromUnits(toUnits(opts.outstanding ?? '0'))\n}\n\nexport function computeAvailableCredit(\n creditLimit: string | number,\n creditUsed: string | number,\n): string {\n return subMoney(creditLimit, creditUsed)\n}\n\n/**\n * | status | condition |\n * |---------------|--------------------------------------------|\n * | within_limit | available_credit > near_limit_buffer |\n * | near_limit | 0 < available_credit <= near_limit_buffer |\n * | over_limit | available_credit <= 0 (and credit is drawn)|\n *\n * `creditUsed` disambiguates the default \"no credit line\" case: with\n * `credit_limit = 0` and nothing drawn (`credit_used = 0`) the available\n * credit is exactly 0 \u2014 that is NOT over-limit (the account simply has no\n * credit line), so it reports `within_limit`. Once any credit is actually\n * drawn (prepaid balance negative, or postpaid outstanding > 0) the formula\n * applies. When `creditUsed` is omitted, a draw is assumed (pure spec\n * formula) \u2014 callers that have the figure should pass it.\n */\nexport function computeCreditStatus(\n availableCredit: string | number,\n nearLimitBuffer: string | number,\n creditUsed?: string | number,\n): CreditStatus {\n const hasDraw = creditUsed === undefined ? true : compareMoney(creditUsed, 0) > 0\n if (compareMoney(availableCredit, 0) <= 0) {\n return hasDraw ? 'over_limit' : 'within_limit'\n }\n if (compareMoney(availableCredit, nearLimitBuffer) <= 0) return 'near_limit'\n return 'within_limit'\n}\n\nexport function resolveNearLimitBuffer(\n configDefault: string | number | null | undefined,\n): string {\n if (configDefault !== null && configDefault !== undefined) return String(configDefault)\n return '0'\n}\n\nexport type CreditSnapshot = {\n mode: BillingMode\n creditLimit: string\n creditUsed: string\n availableCredit: string\n creditStatus: CreditStatus\n}\n\n/** Assemble the full credit snapshot reported on consume responses + the API. */\nexport function computeCreditSnapshot(params: {\n mode: BillingMode\n creditLimit: string | number\n nearLimitBuffer: string | number\n balance?: string | number | null\n outstanding?: string | number | null\n}): CreditSnapshot {\n const creditLimit = fromUnits(toUnits(params.creditLimit))\n const creditUsed = computeCreditUsed(params.mode, {\n balance: params.balance,\n outstanding: params.outstanding,\n })\n const availableCredit = computeAvailableCredit(creditLimit, creditUsed)\n return {\n mode: params.mode,\n creditLimit,\n creditUsed,\n availableCredit,\n creditStatus: computeCreditStatus(availableCredit, params.nearLimitBuffer, creditUsed),\n }\n}\n\nexport type CreditCrossing = {\n /** credit_used crossed upward past credit_limit (available \u2264 0). */\n overLimitCrossedUp: boolean\n /** available_credit crossed downward into (0, buffer] (optional event). */\n nearLimitCrossedUp: boolean\n}\n\n/**\n * Detect upward crossings of `credit_used` (i.e. downward crossings of\n * `available_credit`) for the `billing.credit.over_limit` /\n * `billing.credit.near_limit` events. `over_limit` fires once when usage /\n * recurring pushes credit_used from below the limit to at/above it.\n *\n * `credit_limit == 0` (no credit) means available is never > 0 once any\n * credit is \"used\"; to avoid firing on every prepaid debit while at 0 balance\n * with no credit line, the crossing requires a real limit (> 0).\n */\nexport function detectCreditCrossing(params: {\n creditLimit: string | number\n nearLimitBuffer: string | number\n usedBefore: string | number\n usedAfter: string | number\n}): CreditCrossing {\n const limitUnits = toUnits(params.creditLimit)\n if (limitUnits <= 0) {\n return { overLimitCrossedUp: false, nearLimitCrossedUp: false }\n }\n const usedBefore = toUnits(params.usedBefore)\n const usedAfter = toUnits(params.usedAfter)\n const bufferUnits = toUnits(params.nearLimitBuffer)\n\n const overLimitCrossedUp = usedBefore < limitUnits && usedAfter >= limitUnits\n\n const availBefore = limitUnits - usedBefore\n const availAfter = limitUnits - usedAfter\n const nearLimitCrossedUp =\n bufferUnits > 0 &&\n availBefore > bufferUnits &&\n availAfter <= bufferUnits &&\n availAfter > 0\n\n return { overLimitCrossedUp, nearLimitCrossedUp }\n}\n"],
|
|
5
|
+
"mappings": "AACA,SAAS,cAAc,WAAW,UAAU,eAAe;AAoBpD,SAAS,kBAAkB,SAAkC;AAClE,MAAI,aAAa,SAAS,CAAC,KAAK,EAAG,QAAO;AAC1C,SAAO,UAAU,CAAC,QAAQ,OAAO,CAAC;AACpC;AAEO,SAAS,kBACd,MACA,MACQ;AACR,MAAI,SAAS,WAAW;AACtB,WAAO,kBAAkB,KAAK,WAAW,GAAG;AAAA,EAC9C;AAEA,SAAO,UAAU,QAAQ,KAAK,eAAe,GAAG,CAAC;AACnD;AAEO,SAAS,uBACd,aACA,YACQ;AACR,SAAO,SAAS,aAAa,UAAU;AACzC;AAiBO,SAAS,oBACd,iBACA,iBACA,YACc;AACd,QAAM,UAAU,eAAe,SAAY,OAAO,aAAa,YAAY,CAAC,IAAI;AAChF,MAAI,aAAa,iBAAiB,CAAC,KAAK,GAAG;AACzC,WAAO,UAAU,eAAe;AAAA,EAClC;AACA,MAAI,aAAa,iBAAiB,eAAe,KAAK,EAAG,QAAO;AAChE,SAAO;AACT;AAEO,SAAS,uBACd,eACQ;AACR,MAAI,kBAAkB,QAAQ,kBAAkB,OAAW,QAAO,OAAO,aAAa;AACtF,SAAO;AACT;AAWO,SAAS,sBAAsB,QAMnB;AACjB,QAAM,cAAc,UAAU,QAAQ,OAAO,WAAW,CAAC;AACzD,QAAM,aAAa,kBAAkB,OAAO,MAAM;AAAA,IAChD,SAAS,OAAO;AAAA,IAChB,aAAa,OAAO;AAAA,EACtB,CAAC;AACD,QAAM,kBAAkB,uBAAuB,aAAa,UAAU;AACtE,SAAO;AAAA,IACL,MAAM,OAAO;AAAA,IACb;AAAA,IACA;AAAA,IACA;AAAA,IACA,cAAc,oBAAoB,iBAAiB,OAAO,iBAAiB,UAAU;AAAA,EACvF;AACF;AAmBO,SAAS,qBAAqB,QAKlB;AACjB,QAAM,aAAa,QAAQ,OAAO,WAAW;AAC7C,MAAI,cAAc,GAAG;AACnB,WAAO,EAAE,oBAAoB,OAAO,oBAAoB,MAAM;AAAA,EAChE;AACA,QAAM,aAAa,QAAQ,OAAO,UAAU;AAC5C,QAAM,YAAY,QAAQ,OAAO,SAAS;AAC1C,QAAM,cAAc,QAAQ,OAAO,eAAe;AAElD,QAAM,qBAAqB,aAAa,cAAc,aAAa;AAEnE,QAAM,cAAc,aAAa;AACjC,QAAM,aAAa,aAAa;AAChC,QAAM,qBACJ,cAAc,KACd,cAAc,eACd,cAAc,eACd,aAAa;AAEf,SAAO,EAAE,oBAAoB,mBAAmB;AAClD;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const SCALE = 1e4;
|
|
2
|
+
function toUnits(value) {
|
|
3
|
+
if (value === null || value === void 0) return 0;
|
|
4
|
+
const n = typeof value === "number" ? value : Number.parseFloat(value);
|
|
5
|
+
if (!Number.isFinite(n)) return 0;
|
|
6
|
+
return Math.round((n + Number.EPSILON * Math.sign(n)) * SCALE);
|
|
7
|
+
}
|
|
8
|
+
function fromUnits(units) {
|
|
9
|
+
const sign = units < 0 ? "-" : "";
|
|
10
|
+
const abs = Math.abs(Math.trunc(units));
|
|
11
|
+
const whole = Math.trunc(abs / SCALE);
|
|
12
|
+
const frac = abs % SCALE;
|
|
13
|
+
return `${sign}${whole}.${frac.toString().padStart(4, "0")}`;
|
|
14
|
+
}
|
|
15
|
+
function addMoney(a, b) {
|
|
16
|
+
return fromUnits(toUnits(a) + toUnits(b));
|
|
17
|
+
}
|
|
18
|
+
function subMoney(a, b) {
|
|
19
|
+
return fromUnits(toUnits(a) - toUnits(b));
|
|
20
|
+
}
|
|
21
|
+
function negateMoney(a) {
|
|
22
|
+
return fromUnits(-toUnits(a));
|
|
23
|
+
}
|
|
24
|
+
function absMoney(a) {
|
|
25
|
+
return fromUnits(Math.abs(toUnits(a)));
|
|
26
|
+
}
|
|
27
|
+
function compareMoney(a, b) {
|
|
28
|
+
const ua = toUnits(a);
|
|
29
|
+
const ub = toUnits(b);
|
|
30
|
+
if (ua < ub) return -1;
|
|
31
|
+
if (ua > ub) return 1;
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
function formatMoney(value) {
|
|
35
|
+
return fromUnits(toUnits(value));
|
|
36
|
+
}
|
|
37
|
+
export {
|
|
38
|
+
absMoney,
|
|
39
|
+
addMoney,
|
|
40
|
+
compareMoney,
|
|
41
|
+
formatMoney,
|
|
42
|
+
fromUnits,
|
|
43
|
+
negateMoney,
|
|
44
|
+
subMoney,
|
|
45
|
+
toUnits
|
|
46
|
+
};
|
|
47
|
+
//# sourceMappingURL=money.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/billing/lib/money.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Exact decimal arithmetic for the prepaid balance ledger (SPEC-002).\n *\n * All billing money columns are `numeric(18,4)` \u2014 four decimal places. Doing\n * the running-balance / reconciliation math in JS `number` would accumulate\n * binary floating-point error (`0.1 + 0.2 !== 0.3`), and the reconciliation\n * invariant `balance == SUM(amount) == latest.balance_after` would drift.\n *\n * So we work in **integer ten-thousandths** (\"units\"): the smallest\n * representable step of a `numeric(18,4)` column. `12.3400` \u21C4 `123400` units.\n * Addition/subtraction of integers is exact; we only ever format back to a\n * 4dp string at the persistence boundary.\n *\n * `numeric(18,4)` holds values up to ~10^14, i.e. ~10^18 units \u2014 well within\n * JS `Number.MAX_SAFE_INTEGER` (~9.007\u00D710^15) for any realistic balance\n * (max safe ~\u00B1900,000,000,000 currency units). Magnitudes beyond that would\n * already overflow the column itself; we guard the parse defensively.\n */\n\nconst SCALE = 10_000\n\n/** Parse a numeric(18,4) string (or number) into integer ten-thousandths. */\nexport function toUnits(value: string | number | null | undefined): number {\n if (value === null || value === undefined) return 0\n const n = typeof value === 'number' ? value : Number.parseFloat(value)\n if (!Number.isFinite(n)) return 0\n // Round to the nearest ten-thousandth \u2014 half-up, EPSILON-nudged to survive\n // representations like 12.33999999998.\n return Math.round((n + Number.EPSILON * Math.sign(n)) * SCALE)\n}\n\n/** Format integer ten-thousandths back to a numeric(18,4) string. */\nexport function fromUnits(units: number): string {\n const sign = units < 0 ? '-' : ''\n const abs = Math.abs(Math.trunc(units))\n const whole = Math.trunc(abs / SCALE)\n const frac = abs % SCALE\n return `${sign}${whole}.${frac.toString().padStart(4, '0')}`\n}\n\n/** Add two numeric(18,4) values exactly, returning a 4dp string. */\nexport function addMoney(a: string | number, b: string | number): string {\n return fromUnits(toUnits(a) + toUnits(b))\n}\n\n/** Subtract `b` from `a` exactly, returning a 4dp string. */\nexport function subMoney(a: string | number, b: string | number): string {\n return fromUnits(toUnits(a) - toUnits(b))\n}\n\n/** Negate a numeric(18,4) value, returning a 4dp string. */\nexport function negateMoney(a: string | number): string {\n return fromUnits(-toUnits(a))\n}\n\n/** Absolute value of a numeric(18,4), returning a 4dp string. */\nexport function absMoney(a: string | number): string {\n return fromUnits(Math.abs(toUnits(a)))\n}\n\n/** Compare two numeric(18,4) values. Returns -1 / 0 / 1. */\nexport function compareMoney(a: string | number, b: string | number): -1 | 0 | 1 {\n const ua = toUnits(a)\n const ub = toUnits(b)\n if (ua < ub) return -1\n if (ua > ub) return 1\n return 0\n}\n\n/** Normalize any numeric(18,4) input to a canonical 4dp string. */\nexport function formatMoney(value: string | number): string {\n return fromUnits(toUnits(value))\n}\n"],
|
|
5
|
+
"mappings": "AAmBA,MAAM,QAAQ;AAGP,SAAS,QAAQ,OAAmD;AACzE,MAAI,UAAU,QAAQ,UAAU,OAAW,QAAO;AAClD,QAAM,IAAI,OAAO,UAAU,WAAW,QAAQ,OAAO,WAAW,KAAK;AACrE,MAAI,CAAC,OAAO,SAAS,CAAC,EAAG,QAAO;AAGhC,SAAO,KAAK,OAAO,IAAI,OAAO,UAAU,KAAK,KAAK,CAAC,KAAK,KAAK;AAC/D;AAGO,SAAS,UAAU,OAAuB;AAC/C,QAAM,OAAO,QAAQ,IAAI,MAAM;AAC/B,QAAM,MAAM,KAAK,IAAI,KAAK,MAAM,KAAK,CAAC;AACtC,QAAM,QAAQ,KAAK,MAAM,MAAM,KAAK;AACpC,QAAM,OAAO,MAAM;AACnB,SAAO,GAAG,IAAI,GAAG,KAAK,IAAI,KAAK,SAAS,EAAE,SAAS,GAAG,GAAG,CAAC;AAC5D;AAGO,SAAS,SAAS,GAAoB,GAA4B;AACvE,SAAO,UAAU,QAAQ,CAAC,IAAI,QAAQ,CAAC,CAAC;AAC1C;AAGO,SAAS,SAAS,GAAoB,GAA4B;AACvE,SAAO,UAAU,QAAQ,CAAC,IAAI,QAAQ,CAAC,CAAC;AAC1C;AAGO,SAAS,YAAY,GAA4B;AACtD,SAAO,UAAU,CAAC,QAAQ,CAAC,CAAC;AAC9B;AAGO,SAAS,SAAS,GAA4B;AACnD,SAAO,UAAU,KAAK,IAAI,QAAQ,CAAC,CAAC,CAAC;AACvC;AAGO,SAAS,aAAa,GAAoB,GAAgC;AAC/E,QAAM,KAAK,QAAQ,CAAC;AACpB,QAAM,KAAK,QAAQ,CAAC;AACpB,MAAI,KAAK,GAAI,QAAO;AACpB,MAAI,KAAK,GAAI,QAAO;AACpB,SAAO;AACT;AAGO,SAAS,YAAY,OAAgC;AAC1D,SAAO,UAAU,QAAQ,KAAK,CAAC;AACjC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { ModuleConfig } from "@open-mercato/core/modules/configs/data/entities";
|
|
2
|
+
async function readConfig(em, name) {
|
|
3
|
+
const row = await em.findOne(ModuleConfig, { moduleId: "billing", name });
|
|
4
|
+
if (!row) return void 0;
|
|
5
|
+
return row.valueJson;
|
|
6
|
+
}
|
|
7
|
+
function asNumberString(value, fallback) {
|
|
8
|
+
if (value === null || value === void 0) return fallback;
|
|
9
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
10
|
+
if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) {
|
|
11
|
+
return value.trim();
|
|
12
|
+
}
|
|
13
|
+
return fallback;
|
|
14
|
+
}
|
|
15
|
+
async function getLowBalanceThresholdDefault(em) {
|
|
16
|
+
return asNumberString(await readConfig(em, "prepaid.low_balance_threshold_default"), "0");
|
|
17
|
+
}
|
|
18
|
+
async function getNearLimitBufferDefault(em) {
|
|
19
|
+
return asNumberString(await readConfig(em, "credit.near_limit_buffer_default"), "0");
|
|
20
|
+
}
|
|
21
|
+
async function getTopupProvider(em) {
|
|
22
|
+
const value = await readConfig(em, "prepaid.topup_provider");
|
|
23
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : "stripe";
|
|
24
|
+
}
|
|
25
|
+
async function getStatementEnabled(em) {
|
|
26
|
+
const value = await readConfig(em, "prepaid.statement_enabled");
|
|
27
|
+
if (value === void 0 || value === null) return true;
|
|
28
|
+
if (typeof value === "boolean") return value;
|
|
29
|
+
if (typeof value === "string") return value.trim().toLowerCase() !== "false";
|
|
30
|
+
return Boolean(value);
|
|
31
|
+
}
|
|
32
|
+
async function getTopupTaxRate(em) {
|
|
33
|
+
const value = await readConfig(em, "prepaid.topup_tax_rate");
|
|
34
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
35
|
+
if (typeof value === "string" && Number.isFinite(Number(value))) return Number(value);
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
export {
|
|
39
|
+
getLowBalanceThresholdDefault,
|
|
40
|
+
getNearLimitBufferDefault,
|
|
41
|
+
getStatementEnabled,
|
|
42
|
+
getTopupProvider,
|
|
43
|
+
getTopupTaxRate
|
|
44
|
+
};
|
|
45
|
+
//# sourceMappingURL=prepaidConfig.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/billing/lib/prepaidConfig.ts"],
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { ModuleConfig } from '@open-mercato/core/modules/configs/data/entities'\n\n/**\n * Typed readers for the prepaid billing module configs (SPEC-002), seeded by\n * `setup.ts \u2192 seedBillingConfigs`. Stored as raw JSON scalars in\n * `ModuleConfig.valueJson`. Each reader falls back to the same default the\n * seed uses, so a missing row (older tenant) behaves identically.\n */\n\nasync function readConfig(em: EntityManager, name: string): Promise<unknown> {\n const row = await em.findOne(ModuleConfig, { moduleId: 'billing', name })\n if (!row) return undefined\n return row.valueJson as unknown\n}\n\nfunction asNumberString(value: unknown, fallback: string): string {\n if (value === null || value === undefined) return fallback\n if (typeof value === 'number' && Number.isFinite(value)) return String(value)\n if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) {\n return value.trim()\n }\n return fallback\n}\n\n/** `prepaid.low_balance_threshold_default` \u2014 numeric string (default '0'). */\nexport async function getLowBalanceThresholdDefault(em: EntityManager): Promise<string> {\n return asNumberString(await readConfig(em, 'prepaid.low_balance_threshold_default'), '0')\n}\n\n/** `credit.near_limit_buffer_default` \u2014 numeric string (default '0'). */\nexport async function getNearLimitBufferDefault(em: EntityManager): Promise<string> {\n return asNumberString(await readConfig(em, 'credit.near_limit_buffer_default'), '0')\n}\n\n/** `prepaid.topup_provider` \u2014 gateway provider key (default 'stripe'). */\nexport async function getTopupProvider(em: EntityManager): Promise<string> {\n const value = await readConfig(em, 'prepaid.topup_provider')\n return typeof value === 'string' && value.trim() !== '' ? value.trim() : 'stripe'\n}\n\n/** `prepaid.statement_enabled` \u2014 master switch (default true). */\nexport async function getStatementEnabled(em: EntityManager): Promise<boolean> {\n const value = await readConfig(em, 'prepaid.statement_enabled')\n if (value === undefined || value === null) return true\n if (typeof value === 'boolean') return value\n // A stored string \"false\" must read as false (Boolean(\"false\") === true).\n if (typeof value === 'string') return value.trim().toLowerCase() !== 'false'\n return Boolean(value)\n}\n\n/** `prepaid.topup_tax_rate` \u2014 VAT percent on top-up receipts (default 0). */\nexport async function getTopupTaxRate(em: EntityManager): Promise<number> {\n const value = await readConfig(em, 'prepaid.topup_tax_rate')\n if (typeof value === 'number' && Number.isFinite(value)) return value\n if (typeof value === 'string' && Number.isFinite(Number(value))) return Number(value)\n return 0\n}\n"],
|
|
5
|
+
"mappings": "AACA,SAAS,oBAAoB;AAS7B,eAAe,WAAW,IAAmB,MAAgC;AAC3E,QAAM,MAAM,MAAM,GAAG,QAAQ,cAAc,EAAE,UAAU,WAAW,KAAK,CAAC;AACxE,MAAI,CAAC,IAAK,QAAO;AACjB,SAAO,IAAI;AACb;AAEA,SAAS,eAAe,OAAgB,UAA0B;AAChE,MAAI,UAAU,QAAQ,UAAU,OAAW,QAAO;AAClD,MAAI,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK,EAAG,QAAO,OAAO,KAAK;AAC5E,MAAI,OAAO,UAAU,YAAY,MAAM,KAAK,MAAM,MAAM,OAAO,SAAS,OAAO,KAAK,CAAC,GAAG;AACtF,WAAO,MAAM,KAAK;AAAA,EACpB;AACA,SAAO;AACT;AAGA,eAAsB,8BAA8B,IAAoC;AACtF,SAAO,eAAe,MAAM,WAAW,IAAI,uCAAuC,GAAG,GAAG;AAC1F;AAGA,eAAsB,0BAA0B,IAAoC;AAClF,SAAO,eAAe,MAAM,WAAW,IAAI,kCAAkC,GAAG,GAAG;AACrF;AAGA,eAAsB,iBAAiB,IAAoC;AACzE,QAAM,QAAQ,MAAM,WAAW,IAAI,wBAAwB;AAC3D,SAAO,OAAO,UAAU,YAAY,MAAM,KAAK,MAAM,KAAK,MAAM,KAAK,IAAI;AAC3E;AAGA,eAAsB,oBAAoB,IAAqC;AAC7E,QAAM,QAAQ,MAAM,WAAW,IAAI,2BAA2B;AAC9D,MAAI,UAAU,UAAa,UAAU,KAAM,QAAO;AAClD,MAAI,OAAO,UAAU,UAAW,QAAO;AAEvC,MAAI,OAAO,UAAU,SAAU,QAAO,MAAM,KAAK,EAAE,YAAY,MAAM;AACrE,SAAO,QAAQ,KAAK;AACtB;AAGA,eAAsB,gBAAgB,IAAoC;AACxE,QAAM,QAAQ,MAAM,WAAW,IAAI,wBAAwB;AAC3D,MAAI,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK,EAAG,QAAO;AAChE,MAAI,OAAO,UAAU,YAAY,OAAO,SAAS,OAAO,KAAK,CAAC,EAAG,QAAO,OAAO,KAAK;AACpF,SAAO;AACT;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BillingAccountBalance,
|
|
3
|
+
BillingAccountTransaction,
|
|
4
|
+
BillingAccountUsage,
|
|
5
|
+
BillingItem
|
|
6
|
+
} from "../data/entities.js";
|
|
7
|
+
import {
|
|
8
|
+
applyBalanceMovement,
|
|
9
|
+
PREPAID_USAGE_SENTINEL_RUN_ID
|
|
10
|
+
} from "./balanceLedger.js";
|
|
11
|
+
import {
|
|
12
|
+
computeBalanceStatus,
|
|
13
|
+
resolveLowBalanceThreshold
|
|
14
|
+
} from "./balanceStatus.js";
|
|
15
|
+
import {
|
|
16
|
+
computeCreditStatus,
|
|
17
|
+
computeAvailableCredit,
|
|
18
|
+
prepaidCreditUsed
|
|
19
|
+
} from "./creditStatus.js";
|
|
20
|
+
import { buildBalanceCrossingEvents } from "./balanceEvents.js";
|
|
21
|
+
import { absMoney, negateMoney } from "./money.js";
|
|
22
|
+
import {
|
|
23
|
+
getLowBalanceThresholdDefault,
|
|
24
|
+
getNearLimitBufferDefault
|
|
25
|
+
} from "./prepaidConfig.js";
|
|
26
|
+
import { rateUsageForItem } from "./usageRater.js";
|
|
27
|
+
function breakdownFromMetadata(meta) {
|
|
28
|
+
const b = meta?.usage_tier_breakdown;
|
|
29
|
+
return Array.isArray(b) ? b : [];
|
|
30
|
+
}
|
|
31
|
+
async function findUsageItem(em, input) {
|
|
32
|
+
return em.findOne(
|
|
33
|
+
BillingItem,
|
|
34
|
+
{
|
|
35
|
+
tenantId: input.tenantId,
|
|
36
|
+
organizationId: input.organizationId,
|
|
37
|
+
billAccountId: input.billAccountId,
|
|
38
|
+
type: "usage",
|
|
39
|
+
uomCode: input.uomCode,
|
|
40
|
+
isActive: true,
|
|
41
|
+
deletedAt: null
|
|
42
|
+
},
|
|
43
|
+
{ orderBy: { createdAt: "DESC" } }
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
async function consumePrepaidUsage(em, account, input) {
|
|
47
|
+
const currencyCode = account.currencyCode;
|
|
48
|
+
const threshold = resolveLowBalanceThreshold(
|
|
49
|
+
await readAccountThreshold(em, input),
|
|
50
|
+
await getLowBalanceThresholdDefault(em)
|
|
51
|
+
);
|
|
52
|
+
const nearLimitBuffer = await getNearLimitBufferDefault(em);
|
|
53
|
+
const creditLimit = account.creditLimit ?? "0";
|
|
54
|
+
if (input.sourceRef) {
|
|
55
|
+
const existing = await em.findOne(BillingAccountUsage, {
|
|
56
|
+
tenantId: input.tenantId,
|
|
57
|
+
billAccountId: input.billAccountId,
|
|
58
|
+
sourceRef: input.sourceRef,
|
|
59
|
+
deletedAt: null
|
|
60
|
+
});
|
|
61
|
+
if (existing) {
|
|
62
|
+
const txn = await em.findOne(BillingAccountTransaction, {
|
|
63
|
+
tenantId: input.tenantId,
|
|
64
|
+
billAccountId: input.billAccountId,
|
|
65
|
+
usageId: existing.id,
|
|
66
|
+
deletedAt: null
|
|
67
|
+
});
|
|
68
|
+
const balance = txn?.balanceAfter ?? await readCurrentBalance(em, input);
|
|
69
|
+
const ratedAmount2 = txn ? absMoney(txn.amount) : "0.0000";
|
|
70
|
+
const warning2 = txn?.metadata?.warning ?? null;
|
|
71
|
+
return {
|
|
72
|
+
result: {
|
|
73
|
+
id: existing.id,
|
|
74
|
+
deduplicated: true,
|
|
75
|
+
ratedAmount: ratedAmount2,
|
|
76
|
+
currencyCode,
|
|
77
|
+
balance,
|
|
78
|
+
balanceStatus: computeBalanceStatus(balance, threshold),
|
|
79
|
+
creditStatus: computeCreditStatus(
|
|
80
|
+
computeAvailableCredit(creditLimit, prepaidCreditUsed(balance)),
|
|
81
|
+
nearLimitBuffer,
|
|
82
|
+
prepaidCreditUsed(balance)
|
|
83
|
+
),
|
|
84
|
+
tierBreakdown: breakdownFromMetadata(txn?.metadata),
|
|
85
|
+
warning: warning2
|
|
86
|
+
},
|
|
87
|
+
events: []
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const now = /* @__PURE__ */ new Date();
|
|
92
|
+
const usage = em.create(BillingAccountUsage, {
|
|
93
|
+
organizationId: input.organizationId,
|
|
94
|
+
tenantId: input.tenantId,
|
|
95
|
+
billAccountId: input.billAccountId,
|
|
96
|
+
uomCode: input.uomCode,
|
|
97
|
+
quantity: input.quantity.toFixed(4),
|
|
98
|
+
periodStart: input.periodStart,
|
|
99
|
+
periodEnd: input.periodEnd,
|
|
100
|
+
lineDescription: input.lineDescription ?? null,
|
|
101
|
+
sourceRef: input.sourceRef ?? null,
|
|
102
|
+
// Stamped immediately so a postpaid-style run never re-rates it.
|
|
103
|
+
ratedInBillRunId: PREPAID_USAGE_SENTINEL_RUN_ID,
|
|
104
|
+
isActive: true,
|
|
105
|
+
createdAt: now,
|
|
106
|
+
updatedAt: now
|
|
107
|
+
});
|
|
108
|
+
em.persist(usage);
|
|
109
|
+
await em.flush();
|
|
110
|
+
const item = await findUsageItem(em, input);
|
|
111
|
+
let ratedAmount = "0.0000";
|
|
112
|
+
let breakdown = [];
|
|
113
|
+
let warning = null;
|
|
114
|
+
if (!item) {
|
|
115
|
+
warning = "no_matching_item";
|
|
116
|
+
} else {
|
|
117
|
+
const rated = rateUsageForItem(item, input.quantity);
|
|
118
|
+
if (!rated) {
|
|
119
|
+
warning = "unratable";
|
|
120
|
+
} else {
|
|
121
|
+
ratedAmount = rated.amount;
|
|
122
|
+
breakdown = rated.breakdown;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const movement = await applyBalanceMovement(em, {
|
|
126
|
+
organizationId: input.organizationId,
|
|
127
|
+
tenantId: input.tenantId,
|
|
128
|
+
billAccountId: input.billAccountId,
|
|
129
|
+
currencyCode,
|
|
130
|
+
type: "usage",
|
|
131
|
+
amount: negateMoney(ratedAmount),
|
|
132
|
+
description: buildDescription(item, input, warning),
|
|
133
|
+
usageId: usage.id,
|
|
134
|
+
billingItemId: item?.id ?? null,
|
|
135
|
+
sourceRef: input.sourceRef ?? null,
|
|
136
|
+
metadata: {
|
|
137
|
+
uom_code: input.uomCode,
|
|
138
|
+
usage_tier_breakdown: breakdown,
|
|
139
|
+
...warning ? { warning } : {}
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
const { balanceBefore, balanceAfter } = movement;
|
|
143
|
+
const balanceStatus = computeBalanceStatus(balanceAfter, threshold);
|
|
144
|
+
const scope = {
|
|
145
|
+
billAccountId: input.billAccountId,
|
|
146
|
+
tenantId: input.tenantId,
|
|
147
|
+
organizationId: input.organizationId,
|
|
148
|
+
currencyCode
|
|
149
|
+
};
|
|
150
|
+
const events = buildBalanceCrossingEvents({
|
|
151
|
+
scope,
|
|
152
|
+
balanceBefore,
|
|
153
|
+
balanceAfter,
|
|
154
|
+
threshold,
|
|
155
|
+
creditLimit,
|
|
156
|
+
nearLimitBuffer
|
|
157
|
+
});
|
|
158
|
+
events.push({
|
|
159
|
+
id: "billing.usage.rated",
|
|
160
|
+
payload: {
|
|
161
|
+
...scope,
|
|
162
|
+
balance: balanceAfter,
|
|
163
|
+
usageId: usage.id,
|
|
164
|
+
transactionId: movement.transaction.id,
|
|
165
|
+
ratedAmount,
|
|
166
|
+
balanceStatus
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
const usedAfter = prepaidCreditUsed(balanceAfter);
|
|
170
|
+
const creditStatus = computeCreditStatus(
|
|
171
|
+
computeAvailableCredit(creditLimit, usedAfter),
|
|
172
|
+
nearLimitBuffer,
|
|
173
|
+
usedAfter
|
|
174
|
+
);
|
|
175
|
+
return {
|
|
176
|
+
result: {
|
|
177
|
+
id: usage.id,
|
|
178
|
+
deduplicated: false,
|
|
179
|
+
ratedAmount,
|
|
180
|
+
currencyCode,
|
|
181
|
+
balance: balanceAfter,
|
|
182
|
+
balanceStatus,
|
|
183
|
+
creditStatus,
|
|
184
|
+
tierBreakdown: breakdown,
|
|
185
|
+
warning
|
|
186
|
+
},
|
|
187
|
+
events
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function buildDescription(item, input, warning) {
|
|
191
|
+
if (input.lineDescription) return input.lineDescription;
|
|
192
|
+
const qty = input.quantity.toLocaleString("en-US");
|
|
193
|
+
if (!item) return `Usage \u2014 ${qty} ${input.uomCode} (no matching item)`;
|
|
194
|
+
if (warning === "unratable") return `Usage \u2014 ${qty} ${input.uomCode} (unratable)`;
|
|
195
|
+
return `${item.description} \u2014 ${qty} ${input.uomCode}`;
|
|
196
|
+
}
|
|
197
|
+
async function readAccountThreshold(em, input) {
|
|
198
|
+
const row = await em.findOne(BillingAccountBalance, {
|
|
199
|
+
tenantId: input.tenantId,
|
|
200
|
+
billAccountId: input.billAccountId
|
|
201
|
+
});
|
|
202
|
+
return row?.lowBalanceThreshold ?? null;
|
|
203
|
+
}
|
|
204
|
+
async function readCurrentBalance(em, input) {
|
|
205
|
+
const row = await em.findOne(BillingAccountBalance, {
|
|
206
|
+
tenantId: input.tenantId,
|
|
207
|
+
billAccountId: input.billAccountId
|
|
208
|
+
});
|
|
209
|
+
return row?.balance ?? "0.0000";
|
|
210
|
+
}
|
|
211
|
+
export {
|
|
212
|
+
consumePrepaidUsage
|
|
213
|
+
};
|
|
214
|
+
//# sourceMappingURL=prepaidConsume.js.map
|