test run for user rebalance expenses feature, added safeguard for estimate exepenses acceptance in onboarding
All checks were successful
Deploy / deploy (push) Successful in 1m28s
Security Tests / security-non-db (push) Successful in 19s
Security Tests / security-db (push) Successful in 24s

This commit is contained in:
2026-03-11 21:17:45 -05:00
parent cccce2c854
commit 3199e676a8
12 changed files with 583 additions and 18 deletions

View File

@@ -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;