phase 7: income, payday. and budget handling routes simplified and compacted
This commit is contained in:
139
api/src/routes/income.ts
Normal file
139
api/src/routes/income.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user