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:
2025-09-12 12:41:18 +02:00
parent 701434d532
commit 6ab395e98a
28 changed files with 4412 additions and 94 deletions

184
src/lib/server/scheduler.ts Normal file
View 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);
});
}
}