feat: add multi-currency support to cospend payments
Some checks failed
CI / update (push) Failing after 5s
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:
@@ -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
94
src/lib/utils/currency.ts
Normal 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());
|
||||
}
|
||||
Reference in New Issue
Block a user