Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions __tests__/lib/treasury/service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest"
import { calculateTreasuryPosition, decideTreasuryHold } from "@/lib/treasury/service"

const policy = { minimumReserveMinor: 1_000, maxSingleObligationMinor: 8_000 }
describe("treasury controls", () => {
it("excludes restricted escrow and pending settlements from available liquidity", () => {
const position = calculateTreasuryPosition({ available_cash: 10_000, restricted_escrow: 50_000, settlement_in_transit: 2_000 }, policy)
expect(position.availableLiquidityMinor).toBe(8_000)
expect(position.buckets.restricted_escrow).toBe(50_000)
})
it("holds concurrent payout requests after the first consumes liquidity", () => {
const position = calculateTreasuryPosition({ available_cash: 12_000 }, policy)
expect(decideTreasuryHold(position, 6_000, policy).approved).toBe(true)
const afterFirst = { ...position, availableLiquidityMinor: position.availableLiquidityMinor - 6_000 }
expect(decideTreasuryHold(afterFirst, 6_000, policy)).toMatchObject({ approved: false, code: "RESERVE_BREACH" })
})
it("handles refunds, reversals, negative scenarios, and integer-only values deterministically", () => {
const position = calculateTreasuryPosition({ available_cash: 4_000, refund_payable: 3_500 }, policy)
expect(position.severity).toBe("critical")
expect(decideTreasuryHold(position, 1, policy).code).toBe("RESERVE_BREACH")
expect(() => calculateTreasuryPosition({ available_cash: 1.5 }, policy)).toThrow("minor-unit")
})
it("is reproducible from the same authoritative bucket values", () => {
const source = { available_cash: 12_000, restricted_escrow: 2_000, investor_payable: 1_000 }
expect(calculateTreasuryPosition(source, policy)).toEqual(calculateTreasuryPosition(source, policy))
})
})
20 changes: 20 additions & 0 deletions app/api/admin/treasury/adjustments/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { NextResponse } from "next/server"
import { z } from "zod"
import { requireAuthenticatedUser, finalizeAuthenticatedResponse } from "@/lib/api/route-guard"
import { parseJsonBody } from "@/lib/api/validation"
import { TREASURY_BUCKETS } from "@/lib/treasury/service"
import dbConnect from "@/lib/dbConnect"
import TreasuryAdjustmentProposal from "@/models/TreasuryAdjustmentProposal"
import { logAuditEvent } from "@/lib/security/audit-log"

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) })
export async function POST(request: Request) {
const auth = await requireAuthenticatedUser(request, ["admin"])
if ("response" in auth) return auth.response
const parsed = await parseJsonBody(request, proposalSchema)
if ("response" in parsed) return parsed.response
await dbConnect()
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() }] })
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 })
return finalizeAuthenticatedResponse(NextResponse.json({ success: true, proposal: { id: proposal._id, status: proposal.status } }, { status: 201 }), auth)
}
61 changes: 61 additions & 0 deletions app/api/admin/treasury/summary/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { NextResponse } from "next/server"
import { requireAuthenticatedUser, finalizeAuthenticatedResponse } from "@/lib/api/route-guard"
import { calculateTreasuryPosition, type TreasuryBucket } from "@/lib/treasury/service"
import dbConnect from "@/lib/dbConnect"
import LedgerAccount from "@/models/LedgerAccount"
import LedgerEntry from "@/models/LedgerEntry"
import TreasurySnapshot from "@/models/TreasurySnapshot"

const CATEGORY_BUCKET: Record<string, TreasuryBucket> = {
platform_clearing: "available_cash", pool_escrow: "restricted_escrow", settlement_in_transit: "settlement_in_transit",
payouts_payable: "investor_payable", refunds_payable: "refund_payable", platform_reserve: "platform_reserve",
revenue_fees: "fees", suspense: "suspense",
}

