added api logic, vitest, minimal testing ui
This commit is contained in:
154
api/src/allocator.ts
Normal file
154
api/src/allocator.ts
Normal 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),
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user