260 lines
7.6 KiB
TypeScript
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();
|
|
}
|