diff --git a/__tests__/lib/treasury/service.test.ts b/__tests__/lib/treasury/service.test.ts new file mode 100644 index 00000000..ca5220af --- /dev/null +++ b/__tests__/lib/treasury/service.test.ts @@ -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)) + }) +}) diff --git a/app/api/admin/treasury/adjustments/route.ts b/app/api/admin/treasury/adjustments/route.ts new file mode 100644 index 00000000..57f5ab5a --- /dev/null +++ b/app/api/admin/treasury/adjustments/route.ts @@ -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) +} diff --git a/app/api/admin/treasury/summary/route.ts b/app/api/admin/treasury/summary/route.ts new file mode 100644 index 00000000..a81468cc --- /dev/null +++ b/app/api/admin/treasury/summary/route.ts @@ -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 = { + 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() + 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> = {} + 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 + const payload = await response.json() + 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 }) } +} diff --git a/docs/treasury-controls.md b/docs/treasury-controls.md new file mode 100644 index 00000000..ff09eb48 --- /dev/null +++ b/docs/treasury-controls.md @@ -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. diff --git a/lib/treasury/service.ts b/lib/treasury/service.ts new file mode 100644 index 00000000..70bfa36e --- /dev/null +++ b/lib/treasury/service.ts @@ -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; 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>, policy: TreasuryPolicy): TreasuryPosition { + assertMinor(policy.minimumReserveMinor, "minimumReserveMinor") + const buckets = Object.fromEntries(TREASURY_BUCKETS.map((bucket) => [bucket, input[bucket] ?? 0])) as Record + 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 +} diff --git a/models/LedgerAccount.ts b/models/LedgerAccount.ts index 511b6989..810c3fd3 100644 --- a/models/LedgerAccount.ts +++ b/models/LedgerAccount.ts @@ -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" @@ -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, diff --git a/models/TreasuryAdjustmentProposal.ts b/models/TreasuryAdjustmentProposal.ts new file mode 100644 index 00000000..2f911f08 --- /dev/null +++ b/models/TreasuryAdjustmentProposal.ts @@ -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 }> diff --git a/models/TreasurySnapshot.ts b/models/TreasurySnapshot.ts new file mode 100644 index 00000000..e559bb65 --- /dev/null +++ b/models/TreasurySnapshot.ts @@ -0,0 +1,31 @@ +import mongoose, { Schema } from "mongoose" + +export interface ITreasurySnapshot { + snapshotDate: string + currency: string + buckets: Record + availableLiquidityMinor: number + requiredLiquidityMinor: number + varianceMinor: number + explanations: string[] + sourceJournalCount: number + sourceThrough: Date + createdAt: Date +} + +const TreasurySnapshotSchema = new Schema( + { + 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("TreasurySnapshot", TreasurySnapshotSchema)) as mongoose.Model