Files
SkyMoney/api/src/jobs/auto-payments.ts

260 lines
7.6 KiB
TypeScript

import { PrismaClient, Prisma } from "@prisma/client";
import { fromZonedTime, toZonedTime } from "date-fns-tz";
const DAY_MS = 24 * 60 * 60 * 1000;
export type PaymentSchedule = {
frequency: "monthly" | "weekly" | "biweekly" | "daily" | "custom";
dayOfMonth?: number; // For monthly (1-31)
dayOfWeek?: number; // For weekly/biweekly (0=Sunday, 6=Saturday)
everyNDays?: number; // For custom cadence (fallback to periodDays)
minFundingPercent: number; // 0-100, minimum funding required before auto-pay
};
export type AutoPaymentReport = {
planId: string;
userId: string;
name: string;
paymentAmountCents: number;
success: boolean;
error?: string;
retryCount: number;
nextRetryDate?: string;
};
const isProd = process.env.NODE_ENV === "production";
/**
* Check if auto-payment should run for a user based on their timezone
* Auto-payment runs at 9 AM in the user's timezone
*/
function shouldProcessPaymentForUser(userTimezone: string, asOf: Date): boolean {
const zonedTime = toZonedTime(asOf, userTimezone);
const hour = zonedTime.getHours();
// Process if we're past 9 AM in user's timezone
return hour >= 9;
}
/**
* Process auto-scheduled payments for fixed plans
*/
export async function processAutoPayments(
prisma: PrismaClient,
asOfInput?: Date | string,
{ dryRun = false }: { dryRun?: boolean } = {}
): Promise<AutoPaymentReport[]> {
const asOf = asOfInput ? new Date(asOfInput) : new Date();
// Find plans with auto-payments enabled and due for payment
const candidates = await prisma.fixedPlan.findMany({
where: {
autoPayEnabled: true,
nextPaymentDate: { lte: asOf },
paymentSchedule: { not: Prisma.DbNull },
},
orderBy: { nextPaymentDate: "asc" },
select: {
id: true,
userId: true,
name: true,
totalCents: true,
fundedCents: true,
currentFundedCents: true,
paymentSchedule: true,
nextPaymentDate: true,
lastAutoPayment: true,
maxRetryAttempts: true,
periodDays: true,
user: {
select: {
timezone: true,
},
},
},
});
const reports: AutoPaymentReport[] = [];
for (const plan of candidates) {
// Check if it's time for auto-payment in this user's timezone
const userTimezone = plan.user.timezone ?? "America/New_York";
if (!shouldProcessPaymentForUser(userTimezone, asOf)) {
if (!isProd) {
console.log(
`[auto-payment] Skipping plan ${plan.id} for user ${plan.userId} - not yet 9 AM in ${userTimezone}`
);
}
continue;
}
const schedule = plan.paymentSchedule as PaymentSchedule | null;
if (!schedule) continue;
const funded = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n);
const total = Number(plan.totalCents ?? 0n);
const fundingPercent = total > 0 ? (funded / total) * 100 : 0;
const minFunding = schedule.minFundingPercent ?? 100;
// Check if plan meets minimum funding requirement
if (fundingPercent < minFunding) {
reports.push({
planId: plan.id,
userId: plan.userId,
name: plan.name,
paymentAmountCents: 0,
success: false,
error: `Insufficient funding: ${fundingPercent.toFixed(1)}% < ${minFunding}%`,
retryCount: 0,
});
// Schedule next retry (1 day later)
const nextRetry = new Date(asOf.getTime() + DAY_MS);
if (!dryRun) {
await prisma.fixedPlan.update({
where: { id: plan.id },
data: { nextPaymentDate: nextRetry },
});
}
continue;
}
// Calculate payment amount (use full funded amount)
const paymentAmount = funded;
try {
if (!dryRun) {
// Create the payment transaction
await prisma.$transaction(async (tx) => {
// Create fixed_payment transaction
await tx.transaction.create({
data: {
userId: plan.userId,
kind: "fixed_payment",
amountCents: BigInt(paymentAmount),
occurredAt: asOf,
planId: plan.id,
note: `Auto-payment (${schedule.frequency})`,
isAutoPayment: true,
},
});
// Update plan funding
await tx.fixedPlan.update({
where: { id: plan.id },
data: {
fundedCents: BigInt(funded - paymentAmount),
currentFundedCents: BigInt(funded - paymentAmount),
lastAutoPayment: asOf,
nextPaymentDate: calculateNextPaymentDate(asOf, schedule, plan.periodDays, userTimezone),
},
});
});
}
reports.push({
planId: plan.id,
userId: plan.userId,
name: plan.name,
paymentAmountCents: paymentAmount,
success: true,
retryCount: 0,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
reports.push({
planId: plan.id,
userId: plan.userId,
name: plan.name,
paymentAmountCents: paymentAmount,
success: false,
error: errorMessage,
retryCount: 0,
});
// Schedule retry (1 hour later)
const nextRetry = new Date(asOf.getTime() + 60 * 60 * 1000);
if (!dryRun) {
await prisma.fixedPlan.update({
where: { id: plan.id },
data: { nextPaymentDate: nextRetry },
});
}
}
}
return reports;
}
/**
* Calculate the next payment date based on schedule
*/
export function calculateNextPaymentDate(
currentDate: Date,
schedule: PaymentSchedule,
periodDays: number,
timezone: string
): Date {
const next = toZonedTime(currentDate, timezone);
const hours = next.getUTCHours();
const minutes = next.getUTCMinutes();
const seconds = next.getUTCSeconds();
const ms = next.getUTCMilliseconds();
switch (schedule.frequency) {
case "daily":
next.setUTCDate(next.getUTCDate() + 1);
break;
case "weekly":
// Move to next occurrence of specified day of week
{
const targetDay = schedule.dayOfWeek ?? 0;
const currentDay = next.getUTCDay();
const daysUntilTarget = (targetDay - currentDay + 7) % 7;
next.setUTCDate(next.getUTCDate() + (daysUntilTarget || 7));
}
break;
case "biweekly":
{
const targetDay = schedule.dayOfWeek ?? next.getUTCDay();
const currentDay = next.getUTCDay();
let daysUntilTarget = (targetDay - currentDay + 7) % 7;
// ensure at least one full week gap to make it biweekly
daysUntilTarget = daysUntilTarget === 0 ? 14 : daysUntilTarget + 7;
next.setUTCDate(next.getUTCDate() + daysUntilTarget);
}
break;
case "monthly":
{
const targetDay = schedule.dayOfMonth ?? next.getUTCDate();
// Avoid month overflow (e.g., Jan 31 -> Feb) by resetting to day 1 before adding months.
next.setUTCDate(1);
next.setUTCMonth(next.getUTCMonth() + 1);
const lastDay = getLastDayOfMonth(next);
next.setUTCDate(Math.min(targetDay, lastDay));
}
break;
case "custom":
{
const days = schedule.everyNDays && schedule.everyNDays > 0 ? schedule.everyNDays : periodDays;
next.setUTCDate(next.getUTCDate() + days);
}
break;
default:
// Fallback to periodDays
next.setUTCDate(next.getUTCDate() + periodDays);
}
next.setUTCHours(hours, minutes, seconds, ms);
return fromZonedTime(next, timezone);
}
function getLastDayOfMonth(date: Date): number {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0)).getUTCDate();
}