Files
SkyMoney/api/tests/auto-payments.test.ts

338 lines
11 KiB
TypeScript

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)
});
});