diff --git a/client/src/api/client.ts b/client/src/api/client.ts
index 237d3e64..7417fbe6 100644
--- a/client/src/api/client.ts
+++ b/client/src/api/client.ts
@@ -225,6 +225,7 @@ export const budgetApi = {
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid }).then(r => r.data),
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
settlement: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/settlement`).then(r => r.data),
+ refreshRates: (tripId: number | string) => apiClient.post(`/trips/${tripId}/budget/refresh-rates`).then(r => r.data),
}
export const filesApi = {
diff --git a/client/src/components/Budget/BudgetPanel.tsx b/client/src/components/Budget/BudgetPanel.tsx
index e1f117bc..070cdf0b 100644
--- a/client/src/components/Budget/BudgetPanel.tsx
+++ b/client/src/components/Budget/BudgetPanel.tsx
@@ -4,12 +4,12 @@ import DOM from 'react-dom'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useTranslation } from '../../i18n'
-import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight, Download } from 'lucide-react'
+import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight, Download, RefreshCw } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
import { budgetApi } from '../../api/client'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import type { BudgetItem, BudgetMember } from '../../types'
-import { currencyDecimals } from '../../utils/formatters'
+import { currencyDecimals, CURRENCIES } from '../../utils/formatters'
interface TripMember {
id: number
@@ -31,13 +31,6 @@ interface PerPersonSummaryEntry {
}
// ── Helpers ──────────────────────────────────────────────────────────────────
-const CURRENCIES = [
- 'EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK',
- 'TRY', 'THB', 'AUD', 'CAD', 'NZD', 'BRL', 'MXN', 'INR', 'IDR', 'MYR',
- 'PHP', 'SGD', 'KRW', 'CNY', 'HKD', 'TWD', 'ZAR', 'AED', 'SAR', 'ILS',
- 'EGP', 'MAD', 'HUF', 'RON', 'BGN', 'HRK', 'ISK', 'RUB', 'UAH', 'BDT',
- 'LKR', 'VND', 'CLP', 'COP', 'PEN', 'ARS',
-]
const SYMBOLS = {
EUR: '€', USD: '$', GBP: '£', JPY: '¥', CHF: 'CHF', CZK: 'Kč', PLN: 'zł',
SEK: 'kr', NOK: 'kr', DKK: 'kr', TRY: '₺', THB: '฿', AUD: 'A$', CAD: 'C$',
@@ -101,22 +94,27 @@ function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder
// ── Add Item Row ─────────────────────────────────────────────────────────────
interface AddItemRowProps {
- onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null; expense_date: string | null }) => void
+ onAdd: (data: { name: string; total_price: number; item_currency: string; persons: number | null; days: number | null; note: string | null; expense_date: string | null }) => void
t: (key: string) => string
+ baseCurrency: string
}
-function AddItemRow({ onAdd, t }: AddItemRowProps) {
+function AddItemRow({ onAdd, t, baseCurrency }: AddItemRowProps) {
const [name, setName] = useState('')
const [price, setPrice] = useState('')
+ const [itemCur, setItemCur] = useState(baseCurrency)
const [persons, setPersons] = useState('')
const [days, setDays] = useState('')
const [note, setNote] = useState('')
const [expenseDate, setExpenseDate] = useState('')
const nameRef = useRef(null)
+ // Keep the default currency in sync with the trip's base currency
+ useEffect(() => { setItemCur(baseCurrency) }, [baseCurrency])
+
const handleAdd = () => {
if (!name.trim()) return
- onAdd({ name: name.trim(), total_price: parseFloat(String(price).replace(',', '.')) || 0, persons: parseInt(persons) || null, days: parseInt(days) || null, note: note.trim() || null, expense_date: expenseDate || null })
+ onAdd({ name: name.trim(), total_price: parseFloat(String(price).replace(',', '.')) || 0, item_currency: itemCur, persons: parseInt(persons) || null, days: parseInt(days) || null, note: note.trim() || null, expense_date: expenseDate || null })
setName(''); setPrice(''); setPersons(''); setDays(''); setNote(''); setExpenseDate('')
setTimeout(() => nameRef.current?.focus(), 50)
}
@@ -130,8 +128,20 @@ function AddItemRow({ onAdd, t }: AddItemRowProps) {
placeholder={t('budget.newEntry')} style={inp} />
- setPrice(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
- placeholder="0,00" inputMode="decimal" style={{ ...inp, textAlign: 'center' }} />
+
+ setPrice(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
+ placeholder="0,00" inputMode="decimal" style={{ ...inp, textAlign: 'center', flex: 1, minWidth: 50 }} />
+
+ ({ value: c, label: c }))}
+ searchable
+ size="sm"
+ style={{ fontSize: 11 }}
+ />
+
+
|
setPersons(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
@@ -422,13 +432,14 @@ interface BudgetPanelProps {
}
export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) {
- const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid } = useTripStore()
+ const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid, refreshBudgetRates } = useTripStore()
const can = useCanDo()
const { t, locale } = useTranslation()
const [newCategoryName, setNewCategoryName] = useState('')
const [editingCat, setEditingCat] = useState(null) // { name, value }
const [settlement, setSettlement] = useState<{ balances: any[]; flows: any[] } | null>(null)
const [settlementOpen, setSettlementOpen] = useState(false)
+ const [refreshingRates, setRefreshingRates] = useState(false)
const currency = trip?.currency || 'EUR'
const canEdit = can('budget_edit', trip)
@@ -441,8 +452,20 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
budgetApi.settlement(tripId).then(setSettlement).catch(() => {})
}, [tripId, budgetItems, hasMultipleMembers])
- const setCurrency = (cur) => {
- if (tripId) updateTrip(tripId, { currency: cur })
+ const setCurrency = async (cur) => {
+ if (!tripId) return
+ setRefreshingRates(true)
+ try {
+ await updateTrip(tripId, { currency: cur })
+ await loadBudgetItems(tripId)
+ } finally {
+ setRefreshingRates(false)
+ }
+ }
+
+ const handleRefreshRates = async () => {
+ setRefreshingRates(true)
+ try { await refreshBudgetRates(tripId) } finally { setRefreshingRates(false) }
}
useEffect(() => { if (tripId) loadBudgetItems(tripId) }, [tripId])
@@ -455,17 +478,18 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
}, {}), [budgetItems])
const categoryNames = Object.keys(grouped)
- const grandTotal = (budgetItems || []).reduce((s, i) => s + (i.total_price || 0), 0)
+ const grandTotal = (budgetItems || []).reduce((s, i) => s + (i.converted_price ?? i.total_price ?? 0), 0)
+ const hasMultipleCurrencies = (budgetItems || []).some(i => i.item_currency && i.item_currency.toUpperCase() !== currency.toUpperCase())
const pieSegments = useMemo(() =>
categoryNames.map((cat, i) => ({
name: cat,
- value: grouped[cat].reduce((s, x) => s + (x.total_price || 0), 0),
+ value: grouped[cat].reduce((s, x) => s + (x.converted_price ?? x.total_price ?? 0), 0),
color: PIE_COLORS[i % PIE_COLORS.length],
})).filter(s => s.value > 0)
, [grouped, categoryNames])
- const handleAddItem = async (category, data) => { try { await addBudgetItem(tripId, { ...data, category }) } catch {} }
+ const handleAddItem = async (category, data) => { try { await addBudgetItem(tripId, { ...data, category }); } catch {} }
const handleUpdateField = async (id, field, value) => { try { await updateBudgetItem(tripId, id, { [field]: value }) } catch {} }
const handleDeleteItem = async (id) => { try { await deleteBudgetItem(tripId, id) } catch {} }
const handleDeleteCategory = async (cat) => {
@@ -490,17 +514,19 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
const fmtPrice = (v: number | null | undefined) => v != null ? v.toFixed(d) : ''
const fmtDate = (iso: string) => { if (!iso) return ''; const d = new Date(iso + 'T00:00:00Z'); return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) }
- const header = ['Category', 'Name', 'Date', 'Total (' + currency + ')', 'Persons', 'Days', 'Per Person', 'Per Day', 'Per Person/Day', 'Note']
+ const header = ['Category', 'Name', 'Date', 'Original Price', 'Currency', 'Total (' + currency + ')', 'Persons', 'Days', 'Per Person', 'Per Day', 'Per Person/Day', 'Note']
const rows = [header.join(sep)]
for (const cat of categoryNames) {
for (const item of (grouped[cat] || [])) {
- const pp = calcPP(item.total_price, item.persons)
- const pd = calcPD(item.total_price, item.days)
- const ppd = calcPPD(item.total_price, item.persons, item.days)
+ const effectivePrice = item.converted_price ?? item.total_price ?? 0
+ const pp = calcPP(effectivePrice, item.persons)
+ const pd = calcPD(effectivePrice, item.days)
+ const ppd = calcPPD(effectivePrice, item.persons, item.days)
rows.push([
esc(item.category), esc(item.name), esc(fmtDate(item.expense_date || '')),
- fmtPrice(item.total_price), item.persons ?? '', item.days ?? '',
+ fmtPrice(item.total_price), item.item_currency || currency, fmtPrice(effectivePrice),
+ item.persons ?? '', item.days ?? '',
fmtPrice(pp), fmtPrice(pd), fmtPrice(ppd),
esc(item.note || ''),
].join(sep))
@@ -564,7 +590,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
{categoryNames.map((cat, ci) => {
const items = grouped[cat]
- const subtotal = items.reduce((s, x) => s + (x.total_price || 0), 0)
+ const subtotal = items.reduce((s, x) => s + (x.converted_price ?? x.total_price ?? 0), 0)
const color = PIE_COLORS[ci % PIE_COLORS.length]
return (
@@ -624,9 +650,10 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
{items.map(item => {
- const pp = calcPP(item.total_price, item.persons)
- const pd = calcPD(item.total_price, item.days)
- const ppd = calcPPD(item.total_price, item.persons, item.days)
+ const effectivePrice = item.converted_price ?? item.total_price ?? 0
+ const pp = calcPP(effectivePrice, item.persons)
+ const pd = calcPD(effectivePrice, item.days)
+ const ppd = calcPPD(effectivePrice, item.persons, item.days)
const hasMembers = item.members?.length > 0
return (
- handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
+
+
+ handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(item.item_currency || currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
+ {item.item_currency && item.item_currency.toUpperCase() !== currency.toUpperCase() && (
+ item.converted_price != null ? (
+
+ {'≈ ' + fmtNum(item.converted_price, locale, currency)}
+
+ ) : (
+
+ ⚠ {t('budget.noRate')}
+
+ )
+ )}
+
+
+ handleUpdateField(item.id, 'item_currency', v)}
+ options={CURRENCIES.map(c => ({ value: c, label: c }))}
+ searchable
+ size="sm"
+ style={{ fontSize: 10 }}
+ />
+
+
|
{hasMultipleMembers ? (
@@ -692,7 +744,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
)
})}
- {canEdit && handleAddItem(cat, data)} t={t} />}
+ {canEdit && handleAddItem(cat, data)} t={t} baseCurrency={currency} />}
@@ -712,6 +764,23 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
/>
+ {hasMultipleCurrencies && (
+
+ )}
+
{canEdit && (
s.isEnabled('budget'))
const budgetItems = useTripStore(s => s.budgetItems)
+ const tripCurrency = useTripStore(s => s.trip?.currency || 'EUR')
const budgetCategories = useMemo(() => {
const cats = new Set()
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
@@ -84,13 +86,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
title: '', type: 'other', status: 'pending',
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
notes: '', assignment_id: '', accommodation_id: '',
- price: '', budget_category: '',
- meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '',
- meta_departure_timezone: '', meta_arrival_timezone: '',
- meta_train_number: '', meta_platform: '', meta_seat: '',
- meta_check_in_time: '', meta_check_out_time: '',
- hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
- })
+ price: '', budget_category: '', budget_currency: '',
+ meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '',
+ meta_departure_timezone: '', meta_arrival_timezone: '',
+ meta_train_number: '', meta_platform: '', meta_seat: '',
+ meta_check_in_time: '', meta_check_out_time: '',
+ })
const [isSaving, setIsSaving] = useState(false)
const [uploadingFile, setUploadingFile] = useState(false)
const [pendingFiles, setPendingFiles] = useState([])
@@ -142,13 +143,14 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
hotel_end_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.end_day_id || '' })(),
price: meta.price || '',
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
+ budget_currency: meta.budget_currency || '',
})
} else {
setForm({
title: '', type: 'other', status: 'pending',
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
notes: '', assignment_id: '', accommodation_id: '',
- price: '', budget_category: '',
+ price: '', budget_category: '', budget_currency: '',
meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '',
meta_departure_timezone: '', meta_arrival_timezone: '',
meta_train_number: '', meta_platform: '', meta_seat: '',
@@ -201,6 +203,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
if (isBudgetEnabled) {
if (form.price) metadata.price = form.price
if (form.budget_category) metadata.budget_category = form.budget_category
+ if (form.budget_currency) metadata.budget_currency = form.budget_currency
}
const saveData: Record = {
title: form.title, type: form.type, status: form.status,
@@ -214,7 +217,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
// Auto-create/update budget entry if price is set, or signal removal if cleared
if (isBudgetEnabled) {
saveData.create_budget_entry = form.price && parseFloat(form.price) > 0
- ? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
+ ? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other', item_currency: form.budget_currency || undefined }
: { total_price: 0 }
}
// If hotel with place + days, pass hotel data for auto-creation or update
@@ -649,16 +652,26 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
- {/* Price + Budget Category — only shown when budget addon is enabled */}
{isBudgetEnabled && (
<>
- { const v = e.target.value; if (v === '' || /^\d*\.?\d{0,2}$/.test(v)) set('price', v) }}
- placeholder="0.00"
- style={inputStyle} />
+
+ { const v = e.target.value; if (v === '' || /^\d*\.?\d{0,2}$/.test(v)) set('price', v) }}
+ placeholder="0.00"
+ style={{ ...inputStyle, flex: 1, minWidth: 0 }} />
+
+ set('budget_currency', v === tripCurrency ? '' : v)}
+ options={CURRENCIES.map(c => ({ value: c, label: c }))}
+ searchable
+ size="sm"
+ />
+
+
diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts
index 43f29ee9..ed276988 100644
--- a/client/src/i18n/translations/ar.ts
+++ b/client/src/i18n/translations/ar.ts
@@ -1017,6 +1017,14 @@ const ar: Record = {
'budget.settlement': 'التسوية',
'budget.settlementInfo': 'انقر على صورة العضو في بند الميزانية لتحديده باللون الأخضر — وهذا يعني أنه دفع. ثم تُظهر التسوية من يدين لمن وبكم.',
'budget.netBalances': 'الأرصدة الصافية',
+ 'budget.refreshRates': 'تحديث أسعار الصرف',
+ 'budget.noRate': 'لا يوجد سعر صرف',
+ 'budget.itemCurrency': 'عملة العنصر',
+ 'budget.baseCurrency': 'العملة الأساسية',
+ 'budget.convertedAmount': 'المبلغ المحول',
+ 'budget.approximateConversion': '≈ محول من {currency}',
+ 'budget.ratesUpdated': 'تم تحديث الأسعار',
+ 'budget.ratesFailed': 'تعذر جلب أسعار الصرف',
// Files
'files.title': 'الملفات',
diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts
index 12612daf..8c3b952f 100644
--- a/client/src/i18n/translations/br.ts
+++ b/client/src/i18n/translations/br.ts
@@ -998,6 +998,14 @@ const br: Record = {
'budget.settlement': 'Acerto',
'budget.settlementInfo': 'Clique no avatar de um membro em um item do orçamento para marcá-lo em verde — significa que ele pagou. O acerto mostra quem deve quanto a quem.',
'budget.netBalances': 'Saldos líquidos',
+ 'budget.refreshRates': 'Atualizar taxas de câmbio',
+ 'budget.noRate': 'Taxa não disponível',
+ 'budget.itemCurrency': 'Moeda do item',
+ 'budget.baseCurrency': 'Moeda base',
+ 'budget.convertedAmount': 'Valor convertido',
+ 'budget.approximateConversion': '≈ convertido de {currency}',
+ 'budget.ratesUpdated': 'Taxas atualizadas',
+ 'budget.ratesFailed': 'Não foi possível obter taxas de câmbio',
// Files
'files.title': 'Arquivos',
diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts
index defebfb6..c5d3057d 100644
--- a/client/src/i18n/translations/cs.ts
+++ b/client/src/i18n/translations/cs.ts
@@ -1015,6 +1015,14 @@ const cs: Record = {
'budget.settlement': 'Vyúčtování',
'budget.settlementInfo': 'Klikněte na avatar člena u rozpočtové položky pro zelené označení – to znamená, že zaplatil. Vyúčtování pak ukazuje, kdo komu a kolik dluží.',
'budget.netBalances': 'Čisté zůstatky',
+ 'budget.refreshRates': 'Obnovit směnné kurzy',
+ 'budget.noRate': 'Kurz není k dispozici',
+ 'budget.itemCurrency': 'Měna položky',
+ 'budget.baseCurrency': 'Základní měna',
+ 'budget.convertedAmount': 'Převedená částka',
+ 'budget.approximateConversion': '≈ převedeno z {currency}',
+ 'budget.ratesUpdated': 'Kurzy aktualizovány',
+ 'budget.ratesFailed': 'Nepodařilo se načíst směnné kurzy',
// Soubory (Files)
'files.title': 'Soubory',
diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts
index 1c76a6c1..ffd362ef 100644
--- a/client/src/i18n/translations/de.ts
+++ b/client/src/i18n/translations/de.ts
@@ -1015,6 +1015,14 @@ const de: Record = {
'budget.settlement': 'Ausgleich',
'budget.settlementInfo': 'Klicke auf ein Mitglied-Bild bei einem Eintrag, um es grün zu markieren — das bedeutet, diese Person hat bezahlt. Der Ausgleich zeigt dann, wer wem wie viel schuldet.',
'budget.netBalances': 'Netto-Salden',
+ 'budget.refreshRates': 'Wechselkurse aktualisieren',
+ 'budget.noRate': 'Kein Kurs verfügbar',
+ 'budget.itemCurrency': 'Artikelwährung',
+ 'budget.baseCurrency': 'Basiswährung',
+ 'budget.convertedAmount': 'Umgerechneter Betrag',
+ 'budget.approximateConversion': '≈ umgerechnet von {currency}',
+ 'budget.ratesUpdated': 'Kurse aktualisiert',
+ 'budget.ratesFailed': 'Wechselkurse konnten nicht abgerufen werden',
// Files
'files.title': 'Dateien',
diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts
index 6e6cc0b0..b2d3a788 100644
--- a/client/src/i18n/translations/en.ts
+++ b/client/src/i18n/translations/en.ts
@@ -1034,6 +1034,14 @@ const en: Record = {
'budget.settlement': 'Settlement',
'budget.settlementInfo': 'Click a member avatar on a budget item to mark them green — this means they paid. The settlement then shows who owes whom and how much.',
'budget.netBalances': 'Net Balances',
+ 'budget.itemCurrency': 'Item Currency',
+ 'budget.convertedAmount': 'Converted Amount',
+ 'budget.baseCurrency': 'Base Currency',
+ 'budget.refreshRates': 'Refresh Exchange Rates',
+ 'budget.noRate': 'No rate available',
+ 'budget.ratesUpdated': 'Rates updated',
+ 'budget.ratesFailed': 'Could not fetch exchange rates',
+ 'budget.approximateConversion': '≈ converted from {currency}',
// Files
'files.title': 'Files',
diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts
index c487b25e..ecce1ee2 100644
--- a/client/src/i18n/translations/es.ts
+++ b/client/src/i18n/translations/es.ts
@@ -974,6 +974,14 @@ const es: Record = {
'budget.settlement': 'Liquidación',
'budget.settlementInfo': 'Haz clic en el avatar de un miembro en una partida del presupuesto para marcarlo en verde — esto significa que ha pagado. La liquidación muestra quién debe cuánto a quién.',
'budget.netBalances': 'Saldos netos',
+ 'budget.refreshRates': 'Actualizar tasas de cambio',
+ 'budget.noRate': 'Tasa no disponible',
+ 'budget.itemCurrency': 'Moneda del artículo',
+ 'budget.baseCurrency': 'Moneda base',
+ 'budget.convertedAmount': 'Monto convertido',
+ 'budget.approximateConversion': '≈ convertido de {currency}',
+ 'budget.ratesUpdated': 'Tasas actualizadas',
+ 'budget.ratesFailed': 'No se pudieron obtener las tasas de cambio',
// Files
'files.title': 'Archivos',
diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts
index b1615c9b..e6bcd00a 100644
--- a/client/src/i18n/translations/fr.ts
+++ b/client/src/i18n/translations/fr.ts
@@ -1013,6 +1013,14 @@ const fr: Record = {
'budget.settlement': 'Règlement',
'budget.settlementInfo': 'Cliquez sur l\'avatar d\'un membre sur un poste budgétaire pour le marquer en vert — cela signifie qu\'il a payé. Le règlement indique ensuite qui doit combien à qui.',
'budget.netBalances': 'Soldes nets',
+ 'budget.refreshRates': 'Actualiser les taux de change',
+ 'budget.noRate': 'Taux non disponible',
+ 'budget.itemCurrency': 'Devise de l\'article',
+ 'budget.baseCurrency': 'Devise de base',
+ 'budget.convertedAmount': 'Montant converti',
+ 'budget.approximateConversion': '≈ converti de {currency}',
+ 'budget.ratesUpdated': 'Taux mis à jour',
+ 'budget.ratesFailed': 'Impossible de récupérer les taux de change',
// Files
'files.title': 'Fichiers',
diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts
index 3d6c6603..f6928577 100644
--- a/client/src/i18n/translations/hu.ts
+++ b/client/src/i18n/translations/hu.ts
@@ -1014,6 +1014,14 @@ const hu: Record = {
'budget.settlement': 'Elszámolás',
'budget.settlementInfo': 'Kattints egy tag avatárjára egy költségvetési tételen a zöld jelöléshez — ez azt jelenti, hogy fizetett. Az elszámolás ezután mutatja, ki kinek mennyivel tartozik.',
'budget.netBalances': 'Nettó egyenlegek',
+ 'budget.refreshRates': 'Árfolyamok frissítése',
+ 'budget.noRate': 'Nincs elérhető árfolyam',
+ 'budget.itemCurrency': 'Tétel pénzneme',
+ 'budget.baseCurrency': 'Alap pénznem',
+ 'budget.convertedAmount': 'Átváltott összeg',
+ 'budget.approximateConversion': '≈ átváltva innen: {currency}',
+ 'budget.ratesUpdated': 'Árfolyamok frissítve',
+ 'budget.ratesFailed': 'Nem sikerült lekérni az árfolyamokat',
// Fájlok
'files.title': 'Fájlok',
diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts
index 0a504f9e..3241cdd0 100644
--- a/client/src/i18n/translations/it.ts
+++ b/client/src/i18n/translations/it.ts
@@ -1014,6 +1014,14 @@ const it: Record = {
'budget.settlement': 'Regolamento',
'budget.settlementInfo': 'Clicca sull\'avatar di un membro su una voce di budget per contrassegnarlo in verde — significa che ha pagato. Il regolamento mostra poi chi deve quanto a chi.',
'budget.netBalances': 'Saldi netti',
+ 'budget.refreshRates': 'Aggiorna tassi di cambio',
+ 'budget.noRate': 'Tasso non disponibile',
+ 'budget.itemCurrency': 'Valuta dell\'articolo',
+ 'budget.baseCurrency': 'Valuta base',
+ 'budget.convertedAmount': 'Importo convertito',
+ 'budget.approximateConversion': '≈ convertito da {currency}',
+ 'budget.ratesUpdated': 'Tassi aggiornati',
+ 'budget.ratesFailed': 'Impossibile recuperare i tassi di cambio',
// Files
'files.title': 'File',
diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts
index 93c4e780..5d5a3c5d 100644
--- a/client/src/i18n/translations/nl.ts
+++ b/client/src/i18n/translations/nl.ts
@@ -1013,6 +1013,14 @@ const nl: Record = {
'budget.settlement': 'Afrekening',
'budget.settlementInfo': 'Klik op de avatar van een lid bij een budgetpost om deze groen te markeren — dit betekent dat diegene heeft betaald. De afrekening toont vervolgens wie wie hoeveel verschuldigd is.',
'budget.netBalances': 'Nettosaldi',
+ 'budget.refreshRates': 'Wisselkoersen vernieuwen',
+ 'budget.noRate': 'Geen wisselkoers beschikbaar',
+ 'budget.itemCurrency': 'Artikelvaluta',
+ 'budget.baseCurrency': 'Basisvaluta',
+ 'budget.convertedAmount': 'Omgerekend bedrag',
+ 'budget.approximateConversion': '≈ omgerekend van {currency}',
+ 'budget.ratesUpdated': 'Koersen bijgewerkt',
+ 'budget.ratesFailed': 'Kon wisselkoersen niet ophalen',
// Files
'files.title': 'Bestanden',
diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts
index b0202860..0d354478 100644
--- a/client/src/i18n/translations/pl.ts
+++ b/client/src/i18n/translations/pl.ts
@@ -969,6 +969,14 @@ const pl: Record = {
'budget.settlement': 'Rozliczenie',
'budget.settlementInfo': 'Kliknij avatar członka przy pozycji w budżecie, aby oznaczyć go na zielono — oznacza to, że zapłacił. Rozliczenie pokaże, kto komu i ile jest winien.',
'budget.netBalances': 'Bilans',
+ 'budget.refreshRates': 'Odśwież kursy walut',
+ 'budget.noRate': 'Brak dostępnego kursu',
+ 'budget.itemCurrency': 'Waluta pozycji',
+ 'budget.baseCurrency': 'Waluta bazowa',
+ 'budget.convertedAmount': 'Kwota przeliczona',
+ 'budget.approximateConversion': '≈ przeliczone z {currency}',
+ 'budget.ratesUpdated': 'Kursy zaktualizowane',
+ 'budget.ratesFailed': 'Nie udało się pobrać kursów walut',
// Files
'files.title': 'Pliki',
diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts
index 3cf4cc74..a7ab8327 100644
--- a/client/src/i18n/translations/ru.ts
+++ b/client/src/i18n/translations/ru.ts
@@ -1013,6 +1013,14 @@ const ru: Record = {
'budget.settlement': 'Взаиморасчёт',
'budget.settlementInfo': 'Нажмите на аватар участника в строке бюджета, чтобы отметить его зелёным — это значит, что он заплатил. Взаиморасчёт покажет, кто кому и сколько должен.',
'budget.netBalances': 'Чистые балансы',
+ 'budget.refreshRates': 'Обновить курсы валют',
+ 'budget.noRate': 'Курс недоступен',
+ 'budget.itemCurrency': 'Валюта позиции',
+ 'budget.baseCurrency': 'Базовая валюта',
+ 'budget.convertedAmount': 'Конвертированная сумма',
+ 'budget.approximateConversion': '≈ конвертировано из {currency}',
+ 'budget.ratesUpdated': 'Курсы обновлены',
+ 'budget.ratesFailed': 'Не удалось получить курсы валют',
// Files
'files.title': 'Файлы',
diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts
index 5dc74216..5fa83b32 100644
--- a/client/src/i18n/translations/zh.ts
+++ b/client/src/i18n/translations/zh.ts
@@ -1013,6 +1013,14 @@ const zh: Record = {
'budget.settlement': '结算',
'budget.settlementInfo': '点击预算项目上的成员头像将其标记为绿色——表示该成员已付款。结算会显示谁欠谁多少。',
'budget.netBalances': '净余额',
+ 'budget.refreshRates': '刷新汇率',
+ 'budget.noRate': '无可用汇率',
+ 'budget.itemCurrency': '项目货币',
+ 'budget.baseCurrency': '基础货币',
+ 'budget.convertedAmount': '换算金额',
+ 'budget.approximateConversion': '≈ 从 {currency} 换算',
+ 'budget.ratesUpdated': '汇率已更新',
+ 'budget.ratesFailed': '无法获取汇率',
// Files
'files.title': '文件',
diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts
index fc35e1ab..129f0ab2 100644
--- a/client/src/i18n/translations/zhTw.ts
+++ b/client/src/i18n/translations/zhTw.ts
@@ -967,6 +967,14 @@ const zhTw: Record = {
'budget.settlement': '結算',
'budget.settlementInfo': '點選預算專案上的成員頭像將其標記為綠色——表示該成員已付款。結算會顯示誰欠誰多少。',
'budget.netBalances': '淨餘額',
+ 'budget.refreshRates': '重新整理匯率',
+ 'budget.noRate': '無可用匯率',
+ 'budget.itemCurrency': '項目貨幣',
+ 'budget.baseCurrency': '基礎貨幣',
+ 'budget.convertedAmount': '換算金額',
+ 'budget.approximateConversion': '≈ 從 {currency} 換算',
+ 'budget.ratesUpdated': '匯率已更新',
+ 'budget.ratesFailed': '無法取得匯率',
// Files
'files.title': '檔案',
diff --git a/client/src/store/slices/budgetSlice.ts b/client/src/store/slices/budgetSlice.ts
index 21b107eb..c122a2b3 100644
--- a/client/src/store/slices/budgetSlice.ts
+++ b/client/src/store/slices/budgetSlice.ts
@@ -14,6 +14,7 @@ export interface BudgetSlice {
deleteBudgetItem: (tripId: number | string, id: number) => Promise
setBudgetItemMembers: (tripId: number | string, itemId: number, userIds: number[]) => Promise<{ members: BudgetMember[]; item: BudgetItem }>
toggleBudgetMemberPaid: (tripId: number | string, itemId: number, userId: number, paid: boolean) => Promise
+ refreshBudgetRates: (tripId: number | string) => Promise
}
export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => ({
@@ -42,7 +43,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
set(state => ({
budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item)
}))
- if (result.item.reservation_id && data.total_price !== undefined) {
+ if (result.item.reservation_id && (data.total_price !== undefined || data.item_currency !== undefined)) {
get().loadReservations(tripId)
}
return result.item
@@ -82,4 +83,15 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
)
}));
},
+
+ refreshBudgetRates: async (tripId) => {
+ try {
+ const data = await budgetApi.refreshRates(tripId)
+ if (data.items) {
+ set({ budgetItems: data.items })
+ }
+ } catch (err: unknown) {
+ console.error('Failed to refresh budget rates:', err)
+ }
+ },
})
diff --git a/client/src/store/slices/remoteEventHandler.ts b/client/src/store/slices/remoteEventHandler.ts
index c3fd784d..dc3bfb16 100644
--- a/client/src/store/slices/remoteEventHandler.ts
+++ b/client/src/store/slices/remoteEventHandler.ts
@@ -3,12 +3,13 @@ import type { TripStoreState } from '../tripStore'
import type { Assignment, Place, Day, DayNote, PackingItem, TodoItem, BudgetItem, BudgetMember, Reservation, Trip, TripFile, WebSocketEvent } from '../../types'
type SetState = StoreApi['setState']
+type GetState = StoreApi['getState']
/**
* Applies a remote WebSocket event to the local Zustand store, keeping state in sync across collaborators.
* Each event type maps to an immutable state update (create/update/delete) for the relevant entity.
*/
-export function handleRemoteEvent(set: SetState, event: WebSocketEvent): void {
+export function handleRemoteEvent(set: SetState, get: GetState, event: WebSocketEvent): void {
const { type, ...payload } = event
set(state => {
@@ -214,6 +215,11 @@ export function handleRemoteEvent(set: SetState, event: WebSocketEvent): void {
: i
),
}
+ case 'budget:rates-updated': {
+ const tripId = get().trip?.id
+ if (tripId) get().loadBudgetItems(tripId)
+ return {}
+ }
// Reservations
case 'reservation:created':
diff --git a/client/src/store/tripStore.ts b/client/src/store/tripStore.ts
index fce8403f..077e0912 100644
--- a/client/src/store/tripStore.ts
+++ b/client/src/store/tripStore.ts
@@ -78,7 +78,7 @@ export const useTripStore = create((set, get) => ({
setSelectedDay: (dayId: number | null) => set({ selectedDayId: dayId }),
- handleRemoteEvent: (event: WebSocketEvent) => handleRemoteEvent(set, event),
+ handleRemoteEvent: (event: WebSocketEvent) => handleRemoteEvent(set, get, event),
loadTrip: async (tripId: number | string) => {
set({ isLoading: true, error: null })
diff --git a/client/src/types.ts b/client/src/types.ts
index 18938a45..69c0f3d8 100644
--- a/client/src/types.ts
+++ b/client/src/types.ts
@@ -20,6 +20,7 @@ export interface Trip {
description: string | null
start_date: string
end_date: string
+ currency: string
cover_url: string | null
is_archived: boolean
reminder_days: number
@@ -119,11 +120,18 @@ export interface BudgetItem {
name: string
amount: number
currency: string
+ total_price: number
+ item_currency: string
+ converted_price: number | null
category: string | null
paid_by: number | null
persons: number
+ days: number | null
+ note: string | null
+ sort_order: number
members: BudgetMember[]
expense_date: string | null
+ reservation_id: number | null
}
export interface BudgetMember {
diff --git a/client/src/utils/formatters.ts b/client/src/utils/formatters.ts
index 980f85ac..4e46b418 100644
--- a/client/src/utils/formatters.ts
+++ b/client/src/utils/formatters.ts
@@ -1,5 +1,13 @@
import type { AssignmentsMap } from '../types'
+export const CURRENCIES = [
+ 'EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK',
+ 'TRY', 'THB', 'AUD', 'CAD', 'NZD', 'BRL', 'MXN', 'INR', 'IDR', 'MYR',
+ 'PHP', 'SGD', 'KRW', 'CNY', 'HKD', 'TWD', 'ZAR', 'AED', 'SAR', 'ILS',
+ 'EGP', 'MAD', 'HUF', 'RON', 'BGN', 'HRK', 'ISK', 'RUB', 'UAH', 'BDT',
+ 'LKR', 'VND', 'CLP', 'COP', 'PEN', 'ARS',
+]
+
const ZERO_DECIMAL_CURRENCIES = new Set(['JPY', 'KRW', 'VND', 'CLP', 'ISK', 'HUF'])
export function currencyDecimals(currency: string): number {
diff --git a/server/package-lock.json b/server/package-lock.json
index 062d1241..ddf37c4a 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -1589,6 +1589,7 @@
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^4.17.33",
@@ -2179,6 +2180,7 @@
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
"license": "Apache-2.0",
+ "peer": true,
"peerDependencies": {
"bare-abort-controller": "*"
},
@@ -3158,6 +3160,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -3662,6 +3665,7 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz",
"integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -5768,6 +5772,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -5842,6 +5847,7 @@
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@@ -5997,6 +6003,7 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -6138,6 +6145,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -6151,6 +6159,7 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -6414,6 +6423,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts
index aa3a3e99..1e7e2074 100644
--- a/server/src/db/migrations.ts
+++ b/server/src/db/migrations.ts
@@ -706,7 +706,6 @@ function runMigrations(db: Database.Database): void {
try { db.exec("ALTER TABLE trip_photos ADD COLUMN album_link_id INTEGER REFERENCES trip_album_links(id) ON DELETE SET NULL DEFAULT NULL"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
db.exec('CREATE INDEX IF NOT EXISTS idx_trip_photos_album_link ON trip_photos(album_link_id)');
},
- // Migration 68: Todo items
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS todo_items (
@@ -843,7 +842,6 @@ function runMigrations(db: Database.Database): void {
const ins = db.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)');
for (const b of bagsWithUser) ins.run(b.id, b.user_id);
},
- // Migration: Per-day positions for multi-day reservations
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS reservation_day_positions (
@@ -853,7 +851,6 @@ function runMigrations(db: Database.Database): void {
PRIMARY KEY (reservation_id, day_id)
);
`);
- // Migrate existing global positions to per-day entries
const reservations = db.prepare('SELECT id, trip_id, reservation_time, reservation_end_time, day_plan_position FROM reservations WHERE day_plan_position IS NOT NULL').all() as any[];
const ins = db.prepare('INSERT OR IGNORE INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)');
for (const r of reservations) {
@@ -864,6 +861,33 @@ function runMigrations(db: Database.Database): void {
for (const d of matchingDays) ins.run(r.id, d.id, r.day_plan_position);
}
},
+ () => {
+ try { db.exec('ALTER TABLE budget_items ADD COLUMN item_currency TEXT DEFAULT NULL'); } catch {}
+ try { db.exec('ALTER TABLE budget_items ADD COLUMN converted_price REAL DEFAULT NULL'); } catch {}
+
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS exchange_rates (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ base_currency TEXT NOT NULL,
+ target_currency TEXT NOT NULL,
+ rate REAL NOT NULL,
+ fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(base_currency, target_currency)
+ );
+ CREATE INDEX IF NOT EXISTS idx_exchange_rates_base ON exchange_rates(base_currency);
+ `);
+
+ try {
+ db.exec(`
+ UPDATE budget_items
+ SET item_currency = (SELECT currency FROM trips WHERE trips.id = budget_items.trip_id),
+ converted_price = total_price
+ WHERE item_currency IS NULL
+ `);
+ } catch (e: unknown) {
+ console.error('[DB] Migration backfill error:', e instanceof Error ? e.message : e);
+ }
+ },
];
if (currentVersion < migrations.length) {
diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts
index eb2d72cb..8a774dfc 100644
--- a/server/src/db/schema.ts
+++ b/server/src/db/schema.ts
@@ -210,13 +210,26 @@ function createTables(db: Database.Database): void {
category TEXT NOT NULL DEFAULT 'Other',
name TEXT NOT NULL,
total_price REAL NOT NULL DEFAULT 0,
+ item_currency TEXT DEFAULT NULL,
+ converted_price REAL DEFAULT NULL,
persons INTEGER DEFAULT NULL,
days INTEGER DEFAULT NULL,
note TEXT,
sort_order INTEGER DEFAULT 0,
+ expense_date TEXT DEFAULT NULL,
+ reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
+ CREATE TABLE IF NOT EXISTS exchange_rates (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ base_currency TEXT NOT NULL,
+ target_currency TEXT NOT NULL,
+ rate REAL NOT NULL,
+ fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(base_currency, target_currency)
+ );
+
-- Addon system
CREATE TABLE IF NOT EXISTS addons (
id TEXT PRIMARY KEY,
@@ -400,6 +413,7 @@ function createTables(db: Database.Database): void {
CREATE INDEX IF NOT EXISTS idx_trip_members_user_id ON trip_members(user_id);
CREATE INDEX IF NOT EXISTS idx_packing_items_trip_id ON packing_items(trip_id);
CREATE INDEX IF NOT EXISTS idx_budget_items_trip_id ON budget_items(trip_id);
+ CREATE INDEX IF NOT EXISTS idx_exchange_rates_base ON exchange_rates(base_currency);
CREATE INDEX IF NOT EXISTS idx_reservations_trip_id ON reservations(trip_id);
CREATE INDEX IF NOT EXISTS idx_trip_files_trip_id ON trip_files(trip_id);
CREATE INDEX IF NOT EXISTS idx_day_notes_day_id ON day_notes(day_id);
diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts
index b2dbd8aa..2b3dba72 100644
--- a/server/src/mcp/tools.ts
+++ b/server/src/mcp/tools.ts
@@ -14,6 +14,7 @@ import {
deleteAssignment, reorderAssignments, getAssignmentForTrip, updateTime,
} from '../services/assignmentService';
import { createBudgetItem, updateBudgetItem, deleteBudgetItem } from '../services/budgetService';
+import { recalculateTrip } from '../services/exchangeRates';
import { createItem as createPackingItem, updateItem as updatePackingItem, deleteItem as deletePackingItem } from '../services/packingService';
import { createReservation, getReservation, updateReservation, deleteReservation } from '../services/reservationService';
import { getDay, updateDay, validateAccommodationRefs } from '../services/dayService';
@@ -99,9 +100,15 @@ export function registerTools(server: McpServer, userId: number): void {
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== end_date)
return { content: [{ type: 'text' as const, text: 'end_date is not a valid calendar date.' }], isError: true };
}
- const { updatedTrip } = updateTrip(tripId, userId, { title, description, start_date, end_date, currency }, 'user');
- broadcast(tripId, 'trip:updated', { trip: updatedTrip });
- return ok({ trip: updatedTrip });
+ const result = updateTrip(tripId, userId, { title, description, start_date, end_date, currency }, 'user');
+ if (result.currencyChanged) {
+ try {
+ await recalculateTrip(tripId);
+ broadcast(tripId, 'budget:rates-updated', { tripId });
+ } catch {}
+ }
+ broadcast(tripId, 'trip:updated', { trip: result.updatedTrip });
+ return ok({ trip: result.updatedTrip });
}
);
@@ -307,7 +314,7 @@ export function registerTools(server: McpServer, userId: number): void {
async ({ tripId, name, category, total_price, note }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
- const item = createBudgetItem(tripId, { category, name, total_price, note });
+ const item = await createBudgetItem(tripId, { category, name, total_price, note });
broadcast(tripId, 'budget:created', { item });
return ok({ item });
}
@@ -623,7 +630,7 @@ export function registerTools(server: McpServer, userId: number): void {
async ({ tripId, itemId, name, category, total_price, persons, days, note }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
- const item = updateBudgetItem(itemId, tripId, { name, category, total_price, persons, days, note });
+ const item = await updateBudgetItem(itemId, tripId, { name, category, total_price, persons, days, note });
if (!item) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
broadcast(tripId, 'budget:updated', { item });
return ok({ item });
diff --git a/server/src/routes/budget.ts b/server/src/routes/budget.ts
index 05763864..de30dfca 100644
--- a/server/src/routes/budget.ts
+++ b/server/src/routes/budget.ts
@@ -2,8 +2,9 @@ import express, { Request, Response } from 'express';
import { authenticate } from '../middleware/auth';
import { broadcast } from '../websocket';
import { checkPermission } from '../services/permissions';
-import { AuthRequest } from '../types';
+import { AuthRequest, Trip } from '../types';
import { db } from '../db/database';
+import { recalculateTrip, getRatesFetchedAt } from '../services/exchangeRates';
import {
verifyTripAccess,
listBudgetItems,
@@ -18,6 +19,11 @@ import {
const router = express.Router({ mergeParams: true });
+const VALID_CURRENCY = /^[A-Z]{3}$/;
+function isValidCurrency(val: unknown): val is string {
+ return typeof val === 'string' && VALID_CURRENCY.test(val.toUpperCase());
+}
+
router.get('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
@@ -38,7 +44,7 @@ router.get('/summary/per-person', authenticate, (req: Request, res: Response) =>
res.json({ summary: getPerPersonSummary(tripId) });
});
-router.post('/', authenticate, (req: Request, res: Response) => {
+router.post('/', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
@@ -48,34 +54,39 @@ router.post('/', authenticate, (req: Request, res: Response) => {
if (!checkPermission('budget_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
- const { name } = req.body;
+ const { name, item_currency } = req.body;
if (!name) return res.status(400).json({ error: 'Name is required' });
+ if (item_currency && !isValidCurrency(item_currency)) return res.status(400).json({ error: 'item_currency must be a 3-letter currency code' });
- const item = createBudgetItem(tripId, req.body);
+ const item = await createBudgetItem(tripId, req.body);
res.status(201).json({ item });
broadcast(tripId, 'budget:created', { item }, req.headers['x-socket-id'] as string);
});
-router.put('/:id', authenticate, (req: Request, res: Response) => {
+router.put('/:id', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
+ const { item_currency } = req.body;
+ if (item_currency && !isValidCurrency(item_currency)) return res.status(400).json({ error: 'item_currency must be a 3-letter currency code' });
+
if (!checkPermission('budget_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
- const updated = updateBudgetItem(id, tripId, req.body);
+ const updated = await updateBudgetItem(id, tripId, req.body);
if (!updated) return res.status(404).json({ error: 'Budget item not found' });
// Sync price back to linked reservation
- if (updated.reservation_id && req.body.total_price !== undefined) {
+ if (updated.reservation_id && (req.body.total_price !== undefined || req.body.item_currency !== undefined)) {
try {
const reservation = db.prepare('SELECT id, metadata FROM reservations WHERE id = ? AND trip_id = ?').get(updated.reservation_id, tripId) as { id: number; metadata: string | null } | undefined;
if (reservation) {
const meta = reservation.metadata ? JSON.parse(reservation.metadata) : {};
- meta.price = String(updated.total_price);
+ if (req.body.total_price !== undefined) meta.price = String(updated.total_price);
+ if (req.body.item_currency !== undefined) meta.budget_currency = updated.item_currency || '';
db.prepare('UPDATE reservations SET metadata = ? WHERE id = ?').run(JSON.stringify(meta), reservation.id);
const updatedRes = db.prepare('SELECT * FROM reservations WHERE id = ?').get(reservation.id);
broadcast(tripId, 'reservation:updated', { reservation: updatedRes }, req.headers['x-socket-id'] as string);
@@ -135,6 +146,33 @@ router.get('/settlement', authenticate, (req: Request, res: Response) => {
res.json(calculateSettlement(tripId));
});
+router.post('/refresh-rates', authenticate, async (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const { tripId } = req.params;
+
+ const trip = verifyTripAccess(Number(tripId), authReq.user.id);
+ if (!trip) return res.status(404).json({ error: 'Trip not found' });
+
+ if (!checkPermission('budget_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
+ try {
+ await recalculateTrip(tripId);
+
+ const tripData = db.prepare('SELECT currency FROM trips WHERE id = ?').get(tripId) as Trip | undefined;
+ const baseCurrency = tripData?.currency || 'EUR';
+ const lastFetched = getRatesFetchedAt(baseCurrency);
+
+ const items = listBudgetItems(tripId);
+
+ res.json({ success: true, items, rates_fetched_at: lastFetched });
+ broadcast(Number(tripId), 'budget:rates-updated', { tripId: Number(tripId) }, req.headers['x-socket-id'] as string);
+ } catch (err) {
+ console.error('[Budget] refresh-rates error:', err);
+ res.status(500).json({ error: 'Failed to refresh exchange rates' });
+ }
+});
+
router.delete('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
diff --git a/server/src/routes/reservations.ts b/server/src/routes/reservations.ts
index b3e233d4..00984c9b 100644
--- a/server/src/routes/reservations.ts
+++ b/server/src/routes/reservations.ts
@@ -27,7 +27,7 @@ router.get('/', authenticate, (req: Request, res: Response) => {
res.json({ reservations });
});
-router.post('/', authenticate, (req: Request, res: Response) => {
+router.post('/', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation, create_budget_entry } = req.body;
@@ -54,10 +54,11 @@ router.post('/', authenticate, (req: Request, res: Response) => {
if (create_budget_entry && create_budget_entry.total_price > 0) {
try {
const { createBudgetItem } = require('../services/budgetService');
- const budgetItem = createBudgetItem(tripId, {
+ const budgetItem = await createBudgetItem(tripId, {
name: title,
category: create_budget_entry.category || type || 'Other',
total_price: create_budget_entry.total_price,
+ item_currency: create_budget_entry.item_currency || undefined,
});
db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(reservation.id, budgetItem.id);
budgetItem.reservation_id = reservation.id;
@@ -98,7 +99,7 @@ router.put('/positions', authenticate, (req: Request, res: Response) => {
broadcast(tripId, 'reservation:positions', { positions, day_id }, req.headers['x-socket-id'] as string);
});
-router.put('/:id', authenticate, (req: Request, res: Response) => {
+router.put('/:id', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation, create_budget_entry } = req.body;
@@ -128,7 +129,7 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
if (linked) {
const { deleteBudgetItem } = require('../services/budgetService');
deleteBudgetItem(linked.id, tripId);
- broadcast(tripId, 'budget:deleted', { id: linked.id }, req.headers['x-socket-id'] as string);
+ broadcast(tripId, 'budget:deleted', { itemId: linked.id }, req.headers['x-socket-id'] as string);
}
}
@@ -139,17 +140,19 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
const itemName = title || current.title;
const existing = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
if (existing) {
- const updated = updateBudgetItem(existing.id, tripId, {
+ const updated = await updateBudgetItem(existing.id, tripId, {
name: itemName,
category: create_budget_entry.category || type || current.type || 'Other',
total_price: create_budget_entry.total_price,
+ item_currency: create_budget_entry.item_currency || undefined,
});
broadcast(tripId, 'budget:updated', { item: updated }, req.headers['x-socket-id'] as string);
} else {
- const budgetItem = createBudgetItem(tripId, {
+ const budgetItem = await createBudgetItem(tripId, {
name: itemName,
category: create_budget_entry.category || type || current.type || 'Other',
total_price: create_budget_entry.total_price,
+ item_currency: create_budget_entry.item_currency || undefined,
});
db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(id, budgetItem.id);
budgetItem.reservation_id = Number(id);
diff --git a/server/src/routes/trips.ts b/server/src/routes/trips.ts
index b9d7b94e..4d203ea8 100644
--- a/server/src/routes/trips.ts
+++ b/server/src/routes/trips.ts
@@ -28,6 +28,7 @@ import {
ValidationError,
TRIP_SELECT,
} from '../services/tripService';
+import { recalculateTrip } from '../services/exchangeRates';
const router = express.Router();
@@ -116,7 +117,7 @@ router.get('/:id', authenticate, (req: Request, res: Response) => {
// ── Update trip ───────────────────────────────────────────────────────────
-router.put('/:id', authenticate, (req: Request, res: Response) => {
+router.put('/:id', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const access = canAccessTrip(req.params.id, authReq.user.id);
if (!access) return res.status(404).json({ error: 'Trip not found' });
@@ -159,6 +160,15 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
}
}
+ if (result.currencyChanged) {
+ try {
+ await recalculateTrip(req.params.id);
+ broadcast(Number(req.params.id), 'budget:rates-updated', { tripId: Number(req.params.id) }, req.headers['x-socket-id'] as string);
+ } catch (err) {
+ console.error('[Trips] Failed to recalculate budget after currency change:', err);
+ }
+ }
+
res.json({ trip: result.updatedTrip });
broadcast(req.params.id, 'trip:updated', { trip: result.updatedTrip }, req.headers['x-socket-id'] as string);
} catch (e: any) {
@@ -306,11 +316,11 @@ router.post('/:id/copy', authenticate, (req: Request, res: Response) => {
// 8. Copy budget_items (paid_by_user_id reset to null)
const oldBudget = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(req.params.id) as any[];
const insertBudget = db.prepare(`
- INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ INSERT INTO budget_items (trip_id, category, name, total_price, item_currency, converted_price, persons, days, note, sort_order, expense_date)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const b of oldBudget) {
- insertBudget.run(newTripId, b.category, b.name, b.total_price, b.persons, b.days, b.note, b.sort_order);
+ insertBudget.run(newTripId, b.category, b.name, b.total_price, b.item_currency, b.converted_price, b.persons, b.days, b.note, b.sort_order, b.expense_date);
}
// 9. Copy packing_bags → build ID map
diff --git a/server/src/services/budgetService.ts b/server/src/services/budgetService.ts
index 36927c2e..ca8579c4 100644
--- a/server/src/services/budgetService.ts
+++ b/server/src/services/budgetService.ts
@@ -1,5 +1,6 @@
import { db, canAccessTrip } from '../db/database';
-import { BudgetItem, BudgetItemMember } from '../types';
+import { BudgetItem, BudgetItemMember, Trip } from '../types';
+import { convertAmount } from './exchangeRates';
// ---------------------------------------------------------------------------
// Helpers
@@ -14,12 +15,13 @@ export function verifyTripAccess(tripId: string | number, userId: number) {
}
function loadItemMembers(itemId: number | string) {
- return db.prepare(`
+ const rows = db.prepare(`
SELECT bm.user_id, bm.paid, u.username, u.avatar
FROM budget_item_members bm
JOIN users u ON bm.user_id = u.id
WHERE bm.budget_item_id = ?
`).all(itemId) as BudgetItemMember[];
+ return rows.map(m => ({ user_id: m.user_id, paid: m.paid, username: m.username, avatar_url: avatarUrl(m) }));
}
// ---------------------------------------------------------------------------
@@ -54,22 +56,34 @@ export function listBudgetItems(tripId: string | number) {
return items;
}
-export function createBudgetItem(
+export async function createBudgetItem(
tripId: string | number,
- data: { category?: string; name: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null },
+ data: { category?: string; name: string; total_price?: number; item_currency?: string; persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null },
) {
+ const tripData = db.prepare('SELECT currency FROM trips WHERE id = ?').get(tripId) as Trip | undefined;
+ const baseCurrency = (tripData?.currency || 'EUR').toUpperCase();
+ const itemCur = (data.item_currency || baseCurrency).toUpperCase();
+ const price = data.total_price || 0;
+
+ let convertedPrice: number | null = price;
+ if (itemCur !== baseCurrency) {
+ try { convertedPrice = await convertAmount(price, itemCur, baseCurrency); } catch { convertedPrice = null; }
+ }
+
const maxOrder = db.prepare(
'SELECT MAX(sort_order) as max FROM budget_items WHERE trip_id = ?'
).get(tripId) as { max: number | null };
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
const result = db.prepare(
- 'INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order, expense_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
+ 'INSERT INTO budget_items (trip_id, category, name, total_price, item_currency, converted_price, persons, days, note, sort_order, expense_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(
tripId,
data.category || 'Other',
data.name,
- data.total_price || 0,
+ price,
+ itemCur,
+ convertedPrice,
data.persons != null ? data.persons : null,
data.days !== undefined && data.days !== null ? data.days : null,
data.note || null,
@@ -82,10 +96,10 @@ export function createBudgetItem(
return item;
}
-export function updateBudgetItem(
+export async function updateBudgetItem(
id: string | number,
tripId: string | number,
- data: { category?: string; name?: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; sort_order?: number; expense_date?: string | null },
+ data: { category?: string; name?: string; total_price?: number; item_currency?: string; persons?: number | null; days?: number | null; note?: string | null; sort_order?: number; expense_date?: string | null },
) {
const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return null;
@@ -95,6 +109,7 @@ export function updateBudgetItem(
category = COALESCE(?, category),
name = COALESCE(?, name),
total_price = CASE WHEN ? IS NOT NULL THEN ? ELSE total_price END,
+ item_currency = CASE WHEN ? IS NOT NULL THEN ? ELSE item_currency END,
persons = CASE WHEN ? IS NOT NULL THEN ? ELSE persons END,
days = CASE WHEN ? THEN ? ELSE days END,
note = CASE WHEN ? THEN ? ELSE note END,
@@ -105,6 +120,7 @@ export function updateBudgetItem(
data.category || null,
data.name || null,
data.total_price !== undefined ? 1 : null, data.total_price !== undefined ? data.total_price : 0,
+ data.item_currency !== undefined ? 1 : null, data.item_currency !== undefined ? (data.item_currency?.toUpperCase() ?? null) : null,
data.persons !== undefined ? 1 : null, data.persons !== undefined ? data.persons : null,
data.days !== undefined ? 1 : 0, data.days !== undefined ? data.days : null,
data.note !== undefined ? 1 : 0, data.note !== undefined ? data.note : null,
@@ -113,6 +129,19 @@ export function updateBudgetItem(
id,
);
+ if (data.total_price !== undefined || data.item_currency !== undefined) {
+ const after = db.prepare('SELECT total_price, item_currency FROM budget_items WHERE id = ?').get(id) as { total_price: number; item_currency: string | null };
+ const tripData = db.prepare('SELECT currency FROM trips WHERE id = ?').get(tripId) as Trip | undefined;
+ const baseCurrency = (tripData?.currency || 'EUR').toUpperCase();
+ const itemCur = (after.item_currency || baseCurrency).toUpperCase();
+
+ let convertedPrice: number | null = after.total_price;
+ if (itemCur !== baseCurrency) {
+ try { convertedPrice = await convertAmount(after.total_price, itemCur, baseCurrency); } catch { convertedPrice = null; }
+ }
+ db.prepare('UPDATE budget_items SET converted_price = ? WHERE id = ?').run(convertedPrice, id);
+ }
+
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id) as BudgetItem & { members?: BudgetItemMember[] };
updated.members = loadItemMembers(id);
return updated;
@@ -147,7 +176,7 @@ export function updateMembers(id: string | number, tripId: string | number, user
db.prepare('UPDATE budget_items SET persons = NULL WHERE id = ?').run(id);
}
- const members = loadItemMembers(id).map(m => ({ ...m, avatar_url: avatarUrl(m) }));
+ const members = loadItemMembers(id);
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id) as BudgetItem;
return { members, item: updated };
}
@@ -172,8 +201,8 @@ export function toggleMemberPaid(id: string | number, userId: string | number, p
export function getPerPersonSummary(tripId: string | number) {
const summary = db.prepare(`
SELECT bm.user_id, u.username, u.avatar,
- SUM(bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id)) as total_assigned,
- SUM(CASE WHEN bm.paid = 1 THEN bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id) ELSE 0 END) as total_paid,
+ SUM(COALESCE(bi.converted_price, bi.total_price) * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id)) as total_assigned,
+ SUM(CASE WHEN bm.paid = 1 THEN COALESCE(bi.converted_price, bi.total_price) * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id) ELSE 0 END) as total_paid,
COUNT(bi.id) as items_count
FROM budget_item_members bm
JOIN budget_items bi ON bm.budget_item_id = bi.id
@@ -208,8 +237,9 @@ export function calculateSettlement(tripId: string | number) {
const payers = members.filter(m => m.paid);
if (payers.length === 0) continue; // no one marked as paid
- const sharePerMember = item.total_price / members.length;
- const paidPerPayer = item.total_price / payers.length;
+ const effectivePrice = item.converted_price ?? item.total_price;
+ const sharePerMember = effectivePrice / members.length;
+ const paidPerPayer = effectivePrice / payers.length;
for (const m of members) {
if (!balances[m.user_id]) {
diff --git a/server/src/services/exchangeRates.ts b/server/src/services/exchangeRates.ts
new file mode 100644
index 00000000..b5ac09cc
--- /dev/null
+++ b/server/src/services/exchangeRates.ts
@@ -0,0 +1,178 @@
+import { db } from '../db/database';
+
+const EXCHANGE_RATE_TTL_MS = (parseInt(process.env.EXCHANGE_RATE_TTL_HOURS || '6', 10)) * 3600 * 1000;
+const API_BASE = 'https://api.exchangerate-api.com/v4/latest';
+const FETCH_TIMEOUT_MS = 10000;
+
+interface CachedRate {
+ rate: number;
+ fetched_at: string;
+}
+
+/**
+ * Fetch all exchange rates for a base currency from the external API
+ * and upsert them into the exchange_rates table.
+ */
+export async function fetchAndCacheRates(baseCurrency: string): Promise> {
+ const url = `${API_BASE}/${baseCurrency.toUpperCase()}`;
+ const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
+ if (!res.ok) {
+ throw new Error(`Exchange rate API error: ${res.status} ${res.statusText}`);
+ }
+ const data = (await res.json()) as { rates?: Record };
+ const rates: Record = data.rates || {};
+
+ const upsert = db.prepare(`
+ INSERT INTO exchange_rates (base_currency, target_currency, rate, fetched_at)
+ VALUES (?, ?, ?, CURRENT_TIMESTAMP)
+ ON CONFLICT(base_currency, target_currency) DO UPDATE SET
+ rate = excluded.rate,
+ fetched_at = excluded.fetched_at
+ `);
+
+ const insertMany = db.transaction(() => {
+ for (const [target, rate] of Object.entries(rates)) {
+ upsert.run(baseCurrency.toUpperCase(), target.toUpperCase(), rate);
+ }
+ });
+ insertMany();
+
+ console.log(`[ExchangeRates] Cached ${Object.keys(rates).length} rates for ${baseCurrency}`);
+ return rates;
+}
+
+/**
+ * Get a cached exchange rate. Returns null if no cached rate exists
+ * or if the cached rate is stale (older than TTL).
+ */
+export function getRate(from: string, to: string): number | null {
+ from = from.toUpperCase();
+ to = to.toUpperCase();
+ if (from === to) return 1;
+
+ const row = db.prepare(
+ 'SELECT rate, fetched_at FROM exchange_rates WHERE base_currency = ? AND target_currency = ?'
+ ).get(from, to) as CachedRate | undefined;
+
+ if (!row) return null;
+
+ const age = Date.now() - new Date(row.fetched_at + 'Z').getTime();
+ if (age > EXCHANGE_RATE_TTL_MS) return null; // stale
+
+ return row.rate;
+}
+
+/**
+ * Check whether we have fresh (non-stale) cached rates for a base currency.
+ */
+export function hasFreshRates(baseCurrency: string): boolean {
+ const row = db.prepare(
+ 'SELECT fetched_at FROM exchange_rates WHERE base_currency = ? LIMIT 1'
+ ).get(baseCurrency.toUpperCase()) as CachedRate | undefined;
+ if (!row) return false;
+ const age = Date.now() - new Date(row.fetched_at + 'Z').getTime();
+ return age <= EXCHANGE_RATE_TTL_MS;
+}
+
+/**
+ * Get a cached exchange rate, ignoring staleness (fallback for when API is down).
+ */
+export function getRateAnyAge(from: string, to: string): number | null {
+ from = from.toUpperCase();
+ to = to.toUpperCase();
+ if (from === to) return 1;
+
+ const row = db.prepare(
+ 'SELECT rate FROM exchange_rates WHERE base_currency = ? AND target_currency = ?'
+ ).get(from, to) as { rate: number } | undefined;
+
+ return row?.rate ?? null;
+}
+
+/**
+ * Convert an amount from one currency to another.
+ * Fetches and caches rates if needed. Falls back to stale cache if API fails.
+ * Returns null only if no rate is available at all.
+ */
+export async function convertAmount(amount: number, from: string, to: string): Promise {
+ from = from.toUpperCase();
+ to = to.toUpperCase();
+ if (from === to) return amount;
+
+ // Try fresh cache first
+ let rate = getRate(from, to);
+ if (rate !== null) return amount * rate;
+
+ // Cache miss or stale — try fetching
+ try {
+ await fetchAndCacheRates(from);
+ rate = getRate(from, to);
+ if (rate !== null) return amount * rate;
+ } catch (err) {
+ console.error('[ExchangeRates] Fetch failed, falling back to stale cache:', err instanceof Error ? err.message : err);
+ }
+
+ // Fallback to stale cache
+ rate = getRateAnyAge(from, to);
+ if (rate !== null) return amount * rate;
+
+ return null; // No rate available at all
+}
+
+/**
+ * Recalculate all converted_price values for a trip using current rates.
+ * Called when the trip's base currency changes or when rates are manually refreshed.
+ */
+export async function recalculateTrip(tripId: number | string): Promise {
+ const trip = db.prepare('SELECT currency FROM trips WHERE id = ?').get(tripId) as { currency: string } | undefined;
+ if (!trip) return;
+
+ const baseCurrency = trip.currency.toUpperCase();
+
+ const items = db.prepare(
+ 'SELECT id, total_price, item_currency FROM budget_items WHERE trip_id = ?'
+ ).all(tripId) as { id: number; total_price: number; item_currency: string | null }[];
+
+ const update = db.prepare('UPDATE budget_items SET converted_price = ? WHERE id = ?');
+
+ const uniqueCurrencies = [...new Set(
+ items.map(i => (i.item_currency || baseCurrency).toUpperCase()).filter(c => c !== baseCurrency)
+ )];
+ for (const cur of uniqueCurrencies) {
+ if (!hasFreshRates(cur)) {
+ try { await fetchAndCacheRates(cur); } catch (err) {
+ console.error(`[ExchangeRates] Failed to fetch rates for ${cur}, will use stale cache:`, err instanceof Error ? err.message : err);
+ }
+ }
+ }
+
+ const conversions = items.map((item) => {
+ const itemCur = (item.item_currency || baseCurrency).toUpperCase();
+ if (itemCur === baseCurrency) return { id: item.id, converted: item.total_price };
+ const rate = getRate(itemCur, baseCurrency) ?? getRateAnyAge(itemCur, baseCurrency);
+ return { id: item.id, converted: rate !== null ? item.total_price * rate : null };
+ });
+
+ const failed = conversions.filter(c => c.converted === null).length;
+ if (failed > 0) {
+ console.warn(`[ExchangeRates] recalculateTrip(${tripId}): ${failed}/${conversions.length} items could not be converted (no rate available)`);
+ }
+
+ const updateAll = db.transaction(() => {
+ for (const { id, converted } of conversions) {
+ update.run(converted, id);
+ }
+ });
+ updateAll();
+}
+
+/**
+ * Get the last fetched_at timestamp for any rate with the given base currency.
+ * Used for the "rates last updated" indicator.
+ */
+export function getRatesFetchedAt(baseCurrency: string): string | null {
+ const row = db.prepare(
+ 'SELECT MAX(fetched_at) as last_fetched FROM exchange_rates WHERE base_currency = ?'
+ ).get(baseCurrency.toUpperCase()) as { last_fetched: string | null } | undefined;
+ return row?.last_fetched ?? null;
+}
diff --git a/server/src/services/tripService.ts b/server/src/services/tripService.ts
index e5d4e27d..ac7559ca 100644
--- a/server/src/services/tripService.ts
+++ b/server/src/services/tripService.ts
@@ -188,6 +188,7 @@ export interface UpdateTripResult {
newTitle: string;
newReminder: number;
oldReminder: number;
+ currencyChanged: boolean;
}
export function updateTrip(tripId: string | number, userId: number, data: UpdateTripData, userRole: string): UpdateTripResult {
@@ -203,7 +204,8 @@ export function updateTrip(tripId: string | number, userId: number, data: Update
const newDesc = description !== undefined ? description : trip.description;
const newStart = start_date !== undefined ? start_date : trip.start_date;
const newEnd = end_date !== undefined ? end_date : trip.end_date;
- const newCurrency = currency || trip.currency;
+ const newCurrency = (currency || trip.currency).toUpperCase();
+ const currencyChanged = newCurrency !== (trip.currency || '').toUpperCase();
const newArchived = is_archived !== undefined ? (is_archived ? 1 : 0) : trip.is_archived;
const newCover = cover_image !== undefined ? cover_image : trip.cover_image;
const oldReminder = (trip as any).reminder_days ?? 3;
@@ -236,7 +238,7 @@ export function updateTrip(tripId: string | number, userId: number, data: Update
const updatedTrip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId });
- return { updatedTrip, changes, isAdminEdit, ownerEmail, newTitle, newReminder, oldReminder };
+ return { updatedTrip, changes, isAdminEdit, ownerEmail, newTitle, newReminder, oldReminder, currencyChanged };
}
// ── Delete ─────────────────────────────────────────────────────────────────
@@ -449,7 +451,7 @@ export function getTripSummary(tripId: number) {
const budgetItems = listBudgetItems(tripId);
const budget = {
item_count: budgetItems.length,
- total: budgetItems.reduce((sum, i) => sum + (i.total_price || 0), 0),
+ total: budgetItems.reduce((sum, i) => sum + (i.converted_price ?? i.total_price ?? 0), 0),
currency: trip.currency,
};
diff --git a/server/src/types.ts b/server/src/types.ts
index 62808526..6255c27d 100644
--- a/server/src/types.ts
+++ b/server/src/types.ts
@@ -120,6 +120,8 @@ export interface BudgetItem {
category: string;
name: string;
total_price: number;
+ item_currency?: string | null;
+ converted_price?: number | null;
persons?: number | null;
days?: number | null;
note?: string | null;
|