// tests/allocator.test.ts import { describe, it, expect, beforeEach, afterAll } from "vitest"; import { prisma, resetUser, ensureUser, U, cid, pid, closePrisma } from "./helpers"; import { allocateIncome, buildPlanStates } from "../src/allocator"; describe("allocator — new funding system", () => { beforeEach(async () => { await resetUser(U); await ensureUser(U); // Update user with income frequency await prisma.user.update({ where: { id: U }, data: { incomeFrequency: "biweekly", }, }); }); afterAll(async () => { await closePrisma(); }); describe("buildPlanStates", () => { it("calculates funding needs based on strategy and time remaining", async () => { const p1 = pid("rent"); await prisma.fixedPlan.create({ data: { id: p1, userId: U, name: "Rent", cycleStart: new Date(Date.now() - 86400000).toISOString(), // started yesterday dueOn: new Date(Date.now() + 14 * 86400000).toISOString(), // due in 2 weeks totalCents: 100000n, // $1000 fundedCents: 0n, currentFundedCents: 0n, lastFundingDate: null, priority: 1, fundingMode: "auto-on-deposit", }, }); const user = await prisma.user.findUniqueOrThrow({ where: { id: U } }); const plans = await prisma.fixedPlan.findMany({ where: { userId: U } }); const states = buildPlanStates(plans, { incomeFrequency: user.incomeFrequency! }, new Date()); expect(states).toHaveLength(1); const rentState = states[0]; expect(rentState.id).toBe(p1); expect(rentState.desiredThisIncome).toBeGreaterThan(0); expect(rentState.desiredThisIncome).toBeLessThanOrEqual(100000); expect(rentState.remainingCents).toBeGreaterThan(0); expect(rentState.remainingCents).toBeLessThanOrEqual(100000); }); it("detects crisis mode when payment is due soon", async () => { const p1 = pid("urgent"); await prisma.fixedPlan.create({ data: { id: p1, userId: U, name: "Urgent Payment", cycleStart: new Date(Date.now() - 86400000).toISOString(), dueOn: new Date(Date.now() + 5 * 86400000).toISOString(), // due in 5 days totalCents: 50000n, // $500 fundedCents: 0n, currentFundedCents: 10000n, // only $100 funded lastFundingDate: null, priority: 1, fundingMode: "auto-on-deposit", }, }); const user = await prisma.user.findUniqueOrThrow({ where: { id: U } }); const plans = await prisma.fixedPlan.findMany({ where: { userId: U } }); const states = buildPlanStates(plans, { incomeFrequency: user.incomeFrequency! }, new Date()); const urgentState = states[0]; expect(urgentState.isCrisis).toBe(true); }); }); describe("allocateIncome", () => { it("distributes income with new percentage-based funding", async () => { const c1 = cid("c1"); const c2 = cid("c2"); await prisma.variableCategory.createMany({ data: [ { id: c1, userId: U, name: "Groceries", percent: 60, priority: 2, isSavings: false, balanceCents: 0n }, { id: c2, userId: U, name: "Saver", percent: 40, priority: 1, isSavings: true, balanceCents: 0n }, ], }); const p1 = pid("rent"); await prisma.fixedPlan.create({ data: { id: p1, userId: U, name: "Rent", cycleStart: new Date(Date.now() - 86400000).toISOString(), dueOn: new Date(Date.now() + 14 * 86400000).toISOString(), totalCents: 30000n, // $300 - much smaller so there's leftover fundedCents: 0n, currentFundedCents: 0n, lastFundingDate: null, priority: 1, fundingMode: "auto-on-deposit", }, }); // Allocate $1000 income (much more than needed for small rent) const result = await allocateIncome(prisma as any, U, 100000, new Date().toISOString(), "inc1"); expect(result).toBeDefined(); const fixed = result.fixedAllocations ?? []; const variable = result.variableAllocations ?? []; console.log('DEBUG: result =', JSON.stringify(result, null, 2)); expect(Array.isArray(fixed)).toBe(true); expect(Array.isArray(variable)).toBe(true); // Should have allocations for both fixed and variable expect(fixed.length).toBeGreaterThan(0); expect(variable.length).toBeGreaterThan(0); // Fixed allocation should be based on desired amount, not full income const rentAllocation = fixed.find(f => f.fixedPlanId === p1); expect(rentAllocation).toBeDefined(); expect(rentAllocation!.amountCents).toBeGreaterThan(0); expect(rentAllocation!.amountCents).toBeLessThanOrEqual(30000); // Should not exceed plan's desired amount }); it("handles crisis mode by prioritizing underfunded expenses", async () => { const c1 = cid("c1"); await prisma.variableCategory.create({ data: { id: c1, userId: U, name: "Groceries", percent: 100, priority: 1, isSavings: false, balanceCents: 0n }, }); const p1 = pid("urgent"); const p2 = pid("normal"); await prisma.fixedPlan.createMany({ data: [ { id: p1, userId: U, name: "Urgent Payment", cycleStart: new Date(Date.now() - 86400000).toISOString(), dueOn: new Date(Date.now() + 5 * 86400000).toISOString(), // due soon (crisis) totalCents: 50000n, fundedCents: 0n, currentFundedCents: 10000n, // underfunded lastFundingDate: null, priority: 1, fundingMode: "auto-on-deposit", }, { id: p2, userId: U, name: "Normal Payment", cycleStart: new Date(Date.now() - 86400000).toISOString(), dueOn: new Date(Date.now() + 20 * 86400000).toISOString(), // due later totalCents: 30000n, fundedCents: 0n, currentFundedCents: 0n, lastFundingDate: null, priority: 2, fundingMode: "auto-on-deposit", }, ], }); // Small income that should prioritize crisis const result = await allocateIncome(prisma as any, U, 20000, new Date().toISOString(), "inc1"); const fixed = result.fixedAllocations ?? []; const urgentAllocation = fixed.find(f => f.fixedPlanId === p1); const normalAllocation = fixed.find(f => f.fixedPlanId === p2); // Urgent should get more funding due to crisis mode expect(urgentAllocation).toBeDefined(); if (normalAllocation) { expect(urgentAllocation!.amountCents).toBeGreaterThan(normalAllocation.amountCents); } }); it("updates currentFundedCents correctly", async () => { const p1 = pid("rent"); await prisma.fixedPlan.create({ data: { id: p1, userId: U, name: "Rent", cycleStart: new Date(Date.now() - 86400000).toISOString(), dueOn: new Date(Date.now() + 14 * 86400000).toISOString(), totalCents: 100000n, fundedCents: 0n, currentFundedCents: 20000n, // already $200 funded lastFundingDate: null, priority: 1, fundingMode: "auto-on-deposit", }, }); await allocateIncome(prisma as any, U, 30000, new Date().toISOString(), "inc1"); const updatedPlan = await prisma.fixedPlan.findUniqueOrThrow({ where: { id: p1 } }); expect(updatedPlan.currentFundedCents).toBeGreaterThan(20000n); // Should have increased expect(updatedPlan.lastFundingDate).not.toBeNull(); // Should be updated }); it("handles zeros and empty allocation", async () => { const cOnly = cid("only"); await prisma.variableCategory.create({ data: { id: cOnly, userId: U, name: "Only", percent: 100, priority: 1, isSavings: false, balanceCents: 0n }, }); const result = await allocateIncome(prisma as any, U, 0, new Date().toISOString(), "inc2"); expect(result).toBeDefined(); const variable = result.variableAllocations ?? []; const sum = variable.reduce((s, a: any) => s + (a.amountCents ?? 0), 0); expect(sum).toBe(0); }); }); });