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

154
api/src/allocator.ts Normal file
View File

@@ -0,0 +1,154 @@
import type { PrismaClient } from "@prisma/client";
/**
* Allocate income across fixed plans (need-first) and variable categories (largest remainder).
*
* @param db Prisma client (or tx)
* @param userId string
* @param amountCents number (>= 0)
* @param postedAtISO string ISO timestamp for the income event
* @param incomeId string id to use for IncomeEvent + Allocation FK
*/
export async function allocateIncome(
db: PrismaClient,
userId: string,
amountCents: number,
postedAtISO: string,
incomeId: string
): Promise<{
fixedAllocations: Array<{ fixedPlanId: string; amountCents: number }>;
variableAllocations: Array<{ variableCategoryId: string; amountCents: number }>;
remainingUnallocatedCents: number;
}> {
const amt = Math.max(0, Math.floor(amountCents | 0));
return await db.$transaction(async (tx) => {
// 1) Ensure the IncomeEvent exists to satisfy FK on Allocation
await tx.incomeEvent.upsert({
where: { id: incomeId },
update: {}, // idempotent in case route created it already
create: {
id: incomeId,
userId,
postedAt: new Date(postedAtISO),
amountCents: BigInt(amt),
},
});
// 2) Load current fixed plans + variable categories
const [plans, cats] = await Promise.all([
tx.fixedPlan.findMany({
where: { userId },
orderBy: [{ priority: "asc" }, { dueOn: "asc" }],
select: {
id: true,
totalCents: true,
fundedCents: true,
priority: true,
dueOn: true,
},
}),
tx.variableCategory.findMany({
where: { userId },
orderBy: [{ priority: "asc" }, { name: "asc" }],
select: { id: true, name: true, percent: true, isSavings: true, priority: true },
}),
]);
let remaining = amt;
// 3) Fixed pass: fund by priority then due date up to need
const fixedAllocations: Array<{ fixedPlanId: 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);
if (give > 0) {
// apply fundedCents
await tx.fixedPlan.update({
where: { id: p.id },
data: { fundedCents: (p.fundedCents ?? 0n) + BigInt(give) },
});
// audit allocation row
await tx.allocation.create({
data: {
userId,
kind: "fixed",
toId: p.id,
amountCents: BigInt(give),
incomeId, // FK now valid
},
});
fixedAllocations.push({ fixedPlanId: p.id, amountCents: give });
remaining -= give;
}
}
// 4) Variable pass: largest remainder w/ savings-first tiebreak
const variableAllocations: Array<{ variableCategoryId: string; amountCents: number }> = [];
if (remaining > 0 && cats.length > 0) {
const totalPercent = cats.reduce((s, c) => s + (c.percent || 0), 0);
const norm = totalPercent === 100
? cats
: cats.map(c => ({
...c,
percent: totalPercent > 0 ? (c.percent * 100) / totalPercent : 0,
}));
const base = new Array(norm.length).fill(0);
const tie = [] as Array<{ idx: number; remainder: number; isSavings: boolean; priority: number; name: string }>;
let sumBase = 0;
norm.forEach((c, idx) => {
const exact = (remaining * (c.percent || 0)) / 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]++;
for (let i = 0; i < norm.length; i++) {
const give = base[i] || 0;
if (give > 0) {
const c = norm[i];
await tx.variableCategory.update({
where: { id: c.id },
data: { balanceCents: { increment: BigInt(give) } },
});
await tx.allocation.create({
data: {
userId,
kind: "variable",
toId: c.id,
amountCents: BigInt(give),
incomeId,
},
});
variableAllocations.push({ variableCategoryId: c.id, amountCents: give });
}
}
remaining = leftovers;
}
return {
fixedAllocations,
variableAllocations,
remainingUnallocatedCents: Math.max(0, remaining),
};
});
}

26
api/src/env.ts Normal file
View File

@@ -0,0 +1,26 @@
// api/src/env.ts
import { z } from "zod";
const Env = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().int().positive().default(8080),
HOST: z.string().default("0.0.0.0"),
DATABASE_URL: z.string().min(1),
// Comma-separated list of allowed origins; empty => allow all (dev)
CORS_ORIGIN: z.string().optional(),
// 🔹 New: rate-limit knobs (have defaults so typing is happy)
RATE_LIMIT_MAX: z.coerce.number().int().positive().default(200),
RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000),
});
export const env = Env.parse({
NODE_ENV: process.env.NODE_ENV,
PORT: process.env.PORT,
HOST: process.env.HOST,
DATABASE_URL: process.env.DATABASE_URL,
CORS_ORIGIN: "http://localhost:5173,http://127.0.0.1:5173",
RATE_LIMIT_MAX: process.env.RATE_LIMIT_MAX,
RATE_LIMIT_WINDOW_MS: process.env.RATE_LIMIT_WINDOW_MS,
});

