added api logic, vitest, minimal testing ui
This commit is contained in:
154
api/src/allocator.ts
Normal file
154
api/src/allocator.ts
Normal 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
26
api/src/env.ts
Normal 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,
|
||||
});
|
||||
48
api/src/plugins/error-handler.ts
Normal file
48
api/src/plugins/error-handler.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
15
api/src/plugins/request-id.ts
Normal file
15
api/src/plugins/request-id.ts
Normal 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;
|
||||
23
api/src/plugins/user-stub.ts
Normal file
23
api/src/plugins/user-stub.ts
Normal 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
2
api/src/prisma.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
export const prisma = new PrismaClient();
|
||||
94
api/src/routes/fixed-plans.ts
Normal file
94
api/src/routes/fixed-plans.ts
Normal 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;
|
||||
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) };
|
||||
});
|
||||
}
|
||||
69
api/src/routes/transactions.ts
Normal file
69
api/src/routes/transactions.ts
Normal 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 };
|
||||
});
|
||||
});
|
||||
85
api/src/routes/variable-categories.ts
Normal file
85
api/src/routes/variable-categories.ts
Normal 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;
|
||||
@@ -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());
|
||||
@@ -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
8
api/src/types/fastify-prisma.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
import "fastify";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
prisma: PrismaClient;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user