Skip to content

Commit 01c158d

Browse files
committed
feat: add treasury liquidity controls
1 parent 1ea5593 commit 01c158d

8 files changed

Lines changed: 201 additions & 0 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { describe, expect, it } from "vitest"
2+
import { calculateTreasuryPosition, decideTreasuryHold } from "@/lib/treasury/service"
3+
4+
const policy = { minimumReserveMinor: 1_000, maxSingleObligationMinor: 8_000 }
5+
describe("treasury controls", () => {
6+
it("excludes restricted escrow and pending settlements from available liquidity", () => {
7+
const position = calculateTreasuryPosition({ available_cash: 10_000, restricted_escrow: 50_000, settlement_in_transit: 2_000 }, policy)
8+
expect(position.availableLiquidityMinor).toBe(8_000)
9+
expect(position.buckets.restricted_escrow).toBe(50_000)
10+
})
11+
it("holds concurrent payout requests after the first consumes liquidity", () => {
12+
const position = calculateTreasuryPosition({ available_cash: 12_000 }, policy)
13+
expect(decideTreasuryHold(position, 6_000, policy).approved).toBe(true)
14+
const afterFirst = { ...position, availableLiquidityMinor: position.availableLiquidityMinor - 6_000 }
15+
expect(decideTreasuryHold(afterFirst, 6_000, policy)).toMatchObject({ approved: false, code: "RESERVE_BREACH" })
16+
})
17+
it("handles refunds, reversals, negative scenarios, and integer-only values deterministically", () => {
18+
const position = calculateTreasuryPosition({ available_cash: 4_000, refund_payable: 3_500 }, policy)
19+
expect(position.severity).toBe("critical")
20+
expect(decideTreasuryHold(position, 1, policy).code).toBe("RESERVE_BREACH")
21+
expect(() => calculateTreasuryPosition({ available_cash: 1.5 }, policy)).toThrow("minor-unit")
22+
})
23+
it("is reproducible from the same authoritative bucket values", () => {
24+
const source = { available_cash: 12_000, restricted_escrow: 2_000, investor_payable: 1_000 }
25+
expect(calculateTreasuryPosition(source, policy)).toEqual(calculateTreasuryPosition(source, policy))
26+
})
27+
})
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { NextResponse } from "next/server"
2+
import { z } from "zod"
3+
import { requireAuthenticatedUser, finalizeAuthenticatedResponse } from "@/lib/api/route-guard"
4+
import { parseJsonBody } from "@/lib/api/validation"
5+
import { TREASURY_BUCKETS } from "@/lib/treasury/service"
6+
import dbConnect from "@/lib/dbConnect"
7+
import TreasuryAdjustmentProposal from "@/models/TreasuryAdjustmentProposal"
8+
import { logAuditEvent } from "@/lib/security/audit-log"
9+
10+
const proposalSchema = z.object({ bucket: z.enum(TREASURY_BUCKETS), amountMinor: z.number().int().positive(), currency: z.string().min(3).max(3), reason: z.string().trim().min(10).max(1000) })
11+
export async function POST(request: Request) {
12+
const auth = await requireAuthenticatedUser(request, ["admin"])
13+
if ("response" in auth) return auth.response
14+
const parsed = await parseJsonBody(request, proposalSchema)
15+
if ("response" in parsed) return parsed.response
16+
await dbConnect()
17+
const proposal = await TreasuryAdjustmentProposal.create({ ...parsed.data, proposedBy: auth.user._id, history: [{ action: "proposed", actorId: auth.user._id, reason: parsed.data.reason, timestamp: new Date() }] })
18+
await logAuditEvent({ actor: auth.user, action: "treasury.adjustment.proposed", targetType: "TreasuryAdjustmentProposal", targetId: proposal._id.toString(), metadata: { bucket: parsed.data.bucket, amountMinor: parsed.data.amountMinor }, criticalAction: true })
19+
return finalizeAuthenticatedResponse(NextResponse.json({ success: true, proposal: { id: proposal._id, status: proposal.status } }, { status: 201 }), auth)
20+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { NextResponse } from "next/server"
2+
import { requireAuthenticatedUser, finalizeAuthenticatedResponse } from "@/lib/api/route-guard"
3+
import { calculateTreasuryPosition, type TreasuryBucket } from "@/lib/treasury/service"
4+
import dbConnect from "@/lib/dbConnect"
5+
import LedgerAccount from "@/models/LedgerAccount"
6+
import LedgerEntry from "@/models/LedgerEntry"
7+
import TreasurySnapshot from "@/models/TreasurySnapshot"
8+
9+
const CATEGORY_BUCKET: Record<string, TreasuryBucket> = {
10+
platform_clearing: "available_cash", pool_escrow: "restricted_escrow", settlement_in_transit: "settlement_in_transit",
11+
payouts_payable: "investor_payable", refunds_payable: "refund_payable", platform_reserve: "platform_reserve",
12+
revenue_fees: "fees", suspense: "suspense",
13+
}
14+
15+
export async function GET(request: Request) {
16+
try {
17+
const auth = await requireAuthenticatedUser(request, ["admin"])
18+
if ("response" in auth) return auth.response
19+
await dbConnect()
20+
const currency = new URL(request.url).searchParams.get("currency") || "NGN"
21+
const accounts = await LedgerAccount.find({ currency, category: { $in: Object.keys(CATEGORY_BUCKET) } }).lean()
22+
const accountIds = accounts.map((account: any) => account._id)
23+
const totals = accountIds.length ? await LedgerEntry.aggregate([
24+
{ $match: { accountId: { $in: accountIds }, currency } },
25+
{ $group: { _id: { accountId: "$accountId", direction: "$direction" }, amount: { $sum: "$amount" } } },
26+
]) : []
27+
const byAccount = new Map<string, number>()
28+
for (const total of totals) {
29+
const key = total._id.accountId.toString()
30+
byAccount.set(key, (byAccount.get(key) || 0) + (total._id.direction === "debit" ? total.amount : -total.amount))
31+
}
32+
const buckets: Partial<Record<TreasuryBucket, number>> = {}
33+
for (const account of accounts as any[]) {
34+
const bucket = CATEGORY_BUCKET[account.category]
35+
// Values must be integer minor units; legacy decimal entries are refused rather than rounded.
36+
const amount = Math.abs(byAccount.get(account._id.toString()) || 0)
37+
if (!Number.isSafeInteger(amount)) throw new Error("Treasury cannot summarize legacy non-minor-unit ledger entries.")
38+
buckets[bucket] = (buckets[bucket] || 0) + amount
39+
}
40+
const position = calculateTreasuryPosition(buckets, { minimumReserveMinor: 0 })
41+
const response = NextResponse.json({ success: true, currency, position, source: { ledgerEntryCount: totals.length, credentialsIncluded: false } })
42+
return finalizeAuthenticatedResponse(response, auth)
43+
} catch (error) {
44+
return NextResponse.json({ message: error instanceof Error ? error.message : "Failed to load treasury summary." }, { status: 500 })
45+
}
46+
}
47+
48+
export async function POST(request: Request) {
49+
try {
50+
const auth = await requireAuthenticatedUser(request, ["admin"])
51+
if ("response" in auth) return auth.response
52+
const body = await request.json().catch(() => ({}))
53+
const currency = typeof body.currency === "string" ? body.currency : "NGN"
54+
const response = await GET(new Request(`${request.url}?currency=${encodeURIComponent(currency)}`, { headers: request.headers }))
55+
if (!response.ok) return response
56+
const payload = await response.json()
57+
const snapshotDate = new Date().toISOString().slice(0, 10)
58+
await TreasurySnapshot.findOneAndUpdate({ snapshotDate, currency }, { $set: { snapshotDate, currency, buckets: payload.position.buckets, availableLiquidityMinor: payload.position.availableLiquidityMinor, requiredLiquidityMinor: payload.position.requiredLiquidityMinor, varianceMinor: payload.position.varianceMinor, explanations: payload.position.explanations, sourceJournalCount: payload.source.ledgerEntryCount, sourceThrough: new Date() } }, { upsert: true, new: true })
59+
return NextResponse.json({ success: true, snapshotDate, position: payload.position })
60+
} catch (error) { return NextResponse.json({ message: error instanceof Error ? error.message : "Failed to create snapshot." }, { status: 500 }) }
61+
}

docs/treasury-controls.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Treasury controls
2+
3+
All treasury amounts are integer minor units (for NGN, kobo). Available liquidity is `available_cash - settlement_in_transit`; restricted escrow, fees, and suspense are never free cash. Required liquidity is `investor_payable + refund_payable + platform_reserve + minimum_reserve`.
4+
5+
Before a payout or refund is approved, reserve the amount in the authoritative transaction and evaluate the resulting liquidity. Hold it deterministically when the result is below required liquidity or a concentration limit is exceeded. Provider-pending settlements remain in transit until confirmed.
6+
7+
Daily snapshots store their source journal count and cutoff so a position can be reproduced. Adjustment proposals require an admin, reason, and append-only history; they do not mutate user balances. For a shortfall, pause affected disbursements, reconcile provider-pending settlements, record the incident and variance explanation, and release holds only after the reproduced position meets policy.

lib/treasury/service.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/** Treasury calculations deliberately use integer minor units only. */
2+
export const TREASURY_BUCKETS = ["available_cash", "restricted_escrow", "settlement_in_transit", "investor_payable", "refund_payable", "platform_reserve", "fees", "suspense"] as const
3+
export type TreasuryBucket = (typeof TREASURY_BUCKETS)[number]
4+
export type TreasuryPolicy = { minimumReserveMinor: number; maxSingleObligationMinor?: number }
5+
export type TreasuryPosition = { buckets: Record<TreasuryBucket, number>; availableLiquidityMinor: number; requiredLiquidityMinor: number; varianceMinor: number; severity: "normal" | "warning" | "critical"; explanations: string[] }
6+
7+
export function assertMinor(value: number, name = "amount") {
8+
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${name} must be a non-negative integer minor-unit amount.`)
9+
}
10+
11+
export function calculateTreasuryPosition(input: Partial<Record<TreasuryBucket, number>>, policy: TreasuryPolicy): TreasuryPosition {
12+
assertMinor(policy.minimumReserveMinor, "minimumReserveMinor")
13+
const buckets = Object.fromEntries(TREASURY_BUCKETS.map((bucket) => [bucket, input[bucket] ?? 0])) as Record<TreasuryBucket, number>
14+
for (const [bucket, amount] of Object.entries(buckets)) assertMinor(amount, bucket)
15+
// Escrow, provider-pending cash, and fees are deliberately excluded from free liquidity.
16+
const availableLiquidityMinor = buckets.available_cash - buckets.settlement_in_transit
17+
const requiredLiquidityMinor = buckets.investor_payable + buckets.refund_payable + buckets.platform_reserve + policy.minimumReserveMinor
18+
const varianceMinor = availableLiquidityMinor - requiredLiquidityMinor
19+
const explanations = [
20+
`Restricted escrow excluded: ${buckets.restricted_escrow}.`,
21+
`Provider settlements in transit excluded: ${buckets.settlement_in_transit}.`,
22+
`Required obligations include investor payables, refund payables, reserve, and policy minimum.`,
23+
]
24+
return { buckets, availableLiquidityMinor, requiredLiquidityMinor, varianceMinor, severity: varianceMinor < 0 ? "critical" : varianceMinor < policy.minimumReserveMinor ? "warning" : "normal", explanations }
25+
}
26+
27+
export function decideTreasuryHold(position: TreasuryPosition, amountMinor: number, policy: TreasuryPolicy) {
28+
assertMinor(amountMinor)
29+
if (policy.maxSingleObligationMinor !== undefined && amountMinor > policy.maxSingleObligationMinor) return { approved: false, code: "CONCENTRATION_LIMIT", reason: "Obligation exceeds the configured concentration limit." } as const
30+
if (position.availableLiquidityMinor - amountMinor < position.requiredLiquidityMinor) return { approved: false, code: "RESERVE_BREACH", reason: "Operation is held because it would breach the liquidity reserve." } as const
31+
return { approved: true } as const
32+
}

models/LedgerAccount.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ export interface ILedgerAccount {
1111
| "revenue_fees"
1212
| "repayments_receivable"
1313
| "payouts_payable"
14+
| "settlement_in_transit"
15+
| "refunds_payable"
16+
| "platform_reserve"
17+
| "suspense"
1418
| "adjustment"
1519
ownerId?: Schema.Types.ObjectId
1620
ownerType?: "driver" | "investor" | "admin" | "system"
@@ -40,6 +44,10 @@ const LedgerAccountSchema: Schema = new Schema(
4044
"revenue_fees",
4145
"repayments_receivable",
4246
"payouts_payable",
47+
"settlement_in_transit",
48+
"refunds_payable",
49+
"platform_reserve",
50+
"suspense",
4351
"adjustment",
4452
],
4553
required: true,
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import mongoose, { Schema } from "mongoose"
2+
3+
const TreasuryAdjustmentProposalSchema = new Schema(
4+
{
5+
bucket: { type: String, required: true },
6+
amountMinor: { type: Number, required: true },
7+
currency: { type: String, required: true },
8+
reason: { type: String, required: true, trim: true },
9+
proposedBy: { type: Schema.Types.ObjectId, ref: "User", required: true },
10+
status: { type: String, enum: ["proposed", "rejected"], default: "proposed" },
11+
history: { type: [{ action: String, actorId: Schema.Types.ObjectId, reason: String, timestamp: Date }], default: [] },
12+
},
13+
{ timestamps: true },
14+
)
15+
export default (mongoose.models.TreasuryAdjustmentProposal || mongoose.model("TreasuryAdjustmentProposal", TreasuryAdjustmentProposalSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>

models/TreasurySnapshot.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import mongoose, { Schema } from "mongoose"
2+
3+
export interface ITreasurySnapshot {
4+
snapshotDate: string
5+
currency: string
6+
buckets: Record<string, number>
7+
availableLiquidityMinor: number
8+
requiredLiquidityMinor: number
9+
varianceMinor: number
10+
explanations: string[]
11+
sourceJournalCount: number
12+
sourceThrough: Date
13+
createdAt: Date
14+
}
15+
16+
const TreasurySnapshotSchema = new Schema<ITreasurySnapshot>(
17+
{
18+
snapshotDate: { type: String, required: true },
19+
currency: { type: String, required: true },
20+
buckets: { type: Schema.Types.Mixed, required: true },
21+
availableLiquidityMinor: { type: Number, required: true },
22+
requiredLiquidityMinor: { type: Number, required: true },
23+
varianceMinor: { type: Number, required: true },
24+
explanations: { type: [String], default: [] },
25+
sourceJournalCount: { type: Number, required: true },
26+
sourceThrough: { type: Date, required: true },
27+
},
28+
{ timestamps: { createdAt: true, updatedAt: false } },
29+
)
30+
TreasurySnapshotSchema.index({ snapshotDate: 1, currency: 1 }, { unique: true })
31+
export default (mongoose.models.TreasurySnapshot || mongoose.model<ITreasurySnapshot>("TreasurySnapshot", TreasurySnapshotSchema)) as mongoose.Model<ITreasurySnapshot>

0 commit comments

Comments
 (0)