140 lines
4.1 KiB
TypeScript
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;
|