import { randomUUID } from "node:crypto"; import type { FastifyPluginAsync } from "fastify"; import { z } from "zod"; import { allocateIncome, allocateIncomeManual, previewAllocation, } from "../allocator.js"; type RateLimitRouteOptions = { config: { rateLimit: { max: number; timeWindow: number; keyGenerator?: (req: any) => string; }; }; }; type IncomeRoutesOptions = { mutationRateLimit: RateLimitRouteOptions; }; const AllocationOverrideSchema = z.object({ type: z.enum(["fixed", "variable"]), id: z.string().min(1), amountCents: z.number().int().nonnegative(), }); const incomeRoutes: FastifyPluginAsync = async (app, opts) => { app.post("/income", opts.mutationRateLimit, async (req, reply) => { const Body = z.object({ amountCents: z.number().int().nonnegative(), overrides: z.array(AllocationOverrideSchema).optional(), occurredAtISO: z.string().datetime().optional(), note: z.string().trim().max(500).optional(), isScheduledIncome: z.boolean().optional(), }); const parsed = Body.safeParse(req.body); if (!parsed.success) { return reply.code(400).send({ message: "Invalid amount" }); } const userId = req.userId; const amountCentsNum = Math.max(0, Math.floor(parsed.data.amountCents | 0)); const overrides = (parsed.data.overrides ?? []).filter((o) => o.amountCents > 0); const note = parsed.data.note?.trim() ? parsed.data.note.trim() : null; const isScheduledIncome = parsed.data.isScheduledIncome ?? false; const postedAt = parsed.data.occurredAtISO ? new Date(parsed.data.occurredAtISO) : new Date(); const postedAtISO = postedAt.toISOString(); const incomeId = randomUUID(); if (overrides.length > 0) { const manual = await allocateIncomeManual( app.prisma, userId, amountCentsNum, postedAtISO, incomeId, overrides, note ); return manual; } const result = await allocateIncome( app.prisma, userId, amountCentsNum, postedAtISO, incomeId, note, isScheduledIncome ); return result; }); app.get("/income/history", async (req) => { const userId = req.userId; const events = await app.prisma.incomeEvent.findMany({ where: { userId }, orderBy: { postedAt: "desc" }, take: 5, select: { id: true, postedAt: true, amountCents: true }, }); if (events.length === 0) return []; const allocations = await app.prisma.allocation.findMany({ where: { userId, incomeId: { in: events.map((e) => e.id) } }, select: { incomeId: true, kind: true, amountCents: true }, }); const sums = new Map< string, { fixed: number; variable: number } >(); for (const alloc of allocations) { if (!alloc.incomeId) continue; const entry = sums.get(alloc.incomeId) ?? { fixed: 0, variable: 0 }; const value = Number(alloc.amountCents ?? 0n); if (alloc.kind === "fixed") entry.fixed += value; else entry.variable += value; sums.set(alloc.incomeId, entry); } return events.map((event) => { const totals = sums.get(event.id) ?? { fixed: 0, variable: 0 }; return { id: event.id, postedAt: event.postedAt, amountCents: Number(event.amountCents ?? 0n), fixedTotal: totals.fixed, variableTotal: totals.variable, }; }); }); app.post("/income/preview", async (req, reply) => { const Body = z.object({ amountCents: z.number().int().nonnegative(), occurredAtISO: z.string().datetime().optional(), isScheduledIncome: z.boolean().optional(), }); const parsed = Body.safeParse(req.body); if (!parsed.success) { return reply.code(400).send({ message: "Invalid amount" }); } const userId = req.userId; const result = await previewAllocation( app.prisma, userId, parsed.data.amountCents, parsed.data.occurredAtISO, parsed.data.isScheduledIncome ?? false ); return result; }); }; export default incomeRoutes;