diff --git a/api/src/server.ts b/api/src/server.ts
index 9ca3321..c059feb 100644
--- a/api/src/server.ts
+++ b/api/src/server.ts
@@ -3355,6 +3355,7 @@ const ManualRebalanceBody = z.object({
})
),
forceLowerSavings: z.boolean().optional(),
+ confirmOver80: z.boolean().optional(),
});
async function assertPercentTotal(
@@ -3606,8 +3607,13 @@ app.post("/variable-categories/manual-rebalance", async (req, reply) => {
.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 over80 = availableCents > 0 && targets.some((t) => t.target > maxAllowed);
+ if (over80 && !parsed.data.confirmOver80) {
+ return reply.code(400).send({
+ ok: false,
+ code: "OVER_80_CONFIRM_REQUIRED",
+ message: "A category exceeds 80% of available. Confirm to proceed.",
+ });
}
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);
diff --git a/web/src/api/rebalance.ts b/web/src/api/rebalance.ts
index 62048cd..3e8d25d 100644
--- a/web/src/api/rebalance.ts
+++ b/web/src/api/rebalance.ts
@@ -17,6 +17,7 @@ export type RebalanceInfo = {
export type ManualRebalanceBody = {
targets: Array<{ id: string; targetCents: number }>;
forceLowerSavings?: boolean;
+ confirmOver80?: boolean;
};
export const rebalanceApi = {
diff --git a/web/src/pages/settings/RebalancePage.tsx b/web/src/pages/settings/RebalancePage.tsx
index 131aa1e..05bf1ab 100644
--- a/web/src/pages/settings/RebalancePage.tsx
+++ b/web/src/pages/settings/RebalancePage.tsx
@@ -8,6 +8,38 @@ function sum(values: number[]) {
return values.reduce((s, v) => s + (Number.isFinite(v) ? v : 0), 0);
}
+function normalizeTargetsToAvailable(
+ rows: Array,
+ available: number
+) {
+ const next = rows.map((r) => ({ ...r, targetCents: Math.max(0, Math.floor(r.targetCents || 0)) }));
+ const total = sum(next.map((r) => r.targetCents));
+ const diff = available - total;
+ if (diff === 0) return next;
+
+ if (diff > 0) {
+ const idx = next
+ .map((r, i) => ({ i, p: r.percent, t: r.targetCents }))
+ .sort((a, b) => b.p - a.p || b.t - a.t)[0]?.i;
+ if (idx === undefined) return null;
+ next[idx].targetCents += diff;
+ return next;
+ }
+
+ let needed = -diff;
+ const order = next
+ .map((r, i) => ({ i, t: r.targetCents }))
+ .sort((a, b) => b.t - a.t);
+ for (const o of order) {
+ if (needed <= 0) break;
+ const take = Math.min(next[o.i].targetCents, needed);
+ next[o.i].targetCents -= take;
+ needed -= take;
+ }
+ if (needed > 0) return null;
+ return next;
+}
+
export default function RebalancePage() {
const { push } = useToast();
const qc = useQueryClient();
@@ -17,9 +49,9 @@ export default function RebalancePage() {
});
const [rows, setRows] = useState>([]);
- const [forceSavings, setForceSavings] = useState(false);
const [adjustId, setAdjustId] = useState("");
const [adjustValue, setAdjustValue] = useState("");
+ const [confirmOpen, setConfirmOpen] = useState(false);
useEffect(() => {
if (data?.categories) {
@@ -42,16 +74,19 @@ export default function RebalancePage() {
const savingsFloor = Math.floor(available * 0.2);
const maxSingle = Math.floor(available * 0.8);
+ const hasNegative = rows.some((r) => r.targetCents < 0);
+ const over80Crossed = available > 0 && rows.some((r) => r.targetCents > maxSingle);
+ const sumMismatch = total !== available;
+ const savingsCrossed = savingsTotal < savingsBefore || savingsTotal < savingsFloor;
+
const errors: string[] = [];
const warnings: string[] = [];
- if (rows.some((r) => r.targetCents < 0)) errors.push("No category can be negative.");
- if (available > 0 && rows.some((r) => r.targetCents > maxSingle))
- warnings.push("A category exceeds 80% of available.");
- if (total !== available) warnings.push(`Totals differ from available ($${(available / 100).toFixed(2)}).`);
- if ((savingsTotal < savingsBefore || savingsTotal < savingsFloor) && !forceSavings)
- errors.push("Confirm lowering savings / below 20% floor.");
+ if (hasNegative) errors.push("No category can be negative.");
+ if (over80Crossed) warnings.push("A category exceeds 80% of available.");
+ if (sumMismatch) warnings.push(`Totals differ from available ($${(available / 100).toFixed(2)}).`);
+ if (savingsCrossed) warnings.push("Savings decreased and/or fell below the 20% floor.");
- const canSubmit = errors.length === 0;
+ const canSubmit = !hasNegative;
const applyAdjustOne = () => {
if (!adjustId) return;
@@ -101,22 +136,62 @@ export default function RebalancePage() {
setRows(next);
};
+ const submitRebalance = async (
+ sourceRows: Array,
+ opts?: { forceLowerSavings?: boolean; confirmOver80?: boolean }
+ ) => {
+ const payload = {
+ targets: sourceRows.map((r) => ({ id: r.id, targetCents: r.targetCents })),
+ forceLowerSavings: opts?.forceLowerSavings,
+ confirmOver80: opts?.confirmOver80,
+ };
+ await rebalanceApi.submit(payload);
+ push("ok", "Rebalance applied");
+ setAdjustValue("");
+ await qc.invalidateQueries({ queryKey: ["rebalance", "info"] });
+ const refreshed = await refetch();
+ if (refreshed.data?.categories) {
+ setRows(refreshed.data.categories.map((c) => ({ ...c, targetCents: c.balanceCents })));
+ setAdjustId(refreshed.data.categories[0]?.id ?? "");
+ }
+ };
+
const submit = async () => {
- if (!canSubmit) {
- push("err", errors[0] ?? "Fix validation errors first");
+ if (hasNegative) {
+ push("err", "No category can be negative.");
return;
}
- const payload = {
- targets: rows.map((r) => ({ id: r.id, targetCents: r.targetCents })),
- forceLowerSavings: forceSavings,
- };
+
+ if (over80Crossed || savingsCrossed || sumMismatch) {
+ setConfirmOpen(true);
+ return;
+ }
+
try {
- await rebalanceApi.submit(payload);
- push("ok", "Rebalance applied");
- setForceSavings(false);
- setAdjustValue("");
- await qc.invalidateQueries({ queryKey: ["rebalance", "info"] });
- await refetch();
+ await submitRebalance(rows);
+ } catch (err: any) {
+ const msg = err?.body?.message || err?.message || "Rebalance failed";
+ push("err", msg);
+ }
+ };
+
+ const confirmAndApply = async () => {
+ try {
+ let targetRows = rows;
+ if (sumMismatch) {
+ const normalized = normalizeTargetsToAvailable(rows, available);
+ if (!normalized) {
+ push("err", "Could not auto-balance totals to available.");
+ return;
+ }
+ targetRows = normalized;
+ setRows(normalized);
+ }
+ await submitRebalance(targetRows, {
+ forceLowerSavings: savingsCrossed,
+ confirmOver80: over80Crossed,
+ });
+ setConfirmOpen(false);
} catch (err: any) {
const msg = err?.body?.message || err?.message || "Rebalance failed";
push("err", msg);
@@ -134,7 +209,7 @@ export default function RebalancePage() {
-
+
Available
${(available / 100).toFixed(2)}
@@ -149,14 +224,6 @@ export default function RebalancePage() {
Savings total
${(savingsTotal / 100).toFixed(2)}
-
@@ -253,7 +320,7 @@ export default function RebalancePage() {
className="btn ghost"
type="button"
onClick={async () => {
- setForceSavings(false);
+ setConfirmOpen(false);
setAdjustValue("");
const refreshed = await refetch();
if (refreshed.data?.categories) {
@@ -265,6 +332,32 @@ export default function RebalancePage() {
Reset
+
+ {confirmOpen && (
+
+
+
Confirm safeguard overrides
+
+ You crossed one or more safeguards. Confirm to apply these changes.
+
+
+ {over80Crossed &&
- A category exceeds 80% of available.
}
+ {savingsCrossed &&
- Savings decreased and/or is below 20% floor.
}
+ {sumMismatch && (
+
- Totals do not match available; remaining difference will be auto-balanced.
+ )}
+
+
+
+
+
+
+
+ )}
);
}