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 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<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;
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
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;
|
||||
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";
|
||||
|
||||
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>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/rebalance"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<SettingsPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/health"
|
||||
element={
|
||||
|
||||
@@ -432,7 +432,6 @@ export default function OnboardingPage() {
|
||||
const name = f.name.trim();
|
||||
if (!name) continue; // skip empties just in case
|
||||
try {
|
||||
const amountMode = f.amountMode ?? "fixed";
|
||||
const planAmountCents = Math.max(0, f.amountCents || 0);
|
||||
const schedule = f.autoPayEnabled
|
||||
? {
|
||||
@@ -451,8 +450,6 @@ export default function OnboardingPage() {
|
||||
name,
|
||||
totalCents: planAmountCents,
|
||||
fundedCents: 0,
|
||||
amountMode,
|
||||
estimatedCents: amountMode === "estimated" ? planAmountCents : null,
|
||||
priority: i + 1,
|
||||
dueOn: dueOnISO,
|
||||
frequency: f.frequency,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type FormEvent,
|
||||
} from "react";
|
||||
import type React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
@@ -412,10 +413,15 @@ function CategoriesSettingsInner(
|
||||
<header className="space-y-1">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<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 && (
|
||||
<span className="text-xs text-amber-400">Unsaved changes</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm muted">
|
||||
Decide how every dollar is divided. Percentages must always add up to
|
||||
100%.
|
||||
|
||||
@@ -449,11 +449,6 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
||||
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<PlansSettingsHandle, PlansSettingsProps>(
|
||||
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);
|
||||
|
||||
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 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 <RebalancePage />;
|
||||
case "plans":
|
||||
return (
|
||||
<PlansSettings ref={plansRef} onDirtyChange={setIsDirty} />
|
||||
|
||||
Reference in New Issue
Block a user