export async function GET(request: Request) {
try {
const auth = await requireAuthenticatedUser(request, ["admin"])
if ("response" in auth) return auth.response
await dbConnect()
const currency = new URL(request.url).searchParams.get("currency") || "NGN"
const accounts = await LedgerAccount.find({ currency, category: { $in: Object.keys(CATEGORY_BUCKET) } }).lean()
const accountIds = accounts.map((account: any) => account._id)
const totals = accountIds.length ? await LedgerEntry.aggregate([
{ $match: { accountId: { $in: accountIds }, currency } },
{ $group: { _id: { accountId: "$accountId", direction: "$direction" }, amount: { $sum: "$amount" } } },
]) : []
const byAccount = new Map<string, number>()
for (const total of totals) {
const key = total._id.accountId.toString()
byAccount.set(key, (byAccount.get(key) || 0) + (total._id.direction === "debit" ? total.amount : -total.amount))
}
const buckets: Partial<Record<TreasuryBucket, number>> = {}
for (const account of accounts as any[]) {
const bucket = CATEGORY_BUCKET[account.category]
// Values must be integer minor units; legacy decimal entries are refused rather than rounded.
const amount = Math.abs(byAccount.get(account._id.toString()) || 0)
if (!Number.isSafeInteger(amount)) throw new Error("Treasury cannot summarize legacy non-minor-unit ledger entries.")
buckets[bucket] = (buckets[bucket] || 0) + amount
}
const position = calculateTreasuryPosition(buckets, { minimumReserveMinor: 0 })
const response = NextResponse.json({ success: true, currency, position, source: { ledgerEntryCount: totals.length, credentialsIncluded: false } })
return finalizeAuthenticatedResponse(response, auth)
} catch (error) {
return NextResponse.json({ message: error instanceof Error ? error.message : "Failed to load treasury summary." }, { status: 500 })
}
}

export async function POST(request: Request) {
try {
const auth = await requireAuthenticatedUser(request, ["admin"])
if ("response" in auth) return auth.response
const body = await request.json().catch(() => ({}))
const currency = typeof body.currency === "string" ? body.currency : "NGN"
const response = await GET(new Request(`${request.url}?currency=${encodeURIComponent(currency)}`, { headers: request.headers }))
if (!response.ok) return response

Check failure on line 55 in app/api/admin/treasury/summary/route.ts

View workflow job for this annotation

GitHub Actions / Pull request checks

'response' is possibly 'undefined'.
const payload = await response.json()

Check failure on line 56 in app/api/admin/treasury/summary/route.ts

View workflow job for this annotation

GitHub Actions / Pull request checks

'response' is possibly 'undefined'.
const snapshotDate = new Date().toISOString().slice(0, 10)
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 })
return NextResponse.json({ success: true, snapshotDate, position: payload.position })
} catch (error) { return NextResponse.json({ message: error instanceof Error ? error.message : "Failed to create snapshot." }, { status: 500 }) }
}
7 changes: 7 additions & 0 deletions docs/treasury-controls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Treasury controls

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`.

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.

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.
32 changes: 32 additions & 0 deletions lib/treasury/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/** Treasury calculations deliberately use integer minor units only. */
export const TREASURY_BUCKETS = ["available_cash", "restricted_escrow", "settlement_in_transit", "investor_payable", "refund_payable", "platform_reserve", "fees", "suspense"] as const
export type TreasuryBucket = (typeof TREASURY_BUCKETS)[number]
export type TreasuryPolicy = { minimumReserveMinor: number; maxSingleObligationMinor?: number }
export type TreasuryPosition = { buckets: Record<TreasuryBucket, number>; availableLiquidityMinor: number; requiredLiquidityMinor: number; varianceMinor: number; severity: "normal" | "warning" | "critical"; explanations: string[] }

export function assertMinor(value: number, name = "amount") {
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${name} must be a non-negative integer minor-unit amount.`)
}

