fix rebalance feature get route, moved to top level nav
This commit is contained in:
@@ -3347,6 +3347,15 @@ const CatBody = z.object({
|
|||||||
isSavings: z.boolean(),
|
isSavings: z.boolean(),
|
||||||
priority: z.number().int().min(0),
|
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(
|
async function assertPercentTotal(
|
||||||
tx: PrismaClient | Prisma.TransactionClient,
|
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) => {
|
app.post("/variable-categories", mutationRateLimit, async (req, reply) => {
|
||||||
const parsed = CatBody.safeParse(req.body);
|
const parsed = CatBody.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -3509,6 +3525,110 @@ app.post("/variable-categories/rebalance", mutationRateLimit, async (req, reply)
|
|||||||
return { ok: true, applied: true, totalBalance };
|
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<string, number>();
|
||||||
|
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 -----
|
// ----- Fixed plans -----
|
||||||
const PlanBody = z.object({
|
const PlanBody = z.object({
|
||||||
name: z.string().trim().min(1),
|
name: z.string().trim().min(1),
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export default function NavBar({
|
|||||||
<NavLink to="/spend" className={linkClass}>Transactions</NavLink>
|
<NavLink to="/spend" className={linkClass}>Transactions</NavLink>
|
||||||
<NavLink to="/income" className={linkClass}>Income</NavLink>
|
<NavLink to="/income" className={linkClass}>Income</NavLink>
|
||||||
<NavLink to="/transactions" className={linkClass}>Records</NavLink>
|
<NavLink to="/transactions" className={linkClass}>Records</NavLink>
|
||||||
|
<NavLink to="/rebalance" className={linkClass}>Rebalance</NavLink>
|
||||||
<NavLink to="/settings" className={linkClass}>Settings</NavLink>
|
<NavLink to="/settings" className={linkClass}>Settings</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -88,6 +89,7 @@ export default function NavBar({
|
|||||||
<NavLink to="/spend" className={mobileLinkClass}>Transactions</NavLink>
|
<NavLink to="/spend" className={mobileLinkClass}>Transactions</NavLink>
|
||||||
<NavLink to="/income" className={mobileLinkClass}>Income</NavLink>
|
<NavLink to="/income" className={mobileLinkClass}>Income</NavLink>
|
||||||
<NavLink to="/transactions" className={mobileLinkClass}>Records</NavLink>
|
<NavLink to="/transactions" className={mobileLinkClass}>Records</NavLink>
|
||||||
|
<NavLink to="/rebalance" className={mobileLinkClass}>Rebalance</NavLink>
|
||||||
<NavLink to="/settings" className={mobileLinkClass}>Settings</NavLink>
|
<NavLink to="/settings" className={mobileLinkClass}>Settings</NavLink>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 border-t border-[--color-border] pt-3 flex items-center justify-between">
|
<div className="mt-3 border-t border-[--color-border] pt-3 flex items-center justify-between">
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ const BetaAccessPage = lazy(() => import("./pages/BetaAccessPage"));
|
|||||||
const VerifyPage = lazy(() => import("./pages/VerifyPage"));
|
const VerifyPage = lazy(() => import("./pages/VerifyPage"));
|
||||||
const ForgotPasswordPage = lazy(() => import("./pages/ForgotPasswordPage"));
|
const ForgotPasswordPage = lazy(() => import("./pages/ForgotPasswordPage"));
|
||||||
const ResetPasswordPage = lazy(() => import("./pages/ResetPasswordPage"));
|
const ResetPasswordPage = lazy(() => import("./pages/ResetPasswordPage"));
|
||||||
|
const RebalancePage = lazy(() => import("./pages/settings/RebalancePage"));
|
||||||
|
|
||||||
const router = createBrowserRouter(
|
const router = createBrowserRouter(
|
||||||
createRoutesFromElements(
|
createRoutesFromElements(
|
||||||
@@ -169,10 +170,10 @@ const router = createBrowserRouter(
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/settings/rebalance"
|
path="/rebalance"
|
||||||
element={
|
element={
|
||||||
<RequireAuth>
|
<RequireAuth>
|
||||||
<SettingsPage />
|
<RebalancePage />
|
||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ function CategoriesSettingsInner(
|
|||||||
<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">
|
<div className="flex items-center gap-3">
|
||||||
<Link className="btn" to="/settings/rebalance">
|
<Link className="btn" to="/rebalance">
|
||||||
Rebalance pool
|
Rebalance pool
|
||||||
</Link>
|
</Link>
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
|
|||||||
@@ -10,9 +10,8 @@ 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" | "rebalance" | "plans" | "account" | "theme" | "reconcile";
|
type Tab = "categories" | "plans" | "account" | "theme" | "reconcile";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -22,7 +21,6 @@ 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";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -68,7 +66,6 @@ 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" },
|
||||||
@@ -138,8 +135,6 @@ 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