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