View File

@@ -0,0 +1,48 @@
// api/src/plugins/error-handler.ts
import type { FastifyError, FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { ZodError } from "zod";
function isPrismaError(e: any, code: string) {
return e && typeof e === "object" && e.code === code;
}
export function installErrorHandler(app: FastifyInstance) {
app.setErrorHandler((err: FastifyError & { code?: string; statusCode?: number }, req: FastifyRequest, reply: FastifyReply) => {
const requestId = (req as any).id as string | undefined;
// Respect explicit statusCode + code (e.g., OVERDRAFT_CATEGORY/PLAN)
if (err.statusCode && err.code) {
return reply.code(err.statusCode).send({ ok: false, code: err.code, message: err.message, requestId });
}
// Zod validation
if (err instanceof ZodError) {
return reply.code(400).send({
ok: false,
code: "INVALID_INPUT",
message: err.errors.map(e => e.message).join("; "),
requestId,
});
}
// Prisma common cases
if (isPrismaError(err, "P2002")) {
return reply.code(409).send({ ok: false, code: "UNIQUE_VIOLATION", message: "Duplicate value violates unique constraint", requestId });
}
if (isPrismaError(err, "P2003")) {
return reply.code(400).send({ ok: false, code: "FK_CONSTRAINT", message: "Foreign key constraint violated", requestId });
}
// 404 produced by handlers
if (err.statusCode === 404) {
return reply.code(404).send({ ok: false, code: "NOT_FOUND", message: err.message || "Not found", requestId });
}
// Default
const status = err.statusCode && err.statusCode >= 400 ? err.statusCode : 500;
const code = status >= 500 ? "INTERNAL" : "BAD_REQUEST";
// Log full error with request id for correlation
app.log.error({ err, requestId }, "Request error");
return reply.code(status).send({ ok: false, code, message: err.message || "Unexpected error", requestId });
});
}

View File

@@ -0,0 +1,15 @@
// api/src/plugins/request-id.ts
import type { FastifyPluginCallback } from "fastify";
import { randomUUID } from "crypto";
const requestIdPlugin: FastifyPluginCallback = (app, _opts, done) => {
app.addHook("onRequest", async (req, reply) => {
const incoming = (req.headers["x-request-id"] as string | undefined)?.trim();
const id = incoming && incoming.length > 0 ? incoming : randomUUID();
(req as any).id = id; // attach to request
reply.header("x-request-id", id); // echo on response
});
done();
};
export default requestIdPlugin;

View File

@@ -0,0 +1,23 @@
import fp from "fastify-plugin";
import { prisma } from "../prisma.js";
declare module "fastify" {
interface FastifyRequest {
userId: string;
}
}
export default fp(async (app) => {
app.addHook("onRequest", async (req) => {
// Dev-only stub: use header if provided, else default
const hdr = req.headers["x-user-id"];
req.userId = typeof hdr === "string" && hdr.trim() ? hdr.trim() : "demo-user-1";
// Ensure the user exists (avoids FK P2003 on first write)
await prisma.user.upsert({
where: { id: req.userId },
update: {},
create: { id: req.userId, email: `${req.userId}@demo.local` },
});
});
});

2
api/src/prisma.ts Normal file
View File

@@ -0,0 +1,2 @@
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();

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;

View File

@@ -1,20 +0,0 @@
// prisma/seed.ts (optional: creates one demo user + categories)
import { PrismaClient } from '@prisma/client';
const db = new PrismaClient();
async function main() {
const user = await db.user.upsert({
where: { email: 'demo@user.test' },
update: {},
create: { email: 'demo@user.test' }
});
await db.variableCategory.createMany({
data: [
{ userId: user.id, name: 'Groceries', percent: 30, priority: 10 },
{ userId: user.id, name: 'Gas', percent: 20, priority: 20 },
{ userId: user.id, name: 'Fun', percent: 50, priority: 30 }
],
skipDuplicates: true
});
console.log('Seeded:', user.email);
}
main().finally(()=>db.$disconnect());

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

8
api/src/types/fastify-prisma.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
import "fastify";
import type { PrismaClient } from "@prisma/client";
declare module "fastify" {
interface FastifyInstance {
prisma: PrismaClient;
}
}