317 lines
10 KiB
TypeScript
317 lines
10 KiB
TypeScript
import type { FastifyPluginAsync } from "fastify";
|
|
import type { Prisma, PrismaClient } from "@prisma/client";
|
|
import { z } from "zod";
|
|
import { ensureBudgetSessionAvailableSynced } from "../services/budget-session.js";
|
|
|
|
type RateLimitRouteOptions = {
|
|
config: {
|
|
rateLimit: {
|
|
max: number;
|
|
timeWindow: number;
|
|
keyGenerator?: (req: any) => string;
|
|
};
|
|
};
|
|
};
|
|
|
|
type PercentCategory = {
|
|
id: string;
|
|
percent: number;
|
|
balanceCents: bigint | null;
|
|
};
|
|
|
|
type DepositShareResult =
|
|
| { ok: true; shares: Array<{ id: string; share: number }> }
|
|
| { ok: false; reason: string };
|
|
|
|
type VariableCategoriesRoutesOptions = {
|
|
mutationRateLimit: RateLimitRouteOptions;
|
|
computeDepositShares: (categories: PercentCategory[], amountCents: number) => DepositShareResult;
|
|
};
|
|
|
|
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),
|
|
});
|
|
|
|
const ManualRebalanceBody = z.object({
|
|
targets: z.array(
|
|
z.object({
|
|
id: z.string().min(1),
|
|
targetCents: z.number().int().min(0),
|
|
})
|
|
),
|
|
forceLowerSavings: z.boolean().optional(),
|
|
confirmOver80: z.boolean().optional(),
|
|
});
|
|
|
|
async function assertPercentTotal(
|
|
tx: PrismaClient | Prisma.TransactionClient,
|
|
userId: string
|
|
) {
|
|
const categories = await tx.variableCategory.findMany({
|
|
where: { userId },
|
|
select: { percent: true, isSavings: true },
|
|
});
|
|
const sum = categories.reduce((total, cat) => total + (cat.percent ?? 0), 0);
|
|
const savingsSum = categories.reduce(
|
|
(total, cat) => total + (cat.isSavings ? cat.percent ?? 0 : 0),
|
|
0
|
|
);
|
|
|
|
// Allow partial states during onboarding (< 100%), but enforce exact 100% when sum would be >= 100%
|
|
if (sum > 100) {
|
|
const err: any = new Error("Percents must sum to 100");
|
|
err.statusCode = 400;
|
|
err.code = "PERCENT_TOTAL_OVER_100";
|
|
throw err;
|
|
}
|
|
if (sum >= 100 && savingsSum < 20) {
|
|
const err: any = new Error(
|
|
`Savings must total at least 20% (currently ${savingsSum}%)`
|
|
);
|
|
err.statusCode = 400;
|
|
err.code = "SAVINGS_MINIMUM";
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
const variableCategoriesRoutes: FastifyPluginAsync<VariableCategoriesRoutesOptions> = async (
|
|
app,
|
|
opts
|
|
) => {
|
|
app.post("/variable-categories", opts.mutationRateLimit, async (req, reply) => {
|
|
const parsed = CatBody.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
return reply.code(400).send({ message: "Invalid payload" });
|
|
}
|
|
const userId = req.userId;
|
|
const normalizedName = parsed.data.name.trim().toLowerCase();
|
|
|
|
return await app.prisma.$transaction(async (tx) => {
|
|
try {
|
|
const created = await tx.variableCategory.create({
|
|
data: { userId, balanceCents: 0n, ...parsed.data, name: normalizedName },
|
|
select: { id: true },
|
|
});
|
|
|
|
await assertPercentTotal(tx, userId);
|
|
return reply.status(201).send(created);
|
|
} catch (error: any) {
|
|
if (error.code === "P2002") {
|
|
return reply
|
|
.status(400)
|
|
.send({ error: "DUPLICATE_NAME", message: `Category name '${parsed.data.name}' already exists` });
|
|
}
|
|
throw error;
|
|
}
|
|
});
|
|
});
|
|
|
|
app.patch("/variable-categories/:id", opts.mutationRateLimit, 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;
|
|
const updateData = {
|
|
...patch.data,
|
|
...(patch.data.name !== undefined ? { name: patch.data.name.trim().toLowerCase() } : {}),
|
|
};
|
|
|
|
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" });
|
|
|
|
const updated = await tx.variableCategory.updateMany({
|
|
where: { id, userId },
|
|
data: updateData,
|
|
});
|
|
if (updated.count === 0) return reply.code(404).send({ message: "Not found" });
|
|
|
|
await assertPercentTotal(tx, userId);
|
|
return { ok: true };
|
|
});
|
|
});
|
|
|
|
app.delete("/variable-categories/:id", opts.mutationRateLimit, async (req, reply) => {
|
|
const id = String((req.params as any).id);
|
|
const userId = req.userId;
|
|
|
|
const exists = await app.prisma.variableCategory.findFirst({
|
|
where: { id, userId },
|
|
});
|
|
if (!exists) return reply.code(404).send({ message: "Not found" });
|
|
|
|
const deleted = await app.prisma.variableCategory.deleteMany({
|
|
where: { id, userId },
|
|
});
|
|
if (deleted.count === 0) return reply.code(404).send({ message: "Not found" });
|
|
await assertPercentTotal(app.prisma, userId);
|
|
return { ok: true };
|
|
});
|
|
|
|
app.post("/variable-categories/rebalance", opts.mutationRateLimit, async (req, reply) => {
|
|
const userId = req.userId;
|
|
const categories = await app.prisma.variableCategory.findMany({
|
|
where: { userId },
|
|
select: { id: true, percent: true, balanceCents: true },
|
|
orderBy: [{ priority: "asc" }, { name: "asc" }],
|
|
});
|
|
|
|
if (categories.length === 0) {
|
|
return { ok: true, applied: false };
|
|
}
|
|
|
|
const hasNegative = categories.some((c) => Number(c.balanceCents ?? 0n) < 0);
|
|
if (hasNegative) {
|
|
return reply.code(400).send({
|
|
ok: false,
|
|
code: "NEGATIVE_BALANCE",
|
|
message: "Cannot rebalance while a category has a negative balance.",
|
|
});
|
|
}
|
|
|
|
const totalBalance = categories.reduce((sum, c) => sum + Number(c.balanceCents ?? 0n), 0);
|
|
if (totalBalance <= 0) {
|
|
return { ok: true, applied: false };
|
|
}
|
|
|
|
const shareResult = opts.computeDepositShares(categories, totalBalance);
|
|
if (!shareResult.ok) {
|
|
return reply.code(400).send({
|
|
ok: false,
|
|
code: "NO_PERCENT",
|
|
message: "No percent totals available to rebalance.",
|
|
});
|
|
}
|
|
|
|
await app.prisma.$transaction(
|
|
shareResult.shares.map((s) =>
|
|
app.prisma.variableCategory.update({
|
|
where: { id: s.id },
|
|
data: { balanceCents: BigInt(s.share) },
|
|
})
|
|
)
|
|
);
|
|
|
|
return { ok: true, applied: true, totalBalance };
|
|
});
|
|
|
|
app.get("/variable-categories/manual-rebalance", async (req, reply) => {
|
|
const userId = req.userId;
|
|
const cats = await app.prisma.variableCategory.findMany({
|
|
where: { userId },
|
|
orderBy: [{ priority: "asc" }, { name: "asc" }],
|
|
select: { id: true, name: true, percent: true, isSavings: true, balanceCents: true },
|
|
});
|
|
const totalBalance = cats.reduce((s, c) => s + Number(c.balanceCents ?? 0n), 0);
|
|
await ensureBudgetSessionAvailableSynced(app.prisma, userId, totalBalance);
|
|
|
|
return reply.send({
|
|
ok: true,
|
|
availableCents: totalBalance,
|
|
categories: cats.map((c) => ({ ...c, balanceCents: Number(c.balanceCents ?? 0n) })),
|
|
});
|
|
});
|
|
|
|
app.post("/variable-categories/manual-rebalance", async (req, reply) => {
|
|
const userId = req.userId;
|
|
const parsed = ManualRebalanceBody.safeParse(req.body);
|
|
if (!parsed.success || parsed.data.targets.length === 0) {
|
|
return reply.code(400).send({ ok: false, code: "INVALID_BODY" });
|
|
}
|
|
|
|
const cats = await app.prisma.variableCategory.findMany({
|
|
where: { userId },
|
|
orderBy: [{ priority: "asc" }, { name: "asc" }],
|
|
select: { id: true, name: true, percent: true, isSavings: true, balanceCents: true },
|
|
});
|
|
if (cats.length === 0) return reply.code(400).send({ ok: false, code: "NO_CATEGORIES" });
|
|
|
|
const totalBalance = cats.reduce((s, c) => s + Number(c.balanceCents ?? 0n), 0);
|
|
const availableCents = totalBalance;
|
|
await ensureBudgetSessionAvailableSynced(app.prisma, userId, availableCents);
|
|
|
|
const targetMap = new Map<string, number>();
|
|
for (const t of parsed.data.targets) {
|
|
if (targetMap.has(t.id)) return reply.code(400).send({ ok: false, code: "DUPLICATE_ID" });
|
|
targetMap.set(t.id, t.targetCents);
|
|
}
|
|
if (targetMap.size !== cats.length || cats.some((c) => !targetMap.has(c.id))) {
|
|
return reply.code(400).send({ ok: false, code: "MISSING_CATEGORY" });
|
|
}
|
|
|
|
const targets = cats.map((c) => ({
|
|
...c,
|
|
target: targetMap.get(c.id)!,
|
|
currentBalance: Number(c.balanceCents ?? 0n),
|
|
}));
|
|
|
|
if (targets.some((t) => t.target < 0)) {
|
|
return reply.code(400).send({ ok: false, code: "NEGATIVE_TARGET" });
|
|
}
|
|
const sumTargets = targets.reduce((s, t) => s + t.target, 0);
|
|
if (sumTargets !== availableCents) {
|
|
return reply
|
|
.code(400)
|
|
.send({ ok: false, code: "SUM_MISMATCH", message: `Targets must sum to available (${availableCents}).` });
|
|
}
|
|
const maxAllowed = Math.floor(availableCents * 0.8);
|
|
const over80 = availableCents > 0 && targets.some((t) => t.target > maxAllowed);
|
|
if (over80 && !parsed.data.confirmOver80) {
|
|
return reply.code(400).send({
|
|
ok: false,
|
|
code: "OVER_80_CONFIRM_REQUIRED",
|
|
message: "A category exceeds 80% of available. Confirm to proceed.",
|
|
});
|
|
}
|
|
const totalSavingsBefore = targets.filter((t) => t.isSavings).reduce((s, t) => s + t.currentBalance, 0);
|
|
const totalSavingsAfter = targets.filter((t) => t.isSavings).reduce((s, t) => s + t.target, 0);
|
|
const savingsFloor = Math.floor(availableCents * 0.2);
|
|
const loweringSavings = totalSavingsAfter < totalSavingsBefore;
|
|
const belowFloor = totalSavingsAfter < savingsFloor;
|
|
if ((loweringSavings || belowFloor) && !parsed.data.forceLowerSavings) {
|
|
return reply
|
|
.code(400)
|
|
.send({ ok: false, code: "SAVINGS_FLOOR", message: "Lowering savings requires confirmation." });
|
|
}
|
|
|
|
await app.prisma.$transaction(async (tx) => {
|
|
for (const t of targets) {
|
|
await tx.variableCategory.update({
|
|
where: { id: t.id },
|
|
data: { balanceCents: BigInt(t.target) },
|
|
});
|
|
}
|
|
await tx.transaction.create({
|
|
data: {
|
|
userId,
|
|
kind: "rebalance",
|
|
amountCents: 0n,
|
|
occurredAt: new Date(),
|
|
note: JSON.stringify({
|
|
availableCents,
|
|
before: targets.map((t) => ({ id: t.id, balanceCents: t.currentBalance })),
|
|
after: targets.map((t) => ({ id: t.id, balanceCents: t.target })),
|
|
totalSavingsBefore,
|
|
totalSavingsAfter,
|
|
}),
|
|
},
|
|
});
|
|
});
|
|
|
|
return reply.send({
|
|
ok: true,
|
|
availableCents,
|
|
categories: targets.map((t) => ({ id: t.id, balanceCents: t.target })),
|
|
});
|
|
});
|
|
};
|
|
|
|
export default variableCategoriesRoutes;
|