@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,453 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import {
|
|
3
|
+
BillingAccount,
|
|
4
|
+
BillingAccountBalance,
|
|
5
|
+
BillingAccountTransaction,
|
|
6
|
+
BillingItem,
|
|
7
|
+
BillingStatement,
|
|
8
|
+
BillRun,
|
|
9
|
+
} from '../data/entities'
|
|
10
|
+
import type { BillCycle } from '../data/validators'
|
|
11
|
+
import { advanceNextBillDate, addDays, deriveBillPeriod, isCycleDue, type BillPeriod } from './billPeriod'
|
|
12
|
+
import { applyBalanceMovement } from './balanceLedger'
|
|
13
|
+
import { resolveLowBalanceThreshold } from './balanceStatus'
|
|
14
|
+
import { buildInvoiceLinesFromItems } from './invoiceWriter'
|
|
15
|
+
import { selectItemsForPeriod, type SelectableItem } from './itemSelector'
|
|
16
|
+
import { absMoney, addMoney, fromUnits, subMoney, toUnits } from './money'
|
|
17
|
+
import {
|
|
18
|
+
getLowBalanceThresholdDefault,
|
|
19
|
+
getNearLimitBufferDefault,
|
|
20
|
+
getStatementEnabled,
|
|
21
|
+
} from './prepaidConfig'
|
|
22
|
+
import { buildBalanceCrossingEvents, type DomainEvent } from './balanceEvents'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Prepaid period close (SPEC-002 P3).
|
|
26
|
+
*
|
|
27
|
+
* A prepaid account never gets a payable draft invoice. Instead the Bill Run,
|
|
28
|
+
* for a due prepaid account:
|
|
29
|
+
* 1. charges its `recurring` / `one_time` items to the **balance** (atomic
|
|
30
|
+
* debit + transaction, deterministic `source_ref =
|
|
31
|
+
* recurring-{itemId}-{periodEndISO}`, then `billed_to_date = period_end`),
|
|
32
|
+
* 2. builds a non-fiscal **BillingStatement** (opening, topups, usage,
|
|
33
|
+
* recurring, closing + per-item breakdown).
|
|
34
|
+
*
|
|
35
|
+
* Metered usage was already rated + debited in real time during the period —
|
|
36
|
+
* it is NOT re-rated here; the statement only summarizes it.
|
|
37
|
+
*
|
|
38
|
+
* Three anti-duplicate guards: the statement unique
|
|
39
|
+
* `(bill_account_id, period_start, period_end)`, the per-item `billed_to_date`,
|
|
40
|
+
* and the deterministic charge `source_ref`.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
export type PrepaidCloseMode = 'real' | 'test' | 'dry'
|
|
44
|
+
|
|
45
|
+
export type PrepaidAccountWarnings = {
|
|
46
|
+
// Reserved for future per-close warnings (e.g. negative-balance close).
|
|
47
|
+
closed_negative?: boolean
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type PrepaidAccountResult =
|
|
51
|
+
| {
|
|
52
|
+
status: 'success' | 'success_with_warnings' | 'skipped_existing_statement'
|
|
53
|
+
warnings: PrepaidAccountWarnings | null
|
|
54
|
+
statementId: string | null
|
|
55
|
+
cyclesEmitted: number
|
|
56
|
+
statementEvents: DomainEvent[]
|
|
57
|
+
}
|
|
58
|
+
| { status: 'failed'; errorMessage: string }
|
|
59
|
+
|
|
60
|
+
const SCHEDULED_CYCLE_LIMIT = 1
|
|
61
|
+
const CATCH_UP_HARD_CAP = 60
|
|
62
|
+
|
|
63
|
+
export type ProcessPrepaidParams = {
|
|
64
|
+
asOfDate: Date
|
|
65
|
+
mode: PrepaidCloseMode
|
|
66
|
+
triggeredBy: 'schedule' | 'manual'
|
|
67
|
+
catchUp?: boolean
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Multi-cycle prepaid account processing (mirrors the postpaid engine's
|
|
72
|
+
* catch-up loop). One statement + one set of recurring debits per missed
|
|
73
|
+
* period — never combined.
|
|
74
|
+
*/
|
|
75
|
+
export async function processPrepaidAccount(
|
|
76
|
+
em: EntityManager,
|
|
77
|
+
account: BillingAccount,
|
|
78
|
+
billRun: BillRun,
|
|
79
|
+
params: ProcessPrepaidParams,
|
|
80
|
+
): Promise<PrepaidAccountResult> {
|
|
81
|
+
const maxCycles =
|
|
82
|
+
params.triggeredBy === 'schedule' || !params.catchUp
|
|
83
|
+
? SCHEDULED_CYCLE_LIMIT
|
|
84
|
+
: CATCH_UP_HARD_CAP
|
|
85
|
+
|
|
86
|
+
const statementEnabled = await getStatementEnabled(em)
|
|
87
|
+
// Resolve the crossing thresholds once — they don't change during the run.
|
|
88
|
+
const balanceRow = await em.findOne(BillingAccountBalance, {
|
|
89
|
+
tenantId: account.tenantId,
|
|
90
|
+
billAccountId: account.id,
|
|
91
|
+
})
|
|
92
|
+
const threshold = resolveLowBalanceThreshold(
|
|
93
|
+
balanceRow?.lowBalanceThreshold ?? null,
|
|
94
|
+
await getLowBalanceThresholdDefault(em),
|
|
95
|
+
)
|
|
96
|
+
const nearLimitBuffer = await getNearLimitBufferDefault(em)
|
|
97
|
+
const creditLimit = account.creditLimit ?? '0'
|
|
98
|
+
|
|
99
|
+
let cyclesEmitted = 0
|
|
100
|
+
let lastStatementId: string | null = null
|
|
101
|
+
const statementEvents: DomainEvent[] = []
|
|
102
|
+
// Chain opening → closing across cycles in a catch-up run. Null on the first
|
|
103
|
+
// cycle → closePrepaidPeriod resolves it from the prior statement / ledger.
|
|
104
|
+
let previousClosing: string | null = null
|
|
105
|
+
|
|
106
|
+
while (cyclesEmitted < maxCycles && isCycleDue(account.nextBillDate, params.asOfDate)) {
|
|
107
|
+
const period = deriveBillPeriod(account.nextBillDate, account.billCycle as BillCycle)
|
|
108
|
+
|
|
109
|
+
// Statement anti-duplicate (only when we would actually persist).
|
|
110
|
+
if (params.mode !== 'dry') {
|
|
111
|
+
const existing = await em.findOne(BillingStatement, {
|
|
112
|
+
tenantId: account.tenantId,
|
|
113
|
+
billAccountId: account.id,
|
|
114
|
+
periodStart: period.periodStart,
|
|
115
|
+
periodEnd: period.periodEnd,
|
|
116
|
+
})
|
|
117
|
+
if (existing) {
|
|
118
|
+
return {
|
|
119
|
+
status: 'skipped_existing_statement',
|
|
120
|
+
warnings: null,
|
|
121
|
+
statementId: existing.id,
|
|
122
|
+
cyclesEmitted,
|
|
123
|
+
statementEvents,
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const cycle = await closePrepaidPeriod(em, account, period, billRun, {
|
|
129
|
+
mode: params.mode,
|
|
130
|
+
statementEnabled,
|
|
131
|
+
previousClosing,
|
|
132
|
+
threshold,
|
|
133
|
+
nearLimitBuffer,
|
|
134
|
+
creditLimit,
|
|
135
|
+
})
|
|
136
|
+
previousClosing = cycle.closingBalance
|
|
137
|
+
// Recurring charges move the balance too, so they can trip the same
|
|
138
|
+
// low/exhausted/over_limit crossings as a usage debit (SPEC-002). These
|
|
139
|
+
// are accumulated and emitted by the engine AFTER the run commits.
|
|
140
|
+
statementEvents.push(...cycle.crossingEvents)
|
|
141
|
+
|
|
142
|
+
// A negative closing balance is NORMAL for prepaid (the customer overspent;
|
|
143
|
+
// it's cleared by the next top-up), so it is not flagged as a warning.
|
|
144
|
+
if (cycle.statementId) {
|
|
145
|
+
lastStatementId = cycle.statementId
|
|
146
|
+
statementEvents.push({
|
|
147
|
+
id: 'billing.statement.generated',
|
|
148
|
+
payload: {
|
|
149
|
+
statementId: cycle.statementId,
|
|
150
|
+
billAccountId: account.id,
|
|
151
|
+
tenantId: account.tenantId,
|
|
152
|
+
organizationId: account.organizationId,
|
|
153
|
+
billRunId: billRun.id,
|
|
154
|
+
periodStart: period.periodStart.toISOString().slice(0, 10),
|
|
155
|
+
periodEnd: period.periodEnd.toISOString().slice(0, 10),
|
|
156
|
+
currencyCode: account.currencyCode,
|
|
157
|
+
openingBalance: cycle.openingBalance,
|
|
158
|
+
closingBalance: cycle.closingBalance,
|
|
159
|
+
totalTopups: cycle.totalTopups,
|
|
160
|
+
totalUsage: cycle.totalUsage,
|
|
161
|
+
totalRecurring: cycle.totalRecurring,
|
|
162
|
+
totalAdjustments: cycle.totalAdjustments,
|
|
163
|
+
},
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (params.mode === 'real') {
|
|
168
|
+
account.nextBillDate = advanceNextBillDate(
|
|
169
|
+
account.nextBillDate,
|
|
170
|
+
account.billCycle as BillCycle,
|
|
171
|
+
)
|
|
172
|
+
account.lastBillDate = period.periodEnd
|
|
173
|
+
account.updatedAt = new Date()
|
|
174
|
+
await em.flush()
|
|
175
|
+
} else {
|
|
176
|
+
// test / dry: do not advance state; one iteration only.
|
|
177
|
+
cyclesEmitted += 1
|
|
178
|
+
break
|
|
179
|
+
}
|
|
180
|
+
cyclesEmitted += 1
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
status: 'success',
|
|
185
|
+
warnings: null,
|
|
186
|
+
statementId: lastStatementId,
|
|
187
|
+
cyclesEmitted,
|
|
188
|
+
statementEvents,
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
type ClosePeriodResult = {
|
|
193
|
+
statementId: string | null
|
|
194
|
+
openingBalance: string
|
|
195
|
+
totalTopups: string
|
|
196
|
+
totalUsage: string
|
|
197
|
+
totalRecurring: string
|
|
198
|
+
totalAdjustments: string
|
|
199
|
+
closingBalance: string
|
|
200
|
+
crossingEvents: DomainEvent[]
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
type DebitBreakdownEntry = {
|
|
204
|
+
billing_item_id: string
|
|
205
|
+
billing_type: string
|
|
206
|
+
uom_code?: string
|
|
207
|
+
quantity: string
|
|
208
|
+
amount: string
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function closePrepaidPeriod(
|
|
212
|
+
em: EntityManager,
|
|
213
|
+
account: BillingAccount,
|
|
214
|
+
period: BillPeriod,
|
|
215
|
+
billRun: BillRun,
|
|
216
|
+
opts: {
|
|
217
|
+
mode: PrepaidCloseMode
|
|
218
|
+
statementEnabled: boolean
|
|
219
|
+
previousClosing: string | null
|
|
220
|
+
threshold: string
|
|
221
|
+
nearLimitBuffer: string
|
|
222
|
+
creditLimit: string
|
|
223
|
+
},
|
|
224
|
+
): Promise<ClosePeriodResult> {
|
|
225
|
+
const periodEndISO = period.periodEnd.toISOString().slice(0, 10)
|
|
226
|
+
|
|
227
|
+
// Recurring + one_time items eligible for this period (usage excluded by the
|
|
228
|
+
// selector — it was rated in real time).
|
|
229
|
+
const items = await em.find(BillingItem, {
|
|
230
|
+
tenantId: account.tenantId,
|
|
231
|
+
organizationId: account.organizationId,
|
|
232
|
+
billAccountId: account.id,
|
|
233
|
+
deletedAt: null,
|
|
234
|
+
} as never)
|
|
235
|
+
const { included } = selectItemsForPeriod(items as unknown as SelectableItem[], period)
|
|
236
|
+
const billableItems = included as unknown as BillingItem[]
|
|
237
|
+
const lines = buildInvoiceLinesFromItems(billableItems)
|
|
238
|
+
|
|
239
|
+
// Window bounds for categorized sums (topups / usage already in the ledger).
|
|
240
|
+
const windowStart = period.periodStart
|
|
241
|
+
const windowEnd = addDays(period.periodEnd, 1) // exclusive upper bound
|
|
242
|
+
|
|
243
|
+
// Opening chains from the prior statement's closing — NOT a created_at sum.
|
|
244
|
+
// Recurring debits are stamped at run time (after period_end), so a
|
|
245
|
+
// `SUM(amount WHERE created_at < period_start)` would drop the prior period's
|
|
246
|
+
// recurring charge and break the chain. Resolution order:
|
|
247
|
+
// 1. previous cycle's closing (catch-up, in this run),
|
|
248
|
+
// 2. the prior persisted statement's closing (month-over-month),
|
|
249
|
+
// 3. the ledger balance before the first-ever statement window.
|
|
250
|
+
const openingBalance = await resolveOpeningBalance(em, account, period, opts.previousClosing)
|
|
251
|
+
const totalTopups = await sumByTypeInWindow(em, account, 'topup', windowStart, windowEnd)
|
|
252
|
+
const totalUsage = absMoney(
|
|
253
|
+
await sumByTypeInWindow(em, account, 'usage', windowStart, windowEnd),
|
|
254
|
+
)
|
|
255
|
+
// Manual adjustments / reversals that landed in the window — SIGNED (a credit
|
|
256
|
+
// is +, a debit −). Kept as a first-class term so the statement is
|
|
257
|
+
// self-consistent and the opening→closing chain never drifts.
|
|
258
|
+
const totalAdjustments = addMoney(
|
|
259
|
+
await sumByTypeInWindow(em, account, 'adjustment', windowStart, windowEnd),
|
|
260
|
+
await sumByTypeInWindow(em, account, 'reversal', windowStart, windowEnd),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
let totalRecurring = '0.0000'
|
|
264
|
+
const breakdown: DebitBreakdownEntry[] = []
|
|
265
|
+
const chargeTransactionIds: string[] = []
|
|
266
|
+
// The balance just before / after the recurring batch — for crossing events.
|
|
267
|
+
let recurringBalanceBefore: string | null = null
|
|
268
|
+
let recurringBalanceAfter: string | null = null
|
|
269
|
+
|
|
270
|
+
if (opts.mode === 'real') {
|
|
271
|
+
const now = new Date()
|
|
272
|
+
for (const line of lines) {
|
|
273
|
+
const movement = await applyBalanceMovement(em, {
|
|
274
|
+
organizationId: account.organizationId,
|
|
275
|
+
tenantId: account.tenantId,
|
|
276
|
+
billAccountId: account.id,
|
|
277
|
+
currencyCode: account.currencyCode,
|
|
278
|
+
type: line.billingType === 'one_time' ? 'one_time' : 'recurring',
|
|
279
|
+
amount: fromUnits(-toUnits(line.totalNetAmount)),
|
|
280
|
+
description: line.description,
|
|
281
|
+
billingItemId: line.billingItemId,
|
|
282
|
+
sourceRef: `recurring-${line.billingItemId}-${periodEndISO}`,
|
|
283
|
+
metadata: {
|
|
284
|
+
bill_period_start: period.periodStart.toISOString().slice(0, 10),
|
|
285
|
+
bill_period_end: periodEndISO,
|
|
286
|
+
bill_run_id: billRun.id,
|
|
287
|
+
},
|
|
288
|
+
})
|
|
289
|
+
if (recurringBalanceBefore === null) recurringBalanceBefore = movement.balanceBefore
|
|
290
|
+
recurringBalanceAfter = movement.balanceAfter
|
|
291
|
+
totalRecurring = addMoney(totalRecurring, line.totalNetAmount)
|
|
292
|
+
if (!movement.deduplicated) chargeTransactionIds.push(movement.transaction.id)
|
|
293
|
+
breakdown.push({
|
|
294
|
+
billing_item_id: line.billingItemId,
|
|
295
|
+
billing_type: line.billingType,
|
|
296
|
+
quantity: line.quantity,
|
|
297
|
+
amount: line.totalNetAmount,
|
|
298
|
+
})
|
|
299
|
+
const item = billableItems.find((it) => it.id === line.billingItemId)
|
|
300
|
+
if (item) {
|
|
301
|
+
item.billedToDate = period.periodEnd
|
|
302
|
+
item.updatedAt = now
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
await em.flush()
|
|
306
|
+
} else {
|
|
307
|
+
// test / dry: compute the would-be charges, persist nothing.
|
|
308
|
+
for (const line of lines) {
|
|
309
|
+
totalRecurring = addMoney(totalRecurring, line.totalNetAmount)
|
|
310
|
+
breakdown.push({
|
|
311
|
+
billing_item_id: line.billingItemId,
|
|
312
|
+
billing_type: line.billingType,
|
|
313
|
+
quantity: line.quantity,
|
|
314
|
+
amount: line.totalNetAmount,
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// closing = opening + topups + adjustments − usage − recurring.
|
|
320
|
+
const closingBalance = subMoney(
|
|
321
|
+
subMoney(addMoney(addMoney(openingBalance, totalTopups), totalAdjustments), totalUsage),
|
|
322
|
+
totalRecurring,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
// The recurring batch can push the balance low / exhausted / over-limit —
|
|
326
|
+
// emit those crossings (real mode only; dry/test never move the balance).
|
|
327
|
+
const crossingEvents =
|
|
328
|
+
recurringBalanceBefore !== null && recurringBalanceAfter !== null
|
|
329
|
+
? buildBalanceCrossingEvents({
|
|
330
|
+
scope: {
|
|
331
|
+
billAccountId: account.id,
|
|
332
|
+
tenantId: account.tenantId,
|
|
333
|
+
organizationId: account.organizationId,
|
|
334
|
+
currencyCode: account.currencyCode,
|
|
335
|
+
},
|
|
336
|
+
balanceBefore: recurringBalanceBefore,
|
|
337
|
+
balanceAfter: recurringBalanceAfter,
|
|
338
|
+
threshold: opts.threshold,
|
|
339
|
+
creditLimit: opts.creditLimit,
|
|
340
|
+
nearLimitBuffer: opts.nearLimitBuffer,
|
|
341
|
+
})
|
|
342
|
+
: []
|
|
343
|
+
|
|
344
|
+
let statementId: string | null = null
|
|
345
|
+
if (opts.mode === 'real' && opts.statementEnabled) {
|
|
346
|
+
const now = new Date()
|
|
347
|
+
const statement = em.create(BillingStatement, {
|
|
348
|
+
organizationId: account.organizationId,
|
|
349
|
+
tenantId: account.tenantId,
|
|
350
|
+
billAccountId: account.id,
|
|
351
|
+
billRunId: billRun.id,
|
|
352
|
+
periodStart: period.periodStart,
|
|
353
|
+
periodEnd: period.periodEnd,
|
|
354
|
+
currencyCode: account.currencyCode,
|
|
355
|
+
openingBalance,
|
|
356
|
+
totalTopups,
|
|
357
|
+
totalUsage,
|
|
358
|
+
totalRecurring,
|
|
359
|
+
totalAdjustments,
|
|
360
|
+
closingBalance,
|
|
361
|
+
debitBreakdown: { items: breakdown },
|
|
362
|
+
status: 'generated',
|
|
363
|
+
createdAt: now,
|
|
364
|
+
updatedAt: now,
|
|
365
|
+
})
|
|
366
|
+
em.persist(statement)
|
|
367
|
+
await em.flush()
|
|
368
|
+
statementId = statement.id
|
|
369
|
+
|
|
370
|
+
// Backfill statement_id on the charge transactions for attribution.
|
|
371
|
+
if (chargeTransactionIds.length > 0) {
|
|
372
|
+
await em.nativeUpdate(
|
|
373
|
+
BillingAccountTransaction,
|
|
374
|
+
{ id: { $in: chargeTransactionIds } } as never,
|
|
375
|
+
{ statementId } as never,
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return {
|
|
381
|
+
statementId,
|
|
382
|
+
openingBalance,
|
|
383
|
+
totalTopups,
|
|
384
|
+
totalUsage,
|
|
385
|
+
totalRecurring,
|
|
386
|
+
totalAdjustments,
|
|
387
|
+
closingBalance,
|
|
388
|
+
crossingEvents,
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Resolve a period's opening balance by chaining from the prior statement's
|
|
394
|
+
* closing (the spec's "chains cleanly"), with a ledger fallback for the very
|
|
395
|
+
* first statement. See the call site for why a created_at sum alone is wrong.
|
|
396
|
+
*/
|
|
397
|
+
async function resolveOpeningBalance(
|
|
398
|
+
em: EntityManager,
|
|
399
|
+
account: BillingAccount,
|
|
400
|
+
period: BillPeriod,
|
|
401
|
+
previousClosing: string | null,
|
|
402
|
+
): Promise<string> {
|
|
403
|
+
if (previousClosing !== null) return previousClosing
|
|
404
|
+
const prior = await em.findOne(
|
|
405
|
+
BillingStatement,
|
|
406
|
+
{
|
|
407
|
+
tenantId: account.tenantId,
|
|
408
|
+
organizationId: account.organizationId,
|
|
409
|
+
billAccountId: account.id,
|
|
410
|
+
periodEnd: { $lt: period.periodStart },
|
|
411
|
+
} as never,
|
|
412
|
+
{ orderBy: { periodEnd: 'DESC' } },
|
|
413
|
+
)
|
|
414
|
+
if (prior) return fromUnits(toUnits(prior.closingBalance))
|
|
415
|
+
// First-ever statement: the ledger balance just before the period window
|
|
416
|
+
// (only top-ups/usage exist before the first close, so created_at is safe).
|
|
417
|
+
return sumAmountsBefore(em, account, period.periodStart)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Balance at `before` = SUM(amount) of transactions created strictly before it. */
|
|
421
|
+
async function sumAmountsBefore(
|
|
422
|
+
em: EntityManager,
|
|
423
|
+
account: BillingAccount,
|
|
424
|
+
before: Date,
|
|
425
|
+
): Promise<string> {
|
|
426
|
+
const rows = (await em.execute(
|
|
427
|
+
`SELECT COALESCE(SUM(amount), 0) AS total
|
|
428
|
+
FROM billing_account_transactions
|
|
429
|
+
WHERE tenant_id = ? AND organization_id = ? AND bill_account_id = ?
|
|
430
|
+
AND deleted_at IS NULL AND created_at < ?`,
|
|
431
|
+
[account.tenantId, account.organizationId, account.id, before],
|
|
432
|
+
)) as unknown as Array<{ total: string }>
|
|
433
|
+
return fromUnits(toUnits(rows?.[0]?.total ?? 0))
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** SUM(amount) for one transaction type within [from, to). */
|
|
437
|
+
async function sumByTypeInWindow(
|
|
438
|
+
em: EntityManager,
|
|
439
|
+
account: BillingAccount,
|
|
440
|
+
type: string,
|
|
441
|
+
from: Date,
|
|
442
|
+
to: Date,
|
|
443
|
+
): Promise<string> {
|
|
444
|
+
const rows = (await em.execute(
|
|
445
|
+
`SELECT COALESCE(SUM(amount), 0) AS total
|
|
446
|
+
FROM billing_account_transactions
|
|
447
|
+
WHERE tenant_id = ? AND organization_id = ? AND bill_account_id = ?
|
|
448
|
+
AND deleted_at IS NULL AND type = ?
|
|
449
|
+
AND created_at >= ? AND created_at < ?`,
|
|
450
|
+
[account.tenantId, account.organizationId, account.id, type, from, to],
|
|
451
|
+
)) as unknown as Array<{ total: string }>
|
|
452
|
+
return fromUnits(toUnits(rows?.[0]?.total ?? 0))
|
|
453
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { BillingTopup } from '../data/entities'
|
|
3
|
+
import { applyBalanceMovement } from './balanceLedger'
|
|
4
|
+
import { createTopupReceipt, type TopupReceiptDeps } from './topupReceipt'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Idempotent top-up capture (SPEC-002 P2).
|
|
8
|
+
*
|
|
9
|
+
* On `payment_gateways.payment.captured` for a top-up, in ONE DB transaction:
|
|
10
|
+
* - credit the balance (atomic) + append a `type='topup'` transaction with a
|
|
11
|
+
* deterministic `source_ref = topup-{paymentId}` (so a re-fired capture can
|
|
12
|
+
* never double-credit),
|
|
13
|
+
* - create the posted+paid VAT receipt in core/sales,
|
|
14
|
+
* - transition the BillingTopup → captured, backfilling `transaction_id` +
|
|
15
|
+
* `receipt_invoice_id`.
|
|
16
|
+
*
|
|
17
|
+
* Re-firing on an already-captured top-up is a no-op (the gateway delivers
|
|
18
|
+
* webhooks at-least-once).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type CaptureTopupAccount = {
|
|
22
|
+
id: string
|
|
23
|
+
organizationId: string
|
|
24
|
+
tenantId: string
|
|
25
|
+
currencyCode: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type CaptureTopupParams = {
|
|
29
|
+
topup: BillingTopup
|
|
30
|
+
account: CaptureTopupAccount
|
|
31
|
+
/** VAT percent applied to the receipt. */
|
|
32
|
+
taxRate: number
|
|
33
|
+
/** Provider transaction id for the transaction metadata, if known. */
|
|
34
|
+
gatewayTransactionId?: string | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type CaptureTopupResult = {
|
|
38
|
+
status: 'captured' | 'already_captured'
|
|
39
|
+
balanceAfter?: string
|
|
40
|
+
transactionId?: string
|
|
41
|
+
receiptInvoiceId?: string
|
|
42
|
+
invoiceNumber?: string
|
|
43
|
+
grossAmount?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function captureTopup(
|
|
47
|
+
em: EntityManager,
|
|
48
|
+
deps: TopupReceiptDeps,
|
|
49
|
+
params: CaptureTopupParams,
|
|
50
|
+
): Promise<CaptureTopupResult> {
|
|
51
|
+
const { topup, account } = params
|
|
52
|
+
|
|
53
|
+
if (topup.status === 'captured') {
|
|
54
|
+
return { status: 'already_captured' }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return em.transactional(async (tem) => {
|
|
58
|
+
// 1. Atomic credit + paired transaction (idempotent on source_ref).
|
|
59
|
+
const movement = await applyBalanceMovement(tem as EntityManager, {
|
|
60
|
+
organizationId: account.organizationId,
|
|
61
|
+
tenantId: account.tenantId,
|
|
62
|
+
billAccountId: account.id,
|
|
63
|
+
currencyCode: account.currencyCode,
|
|
64
|
+
type: 'topup',
|
|
65
|
+
amount: topup.amount,
|
|
66
|
+
description: `Top-up — ${topup.providerKey}`,
|
|
67
|
+
topupId: topup.id,
|
|
68
|
+
sourceRef: `topup-${topup.paymentId}`,
|
|
69
|
+
metadata: {
|
|
70
|
+
provider_key: topup.providerKey,
|
|
71
|
+
payment_id: topup.paymentId,
|
|
72
|
+
gateway_transaction_id: params.gatewayTransactionId ?? topup.gatewayTransactionId ?? null,
|
|
73
|
+
},
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// A deduplicated credit means a PRIOR capture already committed (the
|
|
77
|
+
// gateway delivers `payment.captured` at-least-once, and the stale
|
|
78
|
+
// `status` check above can race a concurrent capture). Do NOT issue a
|
|
79
|
+
// second fiscal receipt — return the existing capture's links.
|
|
80
|
+
if (movement.deduplicated) {
|
|
81
|
+
const prior = await tem.findOne(BillingTopup, { id: topup.id })
|
|
82
|
+
return {
|
|
83
|
+
status: 'already_captured' as const,
|
|
84
|
+
balanceAfter: movement.balanceAfter,
|
|
85
|
+
transactionId: prior?.transactionId ?? movement.transaction.id,
|
|
86
|
+
receiptInvoiceId: prior?.receiptInvoiceId ?? undefined,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 2. Fiscal VAT receipt (posted+paid).
|
|
91
|
+
const receipt = await createTopupReceipt(tem as EntityManager, deps, {
|
|
92
|
+
account,
|
|
93
|
+
topupId: topup.id,
|
|
94
|
+
paymentId: topup.paymentId,
|
|
95
|
+
amount: topup.amount,
|
|
96
|
+
taxRate: params.taxRate,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// 3. Transition the top-up.
|
|
100
|
+
const managed = await tem.findOne(BillingTopup, { id: topup.id })
|
|
101
|
+
const target = managed ?? topup
|
|
102
|
+
target.status = 'captured'
|
|
103
|
+
target.transactionId = movement.transaction.id
|
|
104
|
+
target.receiptInvoiceId = receipt.invoiceId
|
|
105
|
+
target.updatedAt = new Date()
|
|
106
|
+
await tem.flush()
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
status: 'captured' as const,
|
|
110
|
+
balanceAfter: movement.balanceAfter,
|
|
111
|
+
transactionId: movement.transaction.id,
|
|
112
|
+
receiptInvoiceId: receipt.invoiceId,
|
|
113
|
+
invoiceNumber: receipt.invoiceNumber,
|
|
114
|
+
grossAmount: receipt.grossAmount,
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
}
|