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:
@@ -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);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -247,16 +319,46 @@
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="amount">Amount (CHF) *</label>
|
||||
<input
|
||||
type="number"
|
||||
id="amount"
|
||||
bind:value={formData.amount}
|
||||
required
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
<label for="amount">Amount *</label>
|
||||
<div class="amount-currency">
|
||||
<input
|
||||
type="number"
|
||||
id="amount"
|
||||
bind:value={formData.amount}
|
||||
required
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
<select id="currency" bind:value={formData.currency} disabled={loadingCurrencies}>
|
||||
{#each supportedCurrencies as currency}
|
||||
<option value={currency}>{currency}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{#if formData.currency !== 'CHF'}
|
||||
<div class="conversion-info">
|
||||
<small class="help-text">Amount will be converted to CHF using exchange rates on each execution</small>
|
||||
|
||||
{#if loadingExchangeRate}
|
||||
<div class="conversion-preview loading">
|
||||
<small>🔄 Fetching exchange rate for start date...</small>
|
||||
</div>
|
||||
{:else if exchangeRateError}
|
||||
<div class="conversion-preview error">
|
||||
<small>⚠️ {exchangeRateError}</small>
|
||||
</div>
|
||||
{:else if convertedAmount !== null && currentExchangeRate !== null && formData.amount}
|
||||
<div class="conversion-preview success">
|
||||
<small>
|
||||
{formData.currency} {parseFloat(formData.amount).toFixed(2)} ≈ CHF {convertedAmount.toFixed(2)}
|
||||
<br>
|
||||
(Rate for start date: 1 {formData.currency} = {currentExchangeRate.toFixed(4)} CHF)
|
||||
</small>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user