import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import request from "supertest"; import { randomUUID } from "node:crypto"; import { toZonedTime, fromZonedTime } from "date-fns-tz"; 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 () => { if (app) { await app.close(); } await closePrisma(); }); function getUserMidnightFromDateOnly(timezone: string, date: Date): Date { const zoned = toZonedTime(date, timezone); zoned.setHours(0, 0, 0, 0); return fromZonedTime(zoned, timezone); } function calculateNextDueDateLikeServer( currentDueDate: Date, frequency: string, timezone: string = "UTC" ): Date { const base = getUserMidnightFromDateOnly(timezone, currentDueDate); const zoned = toZonedTime(base, timezone); switch (frequency) { case "weekly": zoned.setDate(zoned.getDate() + 7); break; case "biweekly": zoned.setDate(zoned.getDate() + 14); break; case "monthly": { const targetDay = zoned.getDate(); zoned.setDate(1); zoned.setMonth(zoned.getMonth() + 1); const lastDay = new Date( zoned.getFullYear(), zoned.getMonth() + 1, 0 ).getDate(); zoned.setDate(Math.min(targetDay, lastDay)); break; } default: return base; } zoned.setHours(0, 0, 0, 0); return fromZonedTime(zoned, timezone); } describe("Payment-Triggered Rollover", () => { async function getUserTimezoneOrDefault() { const user = await prisma.user.findUnique({ where: { id: U }, select: { timezone: true }, }); return user?.timezone ?? "America/New_York"; } function postTransactionsWithCsrf() { const csrf = randomUUID().replace(/-/g, ""); return request(app.server) .post("/transactions") .set("x-user-id", U) .set("x-csrf-token", csrf) .set("Cookie", `csrf=${csrf}`); } it("advances due date for weekly frequency on payment", async () => { // Create a fixed plan with weekly frequency const plan = await prisma.fixedPlan.create({ data: { id: randomUUID(), 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 postTransactionsWithCsrf() .send({ occurredAtISO: "2025-11-27T12:00:00Z", kind: "fixed_payment", amountCents: 1000, planId: plan.id, isReconciled: true, }); 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); const userTimezone = await getUserTimezoneOrDefault(); const expectedDue = calculateNextDueDateLikeServer( new Date("2025-12-01T00:00:00Z"), "weekly", userTimezone ); expect(updated?.dueOn.toISOString()).toBe(expectedDue.toISOString()); }); it("advances due date for biweekly frequency on payment", async () => { const plan = await prisma.fixedPlan.create({ data: { id: randomUUID(), 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 postTransactionsWithCsrf() .send({ occurredAtISO: "2025-11-27T12:00:00Z", kind: "fixed_payment", amountCents: 5000, planId: plan.id, isReconciled: true, }); expect(txRes.status).toBe(200); const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } }); expect(updated?.fundedCents).toBe(0n); const userTimezone = await getUserTimezoneOrDefault(); const expectedDue = calculateNextDueDateLikeServer( new Date("2025-12-01T00:00:00Z"), "biweekly", userTimezone ); expect(updated?.dueOn.toISOString()).toBe(expectedDue.toISOString()); }); it("advances due date for monthly frequency on payment", async () => { const plan = await prisma.fixedPlan.create({ data: { id: randomUUID(), 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 postTransactionsWithCsrf() .send({ occurredAtISO: "2025-11-27T12:00:00Z", kind: "fixed_payment", amountCents: 100000, planId: plan.id, isReconciled: true, }); expect(txRes.status).toBe(200); const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } }); expect(updated?.fundedCents).toBe(0n); const userTimezone = await getUserTimezoneOrDefault(); const expectedDue = calculateNextDueDateLikeServer( new Date("2025-12-01T00:00:00Z"), "monthly", userTimezone ); expect(updated?.dueOn.toISOString()).toBe(expectedDue.toISOString()); }); it("deletes one-time plan after payment", async () => { const originalDueDate = new Date("2025-12-01T00:00:00Z"); const plan = await prisma.fixedPlan.create({ data: { id: randomUUID(), 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 postTransactionsWithCsrf() .send({ occurredAtISO: "2025-11-27T12:00:00Z", kind: "fixed_payment", amountCents: 2000, planId: plan.id, isReconciled: true, }); expect(txRes.status).toBe(200); const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } }); expect(updated).toBeNull(); }); it("treats null frequency as one-time and deletes plan after payment", async () => { const originalDueDate = new Date("2025-12-01T00:00:00Z"); const plan = await prisma.fixedPlan.create({ data: { id: randomUUID(), 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 postTransactionsWithCsrf() .send({ occurredAtISO: "2025-11-27T12:00:00Z", kind: "fixed_payment", amountCents: 3000, planId: plan.id, isReconciled: true, }); expect(txRes.status).toBe(200); const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } }); expect(updated).toBeNull(); }); it("prevents payment when insufficient funded amount", async () => { const plan = await prisma.fixedPlan.create({ data: { id: randomUUID(), 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 postTransactionsWithCsrf() .send({ occurredAtISO: "2025-11-27T12:00:00Z", kind: "fixed_payment", amountCents: 10000, planId: plan.id, isReconciled: true, }); expect(txRes.status).toBe(400); expect(txRes.body.code).toBe("INSUFFICIENT_AVAILABLE_BUDGET"); // 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"); }); });