final touches for beta skymoney (at least i think)
This commit is contained in:
259
api/src/jobs/auto-payments.ts
Normal file
259
api/src/jobs/auto-payments.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user