diff --git a/RECURRING_PAYMENTS_SETUP.md b/RECURRING_PAYMENTS_SETUP.md new file mode 100644 index 0000000..f132608 --- /dev/null +++ b/RECURRING_PAYMENTS_SETUP.md @@ -0,0 +1,191 @@ +# Recurring Payments Setup + +This document explains how to set up and use the recurring payments feature in your Cospend application. + +## Features + +- **Daily, Weekly, Monthly recurring payments**: Simple frequency options +- **Custom Cron scheduling**: Advanced users can use cron expressions for complex schedules +- **Full payment management**: Create, edit, pause, and delete recurring payments +- **Automatic execution**: Payments are automatically created based on schedule +- **Split support**: All payment split methods are supported (equal, proportional, personal+equal, full payment) + +## Setup + +### 1. Environment Variables + +Add the following optional environment variable to your `.env` file for secure cron job execution: + +```env +CRON_API_TOKEN=your-secure-random-token-here +``` + +### 2. Database Setup + +The recurring payments feature uses MongoDB models that are automatically created. No additional database setup is required. + +### 3. Background Job Setup + +You need to set up a recurring job to automatically process due payments. Here are several options: + +#### Option A: System Cron (Linux/macOS) + +Add the following to your crontab (run `crontab -e`): + +```bash +# Run every 5 minutes +*/5 * * * * curl -X POST -H "Authorization: Bearer your-secure-random-token-here" https://yourdomain.com/api/cospend/recurring-payments/cron-execute + +# Or run every hour +0 * * * * curl -X POST -H "Authorization: Bearer your-secure-random-token-here" https://yourdomain.com/api/cospend/recurring-payments/cron-execute +``` + +#### Option B: GitHub Actions (if hosted on a platform that supports it) + +Create `.github/workflows/recurring-payments.yml`: + +```yaml +name: Process Recurring Payments +on: + schedule: + - cron: '*/5 * * * *' # Every 5 minutes + workflow_dispatch: # Allow manual triggering + +jobs: + process-payments: + runs-on: ubuntu-latest + steps: + - name: Process recurring payments + run: | + curl -X POST \ + -H "Authorization: Bearer ${{ secrets.CRON_API_TOKEN }}" \ + https://yourdomain.com/api/cospend/recurring-payments/cron-execute +``` + +#### Option C: Cloud Function/Serverless + +Deploy a simple cloud function that calls the endpoint on a schedule: + +```javascript +// Example for Vercel/Netlify Functions +export default async function handler(req, res) { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + try { + const response = await fetch('https://yourdomain.com/api/cospend/recurring-payments/cron-execute', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${process.env.CRON_API_TOKEN}` + } + }); + + const result = await response.json(); + res.status(200).json(result); + } catch (error) { + res.status(500).json({ error: error.message }); + } +} +``` + +#### Option D: Manual Execution + +For testing or manual processing, you can call the endpoint directly: + +```bash +curl -X POST \ + -H "Authorization: Bearer your-secure-random-token-here" \ + -H "Content-Type: application/json" \ + https://yourdomain.com/api/cospend/recurring-payments/cron-execute +``` + +## Usage + +### Creating Recurring Payments + +1. Navigate to `/cospend/recurring/add` +2. Fill in the payment details (title, amount, category, etc.) +3. Choose frequency: + - **Daily**: Executes every day + - **Weekly**: Executes every week + - **Monthly**: Executes every month + - **Custom**: Use cron expressions for advanced scheduling +4. Set up user splits (same options as regular payments) +5. Set start date and optional end date + +### Managing Recurring Payments + +1. Navigate to `/cospend/recurring` +2. View all recurring payments with their next execution dates +3. Edit, pause, activate, or delete recurring payments +4. Filter by active/inactive status + +### Cron Expression Examples + +For custom frequency, you can use cron expressions: + +- `0 9 * * *` - Every day at 9:00 AM +- `0 9 * * 1` - Every Monday at 9:00 AM +- `0 9 1 * *` - Every 1st of the month at 9:00 AM +- `0 9 1,15 * *` - Every 1st and 15th of the month at 9:00 AM +- `0 9 * * 1-5` - Every weekday at 9:00 AM +- `0 */6 * * *` - Every 6 hours + +## Monitoring + +The cron execution endpoint returns detailed information about processed payments: + +```json +{ + "success": true, + "timestamp": "2024-01-01T09:00:00.000Z", + "processed": 3, + "successful": 2, + "failed": 1, + "results": [ + { + "recurringPaymentId": "...", + "paymentId": "...", + "title": "Monthly Rent", + "amount": 1200, + "nextExecution": "2024-02-01T09:00:00.000Z", + "success": true + } + ] +} +``` + +Check your application logs for detailed processing information. + +## Security Considerations + +1. **API Token**: Use a strong, random token for the `CRON_API_TOKEN` +2. **HTTPS**: Always use HTTPS for the cron endpoint +3. **Rate Limiting**: Consider adding rate limiting to the cron endpoint +4. **Monitoring**: Monitor the cron job execution and set up alerts for failures + +## Troubleshooting + +### Common Issues + +1. **Payments not executing**: Check that your cron job is running and the API token is correct +2. **Permission errors**: Ensure the cron endpoint can access the database +3. **Time zone issues**: The system uses server time for scheduling +4. **Cron expression errors**: Validate cron expressions using online tools + +### Logs + +Check server logs for detailed error messages: +- Look for `[Cron]` prefixed messages +- Monitor database connection issues +- Check for validation errors in payment creation + +## Future Enhancements + +Potential improvements to consider: +- Web-based cron job management +- Email notifications for successful/failed executions +- Payment execution history and analytics +- Time zone support for scheduling +- Webhook notifications \ No newline at end of file diff --git a/package.json b/package.json index 57938d4..d5ee4a1 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@sveltejs/kit": "^2.37.0", "@sveltejs/vite-plugin-svelte": "^6.1.3", "@types/node": "^22.12.0", + "@types/node-cron": "^3.0.11", "svelte": "^5.38.6", "svelte-check": "^4.0.0", "tslib": "^2.6.0", @@ -28,6 +29,7 @@ "@sveltejs/adapter-node": "^5.0.0", "cheerio": "1.0.0-rc.12", "mongoose": "^8.0.0", + "node-cron": "^4.2.1", "sharp": "^0.33.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a5de37a..d9651fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: mongoose: specifier: ^8.0.0 version: 8.18.0(socks@2.7.1) + node-cron: + specifier: ^4.2.1 + version: 4.2.1 sharp: specifier: ^0.33.0 version: 0.33.5 @@ -39,6 +42,9 @@ importers: '@types/node': specifier: ^22.12.0 version: 22.18.0 + '@types/node-cron': + specifier: ^3.0.11 + version: 3.0.11 svelte: specifier: ^5.38.6 version: 5.38.6 @@ -584,6 +590,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/node-cron@3.0.11': + resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==} + '@types/node@22.18.0': resolution: {integrity: sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==} @@ -842,6 +851,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + node-cron@4.2.1: + resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==} + engines: {node: '>=6.0.0'} + nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} @@ -1418,6 +1431,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/node-cron@3.0.11': {} + '@types/node@22.18.0': dependencies: undici-types: 6.21.0 @@ -1669,6 +1684,8 @@ snapshots: nanoid@3.3.11: {} + node-cron@4.2.1: {} + nth-check@2.1.1: dependencies: boolbase: 1.0.0 diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 0aedab4..87c1f1d 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -6,6 +6,10 @@ import Authentik from "@auth/core/providers/authentik" import { AUTHENTIK_ID, AUTHENTIK_SECRET, AUTHENTIK_ISSUER } from "$env/static/private"; import { sequence } from "@sveltejs/kit/hooks" import * as auth from "./auth" +import { initializeScheduler } from "./lib/server/scheduler" + +// Initialize the recurring payment scheduler +initializeScheduler(); async function authorization({ event, resolve }) { // Protect any routes under /authenticated diff --git a/src/lib/components/EnhancedBalance.svelte b/src/lib/components/EnhancedBalance.svelte index 7b03779..c00ca9c 100644 --- a/src/lib/components/EnhancedBalance.svelte +++ b/src/lib/components/EnhancedBalance.svelte @@ -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() { diff --git a/src/lib/server/scheduler.ts b/src/lib/server/scheduler.ts new file mode 100644 index 0000000..b4964bd --- /dev/null +++ b/src/lib/server/scheduler.ts @@ -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); + }); + } +} \ No newline at end of file diff --git a/src/lib/utils/recurring.ts b/src/lib/utils/recurring.ts new file mode 100644 index 0000000..e325ae6 --- /dev/null +++ b/src/lib/utils/recurring.ts @@ -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' + }); + } +} \ No newline at end of file diff --git a/src/models/RecurringPayment.ts b/src/models/RecurringPayment.ts new file mode 100644 index 0000000..f5b203b --- /dev/null +++ b/src/models/RecurringPayment.ts @@ -0,0 +1,141 @@ +import mongoose from 'mongoose'; + +export interface IRecurringPayment { + _id?: string; + title: string; + description?: string; + amount: number; + currency: string; + paidBy: string; // username/nickname of the person who paid + category: 'groceries' | 'shopping' | 'travel' | 'restaurant' | 'utilities' | 'fun' | 'settlement'; + splitMethod: 'equal' | 'full' | 'proportional' | 'personal_equal'; + splits: Array<{ + username: string; + amount?: number; + proportion?: number; + personalAmount?: number; + }>; + frequency: 'daily' | 'weekly' | 'monthly' | 'custom'; + cronExpression?: string; // For custom frequencies using cron syntax + isActive: boolean; + nextExecutionDate: Date; + lastExecutionDate?: Date; + startDate: Date; + endDate?: Date; // Optional end date for the recurring payments + createdBy: string; + createdAt?: Date; + updatedAt?: Date; +} + +const RecurringPaymentSchema = new mongoose.Schema( + { + title: { + type: String, + required: true, + trim: true + }, + description: { + type: String, + trim: true + }, + amount: { + type: Number, + required: true, + min: 0 + }, + currency: { + type: String, + required: true, + default: 'CHF', + enum: ['CHF'] + }, + paidBy: { + type: String, + required: true, + trim: true + }, + category: { + type: String, + required: true, + enum: ['groceries', 'shopping', 'travel', 'restaurant', 'utilities', 'fun', 'settlement'], + default: 'groceries' + }, + splitMethod: { + type: String, + required: true, + enum: ['equal', 'full', 'proportional', 'personal_equal'], + default: 'equal' + }, + splits: [{ + username: { + type: String, + required: true, + trim: true + }, + amount: { + type: Number + }, + proportion: { + type: Number, + min: 0, + max: 1 + }, + personalAmount: { + type: Number, + min: 0 + } + }], + frequency: { + type: String, + required: true, + enum: ['daily', 'weekly', 'monthly', 'custom'] + }, + cronExpression: { + type: String, + validate: { + validator: function(value: string) { + // Only validate if frequency is custom + if (this.frequency === 'custom') { + return value != null && value.trim().length > 0; + } + return true; + }, + message: 'Cron expression is required when frequency is custom' + } + }, + isActive: { + type: Boolean, + default: true + }, + nextExecutionDate: { + type: Date, + required: true + }, + lastExecutionDate: { + type: Date + }, + startDate: { + type: Date, + required: true, + default: Date.now + }, + endDate: { + type: Date + }, + createdBy: { + type: String, + required: true, + trim: true + } + }, + { + timestamps: true, + toJSON: { virtuals: true }, + toObject: { virtuals: true } + } +); + +// Index for efficiently finding payments that need to be executed +RecurringPaymentSchema.index({ nextExecutionDate: 1, isActive: 1 }); + +export const RecurringPayment = mongoose.model("RecurringPayment", RecurringPaymentSchema); \ No newline at end of file diff --git a/src/routes/api/cospend/recurring-payments/+server.ts b/src/routes/api/cospend/recurring-payments/+server.ts new file mode 100644 index 0000000..5bb03da --- /dev/null +++ b/src/routes/api/cospend/recurring-payments/+server.ts @@ -0,0 +1,139 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { RecurringPayment } from '../../../../models/RecurringPayment'; +import { dbConnect, dbDisconnect } from '../../../../utils/db'; +import { error, json } from '@sveltejs/kit'; +import { calculateNextExecutionDate, validateCronExpression } from '../../../../lib/utils/recurring'; + +export const GET: RequestHandler = async ({ locals, url }) => { + const auth = await locals.auth(); + if (!auth || !auth.user?.nickname) { + throw error(401, 'Not logged in'); + } + + const limit = parseInt(url.searchParams.get('limit') || '50'); + const offset = parseInt(url.searchParams.get('offset') || '0'); + const activeOnly = url.searchParams.get('active') === 'true'; + + await dbConnect(); + + try { + const query: any = {}; + if (activeOnly) { + query.isActive = true; + } + + const recurringPayments = await RecurringPayment.find(query) + .sort({ nextExecutionDate: 1, createdAt: -1 }) + .limit(limit) + .skip(offset) + .lean(); + + return json({ recurringPayments }); + } catch (e) { + console.error('Error fetching recurring payments:', e); + throw error(500, 'Failed to fetch recurring payments'); + } finally { + await dbDisconnect(); + } +}; + +export const POST: RequestHandler = async ({ request, locals }) => { + const auth = await locals.auth(); + if (!auth || !auth.user?.nickname) { + throw error(401, 'Not logged in'); + } + + const data = await request.json(); + const { + title, + description, + amount, + paidBy, + category, + splitMethod, + splits, + frequency, + cronExpression, + startDate, + endDate + } = data; + + if (!title || !amount || !paidBy || !splitMethod || !splits || !frequency) { + throw error(400, 'Missing required fields'); + } + + if (amount <= 0) { + throw error(400, 'Amount must be positive'); + } + + if (!['equal', 'full', 'proportional', 'personal_equal'].includes(splitMethod)) { + throw error(400, 'Invalid split method'); + } + + if (!['daily', 'weekly', 'monthly', 'custom'].includes(frequency)) { + throw error(400, 'Invalid frequency'); + } + + if (frequency === 'custom') { + if (!cronExpression || !validateCronExpression(cronExpression)) { + throw error(400, 'Valid cron expression required for custom frequency'); + } + } + + if (category && !['groceries', 'shopping', 'travel', 'restaurant', 'utilities', 'fun', 'settlement'].includes(category)) { + throw error(400, 'Invalid category'); + } + + // Validate personal + equal split method + if (splitMethod === 'personal_equal' && splits) { + const totalPersonal = splits.reduce((sum: number, split: any) => { + return sum + (parseFloat(split.personalAmount) || 0); + }, 0); + + if (totalPersonal > amount) { + throw error(400, 'Personal amounts cannot exceed total payment amount'); + } + } + + await dbConnect(); + + try { + const recurringPaymentData = { + title, + description, + amount, + currency: 'CHF', + paidBy, + category: category || 'groceries', + splitMethod, + splits, + frequency, + cronExpression: frequency === 'custom' ? cronExpression : undefined, + startDate: startDate ? new Date(startDate) : new Date(), + endDate: endDate ? new Date(endDate) : undefined, + createdBy: auth.user.nickname, + isActive: true, + nextExecutionDate: new Date() // Will be calculated below + }; + + // Calculate the next execution date + recurringPaymentData.nextExecutionDate = calculateNextExecutionDate({ + ...recurringPaymentData, + frequency, + cronExpression + } as any, recurringPaymentData.startDate); + + const recurringPayment = await RecurringPayment.create(recurringPaymentData); + + return json({ + success: true, + recurringPayment: recurringPayment._id + }); + + } catch (e) { + console.error('Error creating recurring payment:', e); + throw error(500, 'Failed to create recurring payment'); + } finally { + await dbDisconnect(); + } +}; \ No newline at end of file diff --git a/src/routes/api/cospend/recurring-payments/[id]/+server.ts b/src/routes/api/cospend/recurring-payments/[id]/+server.ts new file mode 100644 index 0000000..141ecff --- /dev/null +++ b/src/routes/api/cospend/recurring-payments/[id]/+server.ts @@ -0,0 +1,184 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { RecurringPayment } from '../../../../../models/RecurringPayment'; +import { dbConnect, dbDisconnect } from '../../../../../utils/db'; +import { error, json } from '@sveltejs/kit'; +import { calculateNextExecutionDate, validateCronExpression } from '../../../../../lib/utils/recurring'; +import mongoose from 'mongoose'; + +export const GET: RequestHandler = async ({ params, locals }) => { + const auth = await locals.auth(); + if (!auth || !auth.user?.nickname) { + throw error(401, 'Not logged in'); + } + + const { id } = params; + if (!id || !mongoose.Types.ObjectId.isValid(id)) { + throw error(400, 'Invalid payment ID'); + } + + await dbConnect(); + + try { + const recurringPayment = await RecurringPayment.findById(id).lean(); + + if (!recurringPayment) { + throw error(404, 'Recurring payment not found'); + } + + return json({ recurringPayment }); + } catch (e) { + console.error('Error fetching recurring payment:', e); + throw error(500, 'Failed to fetch recurring payment'); + } finally { + await dbDisconnect(); + } +}; + +export const PUT: RequestHandler = async ({ params, request, locals }) => { + const auth = await locals.auth(); + if (!auth || !auth.user?.nickname) { + throw error(401, 'Not logged in'); + } + + const { id } = params; + if (!id || !mongoose.Types.ObjectId.isValid(id)) { + throw error(400, 'Invalid payment ID'); + } + + const data = await request.json(); + const { + title, + description, + amount, + paidBy, + category, + splitMethod, + splits, + frequency, + cronExpression, + startDate, + endDate, + isActive + } = data; + + await dbConnect(); + + try { + const existingPayment = await RecurringPayment.findById(id); + if (!existingPayment) { + throw error(404, 'Recurring payment not found'); + } + + const updateData: any = {}; + + if (title !== undefined) updateData.title = title; + if (description !== undefined) updateData.description = description; + if (amount !== undefined) { + if (amount <= 0) { + throw error(400, 'Amount must be positive'); + } + updateData.amount = amount; + } + if (paidBy !== undefined) updateData.paidBy = paidBy; + if (category !== undefined) { + if (!['groceries', 'shopping', 'travel', 'restaurant', 'utilities', 'fun', 'settlement'].includes(category)) { + throw error(400, 'Invalid category'); + } + updateData.category = category; + } + if (splitMethod !== undefined) { + if (!['equal', 'full', 'proportional', 'personal_equal'].includes(splitMethod)) { + throw error(400, 'Invalid split method'); + } + updateData.splitMethod = splitMethod; + } + if (splits !== undefined) { + updateData.splits = splits; + } + if (frequency !== undefined) { + if (!['daily', 'weekly', 'monthly', 'custom'].includes(frequency)) { + throw error(400, 'Invalid frequency'); + } + updateData.frequency = frequency; + } + if (cronExpression !== undefined) { + if (frequency === 'custom' && !validateCronExpression(cronExpression)) { + throw error(400, 'Valid cron expression required for custom frequency'); + } + updateData.cronExpression = frequency === 'custom' ? cronExpression : undefined; + } + if (startDate !== undefined) updateData.startDate = new Date(startDate); + if (endDate !== undefined) updateData.endDate = endDate ? new Date(endDate) : null; + if (isActive !== undefined) updateData.isActive = isActive; + + // Validate personal + equal split method + if (splitMethod === 'personal_equal' && splits && amount) { + const totalPersonal = splits.reduce((sum: number, split: any) => { + return sum + (parseFloat(split.personalAmount) || 0); + }, 0); + + if (totalPersonal > amount) { + throw error(400, 'Personal amounts cannot exceed total payment amount'); + } + } + + // Recalculate next execution date if frequency, cron expression, or start date changed + if (frequency !== undefined || cronExpression !== undefined || startDate !== undefined) { + const updatedPayment = { ...existingPayment.toObject(), ...updateData }; + updateData.nextExecutionDate = calculateNextExecutionDate( + updatedPayment, + updateData.startDate || existingPayment.startDate + ); + } + + const recurringPayment = await RecurringPayment.findByIdAndUpdate( + id, + updateData, + { new: true, runValidators: true } + ); + + return json({ + success: true, + recurringPayment + }); + + } catch (e) { + console.error('Error updating recurring payment:', e); + if (e instanceof mongoose.Error.ValidationError) { + throw error(400, e.message); + } + throw error(500, 'Failed to update recurring payment'); + } finally { + await dbDisconnect(); + } +}; + +export const DELETE: RequestHandler = async ({ params, locals }) => { + const auth = await locals.auth(); + if (!auth || !auth.user?.nickname) { + throw error(401, 'Not logged in'); + } + + const { id } = params; + if (!id || !mongoose.Types.ObjectId.isValid(id)) { + throw error(400, 'Invalid payment ID'); + } + + await dbConnect(); + + try { + const recurringPayment = await RecurringPayment.findByIdAndDelete(id); + + if (!recurringPayment) { + throw error(404, 'Recurring payment not found'); + } + + return json({ success: true }); + + } catch (e) { + console.error('Error deleting recurring payment:', e); + throw error(500, 'Failed to delete recurring payment'); + } finally { + await dbDisconnect(); + } +}; \ No newline at end of file diff --git a/src/routes/api/cospend/recurring-payments/cron-execute/+server.ts b/src/routes/api/cospend/recurring-payments/cron-execute/+server.ts new file mode 100644 index 0000000..e80775e --- /dev/null +++ b/src/routes/api/cospend/recurring-payments/cron-execute/+server.ts @@ -0,0 +1,124 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { RecurringPayment } from '../../../../../models/RecurringPayment'; +import { Payment } from '../../../../../models/Payment'; +import { PaymentSplit } from '../../../../../models/PaymentSplit'; +import { dbConnect, dbDisconnect } from '../../../../../utils/db'; +import { error, json } from '@sveltejs/kit'; +import { calculateNextExecutionDate } from '../../../../../lib/utils/recurring'; + +// This endpoint is designed to be called by a cron job or external scheduler +// It processes all recurring payments that are due for execution +export const POST: RequestHandler = async ({ request }) => { + // Optional: Add basic authentication or API key validation here + const authHeader = request.headers.get('authorization'); + const expectedToken = process.env.CRON_API_TOKEN; + + if (expectedToken && authHeader !== `Bearer ${expectedToken}`) { + throw error(401, 'Unauthorized'); + } + + await dbConnect(); + + try { + const now = new Date(); + console.log(`[Cron] Starting recurring payments processing at ${now.toISOString()}`); + + // Find all active recurring payments that are due + const duePayments = await RecurringPayment.find({ + isActive: true, + nextExecutionDate: { $lte: now }, + $or: [ + { endDate: { $exists: false } }, + { endDate: null }, + { endDate: { $gte: now } } + ] + }); + + console.log(`[Cron] Found ${duePayments.length} due recurring payments`); + + const results = []; + let successCount = 0; + let failureCount = 0; + + for (const recurringPayment of duePayments) { + try { + console.log(`[Cron] Processing recurring payment: ${recurringPayment.title} (${recurringPayment._id})`); + + // Create the payment + const payment = await Payment.create({ + title: `${recurringPayment.title} (Auto)`, + description: `Automatically generated from recurring payment: ${recurringPayment.description || 'No description'}`, + amount: recurringPayment.amount, + currency: recurringPayment.currency, + paidBy: recurringPayment.paidBy, + date: now, + category: recurringPayment.category, + splitMethod: recurringPayment.splitMethod, + createdBy: recurringPayment.createdBy + }); + + // 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++; + results.push({ + recurringPaymentId: recurringPayment._id, + paymentId: payment._id, + title: recurringPayment.title, + amount: recurringPayment.amount, + nextExecution: nextExecutionDate, + success: true + }); + + console.log(`[Cron] Successfully processed: ${recurringPayment.title}, next execution: ${nextExecutionDate.toISOString()}`); + + } catch (paymentError) { + console.error(`[Cron] Error processing recurring payment ${recurringPayment._id}:`, paymentError); + failureCount++; + results.push({ + recurringPaymentId: recurringPayment._id, + title: recurringPayment.title, + amount: recurringPayment.amount, + success: false, + error: paymentError instanceof Error ? paymentError.message : 'Unknown error' + }); + } + } + + console.log(`[Cron] Completed processing. Success: ${successCount}, Failures: ${failureCount}`); + + return json({ + success: true, + timestamp: now.toISOString(), + processed: duePayments.length, + successful: successCount, + failed: failureCount, + results: results + }); + + } catch (e) { + console.error('[Cron] Error executing recurring payments:', e); + throw error(500, 'Failed to execute recurring payments'); + } finally { + await dbDisconnect(); + } +}; \ No newline at end of file diff --git a/src/routes/api/cospend/recurring-payments/execute/+server.ts b/src/routes/api/cospend/recurring-payments/execute/+server.ts new file mode 100644 index 0000000..bf6f114 --- /dev/null +++ b/src/routes/api/cospend/recurring-payments/execute/+server.ts @@ -0,0 +1,104 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { RecurringPayment } from '../../../../../models/RecurringPayment'; +import { Payment } from '../../../../../models/Payment'; +import { PaymentSplit } from '../../../../../models/PaymentSplit'; +import { dbConnect, dbDisconnect } from '../../../../../utils/db'; +import { error, json } from '@sveltejs/kit'; +import { calculateNextExecutionDate } from '../../../../../lib/utils/recurring'; + +export const POST: RequestHandler = async ({ locals }) => { + const auth = await locals.auth(); + if (!auth || !auth.user?.nickname) { + throw error(401, 'Not logged in'); + } + + await dbConnect(); + + try { + const now = new Date(); + + // Find all active recurring payments that are due + const duePayments = await RecurringPayment.find({ + isActive: true, + nextExecutionDate: { $lte: now }, + $or: [ + { endDate: { $exists: false } }, + { endDate: null }, + { endDate: { $gte: now } } + ] + }); + + const results = []; + + for (const recurringPayment of duePayments) { + try { + // Create the payment + const payment = await Payment.create({ + title: recurringPayment.title, + description: recurringPayment.description, + amount: recurringPayment.amount, + currency: recurringPayment.currency, + paidBy: recurringPayment.paidBy, + date: now, + category: recurringPayment.category, + splitMethod: recurringPayment.splitMethod, + createdBy: recurringPayment.createdBy + }); + + // 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 + }); + + results.push({ + recurringPaymentId: recurringPayment._id, + paymentId: payment._id, + title: recurringPayment.title, + amount: recurringPayment.amount, + nextExecution: nextExecutionDate, + success: true + }); + + } catch (paymentError) { + console.error(`Error executing recurring payment ${recurringPayment._id}:`, paymentError); + results.push({ + recurringPaymentId: recurringPayment._id, + title: recurringPayment.title, + amount: recurringPayment.amount, + success: false, + error: paymentError instanceof Error ? paymentError.message : 'Unknown error' + }); + } + } + + return json({ + success: true, + executed: results.filter(r => r.success).length, + failed: results.filter(r => !r.success).length, + results + }); + + } catch (e) { + console.error('Error executing recurring payments:', e); + throw error(500, 'Failed to execute recurring payments'); + } finally { + await dbDisconnect(); + } +}; \ No newline at end of file diff --git a/src/routes/api/cospend/recurring-payments/scheduler/+server.ts b/src/routes/api/cospend/recurring-payments/scheduler/+server.ts new file mode 100644 index 0000000..1b2cec2 --- /dev/null +++ b/src/routes/api/cospend/recurring-payments/scheduler/+server.ts @@ -0,0 +1,57 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { error, json } from '@sveltejs/kit'; +import { recurringPaymentScheduler } from '../../../../../lib/server/scheduler'; + +export const GET: RequestHandler = async ({ locals }) => { + const auth = await locals.auth(); + if (!auth || !auth.user?.nickname) { + throw error(401, 'Not logged in'); + } + + try { + const status = recurringPaymentScheduler.getStatus(); + return json({ + success: true, + scheduler: status, + message: status.isScheduled ? 'Scheduler is running' : 'Scheduler is stopped' + }); + } catch (e) { + console.error('Error getting scheduler status:', e); + throw error(500, 'Failed to get scheduler status'); + } +}; + +export const POST: RequestHandler = async ({ request, locals }) => { + const auth = await locals.auth(); + if (!auth || !auth.user?.nickname) { + throw error(401, 'Not logged in'); + } + + try { + const body = await request.json(); + const { action } = body; + + switch (action) { + case 'execute': + console.log(`[API] Manual execution requested by ${auth.user.nickname}`); + await recurringPaymentScheduler.executeNow(); + return json({ + success: true, + message: 'Manual execution completed' + }); + + case 'status': + const status = recurringPaymentScheduler.getStatus(); + return json({ + success: true, + scheduler: status + }); + + default: + throw error(400, 'Invalid action. Use "execute" or "status"'); + } + } catch (e) { + console.error('Error in scheduler API:', e); + throw error(500, 'Scheduler operation failed'); + } +}; \ No newline at end of file diff --git a/src/routes/cospend/+layout.svelte b/src/routes/cospend/+layout.svelte index a743158..c5d6cce 100644 --- a/src/routes/cospend/+layout.svelte +++ b/src/routes/cospend/+layout.svelte @@ -14,16 +14,7 @@ const match = $page.url.pathname.match(/\/cospend\/payments\/view\/([^\/]+)/); const statePaymentId = $page.state?.paymentId; const isOnDashboard = $page.route.id === '/cospend'; - - console.log('Layout debug:', { - pathname: $page.url.pathname, - routeId: $page.route.id, - match: match, - statePaymentId: statePaymentId, - isOnDashboard: isOnDashboard, - showModal: showModal - }); - + // Only show modal if we're on the dashboard AND have a payment to show if (isOnDashboard && (match || statePaymentId)) { showModal = true; @@ -39,7 +30,7 @@
- +
{#if showModal}
@@ -105,13 +96,13 @@ .layout-container.has-modal { flex-direction: column; } - + .layout-container.has-modal .main-content { flex: none; height: 50vh; overflow-y: auto; } - + .side-panel { flex: none; height: 50vh; @@ -122,4 +113,4 @@ box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1); } } - \ No newline at end of file + diff --git a/src/routes/cospend/+page.server.ts b/src/routes/cospend/+page.server.ts index f3c9373..4cf21e2 100644 --- a/src/routes/cospend/+page.server.ts +++ b/src/routes/cospend/+page.server.ts @@ -1,14 +1,38 @@ import type { PageServerLoad } from './$types'; -import { redirect } from '@sveltejs/kit'; +import { redirect, error } from '@sveltejs/kit'; -export const load: PageServerLoad = async ({ locals }) => { +export const load: PageServerLoad = async ({ locals, fetch }) => { const session = await locals.auth(); if (!session) { throw redirect(302, '/login'); } - return { - session - }; + try { + // Fetch both balance and debt data server-side using existing APIs + const [balanceResponse, debtResponse] = await Promise.all([ + fetch('/api/cospend/balance'), + fetch('/api/cospend/debts') + ]); + + if (!balanceResponse.ok) { + throw new Error('Failed to fetch balance'); + } + + if (!debtResponse.ok) { + throw new Error('Failed to fetch debt data'); + } + + const balance = await balanceResponse.json(); + const debtData = await debtResponse.json(); + + return { + session, + balance, + debtData + }; + } catch (e) { + console.error('Error loading dashboard data:', e); + throw error(500, 'Failed to load dashboard data'); + } }; \ No newline at end of file diff --git a/src/routes/cospend/+page.svelte b/src/routes/cospend/+page.svelte index 3306894..4bbc4be 100644 --- a/src/routes/cospend/+page.svelte +++ b/src/routes/cospend/+page.svelte @@ -8,16 +8,20 @@ import { getCategoryEmoji, getCategoryName } from '$lib/utils/categories'; import { isSettlementPayment, getSettlementIcon, getSettlementClasses, getSettlementReceiver } from '$lib/utils/settlements'; - export let data; // Used by the layout for session data + export let data; // Contains session data and balance from server - let balance = { + // Use server-side data, with fallback for progressive enhancement + let balance = data.balance || { netBalance: 0, recentSplits: [] }; - let loading = true; + let loading = false; // Start as false since we have server data let error = null; + // Progressive enhancement: refresh data if JavaScript is available onMount(async () => { + // Mark that JavaScript is loaded for progressive enhancement + document.body.classList.add('js-loaded'); await fetchBalance(); }); @@ -54,9 +58,12 @@ } function handlePaymentClick(paymentId, event) { - event.preventDefault(); - // Use pushState for true shallow routing - only updates URL without navigation - pushState(`/cospend/payments/view/${paymentId}`, { paymentId }); + // Progressive enhancement: if JavaScript is available, use pushState for modal behavior + if (typeof pushState !== 'undefined') { + event.preventDefault(); + pushState(`/cospend/payments/view/${paymentId}`, { paymentId }); + } + // Otherwise, let the regular link navigation happen (no preventDefault) } function getSettlementReceiverFromSplit(split) { @@ -91,11 +98,12 @@

Track and split expenses with your friends and family

- +
Add Payment View All Payments + Recurring Payments {#if balance.netBalance !== 0} Settle Debts {/if} @@ -274,6 +282,16 @@ background-color: #e8e8e8; } + .btn-recurring { + background: linear-gradient(135deg, #9c27b0, #673ab7); + color: white; + border: none; + } + + .btn-recurring:hover { + background: linear-gradient(135deg, #8e24aa, #5e35b1); + } + .btn-settlement { background: linear-gradient(135deg, #28a745, #20c997); color: white; diff --git a/src/routes/cospend/payments/+page.server.ts b/src/routes/cospend/payments/+page.server.ts index f3c9373..7e76762 100644 --- a/src/routes/cospend/payments/+page.server.ts +++ b/src/routes/cospend/payments/+page.server.ts @@ -1,14 +1,34 @@ import type { PageServerLoad } from './$types'; -import { redirect } from '@sveltejs/kit'; +import { redirect, error } from '@sveltejs/kit'; -export const load: PageServerLoad = async ({ locals }) => { +export const load: PageServerLoad = async ({ locals, fetch, url }) => { const session = await locals.auth(); if (!session) { throw redirect(302, '/login'); } - return { - session - }; + try { + // Get pagination params from URL + const limit = parseInt(url.searchParams.get('limit') || '20'); + const offset = parseInt(url.searchParams.get('offset') || '0'); + + // Fetch payments data server-side using existing API + const paymentsResponse = await fetch(`/api/cospend/payments?limit=${limit}&offset=${offset}`); + if (!paymentsResponse.ok) { + throw new Error('Failed to fetch payments'); + } + const paymentsData = await paymentsResponse.json(); + + return { + session, + payments: paymentsData.payments, + hasMore: paymentsData.payments.length === limit, + currentOffset: offset, + limit + }; + } catch (e) { + console.error('Error loading payments data:', e); + throw error(500, 'Failed to load payments data'); + } }; \ No newline at end of file diff --git a/src/routes/cospend/payments/+page.svelte b/src/routes/cospend/payments/+page.svelte index ebd426d..4817303 100644 --- a/src/routes/cospend/payments/+page.svelte +++ b/src/routes/cospend/payments/+page.svelte @@ -7,15 +7,23 @@ export let data; - let payments = []; - let loading = true; + // Use server-side data with progressive enhancement + let payments = data.payments || []; + let loading = false; // Start as false since we have server data let error = null; - let currentPage = 0; - let limit = 20; - let hasMore = true; + let currentPage = Math.floor(data.currentOffset / data.limit); + let limit = data.limit || 20; + let hasMore = data.hasMore || false; + // Progressive enhancement: only load if JavaScript is available onMount(async () => { - await loadPayments(); + // Mark that JavaScript is loaded for CSS + document.body.classList.add('js-loaded'); + + // Only refresh if we don't have server data + if (payments.length === 0) { + await loadPayments(); + } }); async function loadPayments(page = 0) { @@ -136,7 +144,7 @@ {:else}
{#each payments as payment} -
+
{#if isSettlementPayment(payment)}
@@ -234,17 +242,34 @@
{/if}
-
+ {/each}
- {#if hasMore} -
- -
- {/if} + {/if} +
{/if} @@ -350,15 +375,20 @@ } .payment-card { + display: block; background: white; border-radius: 0.75rem; padding: 1.5rem; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); transition: all 0.2s; + text-decoration: none; + color: inherit; } .payment-card:hover { box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); + text-decoration: none; + color: inherit; } .settlement-card { @@ -593,11 +623,22 @@ background-color: #c62828; } - .load-more { - text-align: center; + .pagination { + display: flex; + justify-content: center; + gap: 1rem; margin-top: 2rem; } + /* Progressive enhancement: show JS features only when JS is loaded */ + .js-only { + display: none; + } + + :global(body.js-loaded) .js-only { + display: inline-block; + } + @media (max-width: 600px) { .payments-list { padding: 1rem; diff --git a/src/routes/cospend/payments/add/+page.server.ts b/src/routes/cospend/payments/add/+page.server.ts index f3c9373..ff4e8ce 100644 --- a/src/routes/cospend/payments/add/+page.server.ts +++ b/src/routes/cospend/payments/add/+page.server.ts @@ -1,5 +1,6 @@ -import type { PageServerLoad } from './$types'; -import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; +import { redirect, fail } from '@sveltejs/kit'; +import { PREDEFINED_USERS, isPredefinedUsersMode } from '$lib/config/users'; export const load: PageServerLoad = async ({ locals }) => { const session = await locals.auth(); @@ -9,6 +10,165 @@ export const load: PageServerLoad = async ({ locals }) => { } return { - session + session, + predefinedUsers: isPredefinedUsersMode() ? PREDEFINED_USERS : [], + currentUser: session.user?.nickname || '' }; +}; + +export const actions: Actions = { + default: async ({ request, locals, fetch }) => { + const session = await locals.auth(); + + if (!session || !session.user?.nickname) { + throw redirect(302, '/login'); + } + + const formData = await request.formData(); + const title = formData.get('title')?.toString().trim(); + const description = formData.get('description')?.toString().trim() || ''; + const amount = parseFloat(formData.get('amount')?.toString() || '0'); + const paidBy = formData.get('paidBy')?.toString().trim(); + const date = formData.get('date')?.toString(); + const category = formData.get('category')?.toString() || 'groceries'; + const splitMethod = formData.get('splitMethod')?.toString() || 'equal'; + + // Basic validation + if (!title || amount <= 0 || !paidBy) { + return fail(400, { + error: 'Please fill in all required fields with valid values', + values: Object.fromEntries(formData) + }); + } + + try { + // Get users from form - either predefined or manual + const users = []; + if (isPredefinedUsersMode()) { + users.push(...PREDEFINED_USERS); + } else { + // First check if we have JavaScript-managed users (hidden inputs) + const entries = Array.from(formData.entries()); + const userEntries = entries.filter(([key]) => key.startsWith('user_')); + const jsUsers = userEntries.map(([, value]) => value.toString().trim()).filter(Boolean); + + if (jsUsers.length > 0) { + users.push(...jsUsers); + } else { + // Fallback: parse manual textarea input (no-JS mode) + const usersManual = formData.get('users_manual')?.toString().trim() || ''; + const manualUsers = usersManual.split('\n') + .map(user => user.trim()) + .filter(Boolean); + users.push(...manualUsers); + } + } + + if (users.length === 0) { + return fail(400, { + error: 'Please add at least one user to split with', + values: Object.fromEntries(formData) + }); + } + + // Calculate splits based on method + let splits = []; + + if (splitMethod === 'equal') { + const splitAmount = amount / users.length; + const paidByAmount = splitAmount - amount; // Payer gets negative (they're owed back) + + splits = users.map(user => ({ + username: user, + amount: user === paidBy ? paidByAmount : splitAmount + })); + } else if (splitMethod === 'full') { + // Payer pays everything, others owe nothing + splits = users.map(user => ({ + username: user, + amount: user === paidBy ? -amount : 0 + })); + } else if (splitMethod === 'personal_equal') { + // Get personal amounts from form + const personalAmounts = {}; + let totalPersonal = 0; + + for (const user of users) { + const personalKey = `personal_${user}`; + const personalAmount = parseFloat(formData.get(personalKey)?.toString() || '0'); + personalAmounts[user] = personalAmount; + totalPersonal += personalAmount; + } + + if (totalPersonal > amount) { + return fail(400, { + error: 'Personal amounts cannot exceed the total payment amount', + values: Object.fromEntries(formData) + }); + } + + const remainingAmount = amount - totalPersonal; + const sharedPerPerson = remainingAmount / users.length; + + splits = users.map(user => { + const personalAmount = personalAmounts[user] || 0; + const totalOwed = personalAmount + sharedPerPerson; + return { + username: user, + amount: user === paidBy ? totalOwed - amount : totalOwed, + personalAmount + }; + }); + } else { + // Default to equal split for unknown methods + const splitAmount = amount / users.length; + const paidByAmount = splitAmount - amount; + + splits = users.map(user => ({ + username: user, + amount: user === paidBy ? paidByAmount : splitAmount + })); + } + + // Submit to API + const payload = { + title, + description, + amount, + paidBy, + date: date || new Date().toISOString().split('T')[0], + category, + splitMethod, + splits + }; + + const response = await fetch('/api/cospend/payments', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const errorData = await response.json(); + return fail(400, { + error: errorData.message || 'Failed to create payment', + values: Object.fromEntries(formData) + }); + } + + // Success - redirect to payments list + throw redirect(303, '/cospend/payments'); + + } catch (error) { + if (error.status === 303) throw error; // Re-throw redirect + + console.error('Error creating payment:', error); + return fail(500, { + error: 'Failed to create payment. Please try again.', + values: Object.fromEntries(formData) + }); + } + } }; \ No newline at end of file diff --git a/src/routes/cospend/payments/add/+page.svelte b/src/routes/cospend/payments/add/+page.svelte index d938f0b..372d2dd 100644 --- a/src/routes/cospend/payments/add/+page.svelte +++ b/src/routes/cospend/payments/add/+page.svelte @@ -1,52 +1,98 @@ + + + Recurring Payments - Cospend + + +
+
+

Recurring Payments

+
+ Add Recurring Payment + ← Back to Cospend +
+
+ +
+ +
+ + {#if loading} +
Loading recurring payments...
+ {:else if error} +
Error: {error}
+ {:else if recurringPayments.length === 0} +
+

No recurring payments found

+

Create your first recurring payment to automate regular expenses like rent, utilities, or subscriptions.

+ Add Your First Recurring Payment +
+ {:else} +
+ {#each recurringPayments as payment} +
+
+
+ {getCategoryEmoji(payment.category)} +

{payment.title}

+ + {payment.isActive ? 'Active' : 'Inactive'} + +
+
+ {formatCurrency(payment.amount)} +
+
+ + {#if payment.description} +

{payment.description}

+ {/if} + +
+
+ Category: + {getCategoryName(payment.category)} +
+ +
+ Frequency: + {getFrequencyDescription(payment)} +
+ +
+ Paid by: +
+ + {payment.paidBy} +
+
+ +
+ Next execution: + + {formatNextExecution(new Date(payment.nextExecutionDate))} + +
+ + {#if payment.lastExecutionDate} +
+ Last executed: + {formatDate(payment.lastExecutionDate)} +
+ {/if} + + {#if payment.endDate} +
+ Ends: + {formatDate(payment.endDate)} +
+ {/if} +
+ +
+

Split between:

+
+ {#each payment.splits as split} +
+ + {split.username} + 0}> + {#if split.amount > 0} + owes {formatCurrency(split.amount)} + {:else if split.amount < 0} + gets {formatCurrency(split.amount)} + {:else} + even + {/if} + +
+ {/each} +
+
+ +
+ + Edit + + + +
+
+ {/each} +
+ {/if} +
+ + \ No newline at end of file diff --git a/src/routes/cospend/recurring/add/+page.server.ts b/src/routes/cospend/recurring/add/+page.server.ts new file mode 100644 index 0000000..f3c9373 --- /dev/null +++ b/src/routes/cospend/recurring/add/+page.server.ts @@ -0,0 +1,14 @@ +import type { PageServerLoad } from './$types'; +import { redirect } from '@sveltejs/kit'; + +export const load: PageServerLoad = async ({ locals }) => { + const session = await locals.auth(); + + if (!session) { + throw redirect(302, '/login'); + } + + return { + session + }; +}; \ No newline at end of file diff --git a/src/routes/cospend/recurring/add/+page.svelte b/src/routes/cospend/recurring/add/+page.svelte new file mode 100644 index 0000000..1690f08 --- /dev/null +++ b/src/routes/cospend/recurring/add/+page.svelte @@ -0,0 +1,942 @@ + + + + Add Recurring Payment - Cospend + + +
+
+

Add Recurring Payment

+ ← Back to Cospend +
+ +
+
+

Payment Details

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+
+ +
+

Recurring Schedule

+ +
+
+ + +
+ +
+ + +
+
+ + {#if formData.frequency === 'custom'} +
+ + +
+

Cron format: minute hour day-of-month month day-of-week

+

Examples:

+
    +
  • 0 9 * * * - Every day at 9:00 AM
  • +
  • 0 9 1 * * - Every 1st of the month at 9:00 AM
  • +
  • 0 9 * * 1 - Every Monday at 9:00 AM
  • +
  • 0 9 1,15 * * - 1st and 15th of every month at 9:00 AM
  • +
+
+ {#if cronError} +
Invalid cron expression
+ {/if} +
+ {/if} + +
+ + +
Leave blank for indefinite recurring payments
+
+ + {#if nextExecutionPreview} +
+

Next Execution

+

{nextExecutionPreview}

+

{getFrequencyDescription(formData)}

+
+ {/if} +
+ +
+

Split Between Users

+ + {#if predefinedMode} +
+

Splitting between predefined users:

+
+ {#each users as user} +
+ + {user} + {#if user === data.session?.user?.nickname} + You + {/if} +
+ {/each} +
+
+ {:else} +
+ {#each users as user} +
+ + {user} + {#if user !== data.session.user.nickname} + + {/if} +
+ {/each} +
+ +
+ e.key === 'Enter' && (e.preventDefault(), addUser())} + /> + +
+ {/if} +
+ +
+

Split Method

+ +
+ + + + +
+ + {#if formData.splitMethod === 'proportional'} +
+

Custom Split Amounts

+ {#each users as user} +
+ + +
+ {/each} +
+ {/if} + + {#if formData.splitMethod === 'personal_equal'} +
+

Personal Amounts

+

Enter personal amounts for each user. The remainder will be split equally.

+ {#each users as user} +
+ + +
+ {/each} + {#if formData.amount} +
+ Total Personal: CHF {Object.values(personalAmounts).reduce((sum, val) => sum + (parseFloat(val) || 0), 0).toFixed(2)} + Remainder to Split: CHF {Math.max(0, parseFloat(formData.amount) - Object.values(personalAmounts).reduce((sum, val) => sum + (parseFloat(val) || 0), 0)).toFixed(2)} + {#if personalTotalError} +
⚠️ Personal amounts exceed total payment amount!
+ {/if} +
+ {/if} +
+ {/if} + + {#if Object.keys(splitAmounts).length > 0} +
+

Split Preview

+ {#each users as user} +
+
+ + {user} +
+ 0}> + {#if splitAmounts[user] > 0} + owes CHF {splitAmounts[user].toFixed(2)} + {:else if splitAmounts[user] < 0} + is owed CHF {Math.abs(splitAmounts[user]).toFixed(2)} + {:else} + even + {/if} + +
+ {/each} +
+ {/if} +
+ + {#if error} +
{error}
+ {/if} + +
+ + +
+
+
+ + \ No newline at end of file diff --git a/src/routes/cospend/recurring/edit/[id]/+page.server.ts b/src/routes/cospend/recurring/edit/[id]/+page.server.ts new file mode 100644 index 0000000..9b75be4 --- /dev/null +++ b/src/routes/cospend/recurring/edit/[id]/+page.server.ts @@ -0,0 +1,15 @@ +import type { PageServerLoad } from './$types'; +import { redirect } from '@sveltejs/kit'; + +export const load: PageServerLoad = async ({ locals, params }) => { + const session = await locals.auth(); + + if (!session) { + throw redirect(302, '/login'); + } + + return { + session, + recurringPaymentId: params.id + }; +}; \ No newline at end of file diff --git a/src/routes/cospend/recurring/edit/[id]/+page.svelte b/src/routes/cospend/recurring/edit/[id]/+page.svelte new file mode 100644 index 0000000..d59cd5c --- /dev/null +++ b/src/routes/cospend/recurring/edit/[id]/+page.svelte @@ -0,0 +1,973 @@ + + + + Edit Recurring Payment - Cospend + + +
+
+

Edit Recurring Payment

+
+ ← Back to Recurring Payments +
+
+ + {#if loadingPayment} +
Loading recurring payment...
+ {:else if error && !formData.title} +
Error: {error}
+ {:else} +
+
+

Payment Details

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+
+ +
+

Recurring Schedule

+ +
+
+ + +
+ +
+ + +
+
+ + {#if formData.frequency === 'custom'} +
+ + +
+

Cron format: minute hour day-of-month month day-of-week

+

Examples:

+
    +
  • 0 9 * * * - Every day at 9:00 AM
  • +
  • 0 9 1 * * - Every 1st of the month at 9:00 AM
  • +
  • 0 9 * * 1 - Every Monday at 9:00 AM
  • +
  • 0 9 1,15 * * - 1st and 15th of every month at 9:00 AM
  • +
+
+ {#if cronError} +
Invalid cron expression
+ {/if} +
+ {/if} + +
+ + +
Leave blank for indefinite recurring payments
+
+ + {#if nextExecutionPreview} +
+

Next Execution

+

{nextExecutionPreview}

+

{getFrequencyDescription(formData)}

+
+ {/if} +
+ +
+

Split Between Users

+ +
+ {#each users as user} +
+ + {user} + {#if user === data.session?.user?.nickname} + You + {/if} + {#if !predefinedMode && user !== data.session?.user?.nickname} + + {/if} +
+ {/each} +
+ + {#if !predefinedMode} +
+ e.key === 'Enter' && (e.preventDefault(), addUser())} + /> + +
+ {/if} +
+ +
+

Split Method

+ +
+ + + + +
+ + {#if formData.splitMethod === 'proportional'} +
+

Custom Split Amounts

+ {#each users as user} +
+ + +
+ {/each} +
+ {/if} + + {#if formData.splitMethod === 'personal_equal'} +
+

Personal Amounts

+

Enter personal amounts for each user. The remainder will be split equally.

+ {#each users as user} +
+ + +
+ {/each} + {#if formData.amount} +
+ Total Personal: CHF {Object.values(personalAmounts).reduce((sum, val) => sum + (parseFloat(val) || 0), 0).toFixed(2)} + Remainder to Split: CHF {Math.max(0, parseFloat(formData.amount) - Object.values(personalAmounts).reduce((sum, val) => sum + (parseFloat(val) || 0), 0)).toFixed(2)} + {#if personalTotalError} +
⚠️ Personal amounts exceed total payment amount!
+ {/if} +
+ {/if} +
+ {/if} + + {#if Object.keys(splitAmounts).length > 0} +
+

Split Preview

+ {#each users as user} +
+
+ + {user} +
+ 0}> + {#if splitAmounts[user] > 0} + owes CHF {splitAmounts[user].toFixed(2)} + {:else if splitAmounts[user] < 0} + is owed CHF {Math.abs(splitAmounts[user]).toFixed(2)} + {:else} + even + {/if} + +
+ {/each} +
+ {/if} +
+ + {#if error} +
{error}
+ {/if} + +
+ + +
+
+ {/if} +
+ + \ No newline at end of file