From 3199e676a813fc1404b60a1a7e3811fc39da832b Mon Sep 17 00:00:00 2001 From: Ricearoni1245 Date: Wed, 11 Mar 2026 21:17:45 -0500 Subject: [PATCH] test run for user rebalance expenses feature, added safeguard for estimate exepenses acceptance in onboarding --- api/src/routes/variable-categories.ts | 139 ++++++++++ api/tests/helpers.ts | 1 + ...riable-categories.manual-rebalance.test.ts | 110 ++++++++ transfer-rebalance-spec.md | 39 +++ web/src/api/fixedPlans.ts | 2 - web/src/api/rebalance.ts | 26 ++ web/src/main.tsx | 8 + web/src/pages/OnboardingPage.tsx | 3 - web/src/pages/settings/CategoriesSettings.tsx | 12 +- web/src/pages/settings/PlansSettings.tsx | 9 - web/src/pages/settings/RebalancePage.tsx | 245 ++++++++++++++++++ web/src/pages/settings/SettingsPage.tsx | 7 +- 12 files changed, 583 insertions(+), 18 deletions(-) create mode 100644 api/tests/variable-categories.manual-rebalance.test.ts create mode 100644 transfer-rebalance-spec.md create mode 100644 web/src/api/rebalance.ts create mode 100644 web/src/pages/settings/RebalancePage.tsx diff --git a/api/src/routes/variable-categories.ts b/api/src/routes/variable-categories.ts index 8c87e45..a73fb1b 100644 --- a/api/src/routes/variable-categories.ts +++ b/api/src/routes/variable-categories.ts @@ -12,6 +12,15 @@ const NewCat = z.object({ }); const PatchCat = NewCat.partial(); const IdParam = z.object({ id: z.string().min(1) }); +const ManualRebalanceBody = z.object({ + targets: z.array( + z.object({ + id: z.string().min(1), + targetCents: z.number().int().min(0), + }) + ), + forceLowerSavings: z.boolean().optional(), +}); function computeBalanceTargets( categories: Array<{ id: string; percent: number }>, @@ -66,6 +75,13 @@ async function assertPercentTotal100(tx: Prisma.TransactionClient, userId: strin // The frontend will ensure 100% total before finishing onboarding } +async function getLatestBudgetSession(userId: string) { + return prisma.budgetSession.findFirst({ + where: { userId }, + orderBy: { periodStart: "desc" }, + }); +} + const plugin: FastifyPluginAsync = async (app) => { // CREATE app.post("/variable-categories", async (req, reply) => { @@ -182,6 +198,129 @@ const plugin: FastifyPluginAsync = async (app) => { return reply.send({ ok: true, applied: true, totalBalance }); }); + + // MANUAL REBALANCE: set explicit dollar targets for variable balances + app.get("/variable-categories/manual-rebalance", async (req, reply) => { + const userId = req.userId; + const session = await getLatestBudgetSession(userId); + if (!session) { + return reply.code(400).send({ ok: false, code: "NO_BUDGET_SESSION" }); + } + const cats = await prisma.variableCategory.findMany({ + where: { userId }, + orderBy: [{ priority: "asc" }, { name: "asc" }], + select: { id: true, name: true, percent: true, isSavings: true, balanceCents: true }, + }); + return reply.send({ + ok: true, + availableCents: Number(session.availableCents ?? 0n), + categories: cats.map((c) => ({ ...c, balanceCents: Number(c.balanceCents ?? 0n) })), + }); + }); + + app.post("/variable-categories/manual-rebalance", async (req, reply) => { + const userId = req.userId; + const body = ManualRebalanceBody.safeParse(req.body); + if (!body.success || body.data.targets.length === 0) { + return reply.code(400).send({ ok: false, code: "INVALID_BODY" }); + } + + const session = await getLatestBudgetSession(userId); + if (!session) { + return reply.code(400).send({ ok: false, code: "NO_BUDGET_SESSION", message: "No active budget session found." }); + } + + const availableCents = Number(session.availableCents ?? 0n); + const cats = await 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 targetMap = new Map(); + for (const t of body.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", message: "Targets must include every 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); + if (availableCents > 0 && targets.some((t) => t.target > maxAllowed)) { + return reply.code(400).send({ ok: false, code: "OVER_80_PERCENT", message: "No category can exceed 80% of available." }); + } + + 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) && !body.data.forceLowerSavings) { + return reply.code(400).send({ + ok: false, + code: "SAVINGS_FLOOR", + message: "Lowering savings requires confirmation.", + }); + } + + await 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 plugin; diff --git a/api/tests/helpers.ts b/api/tests/helpers.ts index 8c72355..48077e7 100644 --- a/api/tests/helpers.ts +++ b/api/tests/helpers.ts @@ -23,6 +23,7 @@ export async function resetUser(userId: string) { prisma.incomeEvent.deleteMany({ where: { userId } }), prisma.fixedPlan.deleteMany({ where: { userId } }), prisma.variableCategory.deleteMany({ where: { userId } }), + prisma.budgetSession.deleteMany({ where: { userId } }), ]); await prisma.user.deleteMany({ where: { id: userId } }); } diff --git a/api/tests/variable-categories.manual-rebalance.test.ts b/api/tests/variable-categories.manual-rebalance.test.ts new file mode 100644 index 0000000..60385b5 --- /dev/null +++ b/api/tests/variable-categories.manual-rebalance.test.ts @@ -0,0 +1,110 @@ +import request from "supertest"; +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import appFactory from "./appFactory"; +import { prisma, resetUser, ensureUser, U, cid, closePrisma } from "./helpers"; +import type { FastifyInstance } from "fastify"; + +let app: FastifyInstance; + +beforeAll(async () => { + app = await appFactory(); +}); + +afterAll(async () => { + if (app) await app.close(); + await closePrisma(); +}); + +async function seedBasics() { + await resetUser(U); + await ensureUser(U); + await prisma.variableCategory.createMany({ + data: [ + { id: cid("s"), userId: U, name: "savings", percent: 30, priority: 1, isSavings: true, balanceCents: 3000n }, + { id: cid("f"), userId: U, name: "food", percent: 20, priority: 2, isSavings: false, balanceCents: 2000n }, + { id: cid("g"), userId: U, name: "gas", percent: 30, priority: 3, isSavings: false, balanceCents: 3000n }, + { id: cid("m"), userId: U, name: "misc", percent: 20, priority: 4, isSavings: false, balanceCents: 2000n }, + ], + }); + await prisma.budgetSession.create({ + data: { + userId: U, + periodStart: new Date("2026-03-01T00:00:00Z"), + periodEnd: new Date("2026-04-01T00:00:00Z"), + totalBudgetCents: 10_000n, + allocatedCents: 0n, + fundedCents: 0n, + availableCents: 10_000n, + }, + }); +} + +describe("manual rebalance", () => { + beforeEach(async () => { + await seedBasics(); + }); + + it("rebalances when sums match available", async () => { + const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } }); + const targets = cats.map((c) => ({ id: c.id, targetCents: 2500 })); // 4 * 2500 = 10000 + + const res = await request(app.server) + .post("/variable-categories/manual-rebalance") + .set("x-user-id", U) + .send({ targets }); + + expect(res.statusCode).toBe(200); + expect(res.body?.ok).toBe(true); + const updated = await prisma.variableCategory.findMany({ where: { userId: U } }); + expect(updated.every((c) => Number(c.balanceCents) === 2500)).toBe(true); + }); + + it("rejects sum mismatch", async () => { + const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } }); + const targets = cats.map((c, i) => ({ id: c.id, targetCents: i === 0 ? 5000 : 1000 })); // 5000 + 3*1000 = 8000 + + const res = await request(app.server) + .post("/variable-categories/manual-rebalance") + .set("x-user-id", U) + .send({ targets }); + + expect(res.statusCode).toBe(400); + expect(res.body?.code).toBe("SUM_MISMATCH"); + }); + + it("requires savings confirmation when lowering below floor", async () => { + const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } }); + // savings to 500 (below 20% of 10000 = 2000) + const targets = cats.map((c) => ({ id: c.id, targetCents: c.isSavings ? 500 : 3166 })); // 500 + 3*3166 = 9998 -> adjust + targets[1].targetCents += 2; // total 10000 + + const res = await request(app.server) + .post("/variable-categories/manual-rebalance") + .set("x-user-id", U) + .send({ targets }); + + expect(res.statusCode).toBe(400); + expect(res.body?.code).toBe("SAVINGS_FLOOR"); + + const resOk = await request(app.server) + .post("/variable-categories/manual-rebalance") + .set("x-user-id", U) + .send({ targets, forceLowerSavings: true }); + + expect(resOk.statusCode).toBe(200); + }); + + it("blocks >80% single category", async () => { + const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } }); + const targets = cats.map((c, i) => ({ id: c.id, targetCents: i === 0 ? 9000 : 333 })); + targets[1].targetCents += 1; // sum 10000 + + const res = await request(app.server) + .post("/variable-categories/manual-rebalance") + .set("x-user-id", U) + .send({ targets }); + + expect(res.statusCode).toBe(400); + expect(res.body?.code).toBe("OVER_80_PERCENT"); + }); +}); diff --git a/transfer-rebalance-spec.md b/transfer-rebalance-spec.md new file mode 100644 index 0000000..a336e8e --- /dev/null +++ b/transfer-rebalance-spec.md @@ -0,0 +1,39 @@ +# Variable Pool Rebalance / Transfer Feature + +## Summary +- Allow users to redistribute current variable pool (BudgetSession.availableCents) by setting per-category dollar targets, without changing future percent-based allocations. +- Enforce guards: sum = available, savings floor confirm, non-negative, no single category >80%, warnings when lowering savings. +- New backend endpoint performs atomic balance updates and audit logging; fixed expenses unaffected. + +## API +- `POST /variable-categories/manual-rebalance` + - Body: `{ targets: [{ id, targetCents }], forceLowerSavings?: boolean }` + - Uses latest BudgetSession by periodStart; availableCents is the pool to balance. + - Validations: targets cover every category; non-negative; sum(targets)=available; each ≤80% of available; lowering savings or savings <20% requires `forceLowerSavings`. + - Transaction: update category balanceCents to targets; insert transaction(kind=`rebalance`, note snapshot); fixed plans untouched. + - Response: `{ ok: true, availableCents, categories: [{ id, balanceCents }] }`. + +## UI/UX +- New Rebalance page (Settings → Expenses tab entry point) showing availableCents and per-category balances. +- Editable dollar inputs with live total meter and inline errors for rule violations. +- Savings floor warning/confirm; optional helper to adjust one category and auto-scale others respecting floors. +- Confirmation modal summarizing before/after deltas. + +## Data / Logic +- Active session = latest BudgetSession for user. +- Rebalance acts on current variable pool only; future income remains percent-based. +- Savings floor default 20% of available; lowering requires confirmation flag. + +## Tests +- Sum=available happy path; savings unchanged. +- Lowering savings w/out flag → 400; with flag → OK. +- Savings total <20% w/out flag → 400; with flag → OK. +- >80% single category → 400. +- Sum mismatch → 400; negative target → 400. +- Negative existing balance allowed only if target >=0 (ending non-negative). +- Adjust-one helper unit: scaling respects floors. +- Audit entry created; fixed plans and percents unchanged. + +## Assumptions +- availableCents equals dashboard “Available” variable pool. +- No localization requirements for new errors. diff --git a/web/src/api/fixedPlans.ts b/web/src/api/fixedPlans.ts index b2d5df4..65e4325 100644 --- a/web/src/api/fixedPlans.ts +++ b/web/src/api/fixedPlans.ts @@ -11,8 +11,6 @@ export type NewPlan = { name: string; totalCents: number; // >= 0 fundedCents?: number; // optional, default 0 - amountMode?: "fixed" | "estimated"; - estimatedCents?: number | null; priority: number; // int dueOn: string; // ISO date frequency?: "one-time" | "weekly" | "biweekly" | "monthly"; diff --git a/web/src/api/rebalance.ts b/web/src/api/rebalance.ts new file mode 100644 index 0000000..62048cd --- /dev/null +++ b/web/src/api/rebalance.ts @@ -0,0 +1,26 @@ +import { apiGet, apiPost } from "./http"; + +export type RebalanceCategory = { + id: string; + name: string; + percent: number; + isSavings: boolean; + balanceCents: number; +}; + +export type RebalanceInfo = { + ok: boolean; + availableCents: number; + categories: RebalanceCategory[]; +}; + +export type ManualRebalanceBody = { + targets: Array<{ id: string; targetCents: number }>; + forceLowerSavings?: boolean; +}; + +export const rebalanceApi = { + fetchInfo: () => apiGet("/variable-categories/manual-rebalance"), + submit: (body: ManualRebalanceBody) => + apiPost("/variable-categories/manual-rebalance", body), +}; diff --git a/web/src/main.tsx b/web/src/main.tsx index 10661ac..4eeb520 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -168,6 +168,14 @@ const router = createBrowserRouter( } /> + + + + } + />

Expense Categories

- {hasChanges && ( - Unsaved changes - )} +
+ + Rebalance pool + + {hasChanges && ( + Unsaved changes + )} +

Decide how every dollar is divided. Percentages must always add up to diff --git a/web/src/pages/settings/PlansSettings.tsx b/web/src/pages/settings/PlansSettings.tsx index e99606c..c379d87 100644 --- a/web/src/pages/settings/PlansSettings.tsx +++ b/web/src/pages/settings/PlansSettings.tsx @@ -449,11 +449,6 @@ const PlansSettings = forwardRef( name: plan.name, totalCents: plan.totalCents, fundedCents: plan.fundedCents ?? 0, - amountMode: plan.amountMode ?? "fixed", - estimatedCents: - (plan.amountMode ?? "fixed") === "estimated" - ? (plan.estimatedCents ?? plan.totalCents) - : null, priority: plan.priority, dueOn: plan.dueOn, frequency: plan.frequency, @@ -502,10 +497,6 @@ const PlansSettings = forwardRef( patch.frequency = local.frequency; if ((local.nextPaymentDate ?? null) !== (server.nextPaymentDate ?? null)) patch.nextPaymentDate = local.nextPaymentDate ?? null; - if ((local.amountMode ?? "fixed") !== (server.amountMode ?? "fixed")) - patch.amountMode = local.amountMode ?? "fixed"; - if ((local.estimatedCents ?? null) !== (server.estimatedCents ?? null)) - patch.estimatedCents = local.estimatedCents ?? 0; if (Object.keys(patch).length > 0) { await fixedPlansApi.update(local.id, patch); diff --git a/web/src/pages/settings/RebalancePage.tsx b/web/src/pages/settings/RebalancePage.tsx new file mode 100644 index 0000000..a539dd6 --- /dev/null +++ b/web/src/pages/settings/RebalancePage.tsx @@ -0,0 +1,245 @@ +import { useEffect, useMemo, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { rebalanceApi, type RebalanceCategory } from "../../api/rebalance"; +import CurrencyInput from "../../components/CurrencyInput"; +import { useToast } from "../../components/Toast"; + +function sum(values: number[]) { + return values.reduce((s, v) => s + (Number.isFinite(v) ? v : 0), 0); +} + +export default function RebalancePage() { + const { push } = useToast(); + const qc = useQueryClient(); + const { data, isLoading, refetch } = useQuery({ + queryKey: ["rebalance", "info"], + queryFn: rebalanceApi.fetchInfo, + }); + + const [rows, setRows] = useState>([]); + const [forceSavings, setForceSavings] = useState(false); + const [adjustId, setAdjustId] = useState(""); + const [adjustValue, setAdjustValue] = useState(""); + + useEffect(() => { + if (data?.categories) { + setRows(data.categories.map((c) => ({ ...c, targetCents: c.balanceCents }))); + setAdjustId(data.categories[0]?.id ?? ""); + } + }, [data?.categories]); + + const available = data?.availableCents ?? 0; + const total = useMemo(() => sum(rows.map((r) => r.targetCents)), [rows]); + const savingsTotal = useMemo( + () => sum(rows.filter((r) => r.isSavings).map((r) => r.targetCents)), + [rows] + ); + const savingsBefore = useMemo( + () => sum(rows.filter((r) => r.isSavings).map((r) => r.balanceCents)), + [rows] + ); + + const savingsFloor = Math.floor(available * 0.2); + const maxSingle = Math.floor(available * 0.8); + + const errors: string[] = []; + if (rows.some((r) => r.targetCents < 0)) errors.push("No category can be negative."); + if (available > 0 && rows.some((r) => r.targetCents > maxSingle)) + errors.push("No category can exceed 80% of available."); + if (total !== available) errors.push(`Totals must equal available ($${(available / 100).toFixed(2)}).`); + if ((savingsTotal < savingsBefore || savingsTotal < savingsFloor) && !forceSavings) + errors.push("Lowering savings requires confirmation."); + + const canSubmit = errors.length === 0; + + const applyAdjustOne = () => { + if (!adjustId) return; + const desired = Math.round(Number(parseFloat(adjustValue || "0") * 100)); + if (!Number.isFinite(desired) || desired < 0) { + push("err", "Enter a valid amount"); + return; + } + if (desired > available) { + push("err", "Amount exceeds available budget"); + return; + } + const others = rows.filter((r) => r.id !== adjustId); + if (others.length === 0) return; + const remaining = available - desired; + if (remaining < 0) { + push("err", "Amount exceeds available budget"); + return; + } + const baseSum = sum(others.map((o) => o.targetCents)) || others.length; + const next = rows.map((r) => ({ ...r })); + let distributed = 0; + others.forEach((o) => { + const share = Math.floor((remaining * (o.targetCents || 1)) / baseSum); + const idx = next.findIndex((n) => n.id === o.id); + next[idx].targetCents = share; + distributed += share; + }); + const leftover = remaining - distributed; + if (leftover > 0) { + const firstIdx = next.findIndex((n) => n.id !== adjustId); + if (firstIdx >= 0) next[firstIdx].targetCents += leftover; + } + const targetIdx = next.findIndex((n) => n.id === adjustId); + next[targetIdx].targetCents = desired; + setRows(next); + }; + + const submit = async () => { + if (!canSubmit) { + push("err", errors[0] ?? "Fix validation errors first"); + return; + } + const payload = { + targets: rows.map((r) => ({ id: r.id, targetCents: r.targetCents })), + forceLowerSavings: forceSavings, + }; + try { + await rebalanceApi.submit(payload); + push("ok", "Rebalance applied"); + setForceSavings(false); + setAdjustValue(""); + await qc.invalidateQueries({ queryKey: ["rebalance", "info"] }); + await refetch(); + } catch (err: any) { + const msg = err?.body?.message || err?.message || "Rebalance failed"; + push("err", msg); + } + }; + + if (isLoading || !data) return

Loading…
; + + return ( +
+
+

Rebalance variable pool

+

+ Redistribute your current variable pool without changing future income percentages. +

+
+ +
+
+
Available
+
${(available / 100).toFixed(2)}
+
+
+
Totals
+
+ ${(total / 100).toFixed(2)} / ${(available / 100).toFixed(2)} +
+
+
+
Savings total
+
${(savingsTotal / 100).toFixed(2)}
+
+ +
+ +
+
+
+ + +
+
+ + setAdjustValue(e.target.value)} + placeholder="0.00" + /> +
+ +
+ +
+ + + + + + + + + + + {rows.map((row) => ( + + + + + + + ))} + +
CategoryCurrentPercentTarget
+ {row.name} {row.isSavings ? Savings : null} + ${(row.balanceCents / 100).toFixed(2)}{row.percent}% + + setRows((prev) => + prev.map((r) => (r.id === row.id ? { ...r, targetCents: cents } : r)) + ) + } + /> +
+
+
+ + {errors.length > 0 && ( +
+ {errors.map((err) => ( +
{err}
+ ))} +
+ )} + +
+ + +
+
+ ); +} diff --git a/web/src/pages/settings/SettingsPage.tsx b/web/src/pages/settings/SettingsPage.tsx index 501d0c3..4999d1c 100644 --- a/web/src/pages/settings/SettingsPage.tsx +++ b/web/src/pages/settings/SettingsPage.tsx @@ -10,8 +10,9 @@ import PlansSettings, { type PlansSettingsHandle } from "./PlansSettings"; import AccountSettings from "./AccountSettings"; import ThemeSettings from "./ThemeSettings"; import ReconcileSettings from "./ReconcileSettings"; +import RebalancePage from "./RebalancePage"; -type Tab = "categories" | "plans" | "account" | "theme" | "reconcile"; +type Tab = "categories" | "rebalance" | "plans" | "account" | "theme" | "reconcile"; export default function SettingsPage() { const location = useLocation(); @@ -21,6 +22,7 @@ export default function SettingsPage() { if (location.pathname.includes("/settings/account")) return "account"; if (location.pathname.includes("/settings/theme")) return "theme"; if (location.pathname.includes("/settings/reconcile")) return "reconcile"; + if (location.pathname.includes("/settings/rebalance")) return "rebalance"; return "categories"; }; @@ -66,6 +68,7 @@ export default function SettingsPage() { const tabs = [ { id: "categories" as const, label: "Expenses" }, + { id: "rebalance" as const, label: "Rebalance" }, { id: "plans" as const, label: "Fixed Expenses" }, { id: "account" as const, label: "Account" }, { id: "theme" as const, label: "Theme" }, @@ -135,6 +138,8 @@ export default function SettingsPage() { onDirtyChange={setIsDirty} /> ); + case "rebalance": + return ; case "plans": return (