phase 3: all variable cateogry references simplified
This commit is contained in:
@@ -23,10 +23,10 @@ COOKIE_SECRET= PYjozZs+CxkU+In/FX/EI/5SB5ETAEw2AzCAF+G4Zgc=
|
|||||||
# COOKIE_DOMAIN=
|
# COOKIE_DOMAIN=
|
||||||
AUTH_DISABLED=false
|
AUTH_DISABLED=false
|
||||||
ALLOW_INSECURE_AUTH_FOR_DEV=false
|
ALLOW_INSECURE_AUTH_FOR_DEV=false
|
||||||
SEED_DEFAULT_BUDGET=true
|
SEED_DEFAULT_BUDGET=false
|
||||||
|
|
||||||
BREAK_GLASS_VERIFY_ENABLED=true
|
BREAK_GLASS_VERIFY_ENABLED=true
|
||||||
BREAK_GLASS_VERIFY_CODE=cbbb87737a056bc01af63a9fedcf11baf3585aafc26e32bdd9a132103fa955e0i9qwU+KY_3PAaiRZi9qwU+KY_3PAaiRZ
|
BREAK_GLASS_VERIFY_CODE=cbbb87737a056bc01af63a9fedcf11baf3585aafc26e32bdd9a132103fa955e0i9qwU+KY_3PAaiRZi9qwU+KY_3PAaiRZ
|
||||||
EMAIL_VERIFY_DEV_CODE_EXPOSE=true
|
EMAIL_VERIFY_DEV_CODE_EXPOSE=true
|
||||||
UNDER_CONSTRUCTION_ENABLED=false
|
UNDER_CONSTRUCTION_ENABLED=false
|
||||||
|
NODE_ENV=development
|
||||||
@@ -1,17 +1,39 @@
|
|||||||
|
import type { FastifyPluginAsync, FastifyInstance } from "fastify";
|
||||||
import { FastifyPluginAsync } from "fastify";
|
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||||
import { Prisma } from "@prisma/client";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { prisma } from "../prisma.js";
|
|
||||||
|
|
||||||
const NewCat = z.object({
|
type RateLimitRouteOptions = {
|
||||||
name: z.string().min(1).max(100),
|
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),
|
percent: z.number().int().min(0).max(100),
|
||||||
isSavings: z.boolean().default(false),
|
isSavings: z.boolean(),
|
||||||
priority: z.number().int().min(0).max(10_000),
|
priority: z.number().int().min(0),
|
||||||
});
|
});
|
||||||
const PatchCat = NewCat.partial();
|
|
||||||
const IdParam = z.object({ id: z.string().min(1) });
|
|
||||||
const ManualRebalanceBody = z.object({
|
const ManualRebalanceBody = z.object({
|
||||||
targets: z.array(
|
targets: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -20,148 +42,193 @@ const ManualRebalanceBody = z.object({
|
|||||||
})
|
})
|
||||||
),
|
),
|
||||||
forceLowerSavings: z.boolean().optional(),
|
forceLowerSavings: z.boolean().optional(),
|
||||||
|
confirmOver80: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
function computeBalanceTargets(
|
async function assertPercentTotal(
|
||||||
categories: Array<{ id: string; percent: number }>,
|
tx: PrismaClient | Prisma.TransactionClient,
|
||||||
totalBalance: number
|
userId: string
|
||||||
) {
|
) {
|
||||||
const percentTotal = categories.reduce((sum, c) => sum + (c.percent || 0), 0);
|
const categories = await tx.variableCategory.findMany({
|
||||||
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"],
|
|
||||||
where: { userId },
|
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%
|
// Allow partial states during onboarding (< 100%), but enforce exact 100% when sum would be >= 100%
|
||||||
if (sum > 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.statusCode = 400;
|
||||||
err.code = "PERCENT_TOTAL_OVER_100";
|
err.code = "PERCENT_TOTAL_OVER_100";
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
if (sum >= 100 && savingsSum < 20) {
|
||||||
// For now, allow partial completion during onboarding
|
const err: any = new Error(
|
||||||
// The frontend will ensure 100% total before finishing onboarding
|
`Savings must total at least 20% (currently ${savingsSum}%)`
|
||||||
|
);
|
||||||
|
err.statusCode = 400;
|
||||||
|
err.code = "SAVINGS_MINIMUM";
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getLatestBudgetSession(userId: string) {
|
async function getLatestBudgetSession(app: FastifyInstance, userId: string) {
|
||||||
return prisma.budgetSession.findFirst({
|
return app.prisma.budgetSession.findFirst({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
orderBy: { periodStart: "desc" },
|
orderBy: { periodStart: "desc" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const plugin: FastifyPluginAsync = async (app) => {
|
async function ensureBudgetSession(
|
||||||
// CREATE
|
app: FastifyInstance,
|
||||||
app.post("/variable-categories", async (req, reply) => {
|
userId: string,
|
||||||
const userId = req.userId;
|
fallbackAvailableCents = 0
|
||||||
const body = NewCat.safeParse(req.body);
|
) {
|
||||||
if (!body.success) return reply.status(400).send({ error: "INVALID_BODY", details: body.error.flatten() });
|
const existing = await getLatestBudgetSession(app, userId);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
const normalizedName = body.data.name.trim().toLowerCase();
|
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 {
|
try {
|
||||||
const result = await prisma.variableCategory.create({
|
const created = await tx.variableCategory.create({
|
||||||
data: { ...body.data, userId, name: normalizedName },
|
data: { userId, balanceCents: 0n, ...parsed.data, name: normalizedName },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
return reply.status(201).send(result);
|
|
||||||
|
await assertPercentTotal(tx, userId);
|
||||||
|
return reply.status(201).send(created);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.code === 'P2002') {
|
if (error.code === "P2002") {
|
||||||
return reply.status(400).send({ error: 'DUPLICATE_NAME', message: `Category name '${body.data.name}' already exists` });
|
return reply
|
||||||
|
.status(400)
|
||||||
|
.send({ error: "DUPLICATE_NAME", message: `Category name '${parsed.data.name}' already exists` });
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// UPDATE
|
app.patch("/variable-categories/:id", opts.mutationRateLimit, async (req, reply) => {
|
||||||
app.patch("/variable-categories/:id", 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 userId = req.userId;
|
||||||
const pid = IdParam.safeParse(req.params);
|
const userTimezone =
|
||||||
const patch = PatchCat.safeParse(req.body);
|
(
|
||||||
if (!pid.success) return reply.status(400).send({ error: "INVALID_ID", details: pid.error.flatten() });
|
await app.prisma.user.findUnique({
|
||||||
if (!patch.success) return reply.status(400).send({ error: "INVALID_BODY", details: patch.error.flatten() });
|
where: { id: userId },
|
||||||
|
select: { timezone: true },
|
||||||
const exists = await prisma.variableCategory.findFirst({ where: { id: pid.data.id, userId } });
|
})
|
||||||
if (!exists) return reply.status(404).send({ error: "NOT_FOUND" });
|
)?.timezone ?? "America/New_York";
|
||||||
|
|
||||||
const updateData = {
|
const updateData = {
|
||||||
...patch.data,
|
...patch.data,
|
||||||
...(patch.data.name !== undefined ? { name: patch.data.name.trim().toLowerCase() } : {}),
|
...(patch.data.name !== undefined ? { name: patch.data.name.trim().toLowerCase() } : {}),
|
||||||
};
|
};
|
||||||
const updated = await prisma.variableCategory.updateMany({
|
void userTimezone;
|
||||||
where: { id: pid.data.id, userId },
|
|
||||||
|
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,
|
data: updateData,
|
||||||
});
|
});
|
||||||
if (updated.count === 0) return reply.status(404).send({ error: "NOT_FOUND" });
|
if (updated.count === 0) return reply.code(404).send({ message: "Not found" });
|
||||||
return reply.send({ ok: true });
|
|
||||||
|
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);
|
||||||
// DELETE
|
|
||||||
app.delete("/variable-categories/:id", async (req, reply) => {
|
|
||||||
const userId = req.userId;
|
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 } });
|
const exists = await app.prisma.variableCategory.findFirst({
|
||||||
if (!exists) return reply.status(404).send({ error: "NOT_FOUND" });
|
where: { id, userId },
|
||||||
|
|
||||||
const deleted = await prisma.variableCategory.deleteMany({
|
|
||||||
where: { id: pid.data.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", opts.mutationRateLimit, async (req, reply) => {
|
||||||
app.post("/variable-categories/rebalance", async (req, reply) => {
|
|
||||||
const userId = req.userId;
|
const userId = req.userId;
|
||||||
const categories = await prisma.variableCategory.findMany({
|
const categories = await app.prisma.variableCategory.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
select: { id: true, percent: true, balanceCents: true },
|
select: { id: true, percent: true, balanceCents: true },
|
||||||
orderBy: [{ priority: "asc" }, { name: "asc" }],
|
orderBy: [{ priority: "asc" }, { name: "asc" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (categories.length === 0) {
|
if (categories.length === 0) {
|
||||||
return reply.send({ ok: true, applied: false });
|
return { ok: true, applied: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasNegative = categories.some(
|
const hasNegative = categories.some((c) => Number(c.balanceCents ?? 0n) < 0);
|
||||||
(c) => Number(c.balanceCents ?? 0n) < 0
|
|
||||||
);
|
|
||||||
if (hasNegative) {
|
if (hasNegative) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -170,16 +237,13 @@ const plugin: FastifyPluginAsync = async (app) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalBalance = categories.reduce(
|
const totalBalance = categories.reduce((sum, c) => sum + Number(c.balanceCents ?? 0n), 0);
|
||||||
(sum, c) => sum + Number(c.balanceCents ?? 0n),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
if (totalBalance <= 0) {
|
if (totalBalance <= 0) {
|
||||||
return reply.send({ ok: true, applied: false });
|
return { ok: true, applied: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetResult = computeBalanceTargets(categories, totalBalance);
|
const shareResult = opts.computeDepositShares(categories, totalBalance);
|
||||||
if (!targetResult.ok) {
|
if (!shareResult.ok) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
ok: false,
|
ok: false,
|
||||||
code: "NO_PERCENT",
|
code: "NO_PERCENT",
|
||||||
@@ -187,69 +251,60 @@ const plugin: FastifyPluginAsync = async (app) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.$transaction(
|
await app.prisma.$transaction(
|
||||||
targetResult.targets.map((t) =>
|
shareResult.shares.map((s) =>
|
||||||
prisma.variableCategory.update({
|
app.prisma.variableCategory.update({
|
||||||
where: { id: t.id },
|
where: { id: s.id },
|
||||||
data: { balanceCents: BigInt(t.target) },
|
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) => {
|
app.get("/variable-categories/manual-rebalance", async (req, reply) => {
|
||||||
const userId = req.userId;
|
const userId = req.userId;
|
||||||
const session = await getLatestBudgetSession(userId);
|
const cats = await app.prisma.variableCategory.findMany({
|
||||||
if (!session) {
|
|
||||||
return reply.code(400).send({ ok: false, code: "NO_BUDGET_SESSION" });
|
|
||||||
}
|
|
||||||
const cats = await prisma.variableCategory.findMany({
|
|
||||||
where: { userId },
|
where: { userId },
|
||||||
orderBy: [{ priority: "asc" }, { name: "asc" }],
|
orderBy: [{ priority: "asc" }, { name: "asc" }],
|
||||||
select: { id: true, name: true, percent: true, isSavings: true, balanceCents: true },
|
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({
|
return reply.send({
|
||||||
ok: true,
|
ok: true,
|
||||||
availableCents: Number(session.availableCents ?? 0n),
|
availableCents: totalBalance,
|
||||||
categories: cats.map((c) => ({ ...c, balanceCents: Number(c.balanceCents ?? 0n) })),
|
categories: cats.map((c) => ({ ...c, balanceCents: Number(c.balanceCents ?? 0n) })),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/variable-categories/manual-rebalance", async (req, reply) => {
|
app.post("/variable-categories/manual-rebalance", async (req, reply) => {
|
||||||
const userId = req.userId;
|
const userId = req.userId;
|
||||||
const body = ManualRebalanceBody.safeParse(req.body);
|
const parsed = ManualRebalanceBody.safeParse(req.body);
|
||||||
if (!body.success || body.data.targets.length === 0) {
|
if (!parsed.success || parsed.data.targets.length === 0) {
|
||||||
return reply.code(400).send({ ok: false, code: "INVALID_BODY" });
|
return reply.code(400).send({ ok: false, code: "INVALID_BODY" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await getLatestBudgetSession(userId);
|
const cats = await app.prisma.variableCategory.findMany({
|
||||||
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({
|
|
||||||
where: { userId },
|
where: { userId },
|
||||||
orderBy: [{ priority: "asc" }, { name: "asc" }],
|
orderBy: [{ priority: "asc" }, { name: "asc" }],
|
||||||
select: { id: true, name: true, percent: true, isSavings: true, balanceCents: true },
|
select: { id: true, name: true, percent: true, isSavings: true, balanceCents: true },
|
||||||
});
|
});
|
||||||
if (cats.length === 0) {
|
if (cats.length === 0) return reply.code(400).send({ ok: false, code: "NO_CATEGORIES" });
|
||||||
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>();
|
const targetMap = new Map<string, number>();
|
||||||
for (const t of body.data.targets) {
|
for (const t of parsed.data.targets) {
|
||||||
if (targetMap.has(t.id)) {
|
if (targetMap.has(t.id)) return reply.code(400).send({ ok: false, code: "DUPLICATE_ID" });
|
||||||
return reply.code(400).send({ ok: false, code: "DUPLICATE_ID" });
|
|
||||||
}
|
|
||||||
targetMap.set(t.id, t.targetCents);
|
targetMap.set(t.id, t.targetCents);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetMap.size !== cats.length || cats.some((c) => !targetMap.has(c.id))) {
|
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) => ({
|
const targets = cats.map((c) => ({
|
||||||
@@ -261,43 +316,39 @@ const plugin: FastifyPluginAsync = async (app) => {
|
|||||||
if (targets.some((t) => t.target < 0)) {
|
if (targets.some((t) => t.target < 0)) {
|
||||||
return reply.code(400).send({ ok: false, code: "NEGATIVE_TARGET" });
|
return reply.code(400).send({ ok: false, code: "NEGATIVE_TARGET" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const sumTargets = targets.reduce((s, t) => s + t.target, 0);
|
const sumTargets = targets.reduce((s, t) => s + t.target, 0);
|
||||||
if (sumTargets !== availableCents) {
|
if (sumTargets !== availableCents) {
|
||||||
return reply
|
return reply
|
||||||
.code(400)
|
.code(400)
|
||||||
.send({ ok: false, code: "SUM_MISMATCH", message: `Targets must sum to available (${availableCents}).` });
|
.send({ ok: false, code: "SUM_MISMATCH", message: `Targets must sum to available (${availableCents}).` });
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxAllowed = Math.floor(availableCents * 0.8);
|
const maxAllowed = Math.floor(availableCents * 0.8);
|
||||||
if (availableCents > 0 && targets.some((t) => t.target > maxAllowed)) {
|
const over80 = 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." });
|
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 totalSavingsAfter = targets.filter((t) => t.isSavings).reduce((s, t) => s + t.target, 0);
|
||||||
|
|
||||||
const savingsFloor = Math.floor(availableCents * 0.2);
|
const savingsFloor = Math.floor(availableCents * 0.2);
|
||||||
const loweringSavings = totalSavingsAfter < totalSavingsBefore;
|
const loweringSavings = totalSavingsAfter < totalSavingsBefore;
|
||||||
const belowFloor = totalSavingsAfter < savingsFloor;
|
const belowFloor = totalSavingsAfter < savingsFloor;
|
||||||
if ((loweringSavings || belowFloor) && !body.data.forceLowerSavings) {
|
if ((loweringSavings || belowFloor) && !parsed.data.forceLowerSavings) {
|
||||||
return reply.code(400).send({
|
return reply
|
||||||
ok: false,
|
.code(400)
|
||||||
code: "SAVINGS_FLOOR",
|
.send({ ok: false, code: "SAVINGS_FLOOR", message: "Lowering savings requires confirmation." });
|
||||||
message: "Lowering savings requires confirmation.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await app.prisma.$transaction(async (tx) => {
|
||||||
for (const t of targets) {
|
for (const t of targets) {
|
||||||
await tx.variableCategory.update({
|
await tx.variableCategory.update({
|
||||||
where: { id: t.id },
|
where: { id: t.id },
|
||||||
data: { balanceCents: BigInt(t.target) },
|
data: { balanceCents: BigInt(t.target) },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await tx.transaction.create({
|
await tx.transaction.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
@@ -323,4 +374,4 @@ const plugin: FastifyPluginAsync = async (app) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export default plugin;
|
export default variableCategoriesRoutes;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import healthRoutes from "./routes/health.js";
|
|||||||
import sessionRoutes from "./routes/session.js";
|
import sessionRoutes from "./routes/session.js";
|
||||||
import userRoutes from "./routes/user.js";
|
import userRoutes from "./routes/user.js";
|
||||||
import authAccountRoutes from "./routes/auth-account.js";
|
import authAccountRoutes from "./routes/auth-account.js";
|
||||||
|
import variableCategoriesRoutes from "./routes/variable-categories.js";
|
||||||
|
|
||||||
export type AppConfig = typeof env;
|
export type AppConfig = typeof env;
|
||||||
|
|
||||||
@@ -930,6 +931,10 @@ await app.register(authAccountRoutes, {
|
|||||||
generatePasswordResetToken,
|
generatePasswordResetToken,
|
||||||
ensureCsrfCookie,
|
ensureCsrfCookie,
|
||||||
});
|
});
|
||||||
|
await app.register(variableCategoriesRoutes, {
|
||||||
|
mutationRateLimit,
|
||||||
|
computeDepositShares,
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/site-access/status", async (req) => {
|
app.get("/site-access/status", async (req) => {
|
||||||
if (!config.UNDER_CONSTRUCTION_ENABLED) {
|
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 -----
|
// ----- Fixed plans -----
|
||||||
const PlanBody = z.object({
|
const PlanBody = z.object({
|
||||||
name: z.string().trim().min(1),
|
name: z.string().trim().min(1),
|
||||||
|
|||||||
65
docs/api-phase3-move-log.md
Normal file
65
docs/api-phase3-move-log.md
Normal file
@@ -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.
|
||||||
@@ -333,19 +333,12 @@ function CategoriesSettingsInner(
|
|||||||
await categoriesApi.delete(cat.id);
|
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 toUpdate = normalizedCats.filter((c) => !c._isNew && !c._isDeleted);
|
||||||
|
const updateOps: Array<{
|
||||||
|
id: string;
|
||||||
|
patch: Partial<Row>;
|
||||||
|
percentDelta: number;
|
||||||
|
}> = [];
|
||||||
for (const local of toUpdate) {
|
for (const local of toUpdate) {
|
||||||
const server = serverCats.find((s) => s.id === local.id);
|
const server = serverCats.find((s) => s.id === local.id);
|
||||||
if (!server) continue;
|
if (!server) continue;
|
||||||
@@ -358,10 +351,37 @@ function CategoriesSettingsInner(
|
|||||||
patch.isSavings = local.isSavings;
|
patch.isSavings = local.isSavings;
|
||||||
|
|
||||||
if (Object.keys(patch).length > 0) {
|
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) {
|
if (hasNew) {
|
||||||
try {
|
try {
|
||||||
await categoriesApi.rebalance();
|
await categoriesApi.rebalance();
|
||||||
|
|||||||
Reference in New Issue
Block a user