@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
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exact decimal arithmetic for the prepaid balance ledger (SPEC-002).
|
|
3
|
+
*
|
|
4
|
+
* All billing money columns are `numeric(18,4)` — four decimal places. Doing
|
|
5
|
+
* the running-balance / reconciliation math in JS `number` would accumulate
|
|
6
|
+
* binary floating-point error (`0.1 + 0.2 !== 0.3`), and the reconciliation
|
|
7
|
+
* invariant `balance == SUM(amount) == latest.balance_after` would drift.
|
|
8
|
+
*
|
|
9
|
+
* So we work in **integer ten-thousandths** ("units"): the smallest
|
|
10
|
+
* representable step of a `numeric(18,4)` column. `12.3400` ⇄ `123400` units.
|
|
11
|
+
* Addition/subtraction of integers is exact; we only ever format back to a
|
|
12
|
+
* 4dp string at the persistence boundary.
|
|
13
|
+
*
|
|
14
|
+
* `numeric(18,4)` holds values up to ~10^14, i.e. ~10^18 units — well within
|
|
15
|
+
* JS `Number.MAX_SAFE_INTEGER` (~9.007×10^15) for any realistic balance
|
|
16
|
+
* (max safe ~±900,000,000,000 currency units). Magnitudes beyond that would
|
|
17
|
+
* already overflow the column itself; we guard the parse defensively.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const SCALE = 10_000
|
|
21
|
+
|
|
22
|
+
/** Parse a numeric(18,4) string (or number) into integer ten-thousandths. */
|
|
23
|
+
export function toUnits(value: string | number | null | undefined): number {
|
|
24
|
+
if (value === null || value === undefined) return 0
|
|
25
|
+
const n = typeof value === 'number' ? value : Number.parseFloat(value)
|
|
26
|
+
if (!Number.isFinite(n)) return 0
|
|
27
|
+
// Round to the nearest ten-thousandth — half-up, EPSILON-nudged to survive
|
|
28
|
+
// representations like 12.33999999998.
|
|
29
|
+
return Math.round((n + Number.EPSILON * Math.sign(n)) * SCALE)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Format integer ten-thousandths back to a numeric(18,4) string. */
|
|
33
|
+
export function fromUnits(units: number): string {
|
|
34
|
+
const sign = units < 0 ? '-' : ''
|
|
35
|
+
const abs = Math.abs(Math.trunc(units))
|
|
36
|
+
const whole = Math.trunc(abs / SCALE)
|
|
37
|
+
const frac = abs % SCALE
|
|
38
|
+
return `${sign}${whole}.${frac.toString().padStart(4, '0')}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Add two numeric(18,4) values exactly, returning a 4dp string. */
|
|
42
|
+
export function addMoney(a: string | number, b: string | number): string {
|
|
43
|
+
return fromUnits(toUnits(a) + toUnits(b))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Subtract `b` from `a` exactly, returning a 4dp string. */
|
|
47
|
+
export function subMoney(a: string | number, b: string | number): string {
|
|
48
|
+
return fromUnits(toUnits(a) - toUnits(b))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Negate a numeric(18,4) value, returning a 4dp string. */
|
|
52
|
+
export function negateMoney(a: string | number): string {
|
|
53
|
+
return fromUnits(-toUnits(a))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Absolute value of a numeric(18,4), returning a 4dp string. */
|
|
57
|
+
export function absMoney(a: string | number): string {
|
|
58
|
+
return fromUnits(Math.abs(toUnits(a)))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Compare two numeric(18,4) values. Returns -1 / 0 / 1. */
|
|
62
|
+
export function compareMoney(a: string | number, b: string | number): -1 | 0 | 1 {
|
|
63
|
+
const ua = toUnits(a)
|
|
64
|
+
const ub = toUnits(b)
|
|
65
|
+
if (ua < ub) return -1
|
|
66
|
+
if (ua > ub) return 1
|
|
67
|
+
return 0
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Normalize any numeric(18,4) input to a canonical 4dp string. */
|
|
71
|
+
export function formatMoney(value: string | number): string {
|
|
72
|
+
return fromUnits(toUnits(value))
|
|
73
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { ModuleConfig } from '@open-mercato/core/modules/configs/data/entities'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Typed readers for the prepaid billing module configs (SPEC-002), seeded by
|
|
6
|
+
* `setup.ts → seedBillingConfigs`. Stored as raw JSON scalars in
|
|
7
|
+
* `ModuleConfig.valueJson`. Each reader falls back to the same default the
|
|
8
|
+
* seed uses, so a missing row (older tenant) behaves identically.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
async function readConfig(em: EntityManager, name: string): Promise<unknown> {
|
|
12
|
+
const row = await em.findOne(ModuleConfig, { moduleId: 'billing', name })
|
|
13
|
+
if (!row) return undefined
|
|
14
|
+
return row.valueJson as unknown
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function asNumberString(value: unknown, fallback: string): string {
|
|
18
|
+
if (value === null || value === undefined) return fallback
|
|
19
|
+
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
|
|
20
|
+
if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) {
|
|
21
|
+
return value.trim()
|
|
22
|
+
}
|
|
23
|
+
return fallback
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** `prepaid.low_balance_threshold_default` — numeric string (default '0'). */
|
|
27
|
+
export async function getLowBalanceThresholdDefault(em: EntityManager): Promise<string> {
|
|
28
|
+
return asNumberString(await readConfig(em, 'prepaid.low_balance_threshold_default'), '0')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `credit.near_limit_buffer_default` — numeric string (default '0'). */
|
|
32
|
+
export async function getNearLimitBufferDefault(em: EntityManager): Promise<string> {
|
|
33
|
+
return asNumberString(await readConfig(em, 'credit.near_limit_buffer_default'), '0')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `prepaid.topup_provider` — gateway provider key (default 'stripe'). */
|
|
37
|
+
export async function getTopupProvider(em: EntityManager): Promise<string> {
|
|
38
|
+
const value = await readConfig(em, 'prepaid.topup_provider')
|
|
39
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : 'stripe'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `prepaid.statement_enabled` — master switch (default true). */
|
|
43
|
+
export async function getStatementEnabled(em: EntityManager): Promise<boolean> {
|
|
44
|
+
const value = await readConfig(em, 'prepaid.statement_enabled')
|
|
45
|
+
if (value === undefined || value === null) return true
|
|
46
|
+
if (typeof value === 'boolean') return value
|
|
47
|
+
// A stored string "false" must read as false (Boolean("false") === true).
|
|
48
|
+
if (typeof value === 'string') return value.trim().toLowerCase() !== 'false'
|
|
49
|
+
return Boolean(value)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `prepaid.topup_tax_rate` — VAT percent on top-up receipts (default 0). */
|
|
53
|
+
export async function getTopupTaxRate(em: EntityManager): Promise<number> {
|
|
54
|
+
const value = await readConfig(em, 'prepaid.topup_tax_rate')
|
|
55
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
56
|
+
if (typeof value === 'string' && Number.isFinite(Number(value))) return Number(value)
|
|
57
|
+
return 0
|
|
58
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import {
|
|
3
|
+
BillingAccount,
|
|
4
|
+
BillingAccountBalance,
|
|
5
|
+
BillingAccountTransaction,
|
|
6
|
+
BillingAccountUsage,
|
|
7
|
+
BillingItem,
|
|
8
|
+
} from '../data/entities'
|
|
9
|
+
import type { BalanceStatus, CreditStatus } from '../data/validators'
|
|
10
|
+
import {
|
|
11
|
+
applyBalanceMovement,
|
|
12
|
+
PREPAID_USAGE_SENTINEL_RUN_ID,
|
|
13
|
+
} from './balanceLedger'
|
|
14
|
+
import {
|
|
15
|
+
computeBalanceStatus,
|
|
16
|
+
resolveLowBalanceThreshold,
|
|
17
|
+
} from './balanceStatus'
|
|
18
|
+
import {
|
|
19
|
+
computeCreditStatus,
|
|
20
|
+
computeAvailableCredit,
|
|
21
|
+
prepaidCreditUsed,
|
|
22
|
+
} from './creditStatus'
|
|
23
|
+
import { buildBalanceCrossingEvents, type DomainEvent } from './balanceEvents'
|
|
24
|
+
import { absMoney, negateMoney } from './money'
|
|
25
|
+
import {
|
|
26
|
+
getLowBalanceThresholdDefault,
|
|
27
|
+
getNearLimitBufferDefault,
|
|
28
|
+
} from './prepaidConfig'
|
|
29
|
+
import { rateUsageForItem, type TierBreakdownEntry } from './usageRater'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Real-time prepaid consume (SPEC-002 P1).
|
|
33
|
+
*
|
|
34
|
+
* For a prepaid account, a usage upload is rated in real time, debited from
|
|
35
|
+
* the balance, and the remaining balance is returned — *"I uploaded usage and
|
|
36
|
+
* got my remaining balance back."* Usage is NEVER rejected: a missing price or
|
|
37
|
+
* an exhausted balance still records the usage; we report status and let the
|
|
38
|
+
* client throttle itself.
|
|
39
|
+
*
|
|
40
|
+
* The caller MUST run this inside an `em.transactional(...)` so the usage
|
|
41
|
+
* insert, the atomic balance debit, and the transaction insert commit (or roll
|
|
42
|
+
* back) together. Domain events are returned (not emitted) so the caller can
|
|
43
|
+
* fire them AFTER the DB commit.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
export type PrepaidConsumeInput = {
|
|
47
|
+
organizationId: string
|
|
48
|
+
tenantId: string
|
|
49
|
+
billAccountId: string
|
|
50
|
+
uomCode: string
|
|
51
|
+
quantity: number
|
|
52
|
+
periodStart: Date
|
|
53
|
+
periodEnd: Date
|
|
54
|
+
lineDescription?: string | null
|
|
55
|
+
sourceRef?: string | null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type PrepaidConsumeResult = {
|
|
59
|
+
id: string
|
|
60
|
+
deduplicated: boolean
|
|
61
|
+
ratedAmount: string
|
|
62
|
+
currencyCode: string
|
|
63
|
+
balance: string
|
|
64
|
+
balanceStatus: BalanceStatus
|
|
65
|
+
creditStatus: CreditStatus
|
|
66
|
+
tierBreakdown: TierBreakdownEntry[]
|
|
67
|
+
warning: string | null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type PrepaidConsumeOutcome = {
|
|
71
|
+
result: PrepaidConsumeResult
|
|
72
|
+
events: DomainEvent[]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function breakdownFromMetadata(meta: Record<string, unknown> | null | undefined): TierBreakdownEntry[] {
|
|
76
|
+
const b = meta?.usage_tier_breakdown
|
|
77
|
+
return Array.isArray(b) ? (b as TierBreakdownEntry[]) : []
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Resolve the active `type=usage` Billing Item priced for a uom (exact match,
|
|
82
|
+
* no conversion — same rule as the postpaid runner). Latest active item wins.
|
|
83
|
+
*/
|
|
84
|
+
async function findUsageItem(
|
|
85
|
+
em: EntityManager,
|
|
86
|
+
input: PrepaidConsumeInput,
|
|
87
|
+
): Promise<BillingItem | null> {
|
|
88
|
+
return em.findOne(
|
|
89
|
+
BillingItem,
|
|
90
|
+
{
|
|
91
|
+
tenantId: input.tenantId,
|
|
92
|
+
organizationId: input.organizationId,
|
|
93
|
+
billAccountId: input.billAccountId,
|
|
94
|
+
type: 'usage',
|
|
95
|
+
uomCode: input.uomCode,
|
|
96
|
+
isActive: true,
|
|
97
|
+
deletedAt: null,
|
|
98
|
+
} as never,
|
|
99
|
+
{ orderBy: { createdAt: 'DESC' } },
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function consumePrepaidUsage(
|
|
104
|
+
em: EntityManager,
|
|
105
|
+
account: BillingAccount,
|
|
106
|
+
input: PrepaidConsumeInput,
|
|
107
|
+
): Promise<PrepaidConsumeOutcome> {
|
|
108
|
+
const currencyCode = account.currencyCode
|
|
109
|
+
const threshold = resolveLowBalanceThreshold(
|
|
110
|
+
await readAccountThreshold(em, input),
|
|
111
|
+
await getLowBalanceThresholdDefault(em),
|
|
112
|
+
)
|
|
113
|
+
const nearLimitBuffer = await getNearLimitBufferDefault(em)
|
|
114
|
+
const creditLimit = account.creditLimit ?? '0'
|
|
115
|
+
|
|
116
|
+
// ─── Idempotency: a duplicate sourceRef never double-debits ────
|
|
117
|
+
if (input.sourceRef) {
|
|
118
|
+
const existing = await em.findOne(BillingAccountUsage, {
|
|
119
|
+
tenantId: input.tenantId,
|
|
120
|
+
billAccountId: input.billAccountId,
|
|
121
|
+
sourceRef: input.sourceRef,
|
|
122
|
+
deletedAt: null,
|
|
123
|
+
})
|
|
124
|
+
if (existing) {
|
|
125
|
+
const txn = await em.findOne(BillingAccountTransaction, {
|
|
126
|
+
tenantId: input.tenantId,
|
|
127
|
+
billAccountId: input.billAccountId,
|
|
128
|
+
usageId: existing.id,
|
|
129
|
+
deletedAt: null,
|
|
130
|
+
})
|
|
131
|
+
const balance = txn?.balanceAfter ?? (await readCurrentBalance(em, input))
|
|
132
|
+
const ratedAmount = txn ? absMoney(txn.amount) : '0.0000'
|
|
133
|
+
const warning = (txn?.metadata?.warning as string | undefined) ?? null
|
|
134
|
+
return {
|
|
135
|
+
result: {
|
|
136
|
+
id: existing.id,
|
|
137
|
+
deduplicated: true,
|
|
138
|
+
ratedAmount,
|
|
139
|
+
currencyCode,
|
|
140
|
+
balance,
|
|
141
|
+
balanceStatus: computeBalanceStatus(balance, threshold),
|
|
142
|
+
creditStatus: computeCreditStatus(
|
|
143
|
+
computeAvailableCredit(creditLimit, prepaidCreditUsed(balance)),
|
|
144
|
+
nearLimitBuffer,
|
|
145
|
+
prepaidCreditUsed(balance),
|
|
146
|
+
),
|
|
147
|
+
tierBreakdown: breakdownFromMetadata(txn?.metadata),
|
|
148
|
+
warning,
|
|
149
|
+
},
|
|
150
|
+
events: [],
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ─── 3. Persist the usage record (rated at ingest = sentinel) ──
|
|
156
|
+
const now = new Date()
|
|
157
|
+
const usage = em.create(BillingAccountUsage, {
|
|
158
|
+
organizationId: input.organizationId,
|
|
159
|
+
tenantId: input.tenantId,
|
|
160
|
+
billAccountId: input.billAccountId,
|
|
161
|
+
uomCode: input.uomCode,
|
|
162
|
+
quantity: input.quantity.toFixed(4),
|
|
163
|
+
periodStart: input.periodStart,
|
|
164
|
+
periodEnd: input.periodEnd,
|
|
165
|
+
lineDescription: input.lineDescription ?? null,
|
|
166
|
+
sourceRef: input.sourceRef ?? null,
|
|
167
|
+
// Stamped immediately so a postpaid-style run never re-rates it.
|
|
168
|
+
ratedInBillRunId: PREPAID_USAGE_SENTINEL_RUN_ID,
|
|
169
|
+
isActive: true,
|
|
170
|
+
createdAt: now,
|
|
171
|
+
updatedAt: now,
|
|
172
|
+
})
|
|
173
|
+
em.persist(usage)
|
|
174
|
+
await em.flush()
|
|
175
|
+
|
|
176
|
+
// ─── 4. Rate against the matching usage item ───────────────────
|
|
177
|
+
const item = await findUsageItem(em, input)
|
|
178
|
+
let ratedAmount = '0.0000'
|
|
179
|
+
let breakdown: TierBreakdownEntry[] = []
|
|
180
|
+
let warning: string | null = null
|
|
181
|
+
if (!item) {
|
|
182
|
+
warning = 'no_matching_item'
|
|
183
|
+
} else {
|
|
184
|
+
const rated = rateUsageForItem(item, input.quantity)
|
|
185
|
+
if (!rated) {
|
|
186
|
+
warning = 'unratable'
|
|
187
|
+
} else {
|
|
188
|
+
ratedAmount = rated.amount
|
|
189
|
+
breakdown = rated.breakdown
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ─── 5–7. Atomic debit + paired transaction (signed amount) ────
|
|
194
|
+
const movement = await applyBalanceMovement(em, {
|
|
195
|
+
organizationId: input.organizationId,
|
|
196
|
+
tenantId: input.tenantId,
|
|
197
|
+
billAccountId: input.billAccountId,
|
|
198
|
+
currencyCode,
|
|
199
|
+
type: 'usage',
|
|
200
|
+
amount: negateMoney(ratedAmount),
|
|
201
|
+
description: buildDescription(item, input, warning),
|
|
202
|
+
usageId: usage.id,
|
|
203
|
+
billingItemId: item?.id ?? null,
|
|
204
|
+
sourceRef: input.sourceRef ?? null,
|
|
205
|
+
metadata: {
|
|
206
|
+
uom_code: input.uomCode,
|
|
207
|
+
usage_tier_breakdown: breakdown,
|
|
208
|
+
...(warning ? { warning } : {}),
|
|
209
|
+
},
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
const { balanceBefore, balanceAfter } = movement
|
|
213
|
+
|
|
214
|
+
// ─── 8. Status + crossing events ──────────────────────────────
|
|
215
|
+
const balanceStatus = computeBalanceStatus(balanceAfter, threshold)
|
|
216
|
+
const scope = {
|
|
217
|
+
billAccountId: input.billAccountId,
|
|
218
|
+
tenantId: input.tenantId,
|
|
219
|
+
organizationId: input.organizationId,
|
|
220
|
+
currencyCode,
|
|
221
|
+
}
|
|
222
|
+
const events: DomainEvent[] = buildBalanceCrossingEvents({
|
|
223
|
+
scope,
|
|
224
|
+
balanceBefore,
|
|
225
|
+
balanceAfter,
|
|
226
|
+
threshold,
|
|
227
|
+
creditLimit,
|
|
228
|
+
nearLimitBuffer,
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
// Optional per-debit signal (excluded from workflow triggers).
|
|
232
|
+
events.push({
|
|
233
|
+
id: 'billing.usage.rated',
|
|
234
|
+
payload: {
|
|
235
|
+
...scope,
|
|
236
|
+
balance: balanceAfter,
|
|
237
|
+
usageId: usage.id,
|
|
238
|
+
transactionId: movement.transaction.id,
|
|
239
|
+
ratedAmount,
|
|
240
|
+
balanceStatus,
|
|
241
|
+
},
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
const usedAfter = prepaidCreditUsed(balanceAfter)
|
|
245
|
+
const creditStatus = computeCreditStatus(
|
|
246
|
+
computeAvailableCredit(creditLimit, usedAfter),
|
|
247
|
+
nearLimitBuffer,
|
|
248
|
+
usedAfter,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
result: {
|
|
253
|
+
id: usage.id,
|
|
254
|
+
deduplicated: false,
|
|
255
|
+
ratedAmount,
|
|
256
|
+
currencyCode,
|
|
257
|
+
balance: balanceAfter,
|
|
258
|
+
balanceStatus,
|
|
259
|
+
creditStatus,
|
|
260
|
+
tierBreakdown: breakdown,
|
|
261
|
+
warning,
|
|
262
|
+
},
|
|
263
|
+
events,
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function buildDescription(
|
|
268
|
+
item: BillingItem | null,
|
|
269
|
+
input: PrepaidConsumeInput,
|
|
270
|
+
warning: string | null,
|
|
271
|
+
): string {
|
|
272
|
+
if (input.lineDescription) return input.lineDescription
|
|
273
|
+
const qty = input.quantity.toLocaleString('en-US')
|
|
274
|
+
if (!item) return `Usage — ${qty} ${input.uomCode} (no matching item)`
|
|
275
|
+
if (warning === 'unratable') return `Usage — ${qty} ${input.uomCode} (unratable)`
|
|
276
|
+
return `${item.description} — ${qty} ${input.uomCode}`
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function readAccountThreshold(
|
|
280
|
+
em: EntityManager,
|
|
281
|
+
input: PrepaidConsumeInput,
|
|
282
|
+
): Promise<string | null> {
|
|
283
|
+
const row = await em.findOne(BillingAccountBalance, {
|
|
284
|
+
tenantId: input.tenantId,
|
|
285
|
+
billAccountId: input.billAccountId,
|
|
286
|
+
})
|
|
287
|
+
return row?.lowBalanceThreshold ?? null
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function readCurrentBalance(
|
|
291
|
+
em: EntityManager,
|
|
292
|
+
input: PrepaidConsumeInput,
|
|
293
|
+
): Promise<string> {
|
|
294
|
+
const row = await em.findOne(BillingAccountBalance, {
|
|
295
|
+
tenantId: input.tenantId,
|
|
296
|
+
billAccountId: input.billAccountId,
|
|
297
|
+
})
|
|
298
|
+
return row?.balance ?? '0.0000'
|
|
299
|
+
}
|