import type { FastifyPluginAsync } from "fastify"; import type { Prisma, PrismaClient } from "@prisma/client"; import { z } from "zod"; import { ensureBudgetSessionAvailableSynced } from "../services/budget-session.js"; type RateLimitRouteOptions = { config: { rateLimit: { max: number; timeWindow: number; keyGenerator?: (req: any) => string; }; }; }; type PercentCategory = { id: string; percent: number; balanceCents: bigint | null; }; type DepositShareResult = | { ok: true; shares: Array<{ id: string; share: number }> } | { ok: false; reason: string }; type VariableCategoriesRoutesOptions = { mutationRateLimit: RateLimitRouteOptions; computeDepositShares: (categories: PercentCategory[], amountCents: number) => DepositShareResult; }; const CatBody = z.object({ name: z.string().trim().min(1), percent: z.number().int().min(0).max(100), isSavings: z.boolean(), priority: z.number().int().min(0), }); const ManualRebalanceBody = z.object({ targets: z.array( z.object({ id: z.string().min(1), targetCents: z.number().int().min(0), }) ), forceLowerSavings: z.boolean().optional(), confirmOver80: z.boolean().optional(), }); async function assertPercentTotal( tx: PrismaClient | Prisma.TransactionClient, userId: string ) { const categories = await tx.variableCategory.findMany({ where: { userId }, select: { percent: true, isSavings: true }, }); const sum = categories.reduce((total, cat) => total + (cat.percent ?? 0), 0); const savingsSum = categories.reduce( (total, cat) => total + (cat.isSavings ? cat.percent ?? 0 : 0), 0 ); // Allow partial states during onboarding (< 100%), but enforce exact 100% when sum would be >= 100% if (sum > 100) { const err: any = new Error("Percents must sum to 100"); err.statusCode = 400; err.code = "PERCENT_TOTAL_OVER_100"; throw err; } if (sum >= 100 && savingsSum < 20) { const err: any = new Error( `Savings must total at least 20% (currently ${savingsSum}%)` ); err.statusCode = 400; err.code = "SAVINGS_MINIMUM"; throw err; } } const variableCategoriesRoutes: FastifyPluginAsync = async ( app, opts ) => { app.post("/variable-categories", opts.mutationRateLimit, async (req, reply) => { const parsed = CatBody.safeParse(req.body); if (!parsed.success) { return reply.code(400).send({ message: "Invalid payload" }); } const userId = req.userId; const normalizedName = parsed.data.name.trim().toLowerCase(); return await app.prisma.$transaction(async (tx) => { try { const created = await tx.variableCategory.create({ data: { userId, balanceCents: 0n, ...parsed.data, name: normalizedName }, select: { id: true }, }); await assertPercentTotal(tx, userId); return reply.status(201).send(created); } catch (error: any) { if (error.code === "P2002") { return reply .status(400) .send({ error: "DUPLICATE_NAME", message: `Category name '${parsed.data.name}' already exists` }); } throw error; } }); }); app.patch("/variable-categories/:id", opts.mutationRateLimit, async (req, reply) => { const patch = CatBody.partial().safeParse(req.body); if (!patch.success) { return reply.code(400).send({ message: "Invalid payload" }); } const id = String((req.params as any).id); const userId = req.userId; const updateData = { ...patch.data, ...(patch.data.name !== undefined ? { name: patch.data.name.trim().toLowerCase() } : {}), }; return await app.prisma.$transaction(async (tx) => { const exists = await tx.variableCategory.findFirst({ where: { id, userId }, }); if (!exists) return reply.code(404).send({ message: "Not found" }); const updated = await tx.variableCategory.updateMany({ where: { id, userId }, data: updateData, }); if (updated.count === 0) return reply.code(404).send({ message: "Not found" }); await assertPercentTotal(tx, userId); return { ok: true }; }); }); app.delete("/variable-categories/:id", opts.mutationRateLimit, async (req, reply) => { const id = String((req.params as any).id); const userId = req.userId; const exists = await app.prisma.variableCategory.findFirst({ where: { id, userId }, }); if (!exists) return reply.code(404).send({ message: "Not found" }); const deleted = await app.prisma.variableCategory.deleteMany({ where: { id, userId }, }); if (deleted.count === 0) return reply.code(404).send({ message: "Not found" }); await assertPercentTotal(app.prisma, userId); return { ok: true }; }); app.post("/variable-categories/rebalance", opts.mutationRateLimit, async (req, reply) => { const userId = req.userId; const categories = await app.prisma.variableCategory.findMany({ where: { userId }, select: { id: true, percent: true, balanceCents: true }, orderBy: [{ priority: "asc" }, { name: "asc" }], }); if (categories.length === 0) { return { ok: true, applied: false }; } const hasNegative = categories.some((c) => Number(c.balanceCents ?? 0n) < 0); if (hasNegative) { return reply.code(400).send({ ok: false, code: "NEGATIVE_BALANCE", message: "Cannot rebalance while a category has a negative balance.", }); } const totalBalance = categories.reduce((sum, c) => sum + Number(c.balanceCents ?? 0n), 0); if (totalBalance <= 0) { return { ok: true, applied: false }; } const shareResult = opts.computeDepositShares(categories, totalBalance); if (!shareResult.ok) { return reply.code(400).send({ ok: false, code: "NO_PERCENT", message: "No percent totals available to rebalance.", }); } await app.prisma.$transaction( shareResult.shares.map((s) => app.prisma.variableCategory.update({ where: { id: s.id }, data: { balanceCents: BigInt(s.share) }, }) ) ); return { ok: true, applied: true, totalBalance }; }); app.get("/variable-categories/manual-rebalance", async (req, reply) => { const userId = req.userId; const cats = await app.prisma.variableCategory.findMany({ where: { userId }, orderBy: [{ priority: "asc" }, { name: "asc" }], select: { id: true, name: true, percent: true, isSavings: true, balanceCents: true }, }); const totalBalance = cats.reduce((s, c) => s + Number(c.balanceCents ?? 0n), 0); await ensureBudgetSessionAvailableSynced(app.prisma, userId, totalBalance); return reply.send({ ok: true, availableCents: totalBalance, categories: cats.map((c) => ({ ...c, balanceCents: Number(c.balanceCents ?? 0n) })), }); }); app.post("/variable-categories/manual-rebalance", async (req, reply) => { const userId = req.userId; const parsed = ManualRebalanceBody.safeParse(req.body); if (!parsed.success || parsed.data.targets.length === 0) { return reply.code(400).send({ ok: false, code: "INVALID_BODY" }); } const cats = await app.prisma.variableCategory.findMany({ where: { userId }, orderBy: [{ priority: "asc" }, { name: "asc" }], select: { id: true, name: true, percent: true, isSavings: true, balanceCents: true }, }); if (cats.length === 0) return reply.code(400).send({ ok: false, code: "NO_CATEGORIES" }); const totalBalance = cats.reduce((s, c) => s + Number(c.balanceCents ?? 0n), 0); const availableCents = totalBalance; await ensureBudgetSessionAvailableSynced(app.prisma, userId, availableCents); const targetMap = new Map(); for (const t of parsed.data.targets) { if (targetMap.has(t.id)) return reply.code(400).send({ ok: false, code: "DUPLICATE_ID" }); targetMap.set(t.id, t.targetCents); } if (targetMap.size !== cats.length || cats.some((c) => !targetMap.has(c.id))) { return reply.code(400).send({ ok: false, code: "MISSING_CATEGORY" }); } const targets = cats.map((c) => ({ ...c, target: targetMap.get(c.id)!, currentBalance: Number(c.balanceCents ?? 0n), })); if (targets.some((t) => t.target < 0)) { return reply.code(400).send({ ok: false, code: "NEGATIVE_TARGET" }); } const sumTargets = targets.reduce((s, t) => s + t.target, 0); if (sumTargets !== availableCents) { return reply .code(400) .send({ ok: false, code: "SUM_MISMATCH", message: `Targets must sum to available (${availableCents}).` }); } const maxAllowed = Math.floor(availableCents * 0.8); const over80 = availableCents > 0 && targets.some((t) => t.target > maxAllowed); if (over80 && !parsed.data.confirmOver80) { return reply.code(400).send({ ok: false, code: "OVER_80_CONFIRM_REQUIRED", message: "A category exceeds 80% of available. Confirm to proceed.", }); } const totalSavingsBefore = targets.filter((t) => t.isSavings).reduce((s, t) => s + t.currentBalance, 0); const totalSavingsAfter = targets.filter((t) => t.isSavings).reduce((s, t) => s + t.target, 0); const savingsFloor = Math.floor(availableCents * 0.2); const loweringSavings = totalSavingsAfter < totalSavingsBefore; const belowFloor = totalSavingsAfter < savingsFloor; if ((loweringSavings || belowFloor) && !parsed.data.forceLowerSavings) { return reply .code(400) .send({ ok: false, code: "SAVINGS_FLOOR", message: "Lowering savings requires confirmation." }); } await app.prisma.$transaction(async (tx) => { for (const t of targets) { await tx.variableCategory.update({ where: { id: t.id }, data: { balanceCents: BigInt(t.target) }, }); } await tx.transaction.create({ data: { userId, kind: "rebalance", amountCents: 0n, occurredAt: new Date(), note: JSON.stringify({ availableCents, before: targets.map((t) => ({ id: t.id, balanceCents: t.currentBalance })), after: targets.map((t) => ({ id: t.id, balanceCents: t.target })), totalSavingsBefore, totalSavingsAfter, }), }, }); }); return reply.send({ ok: true, availableCents, categories: targets.map((t) => ({ id: t.id, balanceCents: t.target })), }); }); }; export default variableCategoriesRoutes;