added api logic, vitest, minimal testing ui

This commit is contained in:
2025-11-15 23:26:57 -06:00
parent f4160b91db
commit 4eae966f96
95 changed files with 14155 additions and 469 deletions

154
api/src/allocator.ts Normal file
View File

@@ -0,0 +1,154 @@
import type { PrismaClient } from "@prisma/client";
/**
* Allocate income across fixed plans (need-first) and variable categories (largest remainder).
*
* @param db Prisma client (or tx)
* @param userId string
* @param amountCents number (>= 0)
* @param postedAtISO string ISO timestamp for the income event
* @param incomeId string id to use for IncomeEvent + Allocation FK
*/
export async function allocateIncome(
db: PrismaClient,
userId: string,
amountCents: number,
postedAtISO: string,
incomeId: string
): Promise<{
fixedAllocations: Array<{ fixedPlanId: string; amountCents: number }>;
variableAllocations: Array<{ variableCategoryId: string; amountCents: number }>;
remainingUnallocatedCents: number;
}> {
const amt = Math.max(0, Math.floor(amountCents | 0));
return await db.$transaction(async (tx) => {
// 1) Ensure the IncomeEvent exists to satisfy FK on Allocation
await tx.incomeEvent.upsert({
where: { id: incomeId },
update: {}, // idempotent in case route created it already
create: {
id: incomeId,
userId,
postedAt: new Date(postedAtISO),
amountCents: BigInt(amt),
},
});
// 2) Load current fixed plans + variable categories
const [plans, cats] = await Promise.all([
tx.fixedPlan.findMany({
where: { userId },
orderBy: [{ priority: "asc" }, { dueOn: "asc" }],
select: {
id: true,
totalCents: true,
fundedCents: true,
priority: true,
dueOn: true,
},
}),
tx.variableCategory.findMany({
where: { userId },
orderBy: [{ priority: "asc" }, { name: "asc" }],
select: { id: true, name: true, percent: true, isSavings: true, priority: true },
}),
]);
let remaining = amt;
// 3) Fixed pass: fund by priority then due date up to need
const fixedAllocations: Array<{ fixedPlanId: string; amountCents: number }> = [];
for (const p of plans) {
if (remaining <= 0) break;
const needBig = (p.totalCents ?? 0n) - (p.fundedCents ?? 0n);
const need = Number(needBig > 0n ? needBig : 0n);
if (need <= 0) continue;
const give = Math.min(need, remaining);
if (give > 0) {
// apply fundedCents
await tx.fixedPlan.update({
where: { id: p.id },
data: { fundedCents: (p.fundedCents ?? 0n) + BigInt(give) },
});
// audit allocation row
await tx.allocation.create({
data: {
userId,
kind: "fixed",
toId: p.id,
amountCents: BigInt(give),
incomeId, // FK now valid
},
});
fixedAllocations.push({ fixedPlanId: p.id, amountCents: give });
remaining -= give;
}
}
// 4) Variable pass: largest remainder w/ savings-first tiebreak
const variableAllocations: Array<{ variableCategoryId: string; amountCents: number }> = [];
if (remaining > 0 && cats.length > 0) {
const totalPercent = cats.reduce((s, c) => s + (c.percent || 0), 0);
const norm = totalPercent === 100
? cats
: cats.map(c => ({
...c,
percent: totalPercent > 0 ? (c.percent * 100) / totalPercent : 0,
}));
const base = new Array(norm.length).fill(0);
const tie = [] as Array<{ idx: number; remainder: number; isSavings: boolean; priority: number; name: string }>;
let sumBase = 0;
norm.forEach((c, idx) => {
const exact = (remaining * (c.percent || 0)) / 100;
const floor = Math.floor(exact);
base[idx] = floor;
sumBase += floor;
tie.push({ idx, remainder: exact - floor, isSavings: !!c.isSavings, priority: c.priority, name: c.name });
});
let leftovers = remaining - sumBase;
tie.sort((a, b) => {
if (a.isSavings !== b.isSavings) return a.isSavings ? -1 : 1; // savings first
if (a.remainder !== b.remainder) return b.remainder - a.remainder;
if (a.priority !== b.priority) return a.priority - b.priority;
return a.name.localeCompare(b.name);
});
for (let i = 0; i < tie.length && leftovers > 0; i++, leftovers--) base[tie[i].idx]++;
for (let i = 0; i < norm.length; i++) {
const give = base[i] || 0;
if (give > 0) {
const c = norm[i];
await tx.variableCategory.update({
where: { id: c.id },
data: { balanceCents: { increment: BigInt(give) } },
});
await tx.allocation.create({
data: {
userId,
kind: "variable",
toId: c.id,
amountCents: BigInt(give),
incomeId,
},
});
variableAllocations.push({ variableCategoryId: c.id, amountCents: give });
}
}
remaining = leftovers;
}
return {
fixedAllocations,
variableAllocations,
remainingUnallocatedCents: Math.max(0, remaining),
};
});
}