Files
homepage/src/routes/cospend/payments/add/+page.server.ts
T
Alexander 2b857b503b feat: add multi-currency support to cospend payments
- Add ExchangeRate model for currency conversion tracking
- Implement currency utility functions for formatting and conversion
- Add exchange rates API endpoint with caching and fallback rates
- Update Payment and RecurringPayment models to support multiple currencies
- Enhanced payment forms with currency selection and conversion display
- Update split method selector with better currency handling
- Add currency-aware payment display and balance calculations
- Support for EUR, USD, GBP, and CHF with automatic exchange rate fetching
2025-09-14 19:54:31 +02:00

232 lines
8.0 KiB
TypeScript

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();
if (!session) {
throw redirect(302, '/login');
}
return {
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 currency = formData.get('currency')?.toString()?.toUpperCase() || 'CHF';
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';
// Recurring payment data
const isRecurring = formData.get('isRecurring') === 'true';
const recurringFrequency = formData.get('recurringFrequency')?.toString() || 'monthly';
const recurringCronExpression = formData.get('recurringCronExpression')?.toString() || '';
const recurringStartDate = formData.get('recurringStartDate')?.toString() || '';
const recurringEndDate = formData.get('recurringEndDate')?.toString() || '';
// Basic validation
if (!title || amount <= 0 || !paidBy) {
return fail(400, {
error: 'Please fill in all required fields with valid values',
values: Object.fromEntries(formData)
});
}
// Recurring payment validation
if (isRecurring) {
if (recurringFrequency === 'custom' && !recurringCronExpression) {
return fail(400, {
error: 'Please provide a cron expression for custom recurring payments',
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 their share of the full amount
const otherUsers = users.filter(user => user !== paidBy);
const amountPerOtherUser = otherUsers.length > 0 ? amount / otherUsers.length : 0;
splits = users.map(user => ({
username: user,
amount: user === paidBy ? -amount : amountPerOtherUser
}));
} 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,
currency,
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)
});
}
const paymentResult = await response.json();
// If this is a recurring payment, create the recurring payment record
if (isRecurring) {
const recurringPayload = {
title,
description,
amount,
currency,
paidBy,
category,
splitMethod,
splits,
frequency: recurringFrequency,
cronExpression: recurringFrequency === 'custom' ? recurringCronExpression : undefined,
startDate: recurringStartDate ? new Date(recurringStartDate).toISOString() : new Date().toISOString(),
endDate: recurringEndDate ? new Date(recurringEndDate).toISOString() : null,
isActive: true,
nextExecutionDate: recurringStartDate ? new Date(recurringStartDate).toISOString() : new Date().toISOString()
};
const recurringResponse = await fetch('/api/cospend/recurring-payments', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(recurringPayload)
});
if (!recurringResponse.ok) {
// Log the error but don't fail the entire operation since the payment was created
console.error('Failed to create recurring payment:', await recurringResponse.text());
// Could optionally return a warning to the user
}
}
// Success - redirect to dashboard
throw redirect(303, '/cospend');
} 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)
});
}
}
};