338 lines
11 KiB
TypeScript
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)
|
|
});
|
|
}); |