added api logic, vitest, minimal testing ui

This commit is contained in:
2025-11-15 23:26:57 -06:00
parent f4160b91db
commit 4eae966f96
95 changed files with 14155 additions and 469 deletions

View File

@@ -0,0 +1,94 @@
import { FastifyPluginAsync } from "fastify";
import { z } from "zod";
import { prisma } from "../prisma.js";
const NewPlan = z.object({
name: z.string().min(1).max(120),
totalCents: z.number().int().min(0),
fundedCents: z.number().int().min(0).default(0),
priority: z.number().int().min(0).max(10_000),
dueOn: z.string().datetime(), // ISO
});
const PatchPlan = NewPlan.partial();
const IdParam = z.object({ id: z.string().min(1) });
const bi = (n: number | bigint | undefined) => BigInt(n ?? 0);
function validateFunding(total: bigint, funded: bigint) {
if (funded > total) {
const err: any = new Error("fundedCents must be ≤ totalCents");
err.statusCode = 400;
err.code = "FUNDED_GT_TOTAL";
throw err;
}
}
const plugin: FastifyPluginAsync = async (app) => {
// CREATE
app.post("/api/fixed-plans", async (req, reply) => {
const userId = req.userId;
const parsed = NewPlan.safeParse(req.body);
if (!parsed.success) return reply.status(400).send({ error: "INVALID_BODY", details: parsed.error.flatten() });
const totalBI = bi(parsed.data.totalCents);
const fundedBI = bi(parsed.data.fundedCents);
validateFunding(totalBI, fundedBI);
const rec = await prisma.fixedPlan.create({
data: {
userId,
name: parsed.data.name,
priority: parsed.data.priority,
dueOn: new Date(parsed.data.dueOn),
totalCents: totalBI,
fundedCents: fundedBI,
cycleStart: new Date(), // required by your schema
},
select: { id: true },
});
return reply.status(201).send(rec);
});
// UPDATE
app.patch("/api/fixed-plans/:id", async (req, reply) => {
const userId = req.userId;
const pid = IdParam.safeParse(req.params);
const patch = PatchPlan.safeParse(req.body);
if (!pid.success) return reply.status(400).send({ error: "INVALID_ID", details: pid.error.flatten() });
if (!patch.success) return reply.status(400).send({ error: "INVALID_BODY", details: patch.error.flatten() });
const existing = await prisma.fixedPlan.findFirst({ where: { id: pid.data.id, userId } });
if (!existing) return reply.status(404).send({ error: "NOT_FOUND" });
const nextTotal = patch.data.totalCents !== undefined ? bi(patch.data.totalCents) : (existing.totalCents as bigint);
const nextFunded = patch.data.fundedCents !== undefined ? bi(patch.data.fundedCents) : (existing.fundedCents as bigint);
validateFunding(nextTotal, nextFunded);
await prisma.fixedPlan.update({
where: { id: pid.data.id },
data: {
...(patch.data.name !== undefined ? { name: patch.data.name } : null),
...(patch.data.priority !== undefined ? { priority: patch.data.priority } : null),
...(patch.data.dueOn !== undefined ? { dueOn: new Date(patch.data.dueOn) } : null),
...(patch.data.totalCents !== undefined ? { totalCents: bi(patch.data.totalCents) } : null),
...(patch.data.fundedCents !== undefined ? { fundedCents: bi(patch.data.fundedCents) } : null),
},
});
return reply.send({ ok: true });
});
// DELETE
app.delete("/api/fixed-plans/:id", async (req, reply) => {
const userId = req.userId;
const pid = IdParam.safeParse(req.params);
if (!pid.success) return reply.status(400).send({ error: "INVALID_ID", details: pid.error.flatten() });
const existing = await prisma.fixedPlan.findFirst({ where: { id: pid.data.id, userId } });
if (!existing) return reply.status(404).send({ error: "NOT_FOUND" });
await prisma.fixedPlan.delete({ where: { id: pid.data.id } });
return reply.send({ ok: true });
});
};
export default plugin;

View 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) };
});
}

View File

