From 4a63309153afbeeedbbf49d884da476b581165cb Mon Sep 17 00:00:00 2001 From: Ricearoni1245 Date: Mon, 16 Mar 2026 15:20:12 -0500 Subject: [PATCH] phase 3: all variable cateogry references simplified --- .env.localdev | 4 +- api/src/routes/variable-categories.ts | 377 ++++++++++-------- api/src/server.ts | 334 +--------------- docs/api-phase3-move-log.md | 65 +++ web/src/pages/settings/CategoriesSettings.tsx | 46 ++- 5 files changed, 319 insertions(+), 507 deletions(-) create mode 100644 docs/api-phase3-move-log.md diff --git a/.env.localdev b/.env.localdev index 6bc959c..669409c 100644 --- a/.env.localdev +++ b/.env.localdev @@ -23,10 +23,10 @@ COOKIE_SECRET= PYjozZs+CxkU+In/FX/EI/5SB5ETAEw2AzCAF+G4Zgc= # COOKIE_DOMAIN= AUTH_DISABLED=false ALLOW_INSECURE_AUTH_FOR_DEV=false -SEED_DEFAULT_BUDGET=true +SEED_DEFAULT_BUDGET=false BREAK_GLASS_VERIFY_ENABLED=true BREAK_GLASS_VERIFY_CODE=cbbb87737a056bc01af63a9fedcf11baf3585aafc26e32bdd9a132103fa955e0i9qwU+KY_3PAaiRZi9qwU+KY_3PAaiRZ EMAIL_VERIFY_DEV_CODE_EXPOSE=true UNDER_CONSTRUCTION_ENABLED=false - +NODE_ENV=development \ No newline at end of file diff --git a/api/src/routes/variable-categories.ts b/api/src/routes/variable-categories.ts index a73fb1b..a9686c5 100644 --- a/api/src/routes/variable-categories.ts +++ b/api/src/routes/variable-categories.ts @@ -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 = 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(); - 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; diff --git a/api/src/server.ts b/api/src/server.ts index e8d3cee..7799fde 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -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(); - 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), diff --git a/docs/api-phase3-move-log.md b/docs/api-phase3-move-log.md new file mode 100644 index 0000000..6b1cb8f --- /dev/null +++ b/docs/api-phase3-move-log.md @@ -0,0 +1,65 @@ +# API Phase 3 Move Log + +Date: 2026-03-16 +Scope: Move `variable-categories` endpoints out of `api/src/server.ts` into a dedicated route module. + +## Route Registration Changes +- Added variable-categories route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:18) +- Registered variable-categories routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:934) +- New canonical route module: [variable-categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/variable-categories.ts:125) +- Removed inline variable-categories route block from `server.ts` to avoid duplicate endpoint registration. + +## Endpoint Movements + +1. `POST /variable-categories` +- Moved to [variable-categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/variable-categories.ts:129) +- References: + - [categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/categories.ts:12) + - [variable-categories.guard.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/variable-categories.guard.test.ts:38) + +2. `PATCH /variable-categories/:id` +- Moved to [variable-categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/variable-categories.ts:165) +- References: + - [categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/categories.ts:13) + - [variable-categories.guard.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/variable-categories.guard.test.ts:49) + +3. `DELETE /variable-categories/:id` +- Moved to [variable-categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/variable-categories.ts:202) +- References: + - [categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/categories.ts:14) + - [CategoriesSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/CategoriesSettings.tsx:360) + +4. `POST /variable-categories/rebalance` +- Moved to [variable-categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/variable-categories.ts:219) +- References: + - [categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/categories.ts:15) + - [CategoriesSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/CategoriesSettings.tsx:367) + +5. `GET /variable-categories/manual-rebalance` +- Moved to [variable-categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/variable-categories.ts:266) +- References: + - [rebalance.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/rebalance.ts:24) + - [variable-categories.manual-rebalance.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/variable-categories.manual-rebalance.test.ts:54) + +6. `POST /variable-categories/manual-rebalance` +- Moved to [variable-categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/variable-categories.ts:283) +- References: + - [rebalance.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/rebalance.ts:25) + - [RebalancePage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/RebalancePage.tsx:148) + - [variable-categories.manual-rebalance.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/variable-categories.manual-rebalance.test.ts:63) + +## Helper Ownership in Phase 3 +- Kept variable-category-specific helpers local to the route module: + - `assertPercentTotal` + - `getLatestBudgetSession` + - `ensureBudgetSession` + - `ensureBudgetSessionAvailableSynced` +- Reused shared server helper by injection: + - `computeDepositShares` injected from `server.ts` registration. +- Reused shared mutation throttling by injection: + - `mutationRateLimit` injected from `server.ts` registration. + +## Follow-ups To Revisit +1. Consolidate budget-session sync helpers into a shared service (`api/src/services/budget-session.ts`) once Phase 4 starts. +2. Standardize response error envelopes for variable-category routes (`message` vs `ok/code/message`) to reduce client branching. +3. Recheck `variable-categories.manual-rebalance.test.ts` over-80 error-code expectation versus current confirm-style behavior to keep tests aligned with product policy. diff --git a/web/src/pages/settings/CategoriesSettings.tsx b/web/src/pages/settings/CategoriesSettings.tsx index eec0d3d..77a5575 100644 --- a/web/src/pages/settings/CategoriesSettings.tsx +++ b/web/src/pages/settings/CategoriesSettings.tsx @@ -333,19 +333,12 @@ function CategoriesSettingsInner( await categoriesApi.delete(cat.id); } - // Creates - const toCreate = normalizedCats.filter((c) => c._isNew && !c._isDeleted); - for (const cat of toCreate) { - await categoriesApi.create({ - name: normalizeName(cat.name), - percent: cat.percent, - priority: cat.priority, - isSavings: cat.isSavings, - }); - } - - // Updates const toUpdate = normalizedCats.filter((c) => !c._isNew && !c._isDeleted); + const updateOps: Array<{ + id: string; + patch: Partial; + percentDelta: number; + }> = []; for (const local of toUpdate) { const server = serverCats.find((s) => s.id === local.id); if (!server) continue; @@ -358,10 +351,37 @@ function CategoriesSettingsInner( patch.isSavings = local.isSavings; if (Object.keys(patch).length > 0) { - await categoriesApi.update(local.id, patch); + updateOps.push({ + id: local.id, + patch, + // Apply percent decreases first to avoid temporary >100 totals on server. + percentDelta: local.percent - server.percent, + }); } } + const preCreateUpdates = updateOps.filter((op) => op.percentDelta < 0); + const postCreateUpdates = updateOps.filter((op) => op.percentDelta >= 0); + + for (const op of preCreateUpdates) { + await categoriesApi.update(op.id, op.patch); + } + + // Creates + const toCreate = normalizedCats.filter((c) => c._isNew && !c._isDeleted); + for (const cat of toCreate) { + await categoriesApi.create({ + name: normalizeName(cat.name), + percent: cat.percent, + priority: cat.priority, + isSavings: cat.isSavings, + }); + } + + for (const op of postCreateUpdates) { + await categoriesApi.update(op.id, op.patch); + } + if (hasNew) { try { await categoriesApi.rebalance();