test run for user rebalance expenses feature, added safeguard for estimate exepenses acceptance in onboarding
This commit is contained in:
@@ -12,6 +12,15 @@ const NewCat = z.object({
|
|||||||
});
|
});
|
||||||
const PatchCat = NewCat.partial();
|
const PatchCat = NewCat.partial();
|
||||||
const IdParam = z.object({ id: z.string().min(1) });
|
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(
|
function computeBalanceTargets(
|
||||||
categories: Array<{ id: string; percent: number }>,
|
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
|
// 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) => {
|
const plugin: FastifyPluginAsync = async (app) => {
|
||||||
// CREATE
|
// CREATE
|
||||||
app.post("/variable-categories", async (req, reply) => {
|
app.post("/variable-categories", async (req, reply) => {
|
||||||
@@ -182,6 +198,129 @@ const plugin: FastifyPluginAsync = async (app) => {
|
|||||||
|
|
||||||
return reply.send({ ok: true, applied: true, totalBalance });
|
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<string, number>();
|
||||||
|
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;
|
export default plugin;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export async function resetUser(userId: string) {
|
|||||||
prisma.incomeEvent.deleteMany({ where: { userId } }),
|
prisma.incomeEvent.deleteMany({ where: { userId } }),
|
||||||
prisma.fixedPlan.deleteMany({ where: { userId } }),
|
prisma.fixedPlan.deleteMany({ where: { userId } }),
|
||||||
prisma.variableCategory.deleteMany({ where: { userId } }),
|
prisma.variableCategory.deleteMany({ where: { userId } }),
|
||||||
|
prisma.budgetSession.deleteMany({ where: { userId } }),
|
||||||
]);
|
]);
|
||||||
await prisma.user.deleteMany({ where: { id: userId } });
|
await prisma.user.deleteMany({ where: { id: userId } });
|
||||||
}
|
}
|
||||||
|
|||||||
110
api/tests/variable-categories.manual-rebalance.test.ts
Normal file
110
api/tests/variable-categories.manual-rebalance.test.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
39
transfer-rebalance-spec.md
Normal file
39
transfer-rebalance-spec.md
Normal file
@@ -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.
|
||||||
@@ -11,8 +11,6 @@ export type NewPlan = {
|
|||||||
name: string;
|
name: string;
|
||||||
totalCents: number; // >= 0
|
totalCents: number; // >= 0
|
||||||
fundedCents?: number; // optional, default 0
|
fundedCents?: number; // optional, default 0
|
||||||
amountMode?: "fixed" | "estimated";
|
|
||||||
estimatedCents?: number | null;
|
|
||||||
priority: number; // int
|
priority: number; // int
|
||||||
dueOn: string; // ISO date
|
dueOn: string; // ISO date
|
||||||
frequency?: "one-time" | "weekly" | "biweekly" | "monthly";
|
frequency?: "one-time" | "weekly" | "biweekly" | "monthly";
|
||||||
|
|||||||
26
web/src/api/rebalance.ts
Normal file
26
web/src/api/rebalance.ts
Normal file
@@ -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<RebalanceInfo>("/variable-categories/manual-rebalance"),
|
||||||
|
submit: (body: ManualRebalanceBody) =>
|
||||||
|
apiPost<RebalanceInfo>("/variable-categories/manual-rebalance", body),
|
||||||
|
};
|
||||||
@@ -168,6 +168,14 @@ const router = createBrowserRouter(
|
|||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings/rebalance"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<SettingsPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/health"
|
path="/health"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -432,7 +432,6 @@ export default function OnboardingPage() {
|
|||||||
const name = f.name.trim();
|
const name = f.name.trim();
|
||||||
if (!name) continue; // skip empties just in case
|
if (!name) continue; // skip empties just in case
|
||||||
try {
|
try {
|
||||||
const amountMode = f.amountMode ?? "fixed";
|
|
||||||
const planAmountCents = Math.max(0, f.amountCents || 0);
|
const planAmountCents = Math.max(0, f.amountCents || 0);
|
||||||
const schedule = f.autoPayEnabled
|
const schedule = f.autoPayEnabled
|
||||||
? {
|
? {
|
||||||
@@ -451,8 +450,6 @@ export default function OnboardingPage() {
|
|||||||
name,
|
name,
|
||||||
totalCents: planAmountCents,
|
totalCents: planAmountCents,
|
||||||
fundedCents: 0,
|
fundedCents: 0,
|
||||||
amountMode,
|
|
||||||
estimatedCents: amountMode === "estimated" ? planAmountCents : null,
|
|
||||||
priority: i + 1,
|
priority: i + 1,
|
||||||
dueOn: dueOnISO,
|
dueOn: dueOnISO,
|
||||||
frequency: f.frequency,
|
frequency: f.frequency,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type FormEvent,
|
type FormEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
|
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
|
||||||
import {
|
import {
|
||||||
SortableContext,
|
SortableContext,
|
||||||
@@ -412,10 +413,15 @@ function CategoriesSettingsInner(
|
|||||||
<header className="space-y-1">
|
<header className="space-y-1">
|
||||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
<h2 className="text-lg font-semibold">Expense Categories</h2>
|
<h2 className="text-lg font-semibold">Expense Categories</h2>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link className="btn" to="/settings/rebalance">
|
||||||
|
Rebalance pool
|
||||||
|
</Link>
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
<span className="text-xs text-amber-400">Unsaved changes</span>
|
<span className="text-xs text-amber-400">Unsaved changes</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<p className="text-sm muted">
|
<p className="text-sm muted">
|
||||||
Decide how every dollar is divided. Percentages must always add up to
|
Decide how every dollar is divided. Percentages must always add up to
|
||||||
100%.
|
100%.
|
||||||
|
|||||||
@@ -449,11 +449,6 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
|||||||
name: plan.name,
|
name: plan.name,
|
||||||
totalCents: plan.totalCents,
|
totalCents: plan.totalCents,
|
||||||
fundedCents: plan.fundedCents ?? 0,
|
fundedCents: plan.fundedCents ?? 0,
|
||||||
amountMode: plan.amountMode ?? "fixed",
|
|
||||||
estimatedCents:
|
|
||||||
(plan.amountMode ?? "fixed") === "estimated"
|
|
||||||
? (plan.estimatedCents ?? plan.totalCents)
|
|
||||||
: null,
|
|
||||||
priority: plan.priority,
|
priority: plan.priority,
|
||||||
dueOn: plan.dueOn,
|
dueOn: plan.dueOn,
|
||||||
frequency: plan.frequency,
|
frequency: plan.frequency,
|
||||||
@@ -502,10 +497,6 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
|||||||
patch.frequency = local.frequency;
|
patch.frequency = local.frequency;
|
||||||
if ((local.nextPaymentDate ?? null) !== (server.nextPaymentDate ?? null))
|
if ((local.nextPaymentDate ?? null) !== (server.nextPaymentDate ?? null))
|
||||||
patch.nextPaymentDate = local.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) {
|
if (Object.keys(patch).length > 0) {
|
||||||
await fixedPlansApi.update(local.id, patch);
|
await fixedPlansApi.update(local.id, patch);
|
||||||
|
|||||||
245
web/src/pages/settings/RebalancePage.tsx
Normal file
245
web/src/pages/settings/RebalancePage.tsx
Normal file
@@ -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<Array<RebalanceCategory & { targetCents: number }>>([]);
|
||||||
|
const [forceSavings, setForceSavings] = useState(false);
|
||||||
|
const [adjustId, setAdjustId] = useState<string>("");
|
||||||
|
const [adjustValue, setAdjustValue] = useState<string>("");
|
||||||
|
|
||||||
|
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 <div className="muted">Loading…</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<header className="space-y-1">
|
||||||
|
<h2 className="text-lg font-semibold">Rebalance variable pool</h2>
|
||||||
|
<p className="text-sm muted">
|
||||||
|
Redistribute your current variable pool without changing future income percentages.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="card p-4 flex flex-wrap gap-4 items-center justify-between">
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="font-semibold">Available</div>
|
||||||
|
<div className="text-xl font-mono">${(available / 100).toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<div>Totals</div>
|
||||||
|
<div className="font-mono">
|
||||||
|
${(total / 100).toFixed(2)} / ${(available / 100).toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<div>Savings total</div>
|
||||||
|
<div className="font-mono">${(savingsTotal / 100).toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={forceSavings}
|
||||||
|
onChange={(e) => setForceSavings(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Confirm lowering savings / below 20% floor</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card p-4 space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-2 items-end">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<label className="text-xs muted">Adjust one category</label>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={adjustId}
|
||||||
|
onChange={(e) => setAdjustId(e.target.value)}
|
||||||
|
>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{r.name} {r.isSavings ? "(Savings)" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<label className="text-xs muted">Amount</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="0.01"
|
||||||
|
value={adjustValue}
|
||||||
|
onChange={(e) => setAdjustValue(e.target.value)}
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button className="btn" type="button" onClick={applyAdjustOne}>
|
||||||
|
Apply helper
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left">
|
||||||
|
<th className="py-2">Category</th>
|
||||||
|
<th className="py-2">Current</th>
|
||||||
|
<th className="py-2">Percent</th>
|
||||||
|
<th className="py-2">Target</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<tr key={row.id} className="border-t border-[--color-panel]">
|
||||||
|
<td className="py-2 font-medium">
|
||||||
|
{row.name} {row.isSavings ? <span className="text-xs text-emerald-500">Savings</span> : null}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 font-mono">${(row.balanceCents / 100).toFixed(2)}</td>
|
||||||
|
<td className="py-2">{row.percent}%</td>
|
||||||
|
<td className="py-2">
|
||||||
|
<CurrencyInput
|
||||||
|
className="w-32"
|
||||||
|
valueCents={row.targetCents}
|
||||||
|
onChange={(cents) =>
|
||||||
|
setRows((prev) =>
|
||||||
|
prev.map((r) => (r.id === row.id ? { ...r, targetCents: cents } : r))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errors.length > 0 && (
|
||||||
|
<div className="toast-err space-y-1">
|
||||||
|
{errors.map((err) => (
|
||||||
|
<div key={err}>{err}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button className="btn primary" type="button" onClick={submit} disabled={!canSubmit}>
|
||||||
|
Apply rebalance
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
setForceSavings(false);
|
||||||
|
setAdjustValue("");
|
||||||
|
await refetch();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,8 +10,9 @@ import PlansSettings, { type PlansSettingsHandle } from "./PlansSettings";
|
|||||||
import AccountSettings from "./AccountSettings";
|
import AccountSettings from "./AccountSettings";
|
||||||
import ThemeSettings from "./ThemeSettings";
|
import ThemeSettings from "./ThemeSettings";
|
||||||
import ReconcileSettings from "./ReconcileSettings";
|
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() {
|
export default function SettingsPage() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -21,6 +22,7 @@ export default function SettingsPage() {
|
|||||||
if (location.pathname.includes("/settings/account")) return "account";
|
if (location.pathname.includes("/settings/account")) return "account";
|
||||||
if (location.pathname.includes("/settings/theme")) return "theme";
|
if (location.pathname.includes("/settings/theme")) return "theme";
|
||||||
if (location.pathname.includes("/settings/reconcile")) return "reconcile";
|
if (location.pathname.includes("/settings/reconcile")) return "reconcile";
|
||||||
|
if (location.pathname.includes("/settings/rebalance")) return "rebalance";
|
||||||
return "categories";
|
return "categories";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -66,6 +68,7 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: "categories" as const, label: "Expenses" },
|
{ id: "categories" as const, label: "Expenses" },
|
||||||
|
{ id: "rebalance" as const, label: "Rebalance" },
|
||||||
{ id: "plans" as const, label: "Fixed Expenses" },
|
{ id: "plans" as const, label: "Fixed Expenses" },
|
||||||
{ id: "account" as const, label: "Account" },
|
{ id: "account" as const, label: "Account" },
|
||||||
{ id: "theme" as const, label: "Theme" },
|
{ id: "theme" as const, label: "Theme" },
|
||||||
@@ -135,6 +138,8 @@ export default function SettingsPage() {
|
|||||||
onDirtyChange={setIsDirty}
|
onDirtyChange={setIsDirty}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case "rebalance":
|
||||||
|
return <RebalancePage />;
|
||||||
case "plans":
|
case "plans":
|
||||||
return (
|
return (
|
||||||
<PlansSettings ref={plansRef} onDirtyChange={setIsDirty} />
|
<PlansSettings ref={plansRef} onDirtyChange={setIsDirty} />
|
||||||
|
|||||||
Reference in New Issue
Block a user