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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user