diff --git a/src/lib/components/SplitMethodSelector.svelte b/src/lib/components/SplitMethodSelector.svelte index 4be72dd..e364209 100644 --- a/src/lib/components/SplitMethodSelector.svelte +++ b/src/lib/components/SplitMethodSelector.svelte @@ -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}
- Total Personal: CHF {Object.values(personalAmounts).reduce((sum, val) => sum + (parseFloat(val) || 0), 0).toFixed(2)} - Remainder to Split: CHF {Math.max(0, parseFloat(amount) - Object.values(personalAmounts).reduce((sum, val) => sum + (parseFloat(val) || 0), 0)).toFixed(2)} + Total Personal: {currency} {Object.values(personalAmounts).reduce((sum, val) => sum + (parseFloat(val) || 0), 0).toFixed(2)} + Remainder to Split: {currency} {Math.max(0, parseFloat(amount) - Object.values(personalAmounts).reduce((sum, val) => sum + (parseFloat(val) || 0), 0)).toFixed(2)} {#if personalTotalError}
⚠️ Personal amounts exceed total payment amount!
{/if} @@ -194,11 +195,11 @@
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} diff --git a/src/lib/utils/currency.ts b/src/lib/utils/currency.ts new file mode 100644 index 0000000..c3ce617 --- /dev/null +++ b/src/lib/utils/currency.ts @@ -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 { + 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 { + 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()); +} \ No newline at end of file diff --git a/src/models/ExchangeRate.ts b/src/models/ExchangeRate.ts new file mode 100644 index 0000000..a76cfec --- /dev/null +++ b/src/models/ExchangeRate.ts @@ -0,0 +1,47 @@ +import mongoose from 'mongoose'; + +export interface IExchangeRate { + _id?: string; + fromCurrency: string; // e.g., "USD" + toCurrency: string; // Always "CHF" for our use case + rate: number; + date: string; // Date in YYYY-MM-DD format + createdAt?: Date; + updatedAt?: Date; +} + +const ExchangeRateSchema = new mongoose.Schema( + { + fromCurrency: { + type: String, + required: true, + uppercase: true, + trim: true + }, + toCurrency: { + type: String, + required: true, + uppercase: true, + trim: true, + default: 'CHF' + }, + rate: { + type: Number, + required: true, + min: 0 + }, + date: { + type: String, + required: true, + match: /^\d{4}-\d{2}-\d{2}$/ + } + }, + { + timestamps: true + } +); + +// Create compound index for efficient lookups +ExchangeRateSchema.index({ fromCurrency: 1, toCurrency: 1, date: 1 }, { unique: true }); + +export const ExchangeRate = mongoose.model("ExchangeRate", ExchangeRateSchema); \ No newline at end of file diff --git a/src/models/Payment.ts b/src/models/Payment.ts index 537d7d3..5a14b6c 100644 --- a/src/models/Payment.ts +++ b/src/models/Payment.ts @@ -4,8 +4,10 @@ export interface IPayment { _id?: string; title: string; description?: string; - amount: number; - currency: string; + amount: number; // Always in CHF (converted if necessary) + currency: string; // Currency code (CHF if no conversion, foreign currency if converted) + originalAmount?: number; // Amount in foreign currency (only if currency != CHF) + exchangeRate?: number; // Exchange rate used for conversion (only if currency != CHF) paidBy: string; // username/nickname of the person who paid date: Date; image?: string; // path to uploaded image @@ -36,7 +38,17 @@ const PaymentSchema = new mongoose.Schema( type: String, required: true, default: 'CHF', - enum: ['CHF'] // For now only CHF as requested + uppercase: true + }, + originalAmount: { + type: Number, + required: false, + min: 0 + }, + exchangeRate: { + type: Number, + required: false, + min: 0 }, paidBy: { type: String, diff --git a/src/models/RecurringPayment.ts b/src/models/RecurringPayment.ts index f5b203b..36c2350 100644 --- a/src/models/RecurringPayment.ts +++ b/src/models/RecurringPayment.ts @@ -4,16 +4,16 @@ export interface IRecurringPayment { _id?: string; title: string; description?: string; - amount: number; - currency: string; + amount: number; // Amount in the original currency + currency: string; // Original currency code 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; + amount?: number; // Amount in original currency proportion?: number; - personalAmount?: number; + personalAmount?: number; // Amount in original currency }>; frequency: 'daily' | 'weekly' | 'monthly' | 'custom'; cronExpression?: string; // For custom frequencies using cron syntax @@ -47,7 +47,7 @@ const RecurringPaymentSchema = new mongoose.Schema( type: String, required: true, default: 'CHF', - enum: ['CHF'] + uppercase: true }, paidBy: { type: String, diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte index de9817a..24d9d64 100644 --- a/src/routes/+error.svelte +++ b/src/routes/+error.svelte @@ -88,15 +88,15 @@
{getErrorIcon(status)}
- +

{getErrorTitle(status)}

- +
Fehler {status}
- +

{getErrorDescription(status)}

@@ -163,7 +163,7 @@ \ No newline at end of file + diff --git a/src/routes/api/cospend/exchange-rates/+server.ts b/src/routes/api/cospend/exchange-rates/+server.ts new file mode 100644 index 0000000..f3c47cd --- /dev/null +++ b/src/routes/api/cospend/exchange-rates/+server.ts @@ -0,0 +1,115 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { ExchangeRate } from '../../../../models/ExchangeRate'; +import { dbConnect } from '../../../../utils/db'; +import { error, json } from '@sveltejs/kit'; + +export const GET: RequestHandler = async ({ locals, url }) => { + const auth = await locals.auth(); + if (!auth || !auth.user?.nickname) { + throw error(401, 'Not logged in'); + } + + const fromCurrency = url.searchParams.get('from')?.toUpperCase(); + const date = url.searchParams.get('date'); + const action = url.searchParams.get('action') || 'rate'; + + if (action === 'currencies') { + return await getSupportedCurrencies(); + } + + if (!fromCurrency || !date) { + throw error(400, 'Missing required parameters: from and date'); + } + + if (!isValidCurrencyCode(fromCurrency)) { + throw error(400, 'Invalid currency code'); + } + + try { + const rate = await getExchangeRate(fromCurrency, date); + return json({ rate, fromCurrency, toCurrency: 'CHF', date }); + } catch (e) { + console.error('Error getting exchange rate:', e); + throw error(500, 'Failed to get exchange rate'); + } +}; + +async function getExchangeRate(fromCurrency: string, date: string): Promise { + if (fromCurrency === 'CHF') { + return 1; + } + + 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 { + // Connection will be reused + } +} + +async function fetchFromFrankfurterAPI(fromCurrency: string, date: string): Promise { + 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; +} + +async function getSupportedCurrencies() { + try { + const response = await fetch('https://api.frankfurter.app/currencies'); + + if (!response.ok) { + throw new Error(`API request failed: ${response.status}`); + } + + const data = await response.json(); + const currencies = Object.keys(data); + + return json({ currencies }); + } catch (e) { + console.error('Error fetching supported currencies:', e); + // Return common currencies as fallback + const fallbackCurrencies = ['EUR', 'USD', 'GBP', 'JPY', 'CAD', 'AUD', 'SEK', 'NOK', 'DKK']; + return json({ currencies: fallbackCurrencies }); + } +} + +function isValidCurrencyCode(currency: string): boolean { + return /^[A-Z]{3}$/.test(currency); +} \ No newline at end of file diff --git a/src/routes/cospend/payments/+page.svelte b/src/routes/cospend/payments/+page.svelte index 61c4c8a..f658f1c 100644 --- a/src/routes/cospend/payments/+page.svelte +++ b/src/routes/cospend/payments/+page.svelte @@ -80,13 +80,21 @@ } } - function formatCurrency(amount) { + function formatCurrency(amount, currency = 'CHF') { return new Intl.NumberFormat('de-CH', { style: 'currency', - currency: 'CHF' + currency: currency }).format(amount); } + function formatAmountWithCurrency(payment) { + if (payment.currency === 'CHF' || !payment.originalAmount) { + return formatCurrency(payment.amount); + } + + return `${formatCurrency(payment.originalAmount, payment.currency)} ≈ ${formatCurrency(payment.amount)}`; + } + function formatDate(dateString) { return new Date(dateString).toLocaleDateString('de-CH'); } @@ -158,7 +166,7 @@
- {formatCurrency(payment.amount)} + {formatAmountWithCurrency(payment)} {formatDate(payment.date)}
{:else} @@ -172,7 +180,7 @@
{getCategoryName(payment.category || 'groceries')} {formatDate(payment.date)} - {formatCurrency(payment.amount)} + {formatAmountWithCurrency(payment)}
diff --git a/src/routes/cospend/payments/add/+page.server.ts b/src/routes/cospend/payments/add/+page.server.ts index 7eabc20..3341445 100644 --- a/src/routes/cospend/payments/add/+page.server.ts +++ b/src/routes/cospend/payments/add/+page.server.ts @@ -28,6 +28,7 @@ export const actions: Actions = { 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'; @@ -155,6 +156,7 @@ export const actions: Actions = { title, description, amount, + currency, paidBy, date: date || new Date().toISOString().split('T')[0], category, @@ -186,6 +188,7 @@ export const actions: Actions = { title, description, amount, + currency, paidBy, category, splitMethod, diff --git a/src/routes/cospend/payments/add/+page.svelte b/src/routes/cospend/payments/add/+page.svelte index e9f1e37..443ee4c 100644 --- a/src/routes/cospend/payments/add/+page.svelte +++ b/src/routes/cospend/payments/add/+page.svelte @@ -18,6 +18,7 @@ title: form?.values?.title || '', description: form?.values?.description || '', amount: form?.values?.amount || '', + currency: form?.values?.currency || 'CHF', paidBy: form?.values?.paidBy || data.currentUser || '', date: form?.values?.date || new Date().toISOString().split('T')[0], category: form?.values?.category || 'groceries', @@ -46,6 +47,13 @@ let jsEnhanced = false; let cronError = false; let nextExecutionPreview = ''; + let supportedCurrencies = ['CHF']; + let loadingCurrencies = false; + let currentExchangeRate = null; + let convertedAmount = null; + let loadingExchangeRate = false; + let exchangeRateError = null; + let exchangeRateTimeout; // Initialize users from server data for no-JS support let users = predefinedMode ? [...data.predefinedUsers] : (data.currentUser ? [data.currentUser] : []); @@ -89,7 +97,7 @@ } })(); - onMount(() => { + onMount(async () => { jsEnhanced = true; document.body.classList.add('js-loaded'); @@ -110,8 +118,65 @@ addSplitForUser(data.currentUser); } } + + // Load supported currencies + await loadSupportedCurrencies(); }); + async function loadSupportedCurrencies() { + try { + loadingCurrencies = true; + const response = await fetch('/api/cospend/exchange-rates?action=currencies'); + if (response.ok) { + const data = await response.json(); + supportedCurrencies = ['CHF', ...data.currencies.filter(c => c !== 'CHF')]; + } + } catch (e) { + console.warn('Could not load supported currencies:', e); + // Keep default CHF + } finally { + loadingCurrencies = false; + } + } + + async function fetchExchangeRate() { + if (formData.currency === 'CHF' || !formData.currency || !formData.date) { + currentExchangeRate = null; + convertedAmount = null; + exchangeRateError = null; + return; + } + + if (!formData.amount || parseFloat(formData.amount) <= 0) { + convertedAmount = null; + return; + } + + try { + loadingExchangeRate = true; + exchangeRateError = null; + + const url = `/api/cospend/exchange-rates?from=${formData.currency}&date=${formData.date}`; + + const response = await fetch(url); + + if (!response.ok) { + throw new Error('Failed to fetch exchange rate'); + } + + const data = await response.json(); + currentExchangeRate = data.rate; + convertedAmount = parseFloat(formData.amount) * data.rate; + } catch (e) { + console.warn('Could not fetch exchange rate:', e); + exchangeRateError = e.message; + currentExchangeRate = null; + convertedAmount = null; + } finally { + loadingExchangeRate = false; + } + } + function handleImageSelected(event) { imageFile = event.detail; } @@ -190,6 +255,7 @@ const payload = { ...formData, amount: parseFloat(formData.amount), + currency: formData.currency, image: imagePath, splits }; @@ -257,6 +323,13 @@ $: if (recurringData.frequency || recurringData.cronExpression || recurringData.startDate || formData.isRecurring) { updateNextExecutionPreview(); } + + // Fetch exchange rate when currency, amount, or date changes + $: if (jsEnhanced && formData.currency && formData.currency !== 'CHF' && formData.date && formData.amount) { + // Add a small delay to avoid excessive API calls while user is typing + clearTimeout(exchangeRateTimeout); + exchangeRateTimeout = setTimeout(fetchExchangeRate, 300); + } @@ -307,28 +380,61 @@
- - + +
+ + +
+ {#if formData.currency !== 'CHF'} +
+ Amount will be converted to CHF using exchange rates for the payment date + + {#if loadingExchangeRate} +
+ 🔄 Fetching exchange rate... +
+ {:else if exchangeRateError} +
+ ⚠️ {exchangeRateError} +
+ {:else if convertedAmount !== null && currentExchangeRate !== null && formData.amount} +
+ + {formData.currency} {parseFloat(formData.amount).toFixed(2)} ≈ CHF {convertedAmount.toFixed(2)} +
+ (Rate: 1 {formData.currency} = {currentExchangeRate.toFixed(4)} CHF) +
+
+ {/if} +
+ {/if}
- + + {#if formData.currency !== 'CHF'} + Exchange rate will be fetched for this date + {/if}
@@ -480,6 +586,7 @@ bind:personalAmounts={personalAmounts} {users} amount={formData.amount} + currency={formData.currency} paidBy={formData.paidBy} currentUser={data.session?.user?.nickname || data.currentUser} {predefinedMode} @@ -851,6 +958,71 @@ } } + /* Amount-currency styling */ + .amount-currency { + display: flex; + gap: 0.5rem; + } + + .amount-currency input { + flex: 2; + } + + .amount-currency select { + flex: 1; + min-width: 80px; + } + + /* Currency conversion preview */ + .conversion-info { + margin-top: 0.5rem; + } + + .conversion-preview { + margin-top: 0.5rem; + padding: 0.75rem; + border-radius: 0.5rem; + border: 1px solid transparent; + } + + .conversion-preview.loading { + background-color: var(--nord8); + border-color: var(--blue); + color: var(--blue); + } + + .conversion-preview.error { + background-color: var(--nord6); + border-color: var(--red); + color: var(--red); + } + + .conversion-preview.success { + background-color: var(--nord14); + border-color: var(--green); + color: var(--nord0); + } + + .conversion-preview small { + font-size: 0.85rem; + font-weight: 500; + } + + @media (prefers-color-scheme: dark) { + .conversion-preview.loading { + background-color: var(--nord2); + } + + .conversion-preview.error { + background-color: var(--accent-dark); + } + + .conversion-preview.success { + background-color: var(--nord2); + color: var(--font-default-dark); + } + } + @media (max-width: 600px) { .add-payment { padding: 1rem; @@ -863,5 +1035,14 @@ .form-actions { flex-direction: column; } + + .amount-currency { + flex-direction: column; + } + + .amount-currency input, + .amount-currency select { + flex: none; + } } \ No newline at end of file diff --git a/src/routes/cospend/payments/edit/[id]/+page.svelte b/src/routes/cospend/payments/edit/[id]/+page.svelte index 92a35b1..790e1b5 100644 --- a/src/routes/cospend/payments/edit/[id]/+page.svelte +++ b/src/routes/cospend/payments/edit/[id]/+page.svelte @@ -14,11 +14,22 @@ let error = null; let imageFile = null; let imagePreview = ''; + let supportedCurrencies = ['CHF']; + let loadingCurrencies = false; + let currentExchangeRate = null; + let convertedAmount = null; + let loadingExchangeRate = false; + let exchangeRateError = null; + let exchangeRateTimeout; + let jsEnhanced = false; $: categoryOptions = getCategoryOptions(); onMount(async () => { + jsEnhanced = true; + document.body.classList.add('js-loaded'); await loadPayment(); + await loadSupportedCurrencies(); }); async function loadPayment() { @@ -139,6 +150,71 @@ deleting = false; } } + + async function loadSupportedCurrencies() { + try { + loadingCurrencies = true; + const response = await fetch('/api/cospend/exchange-rates?action=currencies'); + if (response.ok) { + const data = await response.json(); + supportedCurrencies = ['CHF', ...data.currencies.filter(c => c !== 'CHF')]; + } + } catch (e) { + console.warn('Could not load supported currencies:', e); + } finally { + loadingCurrencies = false; + } + } + + async function fetchExchangeRate() { + if (!payment || payment.currency === 'CHF' || !payment.currency || !payment.date) { + currentExchangeRate = null; + convertedAmount = null; + exchangeRateError = null; + return; + } + + if (!payment.originalAmount || payment.originalAmount <= 0) { + convertedAmount = null; + return; + } + + try { + loadingExchangeRate = true; + exchangeRateError = null; + + const dateStr = new Date(payment.date).toISOString().split('T')[0]; + const url = `/api/cospend/exchange-rates?from=${payment.currency}&date=${dateStr}`; + + const response = await fetch(url); + + if (!response.ok) { + throw new Error('Failed to fetch exchange rate'); + } + + const data = await response.json(); + currentExchangeRate = data.rate; + convertedAmount = payment.originalAmount * data.rate; + } catch (e) { + console.warn('Could not fetch exchange rate:', e); + exchangeRateError = e.message; + currentExchangeRate = null; + convertedAmount = null; + } finally { + loadingExchangeRate = false; + } + } + + // Reactive statement for exchange rate fetching + $: if (jsEnhanced && payment && payment.currency && payment.currency !== 'CHF' && payment.date && payment.originalAmount) { + clearTimeout(exchangeRateTimeout); + exchangeRateTimeout = setTimeout(fetchExchangeRate, 300); + } + + function formatDateForInput(dateString) { + if (!dateString) return ''; + return new Date(dateString).toISOString().split('T')[0]; + } @@ -188,15 +264,66 @@
- - + +
+ {#if payment.originalAmount && payment.currency !== 'CHF'} + + + + {:else} + + + + {/if} +
+ + {#if payment.currency !== 'CHF' && payment.originalAmount} +
+ Original amount in {payment.currency}, converted to CHF at payment date + + {#if loadingExchangeRate} +
+ 🔄 Fetching current exchange rate... +
+ {:else if exchangeRateError} +
+ ⚠️ {exchangeRateError} +
+ {:else if convertedAmount !== null && currentExchangeRate !== null} +
+ + {payment.currency} {payment.originalAmount.toFixed(2)} ≈ CHF {convertedAmount.toFixed(2)} +
+ (Current rate: 1 {payment.currency} = {currentExchangeRate.toFixed(4)} CHF) +
+ Stored: CHF {payment.amount.toFixed(2)} (Rate: {payment.exchangeRate ? payment.exchangeRate.toFixed(4) : 'N/A'}) +
+
+ {/if} +
+ {/if}
@@ -204,7 +331,7 @@ payment.date = new Date(e.target.value).toISOString()} required /> @@ -537,6 +664,82 @@ cursor: not-allowed; } + /* Amount-currency styling */ + .amount-currency { + display: flex; + gap: 0.5rem; + } + + .amount-currency input { + flex: 2; + } + + .amount-currency select { + flex: 1; + min-width: 80px; + } + + /* Currency conversion preview */ + .conversion-info { + margin-top: 0.5rem; + } + + .conversion-preview { + margin-top: 0.5rem; + padding: 0.75rem; + border-radius: 0.5rem; + border: 1px solid transparent; + } + + .conversion-preview.loading { + background-color: var(--nord8); + border-color: var(--blue); + color: var(--blue); + } + + .conversion-preview.error { + background-color: var(--nord6); + border-color: var(--red); + color: var(--red); + } + + .conversion-preview.success { + background-color: var(--nord14); + border-color: var(--green); + color: var(--nord0); + } + + .conversion-preview small { + font-size: 0.85rem; + font-weight: 500; + } + + .help-text { + display: block; + margin-top: 0.25rem; + font-size: 0.8rem; + color: var(--nord3); + font-style: italic; + } + + @media (prefers-color-scheme: dark) { + .conversion-preview.loading { + background-color: var(--nord2); + } + + .conversion-preview.error { + background-color: var(--accent-dark); + } + + .conversion-preview.success { + background-color: var(--nord2); + color: var(--font-default-dark); + } + + .help-text { + color: var(--nord4); + } + } @media (max-width: 600px) { .edit-payment { @@ -555,5 +758,14 @@ .main-actions { flex-direction: column; } + + .amount-currency { + flex-direction: column; + } + + .amount-currency input, + .amount-currency select { + flex: none; + } } \ No newline at end of file diff --git a/src/routes/cospend/payments/view/[id]/+page.svelte b/src/routes/cospend/payments/view/[id]/+page.svelte index 31835c5..d2783a0 100644 --- a/src/routes/cospend/payments/view/[id]/+page.svelte +++ b/src/routes/cospend/payments/view/[id]/+page.svelte @@ -39,13 +39,21 @@ } } - function formatCurrency(amount) { + function formatCurrency(amount, currency = 'CHF') { return new Intl.NumberFormat('de-CH', { style: 'currency', - currency: 'CHF' + currency: currency }).format(Math.abs(amount)); } + function formatAmountWithCurrency(payment) { + if (payment.currency === 'CHF' || !payment.originalAmount) { + return formatCurrency(payment.amount); + } + + return `${formatCurrency(payment.originalAmount, payment.currency)} ≈ ${formatCurrency(payment.amount)}`; + } + function formatDate(dateString) { return new Date(dateString).toLocaleDateString('de-CH'); } @@ -111,7 +119,12 @@

{payment.title}

- {formatCurrency(payment.amount)} + {formatAmountWithCurrency(payment)} + {#if payment.currency !== 'CHF' && payment.exchangeRate} +
+ Exchange rate: 1 {payment.currency} = {payment.exchangeRate.toFixed(4)} CHF +
+ {/if}
{#if payment.image} @@ -467,6 +480,22 @@ color: var(--red); } + .exchange-rate-info { + margin-top: 0.5rem; + color: var(--nord3); + font-style: italic; + } + + .exchange-rate-info small { + font-size: 0.8rem; + } + + @media (prefers-color-scheme: dark) { + .exchange-rate-info { + color: var(--nord4); + } + } + @media (max-width: 600px) { .payment-view { padding: 1rem; diff --git a/src/routes/cospend/recurring/edit/[id]/+page.svelte b/src/routes/cospend/recurring/edit/[id]/+page.svelte index cd9b33d..91a9d97 100644 --- a/src/routes/cospend/recurring/edit/[id]/+page.svelte +++ b/src/routes/cospend/recurring/edit/[id]/+page.svelte @@ -14,6 +14,7 @@ title: '', description: '', amount: '', + currency: 'CHF', paidBy: data.session?.user?.nickname || '', category: 'groceries', splitMethod: 'equal', @@ -35,11 +36,22 @@ let predefinedMode = isPredefinedUsersMode(); let cronError = false; let nextExecutionPreview = ''; + let supportedCurrencies = ['CHF']; + let loadingCurrencies = false; + let currentExchangeRate = null; + let convertedAmount = null; + let loadingExchangeRate = false; + let exchangeRateError = null; + let exchangeRateTimeout; + let jsEnhanced = false; $: categoryOptions = getCategoryOptions(); onMount(async () => { + jsEnhanced = true; + document.body.classList.add('js-loaded'); await loadRecurringPayment(); + await loadSupportedCurrencies(); }); async function loadRecurringPayment() { @@ -58,6 +70,7 @@ title: payment.title, description: payment.description || '', amount: payment.amount.toString(), + currency: payment.currency || 'CHF', paidBy: payment.paidBy, category: payment.category, splitMethod: payment.splitMethod, @@ -192,6 +205,65 @@ $: if (formData.frequency || formData.cronExpression || formData.startDate) { updateNextExecutionPreview(); } + + async function loadSupportedCurrencies() { + try { + loadingCurrencies = true; + const response = await fetch('/api/cospend/exchange-rates?action=currencies'); + if (response.ok) { + const data = await response.json(); + supportedCurrencies = ['CHF', ...data.currencies.filter(c => c !== 'CHF')]; + } + } catch (e) { + console.warn('Could not load supported currencies:', e); + } finally { + loadingCurrencies = false; + } + } + + async function fetchExchangeRate() { + if (formData.currency === 'CHF' || !formData.currency || !formData.startDate) { + currentExchangeRate = null; + convertedAmount = null; + exchangeRateError = null; + return; + } + + if (!formData.amount || parseFloat(formData.amount) <= 0) { + convertedAmount = null; + return; + } + + try { + loadingExchangeRate = true; + exchangeRateError = null; + + const url = `/api/cospend/exchange-rates?from=${formData.currency}&date=${formData.startDate}`; + + const response = await fetch(url); + + if (!response.ok) { + throw new Error('Failed to fetch exchange rate'); + } + + const data = await response.json(); + currentExchangeRate = data.rate; + convertedAmount = parseFloat(formData.amount) * data.rate; + } catch (e) { + console.warn('Could not fetch exchange rate:', e); + exchangeRateError = e.message; + currentExchangeRate = null; + convertedAmount = null; + } finally { + loadingExchangeRate = false; + } + } + + // Reactive statement for exchange rate fetching + $: if (jsEnhanced && formData.currency && formData.currency !== 'CHF' && formData.startDate && formData.amount) { + clearTimeout(exchangeRateTimeout); + exchangeRateTimeout = setTimeout(fetchExchangeRate, 300); + } @@ -247,16 +319,46 @@
- - + +
+ + +
+ {#if formData.currency !== 'CHF'} +
+ Amount will be converted to CHF using exchange rates on each execution + + {#if loadingExchangeRate} +
+ 🔄 Fetching exchange rate for start date... +
+ {:else if exchangeRateError} +
+ ⚠️ {exchangeRateError} +
+ {:else if convertedAmount !== null && currentExchangeRate !== null && formData.amount} +
+ + {formData.currency} {parseFloat(formData.amount).toFixed(2)} ≈ CHF {convertedAmount.toFixed(2)} +
+ (Rate for start date: 1 {formData.currency} = {currentExchangeRate.toFixed(4)} CHF) +
+
+ {/if} +
+ {/if}
@@ -363,6 +465,7 @@ bind:personalAmounts={personalAmounts} {users} amount={formData.amount} + currency={formData.currency} paidBy={formData.paidBy} currentUser={data.session?.user?.nickname} {predefinedMode} @@ -637,6 +740,69 @@ .btn-secondary:hover { background-color: var(--nord2); } + + .conversion-preview.loading { + background-color: var(--nord2); + } + + .conversion-preview.error { + background-color: var(--accent-dark); + } + + .conversion-preview.success { + background-color: var(--nord2); + color: var(--font-default-dark); + } + } + + /* Amount-currency styling */ + .amount-currency { + display: flex; + gap: 0.5rem; + } + + .amount-currency input { + flex: 2; + } + + .amount-currency select { + flex: 1; + min-width: 80px; + } + + /* Currency conversion preview */ + .conversion-info { + margin-top: 0.5rem; + } + + .conversion-preview { + margin-top: 0.5rem; + padding: 0.75rem; + border-radius: 0.5rem; + border: 1px solid transparent; + } + + .conversion-preview.loading { + background-color: var(--nord8); + border-color: var(--blue); + color: var(--blue); + } + + .conversion-preview.error { + background-color: var(--nord6); + border-color: var(--red); + color: var(--red); + } + + .conversion-preview.success { + background-color: var(--nord14); + border-color: var(--green); + color: var(--nord0); + } + + .conversion-preview small { + font-size: 0.85rem; + font-weight: 500; } @media (max-width: 600px) { @@ -651,5 +817,14 @@ .form-actions { flex-direction: column; } + + .amount-currency { + flex-direction: column; + } + + .amount-currency input, + .amount-currency select { + flex: none; + } } \ No newline at end of file