export function calculateTreasuryPosition(input: Partial<Record<TreasuryBucket, number>>, policy: TreasuryPolicy): TreasuryPosition {
assertMinor(policy.minimumReserveMinor, "minimumReserveMinor")
const buckets = Object.fromEntries(TREASURY_BUCKETS.map((bucket) => [bucket, input[bucket] ?? 0])) as Record<TreasuryBucket, number>
for (const [bucket, amount] of Object.entries(buckets)) assertMinor(amount, bucket)
// Escrow, provider-pending cash, and fees are deliberately excluded from free liquidity.
const availableLiquidityMinor = buckets.available_cash - buckets.settlement_in_transit
const requiredLiquidityMinor = buckets.investor_payable + buckets.refund_payable + buckets.platform_reserve + policy.minimumReserveMinor
const varianceMinor = availableLiquidityMinor - requiredLiquidityMinor
const explanations = [
`Restricted escrow excluded: ${buckets.restricted_escrow}.`,
`Provider settlements in transit excluded: ${buckets.settlement_in_transit}.`,
`Required obligations include investor payables, refund payables, reserve, and policy minimum.`,
]
return { buckets, availableLiquidityMinor, requiredLiquidityMinor, varianceMinor, severity: varianceMinor < 0 ? "critical" : varianceMinor < policy.minimumReserveMinor ? "warning" : "normal", explanations }
}

export function decideTreasuryHold(position: TreasuryPosition, amountMinor: number, policy: TreasuryPolicy) {
assertMinor(amountMinor)
if (policy.maxSingleObligationMinor !== undefined && amountMinor > policy.maxSingleObligationMinor) return { approved: false, code: "CONCENTRATION_LIMIT", reason: "Obligation exceeds the configured concentration limit." } as const
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
return { approved: true } as const
}
8 changes: 8 additions & 0 deletions models/LedgerAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ export interface ILedgerAccount {
| "revenue_fees"
| "repayments_receivable"
| "payouts_payable"
| "settlement_in_transit"
| "refunds_payable"
| "platform_reserve"
| "suspense"
| "adjustment"
ownerId?: Schema.Types.ObjectId
ownerType?: "driver" | "investor" | "admin" | "system"
Expand Down Expand Up @@ -40,6 +44,10 @@ const LedgerAccountSchema: Schema = new Schema(
"revenue_fees",
"repayments_receivable",
"payouts_payable",
"settlement_in_transit",
"refunds_payable",
"platform_reserve",
"suspense",
"adjustment",
],
required: true,
Expand Down
15 changes: 15 additions & 0 deletions models/TreasuryAdjustmentProposal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import mongoose, { Schema } from "mongoose"

const TreasuryAdjustmentProposalSchema = new Schema(
{
bucket: { type: String, required: true },
amountMinor: { type: Number, required: true },
currency: { type: String, required: true },
reason: { type: String, required: true, trim: true },
proposedBy: { type: Schema.Types.ObjectId, ref: "User", required: true },
status: { type: String, enum: ["proposed", "rejected"], default: "proposed" },
history: { type: [{ action: String, actorId: Schema.Types.ObjectId, reason: String, timestamp: Date }], default: [] },
},
{ timestamps: true },
)
export default (mongoose.models.TreasuryAdjustmentProposal || mongoose.model("TreasuryAdjustmentProposal", TreasuryAdjustmentProposalSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>
31 changes: 31 additions & 0 deletions models/TreasurySnapshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import mongoose, { Schema } from "mongoose"

export interface ITreasurySnapshot {
snapshotDate: string
currency: string
buckets: Record<string, number>
availableLiquidityMinor: number
requiredLiquidityMinor: number
varianceMinor: number
explanations: string[]
sourceJournalCount: number
sourceThrough: Date
createdAt: Date
}

const TreasurySnapshotSchema = new Schema<ITreasurySnapshot>(
{
snapshotDate: { type: String, required: true },
currency: { type: String, required: true },
buckets: { type: Schema.Types.Mixed, required: true },
availableLiquidityMinor: { type: Number, required: true },
requiredLiquidityMinor: { type: Number, required: true },
varianceMinor: { type: Number, required: true },
explanations: { type: [String], default: [] },
sourceJournalCount: { type: Number, required: true },
sourceThrough: { type: Date, required: true },
},
{ timestamps: { createdAt: true, updatedAt: false } },
)
TreasurySnapshotSchema.index({ snapshotDate: 1, currency: 1 }, { unique: true })
export default (mongoose.models.TreasurySnapshot || mongoose.model<ITreasurySnapshot>("TreasurySnapshot", TreasurySnapshotSchema)) as mongoose.Model<ITreasurySnapshot>
Loading