feat: add multi-currency support to cospend payments
Some checks failed
CI / update (push) Failing after 5s

- 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

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-14 19:54:31 +02:00
parent c8e542eec8
commit 579cbd1bc9
13 changed files with 936 additions and 59 deletions

View File

@@ -9,6 +9,7 @@
export let personalAmounts = {};
export let currentUser = '';
export let predefinedMode = false;
export let currency = 'CHF';
let personalTotalError = false;
@@ -173,8 +174,8 @@
{/each}
{#if amount}
<div class="remainder-info" class:error={personalTotalError}>
<span>Total Personal: CHF {Object.values(personalAmounts).reduce((sum, val) => sum + (parseFloat(val) || 0), 0).toFixed(2)}</span>
<span>Remainder to Split: CHF {Math.max(0, parseFloat(amount) - Object.values(personalAmounts).reduce((sum, val) => sum + (parseFloat(val) || 0), 0)).toFixed(2)}</span>
<span>Total Personal: {currency} {Object.values(personalAmounts).reduce((sum, val) => sum + (parseFloat(val) || 0), 0).toFixed(2)}</span>
<span>Remainder to Split: {currency} {Math.max(0, parseFloat(amount) - Object.values(personalAmounts).reduce((sum, val) => sum + (parseFloat(val) || 0), 0)).toFixed(2)}</span>
{#if personalTotalError}
<div class="error-message">⚠️ Personal amounts exceed total payment amount!</div>
{/if}
@@ -194,11 +195,11 @@
</div>
<span class="amount" class:positive={splitAmounts[user] < 0} class:negative={splitAmounts[user] > 0}>
{#if splitAmounts[user] > 0}
owes CHF {splitAmounts[user].toFixed(2)}
owes {currency} {splitAmounts[user].toFixed(2)}
{:else if splitAmounts[user] < 0}
is owed CHF {Math.abs(splitAmounts[user]).toFixed(2)}
is owed {currency} {Math.abs(splitAmounts[user]).toFixed(2)}
{:else}
owes CHF {splitAmounts[user].toFixed(2)}
owes {currency} {splitAmounts[user].toFixed(2)}
{/if}
</span>
</div>

94
src/lib/utils/currency.ts Normal file
View File

@@ -0,0 +1,94 @@
import { ExchangeRate } from '../../models/ExchangeRate';
import { dbConnect, dbDisconnect } from '../../utils/db';
/**
* Convert amount from foreign currency to CHF using direct database/API access
*/
export async function convertToCHF(
amount: number,
fromCurrency: string,
date: string,
fetch?: typeof globalThis.fetch
): Promise<{
convertedAmount: number;
exchangeRate: number;
}> {
if (fromCurrency.toUpperCase() === 'CHF') {
return {
convertedAmount: amount,
exchangeRate: 1
};
}
const rate = await getExchangeRate(fromCurrency.toUpperCase(), date);
return {
convertedAmount: amount * rate,
exchangeRate: rate
};
}
/**
* Get exchange rate from database cache or fetch from API
*/
async function getExchangeRate(fromCurrency: string, date: string): Promise<number> {
const dateStr = date.split('T')[0]; // Extract YYYY-MM-DD
await dbConnect();
try {
// Try cache first
const cachedRate = await ExchangeRate.findOne({
fromCurrency,
toCurrency: 'CHF',
date: dateStr
});
if (cachedRate) {
return cachedRate.rate;
}
// Fetch from API
const rate = await fetchFromFrankfurterAPI(fromCurrency, dateStr);
// Cache the result
await ExchangeRate.create({
fromCurrency,
toCurrency: 'CHF',
rate,
date: dateStr
});
return rate;
} finally {
await dbDisconnect();
}
}
/**
* Fetch exchange rate from Frankfurter API
*/
async function fetchFromFrankfurterAPI(fromCurrency: string, date: string): Promise<number> {
const url = `https://api.frankfurter.app/${date}?from=${fromCurrency}&to=CHF`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Frankfurter API request failed: ${response.status}`);
}
const data = await response.json();
if (!data.rates || !data.rates.CHF) {
throw new Error(`No exchange rate found for ${fromCurrency} to CHF on ${date}`);
}
return data.rates.CHF;
}
/**
* Validate currency code (3-letter ISO code)
*/
export function isValidCurrencyCode(currency: string): boolean {
return /^[A-Z]{3}$/.test(currency.toUpperCase());
}