final touches for beta skymoney (at least i think)
This commit is contained in:
@@ -1,65 +1,232 @@
|
||||
// tests/allocator.test.ts
|
||||
import { describe, it, expect, beforeEach, afterAll } from "vitest";
|
||||
import { prisma, resetUser, ensureUser, U, cid, pid, closePrisma } from "./helpers";
|
||||
import { allocateIncome } from "../src/allocator";
|
||||
import { allocateIncome, buildPlanStates } from "../src/allocator";
|
||||
|
||||
describe("allocator — core behaviors", () => {
|
||||
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();
|
||||
});
|
||||
|
||||
it("distributes remainder to variables by largest remainder with savings-first tie", async () => {
|
||||
const c1 = cid("c1");
|
||||
const c2 = cid("c2"); // make this savings to test the tiebreaker
|
||||
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 },
|
||||
],
|
||||
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);
|
||||
});
|
||||
|
||||
const p1 = pid("rent");
|
||||
await prisma.fixedPlan.create({
|
||||
data: {
|
||||
id: p1,
|
||||
userId: U,
|
||||
name: "Rent",
|
||||
cycleStart: new Date().toISOString(),
|
||||
dueOn: new Date(Date.now() + 7 * 864e5).toISOString(),
|
||||
totalCents: 10000n,
|
||||
fundedCents: 0n,
|
||||
priority: 1,
|
||||
fundingMode: "auto-on-deposit",
|
||||
},
|
||||
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);
|
||||
});
|
||||
|
||||
// $100 income
|
||||
const result = await allocateIncome(prisma as any, U, 10000, new Date().toISOString(), "inc1");
|
||||
expect(result).toBeDefined();
|
||||
// rent should be funded first up to need
|
||||
const fixed = result.fixedAllocations ?? [];
|
||||
const variable = result.variableAllocations ?? [];
|
||||
|
||||
// sanity
|
||||
expect(Array.isArray(fixed)).toBe(true);
|
||||
expect(Array.isArray(variable)).toBe(true);
|
||||
});
|
||||
|
||||
it("handles zeros and single bucket", async () => {
|
||||
const cOnly = cid("only");
|
||||
await prisma.variableCategory.create({
|
||||
data: { id: cOnly, userId: U, name: "Only", percent: 100, priority: 1, isSavings: false, balanceCents: 0n },
|
||||
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
|
||||
});
|
||||
|
||||
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);
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
83
api/tests/auth.routes.test.ts
Normal file
83
api/tests/auth.routes.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import request from "supertest";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { buildApp } from "../src/server";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp({ AUTH_DISABLED: false, SEED_DEFAULT_BUDGET: true });
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("Auth routes", () => {
|
||||
it("rejects protected routes without a session", async () => {
|
||||
const res = await request(app.server).get("/dashboard");
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.code).toBe("UNAUTHENTICATED");
|
||||
});
|
||||
|
||||
it("registers a user and grants access via cookie session", async () => {
|
||||
const agent = request.agent(app.server);
|
||||
const email = `reg-${Date.now()}@test.dev`;
|
||||
const password = "SupersAFE123!";
|
||||
|
||||
const register = await agent.post("/auth/register").send({ email, password });
|
||||
expect(register.status).toBe(200);
|
||||
|
||||
const dash = await agent.get("/dashboard");
|
||||
expect(dash.status).toBe(200);
|
||||
|
||||
const created = await prisma.user.findUniqueOrThrow({ where: { email } });
|
||||
const [catCount, planCount] = await Promise.all([
|
||||
prisma.variableCategory.count({ where: { userId: created.id } }),
|
||||
prisma.fixedPlan.count({ where: { userId: created.id } }),
|
||||
]);
|
||||
expect(catCount).toBeGreaterThan(0);
|
||||
expect(planCount).toBeGreaterThan(0);
|
||||
|
||||
await prisma.user.deleteMany({ where: { email } });
|
||||
});
|
||||
|
||||
it("logs in existing user and accesses dashboard", async () => {
|
||||
const agent = request.agent(app.server);
|
||||
const email = `login-${Date.now()}@test.dev`;
|
||||
const password = "SupersAFE123!";
|
||||
|
||||
await agent.post("/auth/register").send({ email, password });
|
||||
await agent.post("/auth/logout");
|
||||
|
||||
const login = await agent.post("/auth/login").send({ email, password });
|
||||
expect(login.status).toBe(200);
|
||||
|
||||
const dash = await agent.get("/dashboard");
|
||||
expect(dash.status).toBe(200);
|
||||
|
||||
await prisma.user.deleteMany({ where: { email } });
|
||||
});
|
||||
|
||||
it("reports session info and handles logout", async () => {
|
||||
const agent = request.agent(app.server);
|
||||
const email = `session-${Date.now()}@test.dev`;
|
||||
const password = "SupersAFE123!";
|
||||
|
||||
await agent.post("/auth/register").send({ email, password });
|
||||
|
||||
const session = await agent.get("/auth/session");
|
||||
expect(session.status).toBe(200);
|
||||
expect(session.body.userId).toBeDefined();
|
||||
|
||||
await agent.post("/auth/logout");
|
||||
const afterLogout = await agent.get("/dashboard");
|
||||
expect(afterLogout.status).toBe(401);
|
||||
|
||||
await prisma.user.deleteMany({ where: { email } });
|
||||
});
|
||||
});
|
||||
338
api/tests/auto-payments.test.ts
Normal file
338
api/tests/auto-payments.test.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { prisma, ensureUser, resetUser, U, closePrisma } from "./helpers";
|
||||
import { processAutoPayments, type PaymentSchedule } from "../src/jobs/auto-payments";
|
||||
|
||||
describe("processAutoPayments", () => {
|
||||
beforeEach(async () => {
|
||||
await resetUser(U);
|
||||
await ensureUser(U);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closePrisma();
|
||||
});
|
||||
|
||||
it("processes auto-payment for fully funded monthly plan", async () => {
|
||||
const paymentSchedule: PaymentSchedule = {
|
||||
frequency: "monthly",
|
||||
dayOfMonth: 1,
|
||||
minFundingPercent: 100,
|
||||
};
|
||||
|
||||
const plan = await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: U,
|
||||
name: "Rent Auto-Pay",
|
||||
totalCents: 120000n, // $1,200
|
||||
fundedCents: 120000n, // Fully funded
|
||||
currentFundedCents: 120000n, // Set current funding
|
||||
priority: 10,
|
||||
cycleStart: new Date("2025-01-01T00:00:00Z"),
|
||||
dueOn: new Date("2025-01-01T00:00:00Z"),
|
||||
periodDays: 30,
|
||||
autoPayEnabled: true,
|
||||
paymentSchedule,
|
||||
nextPaymentDate: new Date("2025-01-01T09:00:00Z"), // Payment due
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// Process payments as of payment date
|
||||
const reports = await processAutoPayments(prisma, "2025-01-01T10:00:00Z", { dryRun: false });
|
||||
|
||||
expect(reports).toHaveLength(1);
|
||||
expect(reports[0].success).toBe(true);
|
||||
expect(reports[0].paymentAmountCents).toBe(120000);
|
||||
expect(reports[0].planId).toBe(plan.id);
|
||||
|
||||
// Verify payment transaction was created
|
||||
const transaction = await prisma.transaction.findFirst({
|
||||
where: { planId: plan.id, kind: "fixed_payment" },
|
||||
});
|
||||
|
||||
expect(transaction).toBeTruthy();
|
||||
expect(Number(transaction?.amountCents)).toBe(120000);
|
||||
expect(transaction?.note).toBe("Auto-payment (monthly)");
|
||||
|
||||
// Verify plan funding was reduced
|
||||
const updatedPlan = await prisma.fixedPlan.findUniqueOrThrow({
|
||||
where: { id: plan.id },
|
||||
});
|
||||
|
||||
expect(Number(updatedPlan.fundedCents)).toBe(0);
|
||||
expect(updatedPlan.lastAutoPayment).toBeTruthy();
|
||||
expect(updatedPlan.nextPaymentDate).toBeTruthy();
|
||||
|
||||
// Next payment should be February 1st
|
||||
const nextPayment = updatedPlan.nextPaymentDate!;
|
||||
expect(nextPayment.getMonth()).toBe(1); // February (0-indexed)
|
||||
expect(nextPayment.getDate()).toBe(1);
|
||||
});
|
||||
|
||||
it("skips payment when funding is below minimum threshold", async () => {
|
||||
const paymentSchedule: PaymentSchedule = {
|
||||
frequency: "monthly",
|
||||
dayOfMonth: 1,
|
||||
minFundingPercent: 100,
|
||||
};
|
||||
|
||||
await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: U,
|
||||
name: "Under-funded Plan",
|
||||
totalCents: 100000n, // $1,000
|
||||
fundedCents: 50000n, // Only 50% funded
|
||||
currentFundedCents: 50000n, // Set current funding
|
||||
priority: 10,
|
||||
cycleStart: new Date("2025-01-01T00:00:00Z"),
|
||||
dueOn: new Date("2025-01-01T00:00:00Z"),
|
||||
periodDays: 30,
|
||||
autoPayEnabled: true,
|
||||
paymentSchedule,
|
||||
nextPaymentDate: new Date("2025-01-01T09:00:00Z"),
|
||||
},
|
||||
});
|
||||
|
||||
const reports = await processAutoPayments(prisma, "2025-01-01T10:00:00Z", { dryRun: false });
|
||||
|
||||
expect(reports).toHaveLength(1);
|
||||
expect(reports[0].success).toBe(false);
|
||||
expect(reports[0].error).toContain("Insufficient funding: 50.0% < 100%");
|
||||
expect(reports[0].paymentAmountCents).toBe(0);
|
||||
|
||||
// Verify no transaction was created
|
||||
const transaction = await prisma.transaction.findFirst({
|
||||
where: { kind: "fixed_payment" },
|
||||
});
|
||||
|
||||
expect(transaction).toBeNull();
|
||||
});
|
||||
|
||||
it("processes weekly auto-payment", async () => {
|
||||
const paymentSchedule: PaymentSchedule = {
|
||||
frequency: "weekly",
|
||||
dayOfWeek: 1, // Monday
|
||||
minFundingPercent: 50,
|
||||
};
|
||||
|
||||
const plan = await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: U,
|
||||
name: "Weekly Payment",
|
||||
totalCents: 50000n, // $500
|
||||
fundedCents: 30000n, // 60% funded (above 50% minimum)
|
||||
currentFundedCents: 30000n, // Set current funding
|
||||
priority: 10,
|
||||
cycleStart: new Date("2025-01-06T00:00:00Z"), // Monday
|
||||
dueOn: new Date("2025-01-06T00:00:00Z"),
|
||||
periodDays: 7,
|
||||
autoPayEnabled: true,
|
||||
paymentSchedule,
|
||||
nextPaymentDate: new Date("2025-01-06T09:00:00Z"), // Monday 9 AM
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const reports = await processAutoPayments(prisma, "2025-01-06T10:00:00Z", { dryRun: false });
|
||||
|
||||
expect(reports).toHaveLength(1);
|
||||
expect(reports[0].success).toBe(true);
|
||||
expect(reports[0].paymentAmountCents).toBe(30000); // Full funded amount
|
||||
|
||||
// Verify next payment is scheduled for next Monday
|
||||
const updatedPlan = await prisma.fixedPlan.findUniqueOrThrow({
|
||||
where: { id: plan.id },
|
||||
});
|
||||
|
||||
const nextPayment = updatedPlan.nextPaymentDate!;
|
||||
expect(nextPayment.getDay()).toBe(1); // Monday
|
||||
expect(nextPayment.getDate()).toBe(13); // Next Monday (Jan 13)
|
||||
});
|
||||
|
||||
it("processes daily auto-payment", async () => {
|
||||
const paymentSchedule: PaymentSchedule = {
|
||||
frequency: "daily",
|
||||
minFundingPercent: 25,
|
||||
};
|
||||
|
||||
await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: U,
|
||||
name: "Daily Payment",
|
||||
totalCents: 10000n, // $100
|
||||
fundedCents: 3000n, // 30% funded (above 25% minimum)
|
||||
currentFundedCents: 3000n, // Set current funding
|
||||
priority: 10,
|
||||
cycleStart: new Date("2025-01-01T00:00:00Z"),
|
||||
dueOn: new Date("2025-01-01T00:00:00Z"),
|
||||
periodDays: 1,
|
||||
autoPayEnabled: true,
|
||||
paymentSchedule,
|
||||
nextPaymentDate: new Date("2025-01-01T09:00:00Z"),
|
||||
},
|
||||
});
|
||||
|
||||
const reports = await processAutoPayments(prisma, "2025-01-01T10:00:00Z", { dryRun: false });
|
||||
|
||||
expect(reports).toHaveLength(1);
|
||||
expect(reports[0].success).toBe(true);
|
||||
expect(reports[0].paymentAmountCents).toBe(3000);
|
||||
});
|
||||
|
||||
it("handles multiple plans with different schedules", async () => {
|
||||
// Plan 1: Ready for payment
|
||||
await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: U,
|
||||
name: "Ready Plan",
|
||||
totalCents: 50000n,
|
||||
fundedCents: 50000n,
|
||||
currentFundedCents: 50000n,
|
||||
priority: 10,
|
||||
cycleStart: new Date("2025-01-01T00:00:00Z"),
|
||||
dueOn: new Date("2025-01-01T00:00:00Z"),
|
||||
periodDays: 30,
|
||||
autoPayEnabled: true,
|
||||
paymentSchedule: { frequency: "monthly", minFundingPercent: 100 },
|
||||
nextPaymentDate: new Date("2025-01-01T09:00:00Z"),
|
||||
},
|
||||
});
|
||||
|
||||
// Plan 2: Not ready (insufficient funding)
|
||||
await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: U,
|
||||
name: "Not Ready Plan",
|
||||
totalCents: 100000n,
|
||||
fundedCents: 30000n, // Only 30% funded
|
||||
currentFundedCents: 30000n,
|
||||
priority: 20,
|
||||
cycleStart: new Date("2025-01-01T00:00:00Z"),
|
||||
dueOn: new Date("2025-01-01T00:00:00Z"),
|
||||
periodDays: 30,
|
||||
autoPayEnabled: true,
|
||||
paymentSchedule: { frequency: "monthly", minFundingPercent: 100 },
|
||||
nextPaymentDate: new Date("2025-01-01T09:00:00Z"),
|
||||
},
|
||||
});
|
||||
|
||||
// Plan 3: Auto-pay disabled
|
||||
await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: U,
|
||||
name: "Disabled Plan",
|
||||
totalCents: 75000n,
|
||||
fundedCents: 75000n,
|
||||
currentFundedCents: 75000n,
|
||||
priority: 30,
|
||||
cycleStart: new Date("2025-01-01T00:00:00Z"),
|
||||
dueOn: new Date("2025-01-01T00:00:00Z"),
|
||||
periodDays: 30,
|
||||
autoPayEnabled: false, // Disabled
|
||||
nextPaymentDate: new Date("2025-01-01T09:00:00Z"),
|
||||
},
|
||||
});
|
||||
|
||||
const reports = await processAutoPayments(prisma, "2025-01-01T10:00:00Z", { dryRun: false });
|
||||
|
||||
// Should only process the first two plans (third is disabled)
|
||||
expect(reports).toHaveLength(2);
|
||||
|
||||
const successfulPayments = reports.filter(r => r.success);
|
||||
const failedPayments = reports.filter(r => !r.success);
|
||||
|
||||
expect(successfulPayments).toHaveLength(1);
|
||||
expect(failedPayments).toHaveLength(1);
|
||||
|
||||
expect(successfulPayments[0].name).toBe("Ready Plan");
|
||||
expect(failedPayments[0].name).toBe("Not Ready Plan");
|
||||
|
||||
// Verify only one transaction was created
|
||||
const transactions = await prisma.transaction.findMany({
|
||||
where: { kind: "fixed_payment" },
|
||||
});
|
||||
|
||||
expect(transactions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("handles dry run mode", async () => {
|
||||
await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: U,
|
||||
name: "Dry Run Plan",
|
||||
totalCents: 100000n,
|
||||
fundedCents: 100000n,
|
||||
currentFundedCents: 100000n,
|
||||
priority: 10,
|
||||
cycleStart: new Date("2025-01-01T00:00:00Z"),
|
||||
dueOn: new Date("2025-01-01T00:00:00Z"),
|
||||
periodDays: 30,
|
||||
autoPayEnabled: true,
|
||||
paymentSchedule: { frequency: "monthly", minFundingPercent: 100 },
|
||||
nextPaymentDate: new Date("2025-01-01T09:00:00Z"),
|
||||
},
|
||||
});
|
||||
|
||||
// Run in dry-run mode
|
||||
const reports = await processAutoPayments(prisma, "2025-01-01T10:00:00Z", { dryRun: true });
|
||||
|
||||
expect(reports).toHaveLength(1);
|
||||
expect(reports[0].success).toBe(true);
|
||||
expect(reports[0].paymentAmountCents).toBe(100000);
|
||||
|
||||
// Verify no transaction was created in dry-run mode
|
||||
const transaction = await prisma.transaction.findFirst({
|
||||
where: { kind: "fixed_payment" },
|
||||
});
|
||||
|
||||
expect(transaction).toBeNull();
|
||||
|
||||
// Verify plan was not modified in dry-run mode
|
||||
const plan = await prisma.fixedPlan.findFirst({
|
||||
where: { name: "Dry Run Plan" },
|
||||
});
|
||||
|
||||
expect(Number(plan?.fundedCents)).toBe(100000); // Still fully funded
|
||||
expect(plan?.lastAutoPayment).toBeNull(); // Not updated
|
||||
});
|
||||
|
||||
it("calculates next payment dates correctly for end-of-month scenarios", async () => {
|
||||
const paymentSchedule: PaymentSchedule = {
|
||||
frequency: "monthly",
|
||||
dayOfMonth: 31, // End of month
|
||||
minFundingPercent: 100,
|
||||
};
|
||||
|
||||
const plan = await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: U,
|
||||
name: "End of Month Plan",
|
||||
totalCents: 100000n,
|
||||
fundedCents: 100000n,
|
||||
currentFundedCents: 100000n,
|
||||
priority: 10,
|
||||
cycleStart: new Date("2025-01-31T00:00:00Z"), // January 31st
|
||||
dueOn: new Date("2025-01-31T00:00:00Z"),
|
||||
periodDays: 30,
|
||||
autoPayEnabled: true,
|
||||
paymentSchedule,
|
||||
nextPaymentDate: new Date("2025-01-31T09:00:00Z"),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const reports = await processAutoPayments(prisma, "2025-01-31T10:00:00Z", { dryRun: false });
|
||||
|
||||
expect(reports).toHaveLength(1);
|
||||
expect(reports[0].success).toBe(true);
|
||||
|
||||
// Verify next payment is February 28th (since Feb doesn't have 31 days)
|
||||
const updatedPlan = await prisma.fixedPlan.findUniqueOrThrow({
|
||||
where: { id: plan.id },
|
||||
});
|
||||
|
||||
const nextPayment = updatedPlan.nextPaymentDate!;
|
||||
expect(nextPayment.getMonth()).toBe(1); // February
|
||||
expect(nextPayment.getDate()).toBe(28); // February 28th (not 31st)
|
||||
});
|
||||
});
|
||||
132
api/tests/budget-allocation.test.ts
Normal file
132
api/tests/budget-allocation.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
// tests/budget-allocation.test.ts
|
||||
import { describe, it, expect, beforeEach, afterAll } from "vitest";
|
||||
import { prisma, resetUser, ensureUser, U, cid, pid, closePrisma } from "./helpers";
|
||||
import { allocateBudget } from "../src/allocator";
|
||||
|
||||
describe("Budget Allocation for Irregular Income", () => {
|
||||
beforeEach(async () => {
|
||||
await resetUser(U);
|
||||
await ensureUser(U);
|
||||
|
||||
// Update user to irregular income type
|
||||
await prisma.user.update({
|
||||
where: { id: U },
|
||||
data: {
|
||||
incomeType: "irregular",
|
||||
totalBudgetCents: 300000n, // $3000 monthly budget
|
||||
budgetPeriod: "monthly",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closePrisma();
|
||||
});
|
||||
|
||||
describe("allocateBudget", () => {
|
||||
it("allocates budget between fixed plans and variable categories", async () => {
|
||||
const c1 = cid("groceries");
|
||||
const c2 = cid("savings");
|
||||
await prisma.variableCategory.createMany({
|
||||
data: [
|
||||
{ id: c1, userId: U, name: "Groceries", percent: 60, priority: 2, isSavings: false, balanceCents: 0n },
|
||||
{ id: c2, userId: U, name: "Savings", 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().toISOString(),
|
||||
dueOn: new Date(Date.now() + 21 * 86400000).toISOString(), // due in 3 weeks
|
||||
totalCents: 120000n, // $1200 rent
|
||||
fundedCents: 0n,
|
||||
currentFundedCents: 0n,
|
||||
priority: 1,
|
||||
fundingMode: "auto-on-deposit",
|
||||
},
|
||||
});
|
||||
|
||||
// Allocate $3000 budget with 30% to fixed expenses
|
||||
const result = await allocateBudget(prisma as any, U, 300000, 30);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.totalBudgetCents).toBe(300000);
|
||||
|
||||
// Should have both fixed and variable allocations
|
||||
expect(result.fixedAllocations.length).toBeGreaterThan(0);
|
||||
expect(result.variableAllocations.length).toBeGreaterThan(0);
|
||||
|
||||
// Total allocation should not exceed budget
|
||||
const totalAllocated = result.fundedBudgetCents + result.availableBudgetCents;
|
||||
expect(totalAllocated).toBeLessThanOrEqual(300000);
|
||||
|
||||
// Rent should get some funding
|
||||
const rentAllocation = result.fixedAllocations.find(a => a.fixedPlanId === p1);
|
||||
expect(rentAllocation).toBeDefined();
|
||||
expect(rentAllocation!.amountCents).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("handles crisis mode with longer window for irregular income", async () => {
|
||||
const c1 = cid("emergency");
|
||||
await prisma.variableCategory.create({
|
||||
data: { id: c1, userId: U, name: "Emergency", percent: 100, priority: 1, isSavings: true, balanceCents: 0n },
|
||||
});
|
||||
|
||||
const p1 = pid("urgent");
|
||||
await prisma.fixedPlan.create({
|
||||
data: {
|
||||
id: p1,
|
||||
userId: U,
|
||||
name: "Urgent Bill",
|
||||
cycleStart: new Date().toISOString(),
|
||||
dueOn: new Date(Date.now() + 10 * 86400000).toISOString(), // due in 10 days
|
||||
totalCents: 80000n, // $800 - more than the 50% allocation ($500)
|
||||
fundedCents: 0n,
|
||||
currentFundedCents: 0n,
|
||||
priority: 1,
|
||||
fundingMode: "auto-on-deposit",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await allocateBudget(prisma as any, U, 100000, 50); // 50% to fixed for crisis testing
|
||||
|
||||
// Crisis should be detected (10 days < 14 day window for irregular income)
|
||||
expect(result.crisis.active).toBe(true);
|
||||
expect(result.crisis.plans.length).toBeGreaterThan(0);
|
||||
|
||||
const urgentPlan = result.crisis.plans.find(p => p.id === p1);
|
||||
expect(urgentPlan).toBeDefined();
|
||||
});
|
||||
|
||||
it("creates and updates budget session", async () => {
|
||||
const c1 = cid("test");
|
||||
await prisma.variableCategory.create({
|
||||
data: { id: c1, userId: U, name: "Test", percent: 100, priority: 1, isSavings: false, balanceCents: 0n },
|
||||
});
|
||||
|
||||
const result = await allocateBudget(prisma as any, U, 200000, 40); // 40% to fixed expenses
|
||||
|
||||
expect(result.totalBudgetCents).toBe(200000);
|
||||
expect(result.availableBudgetCents).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("handles zero budget gracefully", async () => {
|
||||
const c1 = cid("test");
|
||||
await prisma.variableCategory.create({
|
||||
data: { id: c1, userId: U, name: "Test", percent: 100, priority: 1, isSavings: false, balanceCents: 0n },
|
||||
});
|
||||
|
||||
const result = await allocateBudget(prisma as any, U, 0, 30); // 30% to fixed expenses
|
||||
|
||||
expect(result.totalBudgetCents).toBe(0);
|
||||
expect(result.fundedBudgetCents).toBe(0);
|
||||
expect(result.availableBudgetCents).toBe(0);
|
||||
expect(result.fixedAllocations).toHaveLength(0);
|
||||
expect(result.variableAllocations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
180
api/tests/irregular-income-simple.test.ts
Normal file
180
api/tests/irregular-income-simple.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { allocateBudget, applyIrregularIncome, previewAllocation } from '../src/allocator.ts';
|
||||
import { prisma, U, resetUser, ensureUser, cid, pid } from './helpers.ts';
|
||||
|
||||
describe('Irregular Income Allocation', () => {
|
||||
const userId = U;
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetUser(userId);
|
||||
await ensureUser(userId);
|
||||
});
|
||||
|
||||
async function createFixedPlan(totalCents: number, name: string, priority: number, dueInDays: number) {
|
||||
const id = pid();
|
||||
const now = new Date();
|
||||
await prisma.fixedPlan.create({
|
||||
data: {
|
||||
id,
|
||||
userId,
|
||||
name,
|
||||
totalCents: BigInt(totalCents),
|
||||
priority,
|
||||
dueOn: new Date(Date.now() + dueInDays * 24 * 60 * 60 * 1000),
|
||||
cycleStart: now, // Required field
|
||||
},
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
async function createVariableCategory(name: string, percent: number, priority: number, isSavings = false) {
|
||||
const id = cid();
|
||||
await prisma.variableCategory.create({
|
||||
data: {
|
||||
id,
|
||||
userId,
|
||||
name,
|
||||
percent,
|
||||
priority,
|
||||
isSavings,
|
||||
},
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
async function createIncomeEvent(amountCents: number, daysAgo = 0) {
|
||||
const id = `income_${Date.now()}_${Math.random()}`;
|
||||
await prisma.incomeEvent.create({
|
||||
data: {
|
||||
id,
|
||||
userId,
|
||||
amountCents: BigInt(amountCents),
|
||||
postedAt: new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
describe('Basic Allocation Logic', () => {
|
||||
it('should allocate percentage of new income to fixed expenses', async () => {
|
||||
// Setup: Create fixed plans and variable categories
|
||||
const rentId = await createFixedPlan(50000, 'Rent', 1, 10); // $500, due in 10 days (reduced so both can get funding)
|
||||
const carId = await createFixedPlan(40000, 'Car Payment', 2, 5); // $400, due in 5 days
|
||||
|
||||
await createVariableCategory('Groceries', 50, 1);
|
||||
await createVariableCategory('Entertainment', 30, 2);
|
||||
await createVariableCategory('Savings', 20, 3, true);
|
||||
|
||||
// Add some available budget
|
||||
await createIncomeEvent(50000, 1); // $500 from 1 day ago
|
||||
|
||||
// Test: Allocate $2,000 with 30% to fixed expenses
|
||||
const result = await allocateBudget(prisma, userId, 200000, 30);
|
||||
|
||||
expect(result.totalBudgetCents).toBe(200000); // $2,000 new income
|
||||
|
||||
// Fixed expenses should get 30% of new income = $600
|
||||
const totalFixedAllocated = result.fixedAllocations.reduce((sum, a) => sum + a.amountCents, 0);
|
||||
expect(totalFixedAllocated).toBe(60000); // $600
|
||||
|
||||
// Car payment (5 days, crisis for irregular) should get some allocation
|
||||
const carAllocation = result.fixedAllocations.find(a => a.fixedPlanId === carId);
|
||||
expect(carAllocation).toBeDefined();
|
||||
expect(carAllocation!.amountCents).toBeGreaterThan(0);
|
||||
|
||||
// Variables should get available budget + remaining new income
|
||||
const totalVariableAllocated = result.variableAllocations.reduce((sum, a) => sum + a.amountCents, 0);
|
||||
expect(totalVariableAllocated).toBeGreaterThan(0);
|
||||
|
||||
|
||||
});
|
||||
|
||||
it('should treat plans due within 14 days as crisis', async () => {
|
||||
// Setup: Create fixed plan due in 12 days
|
||||
const urgentBillId = await createFixedPlan(80000, 'Urgent Bill', 1, 12); // $800, due in 12 days
|
||||
|
||||
const result = await allocateBudget(prisma, userId, 100000, 50); // $1,000 with 50% to fixed
|
||||
|
||||
expect(result.crisis.active).toBe(true);
|
||||
expect(result.crisis.plans).toHaveLength(1);
|
||||
expect(result.crisis.plans[0].id).toBe(urgentBillId);
|
||||
expect(result.crisis.plans[0].daysUntilDue).toBe(12);
|
||||
});
|
||||
|
||||
it('should work with different fixed expense percentages', async () => {
|
||||
await createFixedPlan(100000, 'Rent', 1, 30); // $1,000
|
||||
await createVariableCategory('Everything', 100, 1);
|
||||
|
||||
// Test 10% to fixed expenses
|
||||
const result10 = await allocateBudget(prisma, userId, 200000, 10);
|
||||
const fixed10 = result10.fixedAllocations.reduce((sum, a) => sum + a.amountCents, 0);
|
||||
|
||||
// Test 50% to fixed expenses
|
||||
const result50 = await allocateBudget(prisma, userId, 200000, 50);
|
||||
const fixed50 = result50.fixedAllocations.reduce((sum, a) => sum + a.amountCents, 0);
|
||||
|
||||
expect(fixed50).toBeGreaterThan(fixed10);
|
||||
console.log('10% allocation to fixed:', fixed10);
|
||||
console.log('50% allocation to fixed:', fixed50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Comparison with Regular Income', () => {
|
||||
it('should show meaningful differences from regular income allocation', async () => {
|
||||
// Setup same scenario for both
|
||||
await createFixedPlan(120000, 'Rent', 1, 10); // $1,200, due in 10 days
|
||||
await createFixedPlan(40000, 'Car Payment', 2, 5); // $400, due in 5 days
|
||||
|
||||
await createVariableCategory('Groceries', 60, 1);
|
||||
await createVariableCategory('Savings', 40, 2, true);
|
||||
|
||||
// Add available budget
|
||||
await createIncomeEvent(75000, 1); // $750 available
|
||||
|
||||
// Set user to biweekly frequency for regular income test
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { incomeFrequency: 'biweekly' }
|
||||
});
|
||||
|
||||
// Test regular income allocation
|
||||
const regularResult = await previewAllocation(prisma, userId, 200000); // $2,000
|
||||
|
||||
// Test irregular income allocation
|
||||
const irregularResult = await allocateBudget(prisma, userId, 200000, 30); // $2,000 with 30% fixed
|
||||
|
||||
// Compare results
|
||||
const regularFixedTotal = regularResult.fixedAllocations.reduce((sum, a) => sum + a.amountCents, 0);
|
||||
const irregularFixedTotal = irregularResult.fixedAllocations.reduce((sum, a) => sum + a.amountCents, 0);
|
||||
|
||||
// The strategies should be different
|
||||
expect(regularFixedTotal).not.toBe(irregularFixedTotal);
|
||||
|
||||
console.log('Regular Income Fixed Allocation:', regularFixedTotal);
|
||||
console.log('Irregular Income Fixed Allocation:', irregularFixedTotal);
|
||||
console.log('Irregular should be capped at 30% = $600:', irregularFixedTotal <= 60000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle 0% fixed expense allocation', async () => {
|
||||
await createFixedPlan(100000, 'Rent', 1, 30);
|
||||
await createVariableCategory('Everything', 100, 1);
|
||||
|
||||
const result = await allocateBudget(prisma, userId, 200000, 0); // 0% to fixed
|
||||
|
||||
const fixedTotal = result.fixedAllocations.reduce((sum, a) => sum + a.amountCents, 0);
|
||||
expect(fixedTotal).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle no fixed expenses', async () => {
|
||||
await createVariableCategory('Everything', 100, 1);
|
||||
|
||||
const result = await allocateBudget(prisma, userId, 200000, 30);
|
||||
|
||||
expect(result.fixedAllocations).toHaveLength(0);
|
||||
const variableTotal = result.variableAllocations.reduce((sum, a) => sum + a.amountCents, 0);
|
||||
expect(variableTotal).toBe(200000); // All income to variables
|
||||
});
|
||||
});
|
||||
});
|
||||
230
api/tests/payment-rollover.test.ts
Normal file
230
api/tests/payment-rollover.test.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import request from "supertest";
|
||||
import appFactory from "./appFactory";
|
||||
import { prisma, ensureUser, resetUser, U, closePrisma } from "./helpers";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await appFactory();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetUser(U);
|
||||
await ensureUser(U);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
await closePrisma();
|
||||
});
|
||||
|
||||
describe("Payment-Triggered Rollover", () => {
|
||||
it("advances due date for weekly frequency on payment", async () => {
|
||||
// Create a fixed plan with weekly frequency
|
||||
const plan = await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: U,
|
||||
name: "Weekly Subscription",
|
||||
totalCents: 1000n,
|
||||
fundedCents: 1000n,
|
||||
currentFundedCents: 1000n,
|
||||
priority: 10,
|
||||
cycleStart: new Date("2025-11-01T00:00:00Z"),
|
||||
dueOn: new Date("2025-12-01T00:00:00Z"),
|
||||
frequency: "weekly",
|
||||
},
|
||||
});
|
||||
|
||||
// Make payment
|
||||
const txRes = await request(app.server)
|
||||
.post("/transactions")
|
||||
.set("x-user-id", U)
|
||||
.send({
|
||||
occurredAtISO: "2025-11-27T12:00:00Z",
|
||||
kind: "fixed_payment",
|
||||
amountCents: 1000,
|
||||
planId: plan.id,
|
||||
});
|
||||
|
||||
if (txRes.status !== 200) {
|
||||
console.log("Response status:", txRes.status);
|
||||
console.log("Response body:", txRes.body);
|
||||
}
|
||||
expect(txRes.status).toBe(200);
|
||||
|
||||
// Check plan was updated with next due date (7 days later)
|
||||
const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } });
|
||||
expect(updated?.fundedCents).toBe(0n);
|
||||
expect(updated?.currentFundedCents).toBe(0n);
|
||||
expect(updated?.dueOn.toISOString()).toBe("2025-12-08T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("advances due date for biweekly frequency on payment", async () => {
|
||||
const plan = await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: U,
|
||||
name: "Biweekly Bill",
|
||||
totalCents: 5000n,
|
||||
fundedCents: 5000n,
|
||||
currentFundedCents: 5000n,
|
||||
priority: 10,
|
||||
cycleStart: new Date("2025-11-01T00:00:00Z"),
|
||||
dueOn: new Date("2025-12-01T00:00:00Z"),
|
||||
frequency: "biweekly",
|
||||
},
|
||||
});
|
||||
|
||||
const txRes = await request(app.server)
|
||||
.post("/transactions")
|
||||
.set("x-user-id", U)
|
||||
.send({
|
||||
occurredAtISO: "2025-11-27T12:00:00Z",
|
||||
kind: "fixed_payment",
|
||||
amountCents: 5000,
|
||||
planId: plan.id,
|
||||
});
|
||||
|
||||
expect(txRes.status).toBe(200);
|
||||
|
||||
const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } });
|
||||
expect(updated?.fundedCents).toBe(0n);
|
||||
expect(updated?.dueOn.toISOString()).toBe("2025-12-15T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("advances due date for monthly frequency on payment", async () => {
|
||||
const plan = await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: U,
|
||||
name: "Monthly Rent",
|
||||
totalCents: 100000n,
|
||||
fundedCents: 100000n,
|
||||
currentFundedCents: 100000n,
|
||||
priority: 10,
|
||||
cycleStart: new Date("2025-11-01T00:00:00Z"),
|
||||
dueOn: new Date("2025-12-01T00:00:00Z"),
|
||||
frequency: "monthly",
|
||||
},
|
||||
});
|
||||
|
||||
const txRes = await request(app.server)
|
||||
.post("/transactions")
|
||||
.set("x-user-id", U)
|
||||
.send({
|
||||
occurredAtISO: "2025-11-27T12:00:00Z",
|
||||
kind: "fixed_payment",
|
||||
amountCents: 100000,
|
||||
planId: plan.id,
|
||||
});
|
||||
|
||||
expect(txRes.status).toBe(200);
|
||||
|
||||
const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } });
|
||||
expect(updated?.fundedCents).toBe(0n);
|
||||
expect(updated?.dueOn.toISOString()).toBe("2026-01-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("does not advance due date for one-time frequency", async () => {
|
||||
const originalDueDate = new Date("2025-12-01T00:00:00Z");
|
||||
const plan = await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: U,
|
||||
name: "One-time Expense",
|
||||
totalCents: 2000n,
|
||||
fundedCents: 2000n,
|
||||
currentFundedCents: 2000n,
|
||||
priority: 10,
|
||||
cycleStart: new Date("2025-11-01T00:00:00Z"),
|
||||
dueOn: originalDueDate,
|
||||
frequency: "one-time",
|
||||
},
|
||||
});
|
||||
|
||||
const txRes = await request(app.server)
|
||||
.post("/transactions")
|
||||
.set("x-user-id", U)
|
||||
.send({
|
||||
occurredAtISO: "2025-11-27T12:00:00Z",
|
||||
kind: "fixed_payment",
|
||||
amountCents: 2000,
|
||||
planId: plan.id,
|
||||
});
|
||||
|
||||
expect(txRes.status).toBe(200);
|
||||
|
||||
const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } });
|
||||
expect(updated?.fundedCents).toBe(0n);
|
||||
// Due date should remain unchanged for one-time expenses
|
||||
expect(updated?.dueOn.toISOString()).toBe(originalDueDate.toISOString());
|
||||
});
|
||||
|
||||
it("does not advance due date when no frequency is set", async () => {
|
||||
const originalDueDate = new Date("2025-12-01T00:00:00Z");
|
||||
const plan = await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: U,
|
||||
name: "Manual Bill",
|
||||
totalCents: 3000n,
|
||||
fundedCents: 3000n,
|
||||
currentFundedCents: 3000n,
|
||||
priority: 10,
|
||||
cycleStart: new Date("2025-11-01T00:00:00Z"),
|
||||
dueOn: originalDueDate,
|
||||
frequency: null,
|
||||
},
|
||||
});
|
||||
|
||||
const txRes = await request(app.server)
|
||||
.post("/transactions")
|
||||
.set("x-user-id", U)
|
||||
.send({
|
||||
occurredAtISO: "2025-11-27T12:00:00Z",
|
||||
kind: "fixed_payment",
|
||||
amountCents: 3000,
|
||||
planId: plan.id,
|
||||
});
|
||||
|
||||
expect(txRes.status).toBe(200);
|
||||
|
||||
const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } });
|
||||
expect(updated?.fundedCents).toBe(0n);
|
||||
// Due date should remain unchanged when no frequency
|
||||
expect(updated?.dueOn.toISOString()).toBe(originalDueDate.toISOString());
|
||||
});
|
||||
|
||||
it("prevents payment when insufficient funded amount", async () => {
|
||||
const plan = await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: U,
|
||||
name: "Underfunded Bill",
|
||||
totalCents: 10000n,
|
||||
fundedCents: 5000n,
|
||||
currentFundedCents: 5000n,
|
||||
priority: 10,
|
||||
cycleStart: new Date("2025-11-01T00:00:00Z"),
|
||||
dueOn: new Date("2025-12-01T00:00:00Z"),
|
||||
frequency: "monthly",
|
||||
},
|
||||
});
|
||||
|
||||
// Try to pay more than funded amount
|
||||
const txRes = await request(app.server)
|
||||
.post("/transactions")
|
||||
.set("x-user-id", U)
|
||||
.send({
|
||||
occurredAtISO: "2025-11-27T12:00:00Z",
|
||||
kind: "fixed_payment",
|
||||
amountCents: 10000,
|
||||
planId: plan.id,
|
||||
});
|
||||
|
||||
expect(txRes.status).toBe(400);
|
||||
expect(txRes.body.code).toBe("OVERDRAFT_PLAN");
|
||||
|
||||
// Plan should remain unchanged
|
||||
const unchanged = await prisma.fixedPlan.findUnique({ where: { id: plan.id } });
|
||||
expect(unchanged?.fundedCents).toBe(5000n);
|
||||
expect(unchanged?.dueOn.toISOString()).toBe("2025-12-01T00:00:00.000Z");
|
||||
});
|
||||
});
|
||||
63
api/tests/rollover.test.ts
Normal file
63
api/tests/rollover.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { prisma, ensureUser, resetUser, U, closePrisma } from "./helpers";
|
||||
import { rolloverFixedPlans } from "../src/jobs/rollover";
|
||||
|
||||
describe("rolloverFixedPlans", () => {
|
||||
beforeEach(async () => {
|
||||
await resetUser(U);
|
||||
await ensureUser(U);
|
||||
});
|
||||
|
||||
it("advances overdue plans and resets funding", async () => {
|
||||
const plan = await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: U,
|
||||
name: "Test Plan",
|
||||
totalCents: 10000n,
|
||||
fundedCents: 6000n,
|
||||
priority: 10,
|
||||
cycleStart: new Date("2025-01-01T00:00:00Z"),
|
||||
dueOn: new Date("2025-01-05T00:00:00Z"),
|
||||
periodDays: 30,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const results = await rolloverFixedPlans(prisma, "2025-01-10T00:00:00Z");
|
||||
const match = results.find((r) => r.planId === plan.id);
|
||||
expect(match?.cyclesAdvanced).toBe(1);
|
||||
expect(match?.deficitCents).toBe(4000);
|
||||
|
||||
const updated = await prisma.fixedPlan.findUniqueOrThrow({ where: { id: plan.id } });
|
||||
expect(updated.fundedCents).toBe(0n);
|
||||
expect(updated.dueOn.toISOString()).toBe("2025-02-04T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("handles multiple missed cycles and carries surplus", async () => {
|
||||
const plan = await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: U,
|
||||
name: "Surplus Plan",
|
||||
totalCents: 5000n,
|
||||
fundedCents: 12000n,
|
||||
priority: 5,
|
||||
cycleStart: new Date("2025-01-01T00:00:00Z"),
|
||||
dueOn: new Date("2025-01-10T00:00:00Z"),
|
||||
periodDays: 15,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const results = await rolloverFixedPlans(prisma, "2025-02-05T00:00:00Z");
|
||||
const match = results.find((r) => r.planId === plan.id);
|
||||
expect(match?.cyclesAdvanced).toBe(2);
|
||||
expect(match?.carryForwardCents).toBe(2000);
|
||||
|
||||
const updated = await prisma.fixedPlan.findUniqueOrThrow({ where: { id: plan.id } });
|
||||
expect(updated.fundedCents).toBe(2000n);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closePrisma();
|
||||
});
|
||||
@@ -9,6 +9,8 @@ process.env.DATABASE_URL =
|
||||
process.env.PORT = process.env.PORT || "0"; // fastify can bind an ephemeral port
|
||||
process.env.HOST ??= "127.0.0.1";
|
||||
process.env.CORS_ORIGIN = process.env.CORS_ORIGIN || "";
|
||||
process.env.AUTH_DISABLED = process.env.AUTH_DISABLED || "1";
|
||||
process.env.SEED_DEFAULT_BUDGET = process.env.SEED_DEFAULT_BUDGET || "1";
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
|
||||
@@ -25,8 +27,18 @@ export async function resetUser(userId: string) {
|
||||
beforeAll(async () => {
|
||||
// make sure the schema is applied before running tests
|
||||
execSync("npx prisma migrate deploy", { stdio: "inherit" });
|
||||
|
||||
// Ensure a clean slate: wipe all tables to avoid cross-file leakage
|
||||
await prisma.$transaction([
|
||||
prisma.allocation.deleteMany({}),
|
||||
prisma.transaction.deleteMany({}),
|
||||
prisma.incomeEvent.deleteMany({}),
|
||||
prisma.fixedPlan.deleteMany({}),
|
||||
prisma.variableCategory.deleteMany({}),
|
||||
prisma.user.deleteMany({}),
|
||||
]);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import request from "supertest";
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll } from "vitest";
|
||||
import appFactory from "./appFactory";
|
||||
import { prisma, resetUser, ensureUser, U, cid, closePrisma } from "./helpers";
|
||||
import { prisma, resetUser, ensureUser, U, cid, pid, closePrisma } from "./helpers";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
let app: FastifyInstance;
|
||||
@@ -16,16 +16,34 @@ afterAll(async () => {
|
||||
});
|
||||
|
||||
describe("GET /transactions", () => {
|
||||
let catId: string;
|
||||
let planId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetUser(U);
|
||||
await ensureUser(U);
|
||||
|
||||
const c = cid("c");
|
||||
catId = cid("c");
|
||||
planId = pid("p");
|
||||
|
||||
await prisma.variableCategory.create({
|
||||
data: { id: c, userId: U, name: "Groceries", percent: 100, priority: 1, isSavings: false, balanceCents: 5000n },
|
||||
data: { id: catId, userId: U, name: "Groceries", percent: 100, priority: 1, isSavings: false, balanceCents: 5000n },
|
||||
});
|
||||
|
||||
await prisma.fixedPlan.create({
|
||||
data: {
|
||||
id: planId,
|
||||
userId: U,
|
||||
name: "Rent",
|
||||
totalCents: 10000n,
|
||||
fundedCents: 2000n,
|
||||
priority: 1,
|
||||
cycleStart: new Date().toISOString(),
|
||||
dueOn: new Date(Date.now() + 864e5).toISOString(),
|
||||
fundingMode: "auto-on-deposit",
|
||||
},
|
||||
});
|
||||
|
||||
// seed some transactions of different kinds/dates
|
||||
await prisma.transaction.createMany({
|
||||
data: [
|
||||
{
|
||||
@@ -33,7 +51,7 @@ describe("GET /transactions", () => {
|
||||
userId: U,
|
||||
occurredAt: new Date("2025-01-03T12:00:00.000Z"),
|
||||
kind: "variable_spend",
|
||||
categoryId: c,
|
||||
categoryId: catId,
|
||||
amountCents: 1000n,
|
||||
},
|
||||
{
|
||||
@@ -41,17 +59,13 @@ describe("GET /transactions", () => {
|
||||
userId: U,
|
||||
occurredAt: new Date("2025-01-10T12:00:00.000Z"),
|
||||
kind: "fixed_payment",
|
||||
planId: null,
|
||||
planId,
|
||||
amountCents: 2000n,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closePrisma();
|
||||
});
|
||||
|
||||
it("paginates + filters by kind/date", async () => {
|
||||
const res = await request(app.server)
|
||||
.get("/transactions?from=2025-01-02&to=2025-01-06&kind=variable_spend&page=1&limit=10")
|
||||
@@ -63,4 +77,129 @@ describe("GET /transactions", () => {
|
||||
expect(body.items.length).toBe(1);
|
||||
expect(body.items[0].kind).toBe("variable_spend");
|
||||
});
|
||||
|
||||
it("filters by bucket id for either category or plan", async () => {
|
||||
const byCategory = await request(app.server)
|
||||
.get(`/transactions?bucketId=${catId}`)
|
||||
.set("x-user-id", U);
|
||||
|
||||
expect(byCategory.status).toBe(200);
|
||||
expect(byCategory.body.items.every((t: any) => t.categoryId === catId)).toBe(true);
|
||||
|
||||
const byPlan = await request(app.server)
|
||||
.get(`/transactions?bucketId=${planId}`)
|
||||
.set("x-user-id", U);
|
||||
|
||||
expect(byPlan.status).toBe(200);
|
||||
expect(byPlan.body.items.every((t: any) => t.planId === planId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /transactions", () => {
|
||||
const dateISO = new Date().toISOString();
|
||||
let catId: string;
|
||||
let planId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetUser(U);
|
||||
await ensureUser(U);
|
||||
|
||||
catId = cid("cat");
|
||||
planId = pid("plan");
|
||||
|
||||
await prisma.variableCategory.create({
|
||||
data: {
|
||||
id: catId,
|
||||
userId: U,
|
||||
name: "Dining",
|
||||
percent: 100,
|
||||
priority: 1,
|
||||
isSavings: false,
|
||||
balanceCents: 5000n,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.fixedPlan.create({
|
||||
data: {
|
||||
id: planId,
|
||||
userId: U,
|
||||
name: "Loan",
|
||||
totalCents: 10000n,
|
||||
fundedCents: 3000n,
|
||||
priority: 1,
|
||||
cycleStart: new Date().toISOString(),
|
||||
dueOn: new Date(Date.now() + 864e5).toISOString(),
|
||||
fundingMode: "auto-on-deposit",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("spends from a variable category and updates balance", async () => {
|
||||
const res = await request(app.server)
|
||||
.post("/transactions")
|
||||
.set("x-user-id", U)
|
||||
.send({
|
||||
kind: "variable_spend",
|
||||
amountCents: 2000,
|
||||
occurredAtISO: dateISO,
|
||||
categoryId: catId,
|
||||
note: "Groceries run",
|
||||
receiptUrl: "https://example.com/receipt",
|
||||
isReconciled: true,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const category = await prisma.variableCategory.findUniqueOrThrow({ where: { id: catId } });
|
||||
expect(Number(category.balanceCents)).toBe(3000);
|
||||
const tx = await prisma.transaction.findFirstOrThrow({ where: { userId: U, categoryId: catId } });
|
||||
expect(tx.note).toBe("Groceries run");
|
||||
expect(tx.receiptUrl).toBe("https://example.com/receipt");
|
||||
expect(tx.isReconciled).toBe(true);
|
||||
});
|
||||
|
||||
it("prevents overdrawing fixed plans", async () => {
|
||||
const res = await request(app.server)
|
||||
.post("/transactions")
|
||||
.set("x-user-id", U)
|
||||
.send({
|
||||
kind: "fixed_payment",
|
||||
amountCents: 400000, // exceeds funded
|
||||
occurredAtISO: dateISO,
|
||||
planId,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe("OVERDRAFT_PLAN");
|
||||
});
|
||||
|
||||
it("updates note/receipt and reconciliation via patch", async () => {
|
||||
const created = await request(app.server)
|
||||
.post("/transactions")
|
||||
.set("x-user-id", U)
|
||||
.send({
|
||||
kind: "variable_spend",
|
||||
amountCents: 1000,
|
||||
occurredAtISO: dateISO,
|
||||
categoryId: catId,
|
||||
});
|
||||
expect(created.status).toBe(200);
|
||||
const txId = created.body.id;
|
||||
|
||||
const res = await request(app.server)
|
||||
.patch(`/transactions/${txId}`)
|
||||
.set("x-user-id", U)
|
||||
.send({
|
||||
note: "Cleared",
|
||||
isReconciled: true,
|
||||
receiptUrl: "https://example.com/r.pdf",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.isReconciled).toBe(true);
|
||||
expect(res.body.note).toBe("Cleared");
|
||||
expect(res.body.receiptUrl).toBe("https://example.com/r.pdf");
|
||||
|
||||
const tx = await prisma.transaction.findUniqueOrThrow({ where: { id: txId } });
|
||||
expect(tx.isReconciled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user