added api logic, vitest, minimal testing ui
This commit is contained in:
82
api/src/routes/income-preview.ts
Normal file
82
api/src/routes/income-preview.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
|
||||
const Body = z.object({ amountCents: z.number().int().nonnegative() });
|
||||
|
||||
export default async function incomePreviewRoutes(app: FastifyInstance) {
|
||||
app.post("/income/preview", async (req, reply) => {
|
||||
const parsed = Body.safeParse(req.body);
|
||||
if (!parsed.success) return reply.code(400).send({ message: "Invalid amount" });
|
||||
|
||||
const userId = req.userId;
|
||||
let remaining = Math.max(0, parsed.data.amountCents | 0);
|
||||
|
||||
const [plans, cats] = await Promise.all([
|
||||
app.prisma.fixedPlan.findMany({
|
||||
where: { userId },
|
||||
orderBy: [{ priority: "asc" }, { dueOn: "asc" }],
|
||||
select: { id: true, name: true, totalCents: true, fundedCents: true, priority: true, dueOn: true },
|
||||
}),
|
||||
app.prisma.variableCategory.findMany({
|
||||
where: { userId },
|
||||
orderBy: [{ priority: "asc" }, { name: "asc" }],
|
||||
select: { id: true, name: true, percent: true, isSavings: true, priority: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
// Fixed pass
|
||||
const fixed: Array<{ id: string; name: string; amountCents: number }> = [];
|
||||
for (const p of plans) {
|
||||
if (remaining <= 0) break;
|
||||
const needBig = (p.totalCents ?? 0n) - (p.fundedCents ?? 0n);
|
||||
const need = Number(needBig > 0n ? needBig : 0n);
|
||||
if (need <= 0) continue;
|
||||
const give = Math.min(need, remaining);
|
||||
fixed.push({ id: p.id, name: p.name, amountCents: give });
|
||||
remaining -= give;
|
||||
}
|
||||
|
||||
// Variable pass — largest remainder with savings-first tiebreak
|
||||
const variable: Array<{ id: string; name: string; amountCents: number }> = [];
|
||||
if (remaining > 0 && cats.length > 0) {
|
||||
const totalPercent = cats.reduce((s, c) => s + (c.percent || 0), 0);
|
||||
const normCats =
|
||||
totalPercent === 100
|
||||
? cats
|
||||
: cats.map((c) => ({
|
||||
...c,
|
||||
percent: totalPercent > 0 ? (c.percent * 100) / totalPercent : 0,
|
||||
}));
|
||||
|
||||
const base: number[] = new Array(normCats.length).fill(0);
|
||||
const tie: { idx: number; remainder: number; isSavings: boolean; priority: number; name: string }[] = [];
|
||||
let sumBase = 0;
|
||||
|
||||
normCats.forEach((c, idx) => {
|
||||
const exact = (remaining * c.percent) / 100;
|
||||
const floor = Math.floor(exact);
|
||||
base[idx] = floor;
|
||||
sumBase += floor;
|
||||
tie.push({ idx, remainder: exact - floor, isSavings: !!c.isSavings, priority: c.priority, name: c.name });
|
||||
});
|
||||
|
||||
let leftovers = remaining - sumBase;
|
||||
tie.sort((a, b) => {
|
||||
if (a.isSavings !== b.isSavings) return a.isSavings ? -1 : 1; // savings first
|
||||
if (a.remainder !== b.remainder) return b.remainder - a.remainder;
|
||||
if (a.priority !== b.priority) return a.priority - b.priority;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
for (let i = 0; i < tie.length && leftovers > 0; i++, leftovers--) base[tie[i].idx] += 1;
|
||||
|
||||
normCats.forEach((c, idx) => {
|
||||
const give = base[idx] || 0;
|
||||
if (give > 0) variable.push({ id: c.id, name: c.name, amountCents: give });
|
||||
});
|
||||
|
||||
remaining = leftovers;
|
||||
}
|
||||
|
||||
return { fixed, variable, unallocatedCents: Math.max(0, remaining) };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user