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;