Files
SkyMoney/api/src/routes/income.ts
Ricearoni1245 a8e5443b0d
All checks were successful
Deploy / deploy (push) Successful in 1m31s
Security Tests / security-non-db (push) Successful in 21s
Security Tests / security-db (push) Successful in 26s
phase 7: income, payday. and budget handling routes simplified and compacted
2026-03-17 22:05:17 -05:00

140 lines
4.1 KiB
TypeScript

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<IncomeRoutesOptions> = 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;