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' }); } }