diff --git a/api/src/server.ts b/api/src/server.ts index cc30790..bc15dfd 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -3347,6 +3347,15 @@ const CatBody = z.object({ 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(), +}); async function assertPercentTotal( tx: PrismaClient | Prisma.TransactionClient, @@ -3379,6 +3388,13 @@ async function assertPercentTotal( } } +async function getLatestBudgetSession(app: any, userId: string) { + return app.prisma.budgetSession.findFirst({ + where: { userId }, + orderBy: { periodStart: "desc" }, + }); +} + app.post("/variable-categories", mutationRateLimit, async (req, reply) => { const parsed = CatBody.safeParse(req.body); if (!parsed.success) { @@ -3509,6 +3525,110 @@ app.post("/variable-categories/rebalance", mutationRateLimit, async (req, reply) return { ok: true, applied: true, totalBalance }; }); +app.get("/variable-categories/manual-rebalance", async (req, reply) => { + const userId = req.userId; + const session = await getLatestBudgetSession(app, userId); + if (!session) return reply.code(400).send({ ok: false, code: "NO_BUDGET_SESSION" }); + + 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 }, + }); + + 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 parsed = ManualRebalanceBody.safeParse(req.body); + if (!parsed.success || parsed.data.targets.length === 0) { + return reply.code(400).send({ ok: false, code: "INVALID_BODY" }); + } + + const session = await getLatestBudgetSession(app, userId); + if (!session) return reply.code(400).send({ ok: false, code: "NO_BUDGET_SESSION" }); + const availableCents = Number(session.availableCents ?? 0n); + + 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 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); + 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) && !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 })), + }); +}); + // ----- Fixed plans ----- const PlanBody = z.object({ name: z.string().trim().min(1), diff --git a/web/src/components/NavBar.tsx b/web/src/components/NavBar.tsx index 6663c5b..31c353f 100644 --- a/web/src/components/NavBar.tsx +++ b/web/src/components/NavBar.tsx @@ -44,6 +44,7 @@ export default function NavBar({ Transactions Income Records + Rebalance Settings @@ -88,6 +89,7 @@ export default function NavBar({ Transactions Income Records + Rebalance Settings
diff --git a/web/src/main.tsx b/web/src/main.tsx index 4eeb520..79ae47b 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -57,6 +57,7 @@ const BetaAccessPage = lazy(() => import("./pages/BetaAccessPage")); const VerifyPage = lazy(() => import("./pages/VerifyPage")); const ForgotPasswordPage = lazy(() => import("./pages/ForgotPasswordPage")); const ResetPasswordPage = lazy(() => import("./pages/ResetPasswordPage")); +const RebalancePage = lazy(() => import("./pages/settings/RebalancePage")); const router = createBrowserRouter( createRoutesFromElements( @@ -169,10 +170,10 @@ const router = createBrowserRouter( } /> - + } /> diff --git a/web/src/pages/settings/CategoriesSettings.tsx b/web/src/pages/settings/CategoriesSettings.tsx index d3a66fa..eec0d3d 100644 --- a/web/src/pages/settings/CategoriesSettings.tsx +++ b/web/src/pages/settings/CategoriesSettings.tsx @@ -414,7 +414,7 @@ function CategoriesSettingsInner(

Expense Categories

- + Rebalance pool {hasChanges && ( diff --git a/web/src/pages/settings/SettingsPage.tsx b/web/src/pages/settings/SettingsPage.tsx index 4999d1c..501d0c3 100644 --- a/web/src/pages/settings/SettingsPage.tsx +++ b/web/src/pages/settings/SettingsPage.tsx @@ -10,9 +10,8 @@ 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" | "rebalance" | "plans" | "account" | "theme" | "reconcile"; +type Tab = "categories" | "plans" | "account" | "theme" | "reconcile"; export default function SettingsPage() { const location = useLocation(); @@ -22,7 +21,6 @@ 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"; }; @@ -68,7 +66,6 @@ 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" }, @@ -138,8 +135,6 @@ export default function SettingsPage() { onDirtyChange={setIsDirty} /> ); - case "rebalance": - return ; case "plans": return (