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.
+ )} +
+
+ + +
+
+
+ )}
); }