- Add RecurringPayment model with flexible scheduling options - Implement node-cron based scheduler for payment processing - Create API endpoints for CRUD operations on recurring payments - Add recurring payments management UI with create/edit forms - Integrate scheduler initialization in hooks.server.ts - Enhance payments/add form with progressive enhancement - Add recurring payments button to main dashboard - Improve server-side rendering for better performance 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
230 lines
6.4 KiB
TypeScript
230 lines
6.4 KiB
TypeScript
import type { IRecurringPayment } from '../../models/RecurringPayment';
|
|
|
|
export interface CronJobFields {
|
|
minute: string;
|
|
hour: string;
|
|
dayOfMonth: string;
|
|
month: string;
|
|
dayOfWeek: string;
|
|
}
|
|
|
|
export function parseCronExpression(cronExpression: string): CronJobFields | null {
|
|
const parts = cronExpression.trim().split(/\s+/);
|
|
if (parts.length !== 5) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
minute: parts[0],
|
|
hour: parts[1],
|
|
dayOfMonth: parts[2],
|
|
month: parts[3],
|
|
dayOfWeek: parts[4]
|
|
};
|
|
}
|
|
|
|
export function validateCronExpression(cronExpression: string): boolean {
|
|
const fields = parseCronExpression(cronExpression);
|
|
if (!fields) return false;
|
|
|
|
// Basic validation for cron fields
|
|
const validations = [
|
|
{ field: fields.minute, min: 0, max: 59 },
|
|
{ field: fields.hour, min: 0, max: 23 },
|
|
{ field: fields.dayOfMonth, min: 1, max: 31 },
|
|
{ field: fields.month, min: 1, max: 12 },
|
|
{ field: fields.dayOfWeek, min: 0, max: 7 }
|
|
];
|
|
|
|
for (const validation of validations) {
|
|
if (!isValidCronField(validation.field, validation.min, validation.max)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function isValidCronField(field: string, min: number, max: number): boolean {
|
|
if (field === '*') return true;
|
|
|
|
// Handle ranges (e.g., "1-5")
|
|
if (field.includes('-')) {
|
|
const [start, end] = field.split('-').map(Number);
|
|
return !isNaN(start) && !isNaN(end) && start >= min && end <= max && start <= end;
|
|
}
|
|
|
|
// Handle step values (e.g., "*/5", "1-10/2")
|
|
if (field.includes('/')) {
|
|
const [range, step] = field.split('/');
|
|
const stepNum = Number(step);
|
|
if (isNaN(stepNum) || stepNum <= 0) return false;
|
|
|
|
if (range === '*') return true;
|
|
if (range.includes('-')) {
|
|
const [start, end] = range.split('-').map(Number);
|
|
return !isNaN(start) && !isNaN(end) && start >= min && end <= max && start <= end;
|
|
}
|
|
const num = Number(range);
|
|
return !isNaN(num) && num >= min && num <= max;
|
|
}
|
|
|
|
// Handle comma-separated values (e.g., "1,3,5")
|
|
if (field.includes(',')) {
|
|
const values = field.split(',').map(Number);
|
|
return values.every(val => !isNaN(val) && val >= min && val <= max);
|
|
}
|
|
|
|
// Handle single number
|
|
const num = Number(field);
|
|
return !isNaN(num) && num >= min && num <= max;
|
|
}
|
|
|
|
export function calculateNextExecutionDate(
|
|
recurringPayment: IRecurringPayment,
|
|
fromDate: Date = new Date()
|
|
): Date {
|
|
const baseDate = new Date(fromDate);
|
|
|
|
switch (recurringPayment.frequency) {
|
|
case 'daily':
|
|
baseDate.setDate(baseDate.getDate() + 1);
|
|
break;
|
|
|
|
case 'weekly':
|
|
baseDate.setDate(baseDate.getDate() + 7);
|
|
break;
|
|
|
|
case 'monthly':
|
|
baseDate.setMonth(baseDate.getMonth() + 1);
|
|
break;
|
|
|
|
case 'custom':
|
|
if (!recurringPayment.cronExpression) {
|
|
throw new Error('Cron expression required for custom frequency');
|
|
}
|
|
return calculateNextCronDate(recurringPayment.cronExpression, baseDate);
|
|
|
|
default:
|
|
throw new Error('Invalid frequency');
|
|
}
|
|
|
|
return baseDate;
|
|
}
|
|
|
|
export function calculateNextCronDate(cronExpression: string, fromDate: Date): Date {
|
|
const fields = parseCronExpression(cronExpression);
|
|
if (!fields) {
|
|
throw new Error('Invalid cron expression');
|
|
}
|
|
|
|
const next = new Date(fromDate);
|
|
next.setSeconds(0);
|
|
next.setMilliseconds(0);
|
|
|
|
// Start from the next minute
|
|
next.setMinutes(next.getMinutes() + 1);
|
|
|
|
// Find the next valid date
|
|
for (let attempts = 0; attempts < 366; attempts++) { // Prevent infinite loops
|
|
if (matchesCronFields(next, fields)) {
|
|
return next;
|
|
}
|
|
next.setMinutes(next.getMinutes() + 1);
|
|
}
|
|
|
|
throw new Error('Unable to find next execution date within reasonable range');
|
|
}
|
|
|
|
function matchesCronFields(date: Date, fields: CronJobFields): boolean {
|
|
return (
|
|
matchesCronField(date.getMinutes(), fields.minute, 0, 59) &&
|
|
matchesCronField(date.getHours(), fields.hour, 0, 23) &&
|
|
matchesCronField(date.getDate(), fields.dayOfMonth, 1, 31) &&
|
|
matchesCronField(date.getMonth() + 1, fields.month, 1, 12) &&
|
|
matchesCronField(date.getDay(), fields.dayOfWeek, 0, 7)
|
|
);
|
|
}
|
|
|
|
function matchesCronField(value: number, field: string, min: number, max: number): boolean {
|
|
if (field === '*') return true;
|
|
|
|
// Handle ranges (e.g., "1-5")
|
|
if (field.includes('-')) {
|
|
const [start, end] = field.split('-').map(Number);
|
|
return value >= start && value <= end;
|
|
}
|
|
|
|
// Handle step values (e.g., "*/5", "1-10/2")
|
|
if (field.includes('/')) {
|
|
const [range, step] = field.split('/');
|
|
const stepNum = Number(step);
|
|
|
|
if (range === '*') {
|
|
return (value - min) % stepNum === 0;
|
|
}
|
|
|
|
if (range.includes('-')) {
|
|
const [start, end] = range.split('-').map(Number);
|
|
return value >= start && value <= end && (value - start) % stepNum === 0;
|
|
}
|
|
|
|
const rangeStart = Number(range);
|
|
return value >= rangeStart && (value - rangeStart) % stepNum === 0;
|
|
}
|
|
|
|
// Handle comma-separated values (e.g., "1,3,5")
|
|
if (field.includes(',')) {
|
|
const values = field.split(',').map(Number);
|
|
return values.includes(value);
|
|
}
|
|
|
|
// Handle single number
|
|
return value === Number(field);
|
|
}
|
|
|
|
export function getFrequencyDescription(recurringPayment: IRecurringPayment): string {
|
|
switch (recurringPayment.frequency) {
|
|
case 'daily':
|
|
return 'Every day';
|
|
case 'weekly':
|
|
return 'Every week';
|
|
case 'monthly':
|
|
return 'Every month';
|
|
case 'custom':
|
|
return `Custom: ${recurringPayment.cronExpression}`;
|
|
default:
|
|
return 'Unknown frequency';
|
|
}
|
|
}
|
|
|
|
export function formatNextExecution(date: Date): string {
|
|
const now = new Date();
|
|
const diffMs = date.getTime() - now.getTime();
|
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
|
|
if (diffDays === 0) {
|
|
return `Today at ${date.toLocaleTimeString('de-CH', {
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
})}`;
|
|
} else if (diffDays === 1) {
|
|
return `Tomorrow at ${date.toLocaleTimeString('de-CH', {
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
})}`;
|
|
} else if (diffDays < 7) {
|
|
return `In ${diffDays} days at ${date.toLocaleTimeString('de-CH', {
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
})}`;
|
|
} else {
|
|
return date.toLocaleString('de-CH', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
}
|
|
} |