@dainamite/billing 0.17.0 → 0.18.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 +81 -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 +1 -1
- package/src/modules/billing/__tests__/commands/topups.test.ts +104 -0
- package/src/modules/billing/__tests__/commands/transactions.test.ts +84 -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
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import {
|
|
3
|
+
BillingAccountBalance,
|
|
4
|
+
BillingAccountTransaction,
|
|
5
|
+
} from '../data/entities'
|
|
6
|
+
import type { TransactionType } from '../data/validators'
|
|
7
|
+
import { fromUnits, subMoney, toUnits } from './money'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Prepaid balance ledger (SPEC-002).
|
|
11
|
+
*
|
|
12
|
+
* Two append-only primitives back every balance change:
|
|
13
|
+
* - `BillingAccountBalance` — the O(1) running-balance cache, one row per
|
|
14
|
+
* prepaid account; the row the atomic `UPDATE … RETURNING` locks.
|
|
15
|
+
* - `BillingAccountTransaction` — the source of truth; `balance == SUM(amount)`.
|
|
16
|
+
*
|
|
17
|
+
* Concurrency correctness: the single-statement `UPDATE … RETURNING`
|
|
18
|
+
* serializes via the Postgres row lock on the balance row, so N concurrent
|
|
19
|
+
* uploads apply N debits with no lost update and each gets a distinct
|
|
20
|
+
* `balance_after`. The transaction insert in the SAME DB transaction carries
|
|
21
|
+
* that exact snapshot.
|
|
22
|
+
*
|
|
23
|
+
* **All functions here MUST be called inside an `em.transactional(...)`** so
|
|
24
|
+
* the balance move and the transaction insert commit (or roll back) together.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// Sentinel `rated_in_bill_run_id` stamped on a usage record the moment it is
|
|
28
|
+
// rated in real time, so a postpaid-style Bill Run never re-rates it. It is a
|
|
29
|
+
// fixed, recognizable UUID (not a real run id) — its presence simply means
|
|
30
|
+
// "already rated at ingest, paired with a BillingAccountTransaction".
|
|
31
|
+
export const PREPAID_USAGE_SENTINEL_RUN_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'
|
|
32
|
+
|
|
33
|
+
export type BalanceRowInit = {
|
|
34
|
+
organizationId: string
|
|
35
|
+
tenantId: string
|
|
36
|
+
billAccountId: string
|
|
37
|
+
currencyCode: string
|
|
38
|
+
lowBalanceThreshold?: string | null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Find-or-create the 1:1 balance row for an account. Idempotent — safe to
|
|
43
|
+
* call on every prepaid account create. Uses the ORM (this runs at account
|
|
44
|
+
* create, not on the hot consume path).
|
|
45
|
+
*/
|
|
46
|
+
export async function ensureBalanceRow(
|
|
47
|
+
em: EntityManager,
|
|
48
|
+
init: BalanceRowInit,
|
|
49
|
+
): Promise<BillingAccountBalance> {
|
|
50
|
+
const existing = await em.findOne(BillingAccountBalance, {
|
|
51
|
+
tenantId: init.tenantId,
|
|
52
|
+
billAccountId: init.billAccountId,
|
|
53
|
+
})
|
|
54
|
+
if (existing) {
|
|
55
|
+
// Allow create-time threshold to land even if a prior (defensive) row
|
|
56
|
+
// already exists with no threshold set.
|
|
57
|
+
if (
|
|
58
|
+
init.lowBalanceThreshold !== undefined &&
|
|
59
|
+
init.lowBalanceThreshold !== null &&
|
|
60
|
+
(existing.lowBalanceThreshold === null || existing.lowBalanceThreshold === undefined)
|
|
61
|
+
) {
|
|
62
|
+
existing.lowBalanceThreshold = init.lowBalanceThreshold
|
|
63
|
+
existing.updatedAt = new Date()
|
|
64
|
+
}
|
|
65
|
+
return existing
|
|
66
|
+
}
|
|
67
|
+
const now = new Date()
|
|
68
|
+
const row = em.create(BillingAccountBalance, {
|
|
69
|
+
organizationId: init.organizationId,
|
|
70
|
+
tenantId: init.tenantId,
|
|
71
|
+
billAccountId: init.billAccountId,
|
|
72
|
+
currencyCode: init.currencyCode,
|
|
73
|
+
balance: '0',
|
|
74
|
+
lowBalanceThreshold: init.lowBalanceThreshold ?? null,
|
|
75
|
+
lastMovementAt: null,
|
|
76
|
+
createdAt: now,
|
|
77
|
+
updatedAt: now,
|
|
78
|
+
})
|
|
79
|
+
em.persist(row)
|
|
80
|
+
await em.flush()
|
|
81
|
+
return row
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type ApplyMovementParams = {
|
|
85
|
+
organizationId: string
|
|
86
|
+
tenantId: string
|
|
87
|
+
billAccountId: string
|
|
88
|
+
currencyCode: string
|
|
89
|
+
/** Transaction type. */
|
|
90
|
+
type: TransactionType
|
|
91
|
+
/** SIGNED amount: > 0 credit, < 0 debit. */
|
|
92
|
+
amount: string
|
|
93
|
+
description: string
|
|
94
|
+
usageId?: string | null
|
|
95
|
+
billingItemId?: string | null
|
|
96
|
+
topupId?: string | null
|
|
97
|
+
statementId?: string | null
|
|
98
|
+
/** Idempotency key — when set, a duplicate is a no-op (returns the prior row). */
|
|
99
|
+
sourceRef?: string | null
|
|
100
|
+
metadata?: Record<string, unknown> | null
|
|
101
|
+
userId?: string | null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type ApplyMovementResult = {
|
|
105
|
+
/** Running balance BEFORE this movement (4dp string). */
|
|
106
|
+
balanceBefore: string
|
|
107
|
+
/** Running balance AFTER this movement — authoritative (4dp string). */
|
|
108
|
+
balanceAfter: string
|
|
109
|
+
transaction: BillingAccountTransaction
|
|
110
|
+
/** True when `sourceRef` already existed — no balance was moved. */
|
|
111
|
+
deduplicated: boolean
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Atomically move the balance and append the paired transaction.
|
|
116
|
+
*
|
|
117
|
+
* Idempotent on `sourceRef`: if a transaction with the same
|
|
118
|
+
* `(tenant_id, bill_account_id, source_ref)` already exists, NO balance move
|
|
119
|
+
* happens and the existing row is returned (`deduplicated: true`). The unique
|
|
120
|
+
* constraint is the correctness backstop if a race slips past the pre-check —
|
|
121
|
+
* the duplicate insert fails and rolls back the whole (caller's) transaction,
|
|
122
|
+
* so the balance move is undone too. Never double-moves.
|
|
123
|
+
*/
|
|
124
|
+
export async function applyBalanceMovement(
|
|
125
|
+
em: EntityManager,
|
|
126
|
+
params: ApplyMovementParams,
|
|
127
|
+
): Promise<ApplyMovementResult> {
|
|
128
|
+
if (params.sourceRef) {
|
|
129
|
+
const existing = await em.findOne(BillingAccountTransaction, {
|
|
130
|
+
tenantId: params.tenantId,
|
|
131
|
+
billAccountId: params.billAccountId,
|
|
132
|
+
sourceRef: params.sourceRef,
|
|
133
|
+
deletedAt: null,
|
|
134
|
+
})
|
|
135
|
+
if (existing) {
|
|
136
|
+
return {
|
|
137
|
+
balanceBefore: subMoney(existing.balanceAfter, existing.amount),
|
|
138
|
+
balanceAfter: existing.balanceAfter,
|
|
139
|
+
transaction: existing,
|
|
140
|
+
deduplicated: true,
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const now = new Date()
|
|
146
|
+
// Self-healing: ensure the balance row exists (race-safe via the unique
|
|
147
|
+
// constraint), then the atomic single-statement move. `amount` is signed
|
|
148
|
+
// so one statement covers both credit and debit.
|
|
149
|
+
await em.execute(
|
|
150
|
+
`INSERT INTO billing_account_balances
|
|
151
|
+
(id, organization_id, tenant_id, bill_account_id, currency_code, balance, created_at, updated_at)
|
|
152
|
+
VALUES (gen_random_uuid(), ?, ?, ?, ?, 0, ?, ?)
|
|
153
|
+
ON CONFLICT (tenant_id, bill_account_id) DO NOTHING`,
|
|
154
|
+
[
|
|
155
|
+
params.organizationId,
|
|
156
|
+
params.tenantId,
|
|
157
|
+
params.billAccountId,
|
|
158
|
+
params.currencyCode,
|
|
159
|
+
now,
|
|
160
|
+
now,
|
|
161
|
+
],
|
|
162
|
+
)
|
|
163
|
+
const rows = (await em.execute(
|
|
164
|
+
`UPDATE billing_account_balances
|
|
165
|
+
SET balance = balance + ?, last_movement_at = ?, updated_at = ?
|
|
166
|
+
WHERE tenant_id = ? AND bill_account_id = ?
|
|
167
|
+
RETURNING balance`,
|
|
168
|
+
[params.amount, now, now, params.tenantId, params.billAccountId],
|
|
169
|
+
)) as unknown as Array<{ balance: string }>
|
|
170
|
+
|
|
171
|
+
if (!rows || rows.length === 0) {
|
|
172
|
+
// Should be unreachable — the upsert above guarantees a row exists.
|
|
173
|
+
throw new Error(
|
|
174
|
+
`balanceLedger: balance row missing for account ${params.billAccountId} after upsert`,
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
// Normalize whatever the driver hands back (string or number) to 4dp.
|
|
178
|
+
const balanceAfter = fromUnits(toUnits(rows[0].balance))
|
|
179
|
+
const balanceBefore = subMoney(balanceAfter, params.amount)
|
|
180
|
+
|
|
181
|
+
const transaction = em.create(BillingAccountTransaction, {
|
|
182
|
+
organizationId: params.organizationId,
|
|
183
|
+
tenantId: params.tenantId,
|
|
184
|
+
billAccountId: params.billAccountId,
|
|
185
|
+
type: params.type,
|
|
186
|
+
amount: params.amount,
|
|
187
|
+
currencyCode: params.currencyCode,
|
|
188
|
+
balanceAfter,
|
|
189
|
+
usageId: params.usageId ?? null,
|
|
190
|
+
billingItemId: params.billingItemId ?? null,
|
|
191
|
+
topupId: params.topupId ?? null,
|
|
192
|
+
statementId: params.statementId ?? null,
|
|
193
|
+
description: params.description,
|
|
194
|
+
sourceRef: params.sourceRef ?? null,
|
|
195
|
+
metadata: params.metadata ?? null,
|
|
196
|
+
userId: params.userId ?? null,
|
|
197
|
+
createdAt: now,
|
|
198
|
+
})
|
|
199
|
+
em.persist(transaction)
|
|
200
|
+
await em.flush()
|
|
201
|
+
|
|
202
|
+
return { balanceBefore, balanceAfter, transaction, deduplicated: false }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export type ReconcileResult = {
|
|
206
|
+
/** The denormalized running balance (cache). */
|
|
207
|
+
cachedBalance: string
|
|
208
|
+
/** SUM(amount) over all (non-deleted) transactions. */
|
|
209
|
+
sumOfAmounts: string
|
|
210
|
+
/** The most recent transaction's balance_after, or '0.0000' if none. */
|
|
211
|
+
latestBalanceAfter: string
|
|
212
|
+
/** True when all three agree. */
|
|
213
|
+
reconciled: boolean
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Assert the ledger invariant for one account:
|
|
218
|
+
* `cachedBalance == SUM(amount) == latest.balance_after`.
|
|
219
|
+
*
|
|
220
|
+
* The optional reconciliation worker (P5) runs this per account and alerts on
|
|
221
|
+
* drift — a cheap guard against any future non-atomic write slipping in.
|
|
222
|
+
*/
|
|
223
|
+
export async function reconcileAccountBalance(
|
|
224
|
+
em: EntityManager,
|
|
225
|
+
scope: { tenantId: string; organizationId: string; billAccountId: string },
|
|
226
|
+
): Promise<ReconcileResult> {
|
|
227
|
+
const balanceRow = await em.findOne(BillingAccountBalance, {
|
|
228
|
+
tenantId: scope.tenantId,
|
|
229
|
+
billAccountId: scope.billAccountId,
|
|
230
|
+
})
|
|
231
|
+
const cachedBalance = balanceRow ? fromUnits(toUnits(balanceRow.balance)) : '0.0000'
|
|
232
|
+
|
|
233
|
+
const sumRows = (await em.execute(
|
|
234
|
+
`SELECT COALESCE(SUM(amount), 0) AS sum_amount
|
|
235
|
+
FROM billing_account_transactions
|
|
236
|
+
WHERE tenant_id = ? AND organization_id = ? AND bill_account_id = ? AND deleted_at IS NULL`,
|
|
237
|
+
[scope.tenantId, scope.organizationId, scope.billAccountId],
|
|
238
|
+
)) as unknown as Array<{ sum_amount: string }>
|
|
239
|
+
const sumOfAmounts = fromUnits(toUnits(sumRows?.[0]?.sum_amount ?? 0))
|
|
240
|
+
|
|
241
|
+
const latest = await em.findOne(
|
|
242
|
+
BillingAccountTransaction,
|
|
243
|
+
{
|
|
244
|
+
tenantId: scope.tenantId,
|
|
245
|
+
organizationId: scope.organizationId,
|
|
246
|
+
billAccountId: scope.billAccountId,
|
|
247
|
+
deletedAt: null,
|
|
248
|
+
},
|
|
249
|
+
{ orderBy: { createdAt: 'DESC' } },
|
|
250
|
+
)
|
|
251
|
+
const latestBalanceAfter = latest ? fromUnits(toUnits(latest.balanceAfter)) : '0.0000'
|
|
252
|
+
|
|
253
|
+
const reconciled =
|
|
254
|
+
toUnits(cachedBalance) === toUnits(sumOfAmounts) &&
|
|
255
|
+
(latest === null || toUnits(cachedBalance) === toUnits(latestBalanceAfter))
|
|
256
|
+
|
|
257
|
+
return { cachedBalance, sumOfAmounts, latestBalanceAfter, reconciled }
|
|
258
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { BalanceStatus } from '../data/validators'
|
|
2
|
+
import { compareMoney, toUnits } from './money'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Prepaid funds status & threshold-crossing detection (SPEC-002).
|
|
6
|
+
*
|
|
7
|
+
* Usage is NEVER rejected — we only classify and report. Events fire ONLY on
|
|
8
|
+
* a downward crossing (compare `balance_before` vs `balance_after`), so a
|
|
9
|
+
* client sitting at `exhausted` and still pushing usage is not spammed.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve the effective low-balance threshold:
|
|
14
|
+
* account row's own threshold → tenant config default → 0 (off).
|
|
15
|
+
*/
|
|
16
|
+
export function resolveLowBalanceThreshold(
|
|
17
|
+
accountThreshold: string | null | undefined,
|
|
18
|
+
configDefault: string | number | null | undefined,
|
|
19
|
+
): string {
|
|
20
|
+
if (accountThreshold !== null && accountThreshold !== undefined) {
|
|
21
|
+
return accountThreshold
|
|
22
|
+
}
|
|
23
|
+
if (configDefault !== null && configDefault !== undefined) {
|
|
24
|
+
return String(configDefault)
|
|
25
|
+
}
|
|
26
|
+
return '0'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* | status | condition |
|
|
31
|
+
* |------------|------------------------------------|
|
|
32
|
+
* | exhausted | balance <= 0 (incl. negative) |
|
|
33
|
+
* | low | 0 < balance <= threshold |
|
|
34
|
+
* | ok | balance > threshold |
|
|
35
|
+
*/
|
|
36
|
+
export function computeBalanceStatus(
|
|
37
|
+
balance: string | number,
|
|
38
|
+
threshold: string | number,
|
|
39
|
+
): BalanceStatus {
|
|
40
|
+
if (compareMoney(balance, 0) <= 0) return 'exhausted'
|
|
41
|
+
if (compareMoney(balance, threshold) <= 0) return 'low'
|
|
42
|
+
return 'ok'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type BalanceCrossing = {
|
|
46
|
+
/** Crossed downward from > threshold into the (0, threshold] low band. */
|
|
47
|
+
lowCrossedDown: boolean
|
|
48
|
+
/** Crossed downward from > 0 to <= 0. */
|
|
49
|
+
exhaustedCrossedDown: boolean
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Detect downward threshold crossings between two balances.
|
|
54
|
+
*
|
|
55
|
+
* A single large debit that jumps straight past the low band into exhausted
|
|
56
|
+
* fires ONLY `exhausted` (the customer is already negative — "you're low" is
|
|
57
|
+
* moot). `low` fires only when the balance lands in the (0, threshold] band.
|
|
58
|
+
* When `threshold <= 0` the low band is empty, so `low` never fires (the
|
|
59
|
+
* config default 0 = "off"); `exhausted` still works.
|
|
60
|
+
*/
|
|
61
|
+
export function detectBalanceCrossing(
|
|
62
|
+
balanceBefore: string | number,
|
|
63
|
+
balanceAfter: string | number,
|
|
64
|
+
threshold: string | number,
|
|
65
|
+
): BalanceCrossing {
|
|
66
|
+
const beforeUnits = toUnits(balanceBefore)
|
|
67
|
+
const afterUnits = toUnits(balanceAfter)
|
|
68
|
+
const thresholdUnits = toUnits(threshold)
|
|
69
|
+
|
|
70
|
+
const exhaustedCrossedDown = beforeUnits > 0 && afterUnits <= 0
|
|
71
|
+
const lowCrossedDown =
|
|
72
|
+
thresholdUnits > 0 &&
|
|
73
|
+
beforeUnits > thresholdUnits &&
|
|
74
|
+
afterUnits <= thresholdUnits &&
|
|
75
|
+
afterUnits > 0
|
|
76
|
+
|
|
77
|
+
return { lowCrossedDown, exhaustedCrossedDown }
|
|
78
|
+
}
|
|
@@ -20,6 +20,9 @@ import {
|
|
|
20
20
|
} from './invoiceWriter'
|
|
21
21
|
import { withTenantLock } from './tenantLock'
|
|
22
22
|
import { processUsageForAccount } from './usageRunner'
|
|
23
|
+
import { processPrepaidAccount } from './prepaidPeriodClose'
|
|
24
|
+
import type { DomainEvent } from './balanceEvents'
|
|
25
|
+
import { emitBillingEvent, type BillingEventId } from '../events'
|
|
23
26
|
import type { BillCycle } from '../data/validators'
|
|
24
27
|
|
|
25
28
|
/**
|
|
@@ -73,15 +76,27 @@ export type RunBillRunResult = {
|
|
|
73
76
|
outcomes: BillRunOutcome[]
|
|
74
77
|
}
|
|
75
78
|
|
|
79
|
+
type RunOutput = RunBillRunResult & {
|
|
80
|
+
/** Prepaid statement / crossing events to emit AFTER the outer tx commits. */
|
|
81
|
+
pendingEvents: DomainEvent[]
|
|
82
|
+
}
|
|
83
|
+
|
|
76
84
|
type AccountWarnings = {
|
|
77
85
|
currency_mismatch_items?: string[]
|
|
78
86
|
unmatched_usage_uoms?: string[]
|
|
79
87
|
}
|
|
80
88
|
|
|
81
89
|
type AccountProcessSuccess = {
|
|
82
|
-
status:
|
|
83
|
-
|
|
90
|
+
status:
|
|
91
|
+
| 'success'
|
|
92
|
+
| 'success_with_warnings'
|
|
93
|
+
| 'skipped_existing_draft'
|
|
94
|
+
| 'skipped_existing_statement'
|
|
95
|
+
warnings: AccountWarnings | Record<string, unknown> | null
|
|
84
96
|
draftInvoiceId: string | null
|
|
97
|
+
// SPEC-002: prepaid accounts produce a statement instead of a draft invoice.
|
|
98
|
+
statementId?: string | null
|
|
99
|
+
statementEvents?: DomainEvent[]
|
|
85
100
|
cyclesEmitted: number
|
|
86
101
|
usageRecordsRated: number
|
|
87
102
|
}
|
|
@@ -101,18 +116,25 @@ export async function runBillRun(
|
|
|
101
116
|
container: AwilixContainer,
|
|
102
117
|
params: RunBillRunParams,
|
|
103
118
|
): Promise<RunBillRunResult> {
|
|
104
|
-
|
|
119
|
+
const output = await withTenantLock(
|
|
105
120
|
em,
|
|
106
121
|
{ tenantId: params.tenantId, lockName: 'billing-run' },
|
|
107
122
|
async (tem) => executeRunWithinLock(tem, container, params),
|
|
108
123
|
)
|
|
124
|
+
// Emit prepaid statement / crossing events ONLY after the outer transaction
|
|
125
|
+
// (held by withTenantLock) has committed — emitting inside the lock would let
|
|
126
|
+
// a consumer observe an uncommitted statement or a phantom event on rollback.
|
|
127
|
+
for (const ev of output.pendingEvents) {
|
|
128
|
+
await emitBillingEvent(ev.id as BillingEventId, ev.payload, { persistent: true })
|
|
129
|
+
}
|
|
130
|
+
return { billRun: output.billRun, outcomes: output.outcomes }
|
|
109
131
|
}
|
|
110
132
|
|
|
111
133
|
async function executeRunWithinLock(
|
|
112
134
|
em: EntityManager,
|
|
113
135
|
container: AwilixContainer,
|
|
114
136
|
params: RunBillRunParams,
|
|
115
|
-
): Promise<
|
|
137
|
+
): Promise<RunOutput> {
|
|
116
138
|
const now = new Date()
|
|
117
139
|
const billRun = em.create(BillRun, {
|
|
118
140
|
organizationId: params.organizationId,
|
|
@@ -136,6 +158,7 @@ async function executeRunWithinLock(
|
|
|
136
158
|
|
|
137
159
|
const accounts = await selectTargetAccounts(em, params)
|
|
138
160
|
const outcomes: BillRunOutcome[] = []
|
|
161
|
+
const pendingEvents: DomainEvent[] = []
|
|
139
162
|
let totalUsageRecordsRated = 0
|
|
140
163
|
|
|
141
164
|
for (const account of accounts) {
|
|
@@ -155,6 +178,7 @@ async function executeRunWithinLock(
|
|
|
155
178
|
? null
|
|
156
179
|
: (processResult.warnings as Record<string, unknown> | null),
|
|
157
180
|
draftInvoiceId: processResult.status === 'failed' ? null : processResult.draftInvoiceId,
|
|
181
|
+
statementId: processResult.status === 'failed' ? null : (processResult.statementId ?? null),
|
|
158
182
|
isActive: true,
|
|
159
183
|
createdAt: new Date(),
|
|
160
184
|
updatedAt: new Date(),
|
|
@@ -162,6 +186,12 @@ async function executeRunWithinLock(
|
|
|
162
186
|
em.persist(outcome)
|
|
163
187
|
await em.flush()
|
|
164
188
|
outcomes.push(outcome)
|
|
189
|
+
|
|
190
|
+
// Collect statement / crossing events — runBillRun emits them AFTER the
|
|
191
|
+
// outer tx commits (see runBillRun), never from inside the lock.
|
|
192
|
+
if (processResult.status !== 'failed' && processResult.statementEvents) {
|
|
193
|
+
pendingEvents.push(...processResult.statementEvents)
|
|
194
|
+
}
|
|
165
195
|
}
|
|
166
196
|
|
|
167
197
|
billRun.status = computeFinalRunStatus(outcomes)
|
|
@@ -170,7 +200,7 @@ async function executeRunWithinLock(
|
|
|
170
200
|
billRun.summary = buildRunSummary(outcomes, accounts.length, totalUsageRecordsRated)
|
|
171
201
|
await em.flush()
|
|
172
202
|
|
|
173
|
-
return { billRun, outcomes }
|
|
203
|
+
return { billRun, outcomes, pendingEvents }
|
|
174
204
|
}
|
|
175
205
|
|
|
176
206
|
async function selectTargetAccounts(
|
|
@@ -215,6 +245,30 @@ async function processAccount(
|
|
|
215
245
|
account: BillingAccount,
|
|
216
246
|
params: RunBillRunParams,
|
|
217
247
|
): Promise<AccountProcessResult> {
|
|
248
|
+
// SPEC-002: a prepaid account never gets a payable draft invoice. It still
|
|
249
|
+
// runs in the Bill Run, but charges recurring / one_time items to the
|
|
250
|
+
// balance and produces a non-fiscal consumption statement.
|
|
251
|
+
if (account.billingMode === 'prepaid') {
|
|
252
|
+
const result = await processPrepaidAccount(em, account, billRun, {
|
|
253
|
+
asOfDate: params.asOfDate,
|
|
254
|
+
mode: params.mode,
|
|
255
|
+
triggeredBy: params.triggeredBy,
|
|
256
|
+
catchUp: params.catchUp,
|
|
257
|
+
})
|
|
258
|
+
if (result.status === 'failed') {
|
|
259
|
+
return { status: 'failed', errorMessage: result.errorMessage }
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
status: result.status,
|
|
263
|
+
warnings: result.warnings,
|
|
264
|
+
draftInvoiceId: null,
|
|
265
|
+
statementId: result.statementId,
|
|
266
|
+
statementEvents: result.statementEvents,
|
|
267
|
+
cyclesEmitted: result.cyclesEmitted,
|
|
268
|
+
usageRecordsRated: 0,
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
218
272
|
const maxCycles =
|
|
219
273
|
params.triggeredBy === 'schedule' || !params.catchUp
|
|
220
274
|
? SCHEDULED_CYCLE_LIMIT
|
|
@@ -408,6 +462,13 @@ function buildRunSummary(
|
|
|
408
462
|
).length,
|
|
409
463
|
drafts_skipped_existing: outcomes.filter((o) => o.status === 'skipped_existing_draft')
|
|
410
464
|
.length,
|
|
465
|
+
// SPEC-002 prepaid period close.
|
|
466
|
+
statements_created: outcomes.filter(
|
|
467
|
+
(o) => o.statementId !== null && o.status !== 'skipped_existing_statement',
|
|
468
|
+
).length,
|
|
469
|
+
statements_skipped_existing: outcomes.filter(
|
|
470
|
+
(o) => o.status === 'skipped_existing_statement',
|
|
471
|
+
).length,
|
|
411
472
|
accounts_failed: outcomes.filter((o) => o.status === 'failed').length,
|
|
412
473
|
accounts_with_warnings: outcomes.filter((o) => o.status === 'success_with_warnings')
|
|
413
474
|
.length,
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { BillingMode, CreditStatus } from '../data/validators'
|
|
2
|
+
import { compareMoney, fromUnits, subMoney, toUnits } from './money'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Credit-line status (both modes, SPEC-002). Billing **computes and reports**
|
|
6
|
+
* where an account stands against its `credit_limit` — it NEVER blocks
|
|
7
|
+
* (assumption #11). A consumer (CPQ order conversion, a dunning job, an
|
|
8
|
+
* operator) decides what to do with an over-limit signal.
|
|
9
|
+
*
|
|
10
|
+
* `credit_used` per mode:
|
|
11
|
+
* - prepaid: max(0, −balance) — the portion of the balance gone negative
|
|
12
|
+
* (drawing on the credit line).
|
|
13
|
+
* - postpaid: Σ outstanding (posted, not-yet-paid) invoice `outstanding`
|
|
14
|
+
* amounts from core/sales.
|
|
15
|
+
*
|
|
16
|
+
* `available_credit = credit_limit − credit_used` (unified across both modes;
|
|
17
|
+
* for prepaid this equals `balance + credit_limit` when balance < 0, else
|
|
18
|
+
* `credit_limit`).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Prepaid credit drawn = max(0, −balance). */
|
|
22
|
+
export function prepaidCreditUsed(balance: string | number): string {
|
|
23
|
+
if (compareMoney(balance, 0) >= 0) return '0.0000'
|
|
24
|
+
return fromUnits(-toUnits(balance))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function computeCreditUsed(
|
|
28
|
+
mode: BillingMode,
|
|
29
|
+
opts: { balance?: string | number | null; outstanding?: string | number | null },
|
|
30
|
+
): string {
|
|
31
|
+
if (mode === 'prepaid') {
|
|
32
|
+
return prepaidCreditUsed(opts.balance ?? '0')
|
|
33
|
+
}
|
|
34
|
+
// postpaid
|
|
35
|
+
return fromUnits(toUnits(opts.outstanding ?? '0'))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function computeAvailableCredit(
|
|
39
|
+
creditLimit: string | number,
|
|
40
|
+
creditUsed: string | number,
|
|
41
|
+
): string {
|
|
42
|
+
return subMoney(creditLimit, creditUsed)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* | status | condition |
|
|
47
|
+
* |---------------|--------------------------------------------|
|
|
48
|
+
* | within_limit | available_credit > near_limit_buffer |
|
|
49
|
+
* | near_limit | 0 < available_credit <= near_limit_buffer |
|
|
50
|
+
* | over_limit | available_credit <= 0 (and credit is drawn)|
|
|
51
|
+
*
|
|
52
|
+
* `creditUsed` disambiguates the default "no credit line" case: with
|
|
53
|
+
* `credit_limit = 0` and nothing drawn (`credit_used = 0`) the available
|
|
54
|
+
* credit is exactly 0 — that is NOT over-limit (the account simply has no
|
|
55
|
+
* credit line), so it reports `within_limit`. Once any credit is actually
|
|
56
|
+
* drawn (prepaid balance negative, or postpaid outstanding > 0) the formula
|
|
57
|
+
* applies. When `creditUsed` is omitted, a draw is assumed (pure spec
|
|
58
|
+
* formula) — callers that have the figure should pass it.
|
|
59
|
+
*/
|
|
60
|
+
export function computeCreditStatus(
|
|
61
|
+
availableCredit: string | number,
|
|
62
|
+
nearLimitBuffer: string | number,
|
|
63
|
+
creditUsed?: string | number,
|
|
64
|
+
): CreditStatus {
|
|
65
|
+
const hasDraw = creditUsed === undefined ? true : compareMoney(creditUsed, 0) > 0
|
|
66
|
+
if (compareMoney(availableCredit, 0) <= 0) {
|
|
67
|
+
return hasDraw ? 'over_limit' : 'within_limit'
|
|
68
|
+
}
|
|
69
|
+
if (compareMoney(availableCredit, nearLimitBuffer) <= 0) return 'near_limit'
|
|
70
|
+
return 'within_limit'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function resolveNearLimitBuffer(
|
|
74
|
+
configDefault: string | number | null | undefined,
|
|
75
|
+
): string {
|
|
76
|
+
if (configDefault !== null && configDefault !== undefined) return String(configDefault)
|
|
77
|
+
return '0'
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type CreditSnapshot = {
|
|
81
|
+
mode: BillingMode
|
|
82
|
+
creditLimit: string
|
|
83
|
+
creditUsed: string
|
|
84
|
+
availableCredit: string
|
|
85
|
+
creditStatus: CreditStatus
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Assemble the full credit snapshot reported on consume responses + the API. */
|
|
89
|
+
export function computeCreditSnapshot(params: {
|
|
90
|
+
mode: BillingMode
|
|
91
|
+
creditLimit: string | number
|
|
92
|
+
nearLimitBuffer: string | number
|
|
93
|
+
balance?: string | number | null
|
|
94
|
+
outstanding?: string | number | null
|
|
95
|
+
}): CreditSnapshot {
|
|
96
|
+
const creditLimit = fromUnits(toUnits(params.creditLimit))
|
|
97
|
+
const creditUsed = computeCreditUsed(params.mode, {
|
|
98
|
+
balance: params.balance,
|
|
99
|
+
outstanding: params.outstanding,
|
|
100
|
+
})
|
|
101
|
+
const availableCredit = computeAvailableCredit(creditLimit, creditUsed)
|
|
102
|
+
return {
|
|
103
|
+
mode: params.mode,
|
|
104
|
+
creditLimit,
|
|
105
|
+
creditUsed,
|
|
106
|
+
availableCredit,
|
|
107
|
+
creditStatus: computeCreditStatus(availableCredit, params.nearLimitBuffer, creditUsed),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type CreditCrossing = {
|
|
112
|
+
/** credit_used crossed upward past credit_limit (available ≤ 0). */
|
|
113
|
+
overLimitCrossedUp: boolean
|
|
114
|
+
/** available_credit crossed downward into (0, buffer] (optional event). */
|
|
115
|
+
nearLimitCrossedUp: boolean
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Detect upward crossings of `credit_used` (i.e. downward crossings of
|
|
120
|
+
* `available_credit`) for the `billing.credit.over_limit` /
|
|
121
|
+
* `billing.credit.near_limit` events. `over_limit` fires once when usage /
|
|
122
|
+
* recurring pushes credit_used from below the limit to at/above it.
|
|
123
|
+
*
|
|
124
|
+
* `credit_limit == 0` (no credit) means available is never > 0 once any
|
|
125
|
+
* credit is "used"; to avoid firing on every prepaid debit while at 0 balance
|
|
126
|
+
* with no credit line, the crossing requires a real limit (> 0).
|
|
127
|
+
*/
|
|
128
|
+
export function detectCreditCrossing(params: {
|
|
129
|
+
creditLimit: string | number
|
|
130
|
+
nearLimitBuffer: string | number
|
|
131
|
+
usedBefore: string | number
|
|
132
|
+
usedAfter: string | number
|
|
133
|
+
}): CreditCrossing {
|
|
134
|
+
const limitUnits = toUnits(params.creditLimit)
|
|
135
|
+
if (limitUnits <= 0) {
|
|
136
|
+
return { overLimitCrossedUp: false, nearLimitCrossedUp: false }
|
|
137
|
+
}
|
|
138
|
+
const usedBefore = toUnits(params.usedBefore)
|
|
139
|
+
const usedAfter = toUnits(params.usedAfter)
|
|
140
|
+
const bufferUnits = toUnits(params.nearLimitBuffer)
|
|
141
|
+
|
|
142
|
+
const overLimitCrossedUp = usedBefore < limitUnits && usedAfter >= limitUnits
|
|
143
|
+
|
|
144
|
+
const availBefore = limitUnits - usedBefore
|
|
145
|
+
const availAfter = limitUnits - usedAfter
|
|
146
|
+
const nearLimitCrossedUp =
|
|
147
|
+
bufferUnits > 0 &&
|
|
148
|
+
availBefore > bufferUnits &&
|
|
149
|
+
availAfter <= bufferUnits &&
|
|
150
|
+
availAfter > 0
|
|
151
|
+
|
|
152
|
+
return { overLimitCrossedUp, nearLimitCrossedUp }
|
|
153
|
+
}
|