@@ -0,0 +1,69 @@
// api/src/routes/transactions.ts
import fp from "fastify-plugin";
import { z } from "zod";
const Query = z.object({
from: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(), // YYYY-MM-DD
to: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
kind: z.enum(["variable_spend", "fixed_payment"]).optional(),
q: z.string().trim().optional(),
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
});
export default fp(async function transactionsRoute(app) {
app.get("/transactions", async (req, reply) => {
const userId =
typeof req.userId === "string"
? req.userId
: String(req.userId ?? "demo-user-1");
const parsed = Query.safeParse(req.query);
if (!parsed.success) {
return reply
.code(400)
.send({ message: "Invalid query", issues: parsed.error.issues });
}
const { from, to, kind, q, page, limit } = parsed.data;
const where: any = { userId };
if (from || to) {
where.occurredAt = {};
if (from) where.occurredAt.gte = new Date(`${from}T00:00:00.000Z`);
if (to) where.occurredAt.lte = new Date(`${to}T23:59:59.999Z`);
}
if (kind) where.kind = kind;
if (typeof q === "string" && q.trim() !== "") {
const ors: any[] = [];
const asNumber = Number(q);
if (Number.isFinite(asNumber)) {
ors.push({ amountCents: BigInt(asNumber) });
}
if (ors.length > 0) {
where.OR = ors;
}
}
const skip = (page - 1) * limit;
const [total, items] = await Promise.all([
app.prisma.transaction.count({ where }),
app.prisma.transaction.findMany({
where,
orderBy: { occurredAt: "desc" },
skip,
take: limit,
select: { id: true, kind: true, amountCents: true, occurredAt: true },
}),
]);
return { items, page, limit, total };
});
});

View File

@@ -0,0 +1,85 @@
import { FastifyPluginAsync } from "fastify";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { prisma } from "../prisma.js";
const NewCat = z.object({
name: z.string().min(1).max(100),
percent: z.number().int().min(0).max(100),
isSavings: z.boolean().default(false),
priority: z.number().int().min(0).max(10_000),
});
const PatchCat = NewCat.partial();
const IdParam = z.object({ id: z.string().min(1) });
async function assertPercentTotal100(tx: Prisma.TransactionClient, userId: string) {
const g = await tx.variableCategory.groupBy({
by: ["userId"],
where: { userId },
_sum: { percent: true },
});
const sum = g[0]?._sum.percent ?? 0;
if (sum !== 100) {
const err: any = new Error(`Percents must sum to 100 (got ${sum}).`);
err.statusCode = 400;
err.code = "PERCENT_TOTAL_NOT_100";
throw err;
}
}
const plugin: FastifyPluginAsync = async (app) => {
// CREATE
app.post("/api/variable-categories", async (req, reply) => {
const userId = req.userId;
const body = NewCat.safeParse(req.body);
if (!body.success) return reply.status(400).send({ error: "INVALID_BODY", details: body.error.flatten() });
const created = await prisma.$transaction(async (tx) => {
const rec = await tx.variableCategory.create({
data: { ...body.data, userId },
select: { id: true },
});
await assertPercentTotal100(tx, userId);
return rec;
});
return reply.status(201).send(created);
});
// UPDATE
app.patch("/api/variable-categories/:id", async (req, reply) => {
const userId = req.userId;
const pid = IdParam.safeParse(req.params);
const patch = PatchCat.safeParse(req.body);
if (!pid.success) return reply.status(400).send({ error: "INVALID_ID", details: pid.error.flatten() });
if (!patch.success) return reply.status(400).send({ error: "INVALID_BODY", details: patch.error.flatten() });
await prisma.$transaction(async (tx) => {
const exists = await tx.variableCategory.findFirst({ where: { id: pid.data.id, userId } });
if (!exists) return reply.status(404).send({ error: "NOT_FOUND" });
await tx.variableCategory.update({ where: { id: pid.data.id }, data: patch.data });
await assertPercentTotal100(tx, userId);
});
return reply.send({ ok: true });
});
// DELETE
app.delete("/api/variable-categories/:id", async (req, reply) => {
const userId = req.userId;
const pid = IdParam.safeParse(req.params);
if (!pid.success) return reply.status(400).send({ error: "INVALID_ID", details: pid.error.flatten() });
await prisma.$transaction(async (tx) => {
const exists = await tx.variableCategory.findFirst({ where: { id: pid.data.id, userId } });
if (!exists) return reply.status(404).send({ error: "NOT_FOUND" });
await tx.variableCategory.delete({ where: { id: pid.data.id } });
await assertPercentTotal100(tx, userId);
});
return reply.send({ ok: true });
});
};
export default plugin;