180 lines
6.2 KiB
TypeScript
180 lines
6.2 KiB
TypeScript
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
import request from "supertest";
|
|
import type { FastifyInstance } from "fastify";
|
|
import { buildApp } from "../src/server";
|
|
import { prisma, ensureUser, resetUser, U, closePrisma } from "./helpers";
|
|
|
|
let app: FastifyInstance;
|
|
const CSRF = "test-csrf";
|
|
|
|
function mutate(path: string) {
|
|
return request(app.server)
|
|
.post(path)
|
|
.set("x-user-id", U)
|
|
.set("x-csrf-token", CSRF)
|
|
.set("Cookie", [`csrf=${CSRF}`]);
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
app = await buildApp({ AUTH_DISABLED: true, SEED_DEFAULT_BUDGET: false });
|
|
await app.ready();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await resetUser(U);
|
|
await ensureUser(U);
|
|
await prisma.variableCategory.createMany({
|
|
data: [
|
|
{ userId: U, name: "Essentials", percent: 60, priority: 10, balanceCents: 10_000n },
|
|
{ userId: U, name: "Savings", percent: 40, priority: 20, balanceCents: 10_000n, isSavings: true },
|
|
],
|
|
});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (app) await app.close();
|
|
await closePrisma();
|
|
});
|
|
|
|
describe("estimated fixed plans true-up", () => {
|
|
it("creates estimated mode plans with estimate fields", async () => {
|
|
const dueOn = new Date("2026-04-01T00:00:00.000Z").toISOString();
|
|
|
|
const res = await mutate("/fixed-plans").send({
|
|
name: "Water",
|
|
amountMode: "estimated",
|
|
estimatedCents: 12000,
|
|
totalCents: 12000,
|
|
fundedCents: 0,
|
|
priority: 10,
|
|
dueOn,
|
|
frequency: "monthly",
|
|
autoPayEnabled: false,
|
|
});
|
|
|
|
expect(res.status).toBe(201);
|
|
const created = await prisma.fixedPlan.findUniqueOrThrow({ where: { id: res.body.id } });
|
|
expect(created.amountMode).toBe("estimated");
|
|
expect(Number(created.estimatedCents ?? 0n)).toBe(12000);
|
|
expect(Number(created.totalCents ?? 0n)).toBe(12000);
|
|
});
|
|
|
|
it("true-up deficit auto-pulls from available budget and leaves remaining shortfall", async () => {
|
|
const plan = await prisma.fixedPlan.create({
|
|
data: {
|
|
userId: U,
|
|
name: "Water Deficit",
|
|
amountMode: "estimated",
|
|
estimatedCents: 10000n,
|
|
totalCents: 10000n,
|
|
fundedCents: 6000n,
|
|
currentFundedCents: 6000n,
|
|
priority: 10,
|
|
cycleStart: new Date("2026-03-01T00:00:00.000Z"),
|
|
dueOn: new Date("2026-04-01T00:00:00.000Z"),
|
|
frequency: "monthly",
|
|
},
|
|
});
|
|
|
|
const res = await mutate(`/fixed-plans/${plan.id}/true-up-actual`).send({ actualCents: 23000 });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.deltaCents).toBe(13000);
|
|
expect(res.body.autoPulledCents).toBe(13000);
|
|
expect(res.body.remainingShortfallCents).toBe(4000);
|
|
|
|
const updated = await prisma.fixedPlan.findUniqueOrThrow({ where: { id: plan.id } });
|
|
expect(Number(updated.totalCents ?? 0n)).toBe(23000);
|
|
expect(Number(updated.currentFundedCents ?? 0n)).toBe(19000);
|
|
expect(Number(updated.actualCents ?? 0n)).toBe(23000);
|
|
expect(updated.actualCycleDueOn?.toISOString()).toBe(updated.dueOn.toISOString());
|
|
expect(updated.actualRecordedAt).toBeTruthy();
|
|
});
|
|
|
|
it("true-up surplus refunds funded excess back to available budget", async () => {
|
|
const categoriesBefore = await prisma.variableCategory.findMany({ where: { userId: U } });
|
|
const budgetBefore = categoriesBefore.reduce((sum, c) => sum + Number(c.balanceCents ?? 0n), 0);
|
|
|
|
const plan = await prisma.fixedPlan.create({
|
|
data: {
|
|
userId: U,
|
|
name: "Water Surplus",
|
|
amountMode: "estimated",
|
|
estimatedCents: 15000n,
|
|
totalCents: 15000n,
|
|
fundedCents: 15000n,
|
|
currentFundedCents: 15000n,
|
|
priority: 10,
|
|
cycleStart: new Date("2026-03-01T00:00:00.000Z"),
|
|
dueOn: new Date("2026-04-01T00:00:00.000Z"),
|
|
frequency: "monthly",
|
|
},
|
|
});
|
|
|
|
const res = await mutate(`/fixed-plans/${plan.id}/true-up-actual`).send({ actualCents: 9000 });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.deltaCents).toBe(-6000);
|
|
expect(res.body.refundedCents).toBe(6000);
|
|
|
|
const updated = await prisma.fixedPlan.findUniqueOrThrow({ where: { id: plan.id } });
|
|
expect(Number(updated.totalCents ?? 0n)).toBe(9000);
|
|
expect(Number(updated.currentFundedCents ?? 0n)).toBe(9000);
|
|
|
|
const categoriesAfter = await prisma.variableCategory.findMany({ where: { userId: U } });
|
|
const budgetAfter = categoriesAfter.reduce((sum, c) => sum + Number(c.balanceCents ?? 0n), 0);
|
|
expect(budgetAfter - budgetBefore).toBe(6000);
|
|
});
|
|
|
|
it("rejects true-up for fixed mode plans", async () => {
|
|
const plan = await prisma.fixedPlan.create({
|
|
data: {
|
|
userId: U,
|
|
name: "Rent Fixed",
|
|
amountMode: "fixed",
|
|
totalCents: 120000n,
|
|
fundedCents: 120000n,
|
|
currentFundedCents: 120000n,
|
|
priority: 10,
|
|
cycleStart: new Date("2026-03-01T00:00:00.000Z"),
|
|
dueOn: new Date("2026-04-01T00:00:00.000Z"),
|
|
frequency: "monthly",
|
|
},
|
|
});
|
|
|
|
const res = await mutate(`/fixed-plans/${plan.id}/true-up-actual`).send({ actualCents: 100000 });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.code).toBe("PLAN_NOT_ESTIMATED");
|
|
});
|
|
|
|
it("pay-now rollover resets estimated plan target back to estimate and clears cycle actual metadata", async () => {
|
|
const plan = await prisma.fixedPlan.create({
|
|
data: {
|
|
userId: U,
|
|
name: "Water Rollover",
|
|
amountMode: "estimated",
|
|
estimatedCents: 10000n,
|
|
totalCents: 16000n,
|
|
fundedCents: 16000n,
|
|
currentFundedCents: 16000n,
|
|
actualCents: 16000n,
|
|
actualCycleDueOn: new Date("2026-04-01T00:00:00.000Z"),
|
|
actualRecordedAt: new Date(),
|
|
priority: 10,
|
|
cycleStart: new Date("2026-03-01T00:00:00.000Z"),
|
|
dueOn: new Date("2026-04-01T00:00:00.000Z"),
|
|
frequency: "monthly",
|
|
},
|
|
});
|
|
|
|
const res = await mutate(`/fixed-plans/${plan.id}/pay-now`).send({});
|
|
expect(res.status).toBe(200);
|
|
|
|
const updated = await prisma.fixedPlan.findUniqueOrThrow({ where: { id: plan.id } });
|
|
expect(Number(updated.totalCents ?? 0n)).toBe(10000);
|
|
expect(updated.actualCents).toBeNull();
|
|
expect(updated.actualCycleDueOn).toBeNull();
|
|
expect(updated.actualRecordedAt).toBeNull();
|
|
});
|
|
});
|