Add comprehensive recurring payments system with scheduling
- 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>
This commit is contained in:
@@ -2,17 +2,20 @@
|
||||
import { onMount } from 'svelte';
|
||||
import ProfilePicture from './ProfilePicture.svelte';
|
||||
|
||||
let balance = {
|
||||
export let initialBalance = null;
|
||||
export let initialDebtData = null;
|
||||
|
||||
let balance = initialBalance || {
|
||||
netBalance: 0,
|
||||
recentSplits: []
|
||||
};
|
||||
let debtData = {
|
||||
let debtData = initialDebtData || {
|
||||
whoOwesMe: [],
|
||||
whoIOwe: [],
|
||||
totalOwedToMe: 0,
|
||||
totalIOwe: 0
|
||||
};
|
||||
let loading = true;
|
||||
let loading = !initialBalance || !initialDebtData; // Only show loading if we don't have initial data
|
||||
let error = null;
|
||||
let singleDebtUser = null;
|
||||
let shouldShowIntegratedView = false;
|
||||
@@ -47,7 +50,15 @@
|
||||
|
||||
|
||||
onMount(async () => {
|
||||
await Promise.all([fetchBalance(), fetchDebtBreakdown()]);
|
||||
// Mark that JavaScript is loaded
|
||||
if (typeof document !== 'undefined') {
|
||||
document.body.classList.add('js-loaded');
|
||||
}
|
||||
|
||||
// Only fetch data if we don't have initial data (progressive enhancement)
|
||||
if (!initialBalance || !initialDebtData) {
|
||||
await Promise.all([fetchBalance(), fetchDebtBreakdown()]);
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchBalance() {
|
||||
|
184
src/lib/server/scheduler.ts
Normal file
184
src/lib/server/scheduler.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import cron from 'node-cron';
|
||||
import { RecurringPayment } from '../../models/RecurringPayment';
|
||||
import { Payment } from '../../models/Payment';
|
||||
import { PaymentSplit } from '../../models/PaymentSplit';
|
||||
import { dbConnect, dbDisconnect } from '../../utils/db';
|
||||
import { calculateNextExecutionDate } from '../utils/recurring';
|
||||
|
||||
class RecurringPaymentScheduler {
|
||||
private isRunning = false;
|
||||
private task: cron.ScheduledTask | null = null;
|
||||
|
||||
// Start the scheduler - runs every minute to check for due payments
|
||||
start() {
|
||||
if (this.task) {
|
||||
console.log('[Scheduler] Already running');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Scheduler] Starting recurring payments scheduler');
|
||||
|
||||
// Run every minute to check for due payments
|
||||
this.task = cron.schedule('* * * * *', async () => {
|
||||
if (this.isRunning) {
|
||||
console.log('[Scheduler] Previous execution still running, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.processRecurringPayments();
|
||||
}, {
|
||||
scheduled: true,
|
||||
timezone: 'Europe/Zurich' // Adjust timezone as needed
|
||||
});
|
||||
|
||||
console.log('[Scheduler] Recurring payments scheduler started (runs every minute)');
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.task) {
|
||||
this.task.destroy();
|
||||
this.task = null;
|
||||
console.log('[Scheduler] Recurring payments scheduler stopped');
|
||||
}
|
||||
}
|
||||
|
||||
async processRecurringPayments() {
|
||||
if (this.isRunning) return;
|
||||
|
||||
this.isRunning = true;
|
||||
let dbConnected = false;
|
||||
|
||||
try {
|
||||
await dbConnect();
|
||||
dbConnected = true;
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Find all active recurring payments that are due (with 1 minute tolerance)
|
||||
const duePayments = await RecurringPayment.find({
|
||||
isActive: true,
|
||||
nextExecutionDate: { $lte: now },
|
||||
$or: [
|
||||
{ endDate: { $exists: false } },
|
||||
{ endDate: null },
|
||||
{ endDate: { $gte: now } }
|
||||
]
|
||||
});
|
||||
|
||||
if (duePayments.length === 0) {
|
||||
return; // No payments due
|
||||
}
|
||||
|
||||
console.log(`[Scheduler] Processing ${duePayments.length} due recurring payments at ${now.toISOString()}`);
|
||||
|
||||
let successCount = 0;
|
||||
let failureCount = 0;
|
||||
|
||||
for (const recurringPayment of duePayments) {
|
||||
try {
|
||||
console.log(`[Scheduler] Processing: ${recurringPayment.title} (${recurringPayment._id})`);
|
||||
|
||||
// Create the payment
|
||||
const payment = await Payment.create({
|
||||
title: `${recurringPayment.title}`,
|
||||
description: recurringPayment.description ?
|
||||
`${recurringPayment.description} (Auto-generated from recurring payment)` :
|
||||
'Auto-generated from recurring payment',
|
||||
amount: recurringPayment.amount,
|
||||
currency: recurringPayment.currency,
|
||||
paidBy: recurringPayment.paidBy,
|
||||
date: now,
|
||||
category: recurringPayment.category,
|
||||
splitMethod: recurringPayment.splitMethod,
|
||||
createdBy: `${recurringPayment.createdBy} (Auto)`
|
||||
});
|
||||
|
||||
// Create payment splits
|
||||
const splitPromises = recurringPayment.splits.map((split) => {
|
||||
return PaymentSplit.create({
|
||||
paymentId: payment._id,
|
||||
username: split.username,
|
||||
amount: split.amount,
|
||||
proportion: split.proportion,
|
||||
personalAmount: split.personalAmount
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(splitPromises);
|
||||
|
||||
// Calculate next execution date
|
||||
const nextExecutionDate = calculateNextExecutionDate(recurringPayment, now);
|
||||
|
||||
// Update the recurring payment
|
||||
await RecurringPayment.findByIdAndUpdate(recurringPayment._id, {
|
||||
lastExecutionDate: now,
|
||||
nextExecutionDate: nextExecutionDate
|
||||
});
|
||||
|
||||
successCount++;
|
||||
console.log(`[Scheduler] ✓ Created payment for "${recurringPayment.title}", next execution: ${nextExecutionDate.toISOString()}`);
|
||||
|
||||
} catch (paymentError) {
|
||||
console.error(`[Scheduler] ✗ Error processing recurring payment ${recurringPayment._id}:`, paymentError);
|
||||
failureCount++;
|
||||
|
||||
// Optionally, you could disable recurring payments that fail repeatedly
|
||||
// or implement a retry mechanism here
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0 || failureCount > 0) {
|
||||
console.log(`[Scheduler] Completed. Success: ${successCount}, Failures: ${failureCount}`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Scheduler] Error during recurring payment processing:', error);
|
||||
} finally {
|
||||
this.isRunning = false;
|
||||
if (dbConnected) {
|
||||
try {
|
||||
await dbDisconnect();
|
||||
} catch (disconnectError) {
|
||||
console.error('[Scheduler] Error disconnecting from database:', disconnectError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Manual execution for testing
|
||||
async executeNow() {
|
||||
console.log('[Scheduler] Manual execution requested');
|
||||
await this.processRecurringPayments();
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return {
|
||||
isRunning: this.isRunning,
|
||||
isScheduled: this.task !== null,
|
||||
nextRun: this.task?.nextDate()?.toISOString()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const recurringPaymentScheduler = new RecurringPaymentScheduler();
|
||||
|
||||
// Helper function to initialize the scheduler
|
||||
export function initializeScheduler() {
|
||||
if (typeof window === 'undefined') { // Only run on server
|
||||
recurringPaymentScheduler.start();
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('[Scheduler] Received SIGTERM, stopping scheduler...');
|
||||
recurringPaymentScheduler.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('[Scheduler] Received SIGINT, stopping scheduler...');
|
||||
recurringPaymentScheduler.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
}
|
230
src/lib/utils/recurring.ts
Normal file
230
src/lib/utils/recurring.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
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'
|
||||
});
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user