phase 3: all variable cateogry references simplified
All checks were successful
Deploy / deploy (push) Successful in 1m33s
Security Tests / security-non-db (push) Successful in 20s
Security Tests / security-db (push) Successful in 26s

This commit is contained in:
2026-03-16 15:20:12 -05:00
parent a430dfadcf
commit 4a63309153
5 changed files with 319 additions and 507 deletions

View File

@@ -1,17 +1,39 @@
import { FastifyPluginAsync } from "fastify";
import { Prisma } from "@prisma/client";
import type { FastifyPluginAsync, FastifyInstance } from "fastify";
import type { Prisma, PrismaClient } from "@prisma/client";
import { z } from "zod";
import { prisma } from "../prisma.js";
const NewCat = z.object({
name: z.string().min(1).max(100),
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().default(false),
priority: z.number().int().min(0).max(10_000),
isSavings: z.boolean(),
priority: z.number().int().min(0),
});
const PatchCat = NewCat.partial();
const IdParam = z.object({ id: z.string().min(1) });
const ManualRebalanceBody = z.object({
targets: z.array(
z.object({
@@ -20,148 +42,193 @@ const ManualRebalanceBody = z.object({
})
),
forceLowerSavings: z.boolean().optional(),
confirmOver80: z.boolean().optional(),
});
function computeBalanceTargets(
categories: Array<{ id: string; percent: number }>,
totalBalance: number
async function assertPercentTotal(
tx: PrismaClient | Prisma.TransactionClient,
userId: string
) {
const percentTotal = categories.reduce((sum, c) => sum + (c.percent || 0), 0);
if (percentTotal <= 0) {
return { ok: false as const, reason: "no_percent" };
}
const targets = categories.map((cat) => {
const raw = (totalBalance * cat.percent) / percentTotal;
const floored = Math.floor(raw);
return {
id: cat.id,
target: floored,
frac: raw - floored,
};
});
let remainder = totalBalance - targets.reduce((sum, t) => sum + t.target, 0);
targets
.slice()
.sort((a, b) => b.frac - a.frac)
.forEach((t) => {
if (remainder > 0) {
t.target += 1;
remainder -= 1;
}
});
return { ok: true as const, targets };
}
async function assertPercentTotal100(tx: Prisma.TransactionClient, userId: string) {
const g = await tx.variableCategory.groupBy({
by: ["userId"],
const categories = await tx.variableCategory.findMany({
where: { userId },
_sum: { percent: true },
select: { percent: true, isSavings: true },
});
const sum = g[0]?._sum.percent ?? 0;
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 = new Error(`Percents cannot exceed 100 (got ${sum}).`) as any;
const err: any = new Error("Percents must sum to 100");
err.statusCode = 400;
err.code = "PERCENT_TOTAL_OVER_100";
throw err;
}
// For now, allow partial completion during onboarding
// The frontend will ensure 100% total before finishing onboarding
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;
}
}
async function getLatestBudgetSession(userId: string) {
return prisma.budgetSession.findFirst({
async function getLatestBudgetSession(app: FastifyInstance, userId: string) {
return app.prisma.budgetSession.findFirst({
where: { userId },
orderBy: { periodStart: "desc" },
});
}
const plugin: FastifyPluginAsync = async (app) => {
// CREATE
app.post("/variable-categories", async (req, reply) => {
const userId = req.userId;
const body = NewCat.safeParse(req.body);
if (!body.success) return reply.status(400).send({ error: "INVALID_BODY", details: body.error.flatten() });
async function ensureBudgetSession(
app: FastifyInstance,
userId: string,
fallbackAvailableCents = 0
) {
const existing = await getLatestBudgetSession(app, userId);
if (existing) return existing;
const normalizedName = body.data.name.trim().toLowerCase();
try {
const result = await prisma.variableCategory.create({
data: { ...body.data, userId, name: normalizedName },
select: { id: true },
});
return reply.status(201).send(result);
} catch (error: any) {
if (error.code === 'P2002') {
return reply.status(400).send({ error: 'DUPLICATE_NAME', message: `Category name '${body.data.name}' already exists` });
}
throw error;
const now = new Date();
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1, 0, 0, 0, 0));
const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1, 0, 0, 0, 0));
return app.prisma.budgetSession.create({
data: {
userId,
periodStart: start,
periodEnd: end,
totalBudgetCents: BigInt(Math.max(0, fallbackAvailableCents)),
allocatedCents: 0n,
fundedCents: 0n,
availableCents: BigInt(Math.max(0, fallbackAvailableCents)),
},
});
}
async function ensureBudgetSessionAvailableSynced(
app: FastifyInstance,
userId: string,
availableCents: number
) {
const normalizedAvailableCents = BigInt(Math.max(0, Math.trunc(availableCents)));
const session = await ensureBudgetSession(app, userId, Number(normalizedAvailableCents));
if ((session.availableCents ?? 0n) === normalizedAvailableCents) return session;
return app.prisma.budgetSession.update({
where: { id: session.id },
data: { availableCents: normalizedAvailableCents },
});
}
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 userTimezone =
(
await app.prisma.user.findUnique({
where: { id: userId },
select: { timezone: true },
})
)?.timezone ?? "America/New_York";
const normalizedName = parsed.data.name.trim().toLowerCase();
void userTimezone;
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;
}
});
});
// UPDATE
app.patch("/variable-categories/:id", async (req, reply) => {
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 pid = IdParam.safeParse(req.params);
const patch = PatchCat.safeParse(req.body);
if (!pid.success) return reply.status(400).send({ error: "INVALID_ID", details: pid.error.flatten() });
if (!patch.success) return reply.status(400).send({ error: "INVALID_BODY", details: patch.error.flatten() });
const exists = await prisma.variableCategory.findFirst({ where: { id: pid.data.id, userId } });
if (!exists) return reply.status(404).send({ error: "NOT_FOUND" });
const userTimezone =
(
await app.prisma.user.findUnique({
where: { id: userId },
select: { timezone: true },
})
)?.timezone ?? "America/New_York";
const updateData = {
...patch.data,
...(patch.data.name !== undefined ? { name: patch.data.name.trim().toLowerCase() } : {}),
};
const updated = await prisma.variableCategory.updateMany({
where: { id: pid.data.id, userId },
data: updateData,
void userTimezone;
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 };
});
if (updated.count === 0) return reply.status(404).send({ error: "NOT_FOUND" });
return reply.send({ ok: true });
});
// DELETE
app.delete("/variable-categories/:id", async (req, reply) => {
app.delete("/variable-categories/:id", opts.mutationRateLimit, async (req, reply) => {
const id = String((req.params as any).id);
const userId = req.userId;
const pid = IdParam.safeParse(req.params);
if (!pid.success) return reply.status(400).send({ error: "INVALID_ID", details: pid.error.flatten() });
const exists = await prisma.variableCategory.findFirst({ where: { id: pid.data.id, userId } });
if (!exists) return reply.status(404).send({ error: "NOT_FOUND" });
const deleted = await prisma.variableCategory.deleteMany({
where: { id: pid.data.id, userId },
const exists = await app.prisma.variableCategory.findFirst({
where: { id, userId },
});
if (deleted.count === 0) return reply.status(404).send({ error: "NOT_FOUND" });
if (!exists) return reply.code(404).send({ message: "Not found" });
return reply.send({ ok: true });
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 };
});
// REBALANCE balances based on current percents
app.post("/variable-categories/rebalance", async (req, reply) => {
app.post("/variable-categories/rebalance", opts.mutationRateLimit, async (req, reply) => {
const userId = req.userId;
const categories = await prisma.variableCategory.findMany({
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 reply.send({ ok: true, applied: false });
return { ok: true, applied: false };
}
const hasNegative = categories.some(
(c) => Number(c.balanceCents ?? 0n) < 0
);
const hasNegative = categories.some((c) => Number(c.balanceCents ?? 0n) < 0);
if (hasNegative) {
return reply.code(400).send({
ok: false,
@@ -170,16 +237,13 @@ const plugin: FastifyPluginAsync = async (app) => {
});
}
const totalBalance = categories.reduce(
(sum, c) => sum + Number(c.balanceCents ?? 0n),
0
);
const totalBalance = categories.reduce((sum, c) => sum + Number(c.balanceCents ?? 0n), 0);
if (totalBalance <= 0) {
return reply.send({ ok: true, applied: false });
return { ok: true, applied: false };
}
const targetResult = computeBalanceTargets(categories, totalBalance);
if (!targetResult.ok) {
const shareResult = opts.computeDepositShares(categories, totalBalance);
if (!shareResult.ok) {
return reply.code(400).send({
ok: false,
code: "NO_PERCENT",
@@ -187,69 +251,60 @@ const plugin: FastifyPluginAsync = async (app) => {
});
}
await prisma.$transaction(
targetResult.targets.map((t) =>
prisma.variableCategory.update({
where: { id: t.id },
data: { balanceCents: BigInt(t.target) },
await app.prisma.$transaction(
shareResult.shares.map((s) =>
app.prisma.variableCategory.update({
where: { id: s.id },
data: { balanceCents: BigInt(s.share) },
})
)
);
return reply.send({ ok: true, applied: true, totalBalance });
return { ok: true, applied: true, totalBalance };
});
// MANUAL REBALANCE: set explicit dollar targets for variable balances
app.get("/variable-categories/manual-rebalance", async (req, reply) => {
const userId = req.userId;
const session = await getLatestBudgetSession(userId);
if (!session) {
return reply.code(400).send({ ok: false, code: "NO_BUDGET_SESSION" });
}
const cats = await prisma.variableCategory.findMany({
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, userId, totalBalance);
return reply.send({
ok: true,
availableCents: Number(session.availableCents ?? 0n),
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 body = ManualRebalanceBody.safeParse(req.body);
if (!body.success || body.data.targets.length === 0) {
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 session = await getLatestBudgetSession(userId);
if (!session) {
return reply.code(400).send({ ok: false, code: "NO_BUDGET_SESSION", message: "No active budget session found." });
}
const availableCents = Number(session.availableCents ?? 0n);
const cats = await prisma.variableCategory.findMany({
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" });
}
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, userId, availableCents);
const targetMap = new Map<string, number>();
for (const t of body.data.targets) {
if (targetMap.has(t.id)) {
return reply.code(400).send({ ok: false, code: "DUPLICATE_ID" });
}
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", message: "Targets must include every category." });
return reply.code(400).send({ ok: false, code: "MISSING_CATEGORY" });
}
const targets = cats.map((c) => ({
@@ -261,43 +316,39 @@ const plugin: FastifyPluginAsync = async (app) => {
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);
if (availableCents > 0 && targets.some((t) => t.target > maxAllowed)) {
return reply.code(400).send({ ok: false, code: "OVER_80_PERCENT", message: "No category can exceed 80% of available." });
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 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) && !body.data.forceLowerSavings) {
return reply.code(400).send({
ok: false,
code: "SAVINGS_FLOOR",
message: "Lowering savings requires confirmation.",
});
if ((loweringSavings || belowFloor) && !parsed.data.forceLowerSavings) {
return reply
.code(400)
.send({ ok: false, code: "SAVINGS_FLOOR", message: "Lowering savings requires confirmation." });
}
await prisma.$transaction(async (tx) => {
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,
@@ -323,4 +374,4 @@ const plugin: FastifyPluginAsync = async (app) => {
});
};
export default plugin;
export default variableCategoriesRoutes;

View File

@@ -15,6 +15,7 @@ import healthRoutes from "./routes/health.js";
import sessionRoutes from "./routes/session.js";
import userRoutes from "./routes/user.js";
import authAccountRoutes from "./routes/auth-account.js";
import variableCategoriesRoutes from "./routes/variable-categories.js";
export type AppConfig = typeof env;
@@ -930,6 +931,10 @@ await app.register(authAccountRoutes, {
generatePasswordResetToken,
ensureCsrfCookie,
});
await app.register(variableCategoriesRoutes, {
mutationRateLimit,
computeDepositShares,
});
app.get("/site-access/status", async (req) => {
if (!config.UNDER_CONSTRUCTION_ENABLED) {
@@ -2722,335 +2727,6 @@ app.delete("/transactions/:id", mutationRateLimit, async (req, reply) => {
});
});
// ----- Variable categories -----
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;
}
}
async function getLatestBudgetSession(app: any, userId: string) {
return app.prisma.budgetSession.findFirst({
where: { userId },
orderBy: { periodStart: "desc" },
});
}
async function ensureBudgetSession(app: any, userId: string, fallbackAvailableCents = 0) {
const existing = await getLatestBudgetSession(app, userId);
if (existing) return existing;
const now = new Date();
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1, 0, 0, 0, 0));
const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1, 0, 0, 0, 0));
return app.prisma.budgetSession.create({
data: {
userId,
periodStart: start,
periodEnd: end,
totalBudgetCents: BigInt(Math.max(0, fallbackAvailableCents)),
allocatedCents: 0n,
fundedCents: 0n,
availableCents: BigInt(Math.max(0, fallbackAvailableCents)),
},
});
}
async function ensureBudgetSessionAvailableSynced(
app: any,
userId: string,
availableCents: number
) {
const normalizedAvailableCents = BigInt(Math.max(0, Math.trunc(availableCents)));
const session = await ensureBudgetSession(app, userId, Number(normalizedAvailableCents));
if ((session.availableCents ?? 0n) === normalizedAvailableCents) return session;
return app.prisma.budgetSession.update({
where: { id: session.id },
data: { availableCents: normalizedAvailableCents },
});
}
app.post("/variable-categories", 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 userTimezone =
(await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ??
"America/New_York";
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", 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 userTimezone =
(await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ??
"America/New_York";
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", 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", 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 = 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, 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, 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 })),
});
});
// ----- Fixed plans -----
const PlanBody = z.object({
name: z.string().trim().min(1),