final touches for beta skymoney (at least i think)

This commit is contained in:
2026-01-18 00:00:44 -06:00
parent 4eae966f96
commit f4f0ae5df2
161 changed files with 26016 additions and 1966 deletions

View File

@@ -13,6 +13,39 @@ const NewCat = z.object({
const PatchCat = NewCat.partial();
const IdParam = z.object({ id: z.string().min(1) });
function computeBalanceTargets(
categories: Array<{ id: string; percent: number }>,
totalBalance: number
) {
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"],
@@ -20,66 +53,135 @@ async function assertPercentTotal100(tx: Prisma.TransactionClient, userId: strin
_sum: { percent: true },
});
const sum = g[0]?._sum.percent ?? 0;
if (sum !== 100) {
const err: any = new Error(`Percents must sum to 100 (got ${sum}).`);
// 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;
err.statusCode = 400;
err.code = "PERCENT_TOTAL_NOT_100";
err.code = "PERCENT_TOTAL_OVER_100";
throw err;
}
// For now, allow partial completion during onboarding
// The frontend will ensure 100% total before finishing onboarding
}
const plugin: FastifyPluginAsync = async (app) => {
// CREATE
app.post("/api/variable-categories", async (req, reply) => {
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() });
const created = await prisma.$transaction(async (tx) => {
const rec = await tx.variableCategory.create({
data: { ...body.data, userId },
const normalizedName = body.data.name.trim().toLowerCase();
try {
const result = await prisma.variableCategory.create({
data: { ...body.data, userId, name: normalizedName },
select: { id: true },
});
await assertPercentTotal100(tx, userId);
return rec;
});
return reply.status(201).send(created);
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;
}
});
// UPDATE
app.patch("/api/variable-categories/:id", async (req, reply) => {
app.patch("/variable-categories/:id", async (req, reply) => {
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() });
await prisma.$transaction(async (tx) => {
const exists = await tx.variableCategory.findFirst({ where: { id: pid.data.id, userId } });
if (!exists) return reply.status(404).send({ error: "NOT_FOUND" });
await tx.variableCategory.update({ where: { id: pid.data.id }, data: patch.data });
await assertPercentTotal100(tx, userId);
const exists = await prisma.variableCategory.findFirst({ where: { id: pid.data.id, userId } });
if (!exists) return reply.status(404).send({ error: "NOT_FOUND" });
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,
});
if (updated.count === 0) return reply.status(404).send({ error: "NOT_FOUND" });
return reply.send({ ok: true });
});
// DELETE
app.delete("/api/variable-categories/:id", async (req, reply) => {
app.delete("/variable-categories/:id", async (req, reply) => {
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() });
await prisma.$transaction(async (tx) => {
const exists = await tx.variableCategory.findFirst({ where: { id: pid.data.id, userId } });
if (!exists) return reply.status(404).send({ error: "NOT_FOUND" });
await tx.variableCategory.delete({ where: { id: pid.data.id } });
await assertPercentTotal100(tx, userId);
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 },
});
if (deleted.count === 0) return reply.status(404).send({ error: "NOT_FOUND" });
return reply.send({ ok: true });
});
// REBALANCE balances based on current percents
app.post("/variable-categories/rebalance", async (req, reply) => {
const userId = req.userId;
const categories = await 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 });
}
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 reply.send({ ok: true, applied: false });
}
const targetResult = computeBalanceTargets(categories, totalBalance);
if (!targetResult.ok) {
return reply.code(400).send({
ok: false,
code: "NO_PERCENT",
message: "No percent totals available to rebalance.",
});
}
await prisma.$transaction(
targetResult.targets.map((t) =>
prisma.variableCategory.update({
where: { id: t.id },
data: { balanceCents: BigInt(t.target) },
})
)
);
return reply.send({ ok: true, applied: true, totalBalance });
});
};
export default plugin;
export default plugin;