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

@@ -1,10 +1,516 @@
// api/src/server.ts
import Fastify from "fastify";
import cors from "@fastify/cors";
import rateLimit from "@fastify/rate-limit";
import { env } from "./env.js";
import { PrismaClient } from "@prisma/client";
import { z } from "zod";
import { allocateIncome } from "./allocator.js";
const app = Fastify({ logger: true });
app.get("/health", async () => ({ ok: true }));
const port = Number(process.env.PORT ?? 8080);
app.listen({ port, host: "0.0.0.0" }).catch((err) => {
app.log.error(err);
process.exit(1);
declare module "fastify" {
interface FastifyInstance { prisma: PrismaClient }
interface FastifyRequest { userId: string }
}
const toBig = (n: number | string | bigint) => BigInt(n);
const isDate = (s?: string) => !!s && /^\d{4}-\d{2}-\d{2}$/.test(s);
const isoStart = (d: string) => new Date(`${d}T00:00:00.000Z`);
const isoEnd = (d: string) => new Date(`${d}T23:59:59.999Z`);
function jsonBigIntSafe(obj: unknown) {
return JSON.parse(JSON.stringify(obj, (_k, v) => (typeof v === "bigint" ? Number(v) : v)));
}
const app = Fastify({
logger: true,
requestIdHeader: "x-request-id",
genReqId: (req) => {
const hdr = req.headers["x-request-id"];
if (typeof hdr === "string" && hdr.length <= 64) return hdr;
return Math.random().toString(36).slice(2) + Date.now().toString(36);
},
});
// CORS
await app.register(cors, {
origin: (() => {
if (!env.CORS_ORIGIN) return true; // dev: allow all
const allow = env.CORS_ORIGIN.split(",").map(s => s.trim()).filter(Boolean);
return (origin, cb) => {
if (!origin) return cb(null, true); // curl/health
cb(null, allow.includes(origin));
};
})(),
credentials: true,
});
// Rate limit (light)
await app.register(rateLimit, {
max: env.RATE_LIMIT_MAX,
timeWindow: env.RATE_LIMIT_WINDOW_MS,
hook: "onRequest",
allowList: (req) => {
const ip = (req.ip || "").replace("::ffff:", "");
return ip === "127.0.0.1" || ip === "::1";
},
});
// Prisma
{
const prisma = new PrismaClient();
app.decorate("prisma", prisma);
app.addHook("onClose", async () => prisma.$disconnect());
}
// Auth stub + ensure user exists + set x-request-id header ONCE
app.addHook("onRequest", async (req, reply) => {
const headerId = req.headers["x-user-id"];
if (typeof headerId === "string" && headerId.trim()) req.userId = headerId.trim();
else req.userId = "demo-user-1";
// echo the request id (no per-request hook registration)
if (req.id) reply.header("x-request-id", String(req.id));
await app.prisma.user.upsert({
where: { id: req.userId },
update: {},
create: { id: req.userId, email: `${req.userId}@demo.local` },
});
});
// BigInt-safe JSON (single onSend)
app.addHook("preSerialization", (_req, _reply, payload, done) => {
try {
if (payload && typeof payload === "object") {
const safe = JSON.parse(
JSON.stringify(payload, (_k, v) => (typeof v === "bigint" ? Number(v) : v))
);
return done(null, safe);
}
return done(null, payload);
} catch {
// If anything goes sideways, keep the original payload
return done(null, payload);
}
});
app.setErrorHandler((err, req, reply) => {
// Map prisma/validation-ish errors to 400 by default
const status =
(typeof (err as any).statusCode === "number" && (err as any).statusCode) ||
(typeof (err as any).status === "number" && (err as any).status) ||
(typeof (err as any).code === "string" && (err as any).code.startsWith("P2") ? 400 : 500);
// Never leak stacks to client
const body = {
ok: false,
code: (err as any).code ?? "INTERNAL",
message:
status >= 500
? "Something went wrong"
: (err as any).message ?? "Bad request",
requestId: String(req.id ?? ""),
};
// Log full error with request context
req.log.error({ err, requestId: req.id }, "request failed");
reply.code(status).send(body);
});
// 404 JSON
app.setNotFoundHandler((req, reply) => {
reply.code(404).send({
ok: false,
code: "NOT_FOUND",
message: `No route: ${req.method} ${req.url}`,
requestId: String(req.id ?? ""),
});
});
// ───────────── Health ─────────────
app.get("/health", async () => ({ ok: true }));
app.get("/health/db", async () => {
const start = Date.now();
const [{ now }] = await app.prisma.$queryRawUnsafe<{ now: Date }[]>("SELECT now() as now");
const latencyMs = Date.now() - start;
return { ok: true, nowISO: now.toISOString(), latencyMs };
});
// ───────────── Dashboard ─────────────
app.get("/dashboard", async (req) => {
const userId = req.userId;
const [cats, plans, txs, agg] = await Promise.all([
app.prisma.variableCategory.findMany({
where: { userId }, orderBy: [{ priority: "asc" }, { name: "asc" }]
}),
app.prisma.fixedPlan.findMany({
where: { userId }, orderBy: [{ priority: "asc" }, { dueOn: "asc" }]
}),
app.prisma.transaction.findMany({
where: { userId }, orderBy: { occurredAt: "desc" }, take: 50,
select: { id: true, kind: true, amountCents: true, occurredAt: true }
}),
app.prisma.incomeEvent.aggregate({
where: { userId }, _sum: { amountCents: true }
}),
]);
const totals = {
incomeCents: Number(agg._sum?.amountCents ?? 0n),
variableBalanceCents: Number(cats.reduce((s, c) => s + (c.balanceCents ?? 0n), 0n)),
fixedRemainingCents: Number(plans.reduce((s, p) => {
const rem = (p.totalCents ?? 0n) - (p.fundedCents ?? 0n);
return s + (rem > 0n ? rem : 0n);
}, 0n)),
};
const percentTotal = cats.reduce((s, c) => s + c.percent, 0);
return { totals, variableCategories: cats, fixedPlans: plans, recentTransactions: txs, percentTotal };
});
// ───────────── Income (allocate) ─────────────
app.post("/income", async (req, reply) => {
const Body = z.object({ amountCents: z.number().int().nonnegative() });
const parsed = Body.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ message: "Invalid amount" });
const userId = req.userId;
const nowISO = new Date().toISOString();
const amountCentsNum = parsed.data.amountCents;
const income = await app.prisma.incomeEvent.create({
data: { userId, postedAt: new Date(nowISO), amountCents: toBig(amountCentsNum) },
select: { id: true },
});
const result = await allocateIncome(app.prisma, userId, amountCentsNum, nowISO, income.id);
return result;
});
// ───────────── Transactions: create (strict overdraft) ─────────────
app.post("/transactions", async (req, reply) => {
const Body = z.object({
kind: z.enum(["variable_spend", "fixed_payment"]),
amountCents: z.number().int().positive(),
occurredAtISO: z.string().datetime(),
categoryId: z.string().optional(),
planId: z.string().optional(),
});
const parsed = Body.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ message: "Invalid payload" });
const { kind, amountCents, occurredAtISO, categoryId, planId } = parsed.data;
const userId = req.userId;
const amt = toBig(amountCents);
return await app.prisma.$transaction(async (tx) => {
if (kind === "variable_spend") {
if (!categoryId) return reply.code(400).send({ message: "categoryId required" });
const cat = await tx.variableCategory.findFirst({ where: { id: categoryId, userId } });
if (!cat) return reply.code(404).send({ message: "Category not found" });
const bal = cat.balanceCents ?? 0n;
if (amt > bal) {
const err: any = new Error("Insufficient category balance");
err.statusCode = 400; err.code = "OVERDRAFT_CATEGORY";
throw err;
}
await tx.variableCategory.update({ where: { id: cat.id }, data: { balanceCents: bal - amt } });
} else {
if (!planId) return reply.code(400).send({ message: "planId required" });
const plan = await tx.fixedPlan.findFirst({ where: { id: planId, userId } });
if (!plan) return reply.code(404).send({ message: "Plan not found" });
const funded = plan.fundedCents ?? 0n;
if (amt > funded) {
const err: any = new Error("Insufficient plan funds");
err.statusCode = 400; err.code = "OVERDRAFT_PLAN";
throw err;
}
await tx.fixedPlan.update({ where: { id: plan.id }, data: { fundedCents: funded - amt } });
}
const row = await tx.transaction.create({
data: {
userId,
occurredAt: new Date(occurredAtISO),
kind,
amountCents: amt,
categoryId: kind === "variable_spend" ? categoryId ?? null : null,
planId: kind === "fixed_payment" ? planId ?? null : null,
},
select: { id: true, kind: true, amountCents: true, occurredAt: true },
});
return row;
});
});
// ───────────── Transactions: list ─────────────
app.get("/transactions", async (req, reply) => {
const Query = z.object({
from: z.string().refine(isDate, "YYYY-MM-DD").optional(),
to: z.string().refine(isDate, "YYYY-MM-DD").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),
});
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 userId = req.userId;
const where: any = { userId };
if (from || to) {
where.occurredAt = {};
if (from) where.occurredAt.gte = isoStart(from);
if (to) where.occurredAt.lte = isoEnd(to);
}
if (kind) where.kind = kind;
// 💡 Only add OR if we actually have predicates
if (typeof q === "string" && q.trim() !== "") {
const ors: any[] = [];
const asNumber = Number(q);
if (Number.isFinite(asNumber)) {
ors.push({ amountCents: toBig(asNumber) });
}
// (When you add text fields later, push them here too)
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 };
});
// ───────────── Variable Categories CRUD (sum=100 guard) ─────────────
const CatBody = z.object({
name: z.string().trim().min(1),
percent: z.number().int().min(0).max(100),
isSavings: z.boolean(),
priority: z.number().int().min(0),
});
app.post("/variable-categories", async (req, reply) => {
const parsed = CatBody.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ message: "Invalid payload" });
const userId = req.userId;
return await app.prisma.$transaction(async (tx) => {
await tx.variableCategory.create({ data: { userId, balanceCents: 0n, ...parsed.data } });
const total = await tx.variableCategory.aggregate({ where: { userId }, _sum: { percent: true } });
if ((total._sum?.percent ?? 0) !== 100) throw reply.code(400).send({ message: "Percents must sum to 100" });
return { ok: true };
});
});
app.patch("/variable-categories/:id", async (req, reply) => {
const patch = CatBody.partial().safeParse(req.body);
if (!patch.success) return reply.code(400).send({ message: "Invalid payload" });
const id = String((req.params as any).id);
const userId = req.userId;
return await app.prisma.$transaction(async (tx) => {
const exists = await tx.variableCategory.findFirst({ where: { id, userId } });
if (!exists) return reply.code(404).send({ message: "Not found" });
await tx.variableCategory.update({ where: { id }, data: patch.data });
const total = await tx.variableCategory.aggregate({ where: { userId }, _sum: { percent: true } });
if ((total._sum?.percent ?? 0) !== 100) throw reply.code(400).send({ message: "Percents must sum to 100" });
return { ok: true };
});
});
app.delete("/variable-categories/:id", async (req, reply) => {
const id = String((req.params as any).id);
const userId = req.userId;
return await app.prisma.$transaction(async (tx) => {
const exists = await tx.variableCategory.findFirst({ where: { id, userId } });
if (!exists) return reply.code(404).send({ message: "Not found" });
await tx.variableCategory.delete({ where: { id } });
const total = await tx.variableCategory.aggregate({ where: { userId }, _sum: { percent: true } });
if ((total._sum?.percent ?? 0) !== 100) throw reply.code(400).send({ message: "Percents must sum to 100" });
return { ok: true };
});
});
// ───────────── Fixed Plans CRUD (funded ≤ total) ─────────────
const PlanBody = z.object({
name: z.string().trim().min(1),
totalCents: z.number().int().min(0),
fundedCents: z.number().int().min(0).optional(),
priority: z.number().int().min(0),
dueOn: z.string().datetime(),
cycleStart: z.string().datetime().optional(),
});
app.post("/fixed-plans", async (req, reply) => {
const parsed = PlanBody.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ message: "Invalid payload" });
const userId = req.userId;
const totalBig = toBig(parsed.data.totalCents);
const fundedBig = toBig(parsed.data.fundedCents ?? 0);
if (fundedBig > totalBig) return reply.code(400).send({ message: "fundedCents cannot exceed totalCents" });
await app.prisma.fixedPlan.create({
data: {
userId,
name: parsed.data.name,
totalCents: totalBig,
fundedCents: fundedBig,
priority: parsed.data.priority,
dueOn: new Date(parsed.data.dueOn),
cycleStart: new Date(parsed.data.cycleStart ?? parsed.data.dueOn),
fundingMode: "auto-on-deposit",
},
});
return { ok: true };
});
app.patch("/fixed-plans/:id", async (req, reply) => {
const patch = PlanBody.partial().safeParse(req.body);
if (!patch.success) return reply.code(400).send({ message: "Invalid payload" });
const id = String((req.params as any).id);
const userId = req.userId;
const plan = await app.prisma.fixedPlan.findFirst({ where: { id, userId } });
if (!plan) return reply.code(404).send({ message: "Not found" });
const total = "totalCents" in patch.data ? toBig(patch.data.totalCents as number) : (plan.totalCents ?? 0n);
const funded = "fundedCents" in patch.data ? toBig(patch.data.fundedCents as number) : (plan.fundedCents ?? 0n);
if (funded > total) return reply.code(400).send({ message: "fundedCents cannot exceed totalCents" });
await app.prisma.fixedPlan.update({
where: { id },
data: {
...patch.data,
...(patch.data.totalCents !== undefined ? { totalCents: total } : {}),
...(patch.data.fundedCents !== undefined ? { fundedCents: funded } : {}),
...(patch.data.dueOn ? { dueOn: new Date(patch.data.dueOn) } : {}),
...(patch.data.cycleStart ? { cycleStart: new Date(patch.data.cycleStart) } : {}),
},
});
return { ok: true };
});
app.delete("/fixed-plans/:id", async (req, reply) => {
const id = String((req.params as any).id);
const userId = req.userId;
const plan = await app.prisma.fixedPlan.findFirst({ where: { id, userId } });
if (!plan) return reply.code(404).send({ message: "Not found" });
await app.prisma.fixedPlan.delete({ where: { id } });
return { ok: true };
});
// ───────────── Income Preview (server-side; mirrors FE preview) ─────────────
app.post("/income/preview", async (req, reply) => {
const Body = z.object({ amountCents: z.number().int().nonnegative() });
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 w/ savings-first tie)
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;
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) };
});
// ───────────── Start ─────────────
const PORT = env.PORT;
const HOST = process.env.HOST || "0.0.0.0";
export default app; // <-- add this
if (process.env.NODE_ENV !== "test") {
app.listen({ port: PORT, host: HOST }).catch((err) => {
app.log.error(err);
process.exit(1);
});
}