diff --git a/client/src/pages/SharedTripPage.test.tsx b/client/src/pages/SharedTripPage.test.tsx
index 84840e50..f0a2ad05 100644
--- a/client/src/pages/SharedTripPage.test.tsx
+++ b/client/src/pages/SharedTripPage.test.tsx
@@ -509,4 +509,67 @@ describe('SharedTripPage', () => {
await waitFor(() => expect(screen.getByText('Tag 1')).toBeInTheDocument());
});
});
+
+ describe('FE-PAGE-SHARED-019: budget renders in the owner\'s baseCurrency, not the EUR trip fallback (#1361)', () => {
+ it('labels totals with the payload baseCurrency even when the trip currency is EUR', async () => {
+ server.use(
+ // No FX needed when the expense is already in the base; stub frankfurter so
+ // the live-rate fetch never hits the network in tests.
+ http.get('https://api.frankfurter.dev/v2/rates', () => HttpResponse.json([])),
+ http.get('/api/shared/:token', ({ params }) => {
+ if (params.token !== 'cad-token') return;
+ return HttpResponse.json({
+ trip: { id: 1, title: 'Shared Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05', currency: 'EUR' },
+ baseCurrency: 'CAD',
+ days: [], assignments: {}, dayNotes: {}, places: [], reservations: [], accommodations: [], packing: [],
+ budget: [{ id: 1, name: 'Hotel', total_price: '200', category: 'Accommodation', currency: 'CAD' }],
+ categories: [],
+ permissions: { share_bookings: false, share_packing: false, share_budget: true, share_collab: false },
+ collab: [],
+ });
+ }),
+ );
+
+ renderSharedTrip('cad-token');
+ await waitFor(() => expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument());
+ fireEvent.click(screen.getByRole('button', { name: /budget/i }));
+
+ await waitFor(() => expect(screen.getByText('Hotel')).toBeInTheDocument());
+ // Total + per-row labelled CAD; never the EUR fallback.
+ expect(screen.getAllByText(/200\.00 CAD/).length).toBeGreaterThan(0);
+ expect(screen.queryByText(/EUR/)).toBeNull();
+ });
+ });
+
+ describe('FE-PAGE-SHARED-020: mixed-currency expenses convert into baseCurrency via live FX (#1361)', () => {
+ it('converts a EUR expense into the base using fetched rates', async () => {
+ // Distinct base (NZD) so this test can't read the cached CAD rates seeded by
+ // FE-PAGE-SHARED-019 (useExchangeRates caches per base in module memory).
+ server.use(
+ // rates[X] = units of X per 1 base(NZD); 0.8 EUR per NZD → 100 EUR = 125.00 NZD
+ // (a clean 2-decimal result, distinct from the unconverted 100).
+ http.get('https://api.frankfurter.dev/v2/rates', () => HttpResponse.json([{ quote: 'EUR', rate: 0.8 }])),
+ http.get('/api/shared/:token', ({ params }) => {
+ if (params.token !== 'mixed-token') return;
+ return HttpResponse.json({
+ trip: { id: 1, title: 'Shared Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05', currency: 'EUR' },
+ baseCurrency: 'NZD',
+ days: [], assignments: {}, dayNotes: {}, places: [], reservations: [], accommodations: [], packing: [],
+ budget: [{ id: 1, name: 'Dinner', total_price: '100', category: 'Food', currency: 'EUR' }],
+ categories: [],
+ permissions: { share_bookings: false, share_packing: false, share_budget: true, share_collab: false },
+ collab: [],
+ });
+ }),
+ );
+
+ renderSharedTrip('mixed-token');
+ await waitFor(() => expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument());
+ fireEvent.click(screen.getByRole('button', { name: /budget/i }));
+
+ await waitFor(() => expect(screen.getByText('Dinner')).toBeInTheDocument());
+ // 100 EUR / 0.8 = 125.00 NZD once the rate resolves.
+ await waitFor(() => expect(screen.getAllByText(/125\.00 NZD/).length).toBeGreaterThan(0));
+ });
+ });
});
diff --git a/client/src/pages/SharedTripPage.tsx b/client/src/pages/SharedTripPage.tsx
index 09fa6c4a..f3bcc5d8 100644
--- a/client/src/pages/SharedTripPage.tsx
+++ b/client/src/pages/SharedTripPage.tsx
@@ -42,7 +42,7 @@ function FitBoundsToPlaces({ places }: { places: any[] }) {
export default function SharedTripPage() {
const { t, locale } = useTranslation()
// Page = wiring container: share fetch + view state live in the hook.
- const { data, error, selectedDay, setSelectedDay, activeTab, setActiveTab, showLangPicker, setShowLangPicker } = useSharedTrip()
+ const { data, error, base, convert, selectedDay, setSelectedDay, activeTab, setActiveTab, showLangPicker, setShowLangPicker } = useSharedTrip()
if (error) return (
@@ -328,26 +328,30 @@ export default function SharedTripPage() {
{/* Budget */}
{activeTab === 'budget' && (budget || []).length > 0 && (() => {
+ // Pre-rework rows store currency = NULL ("the trip's own currency"); convert
+ // each expense into the owner's display base via live FX, mirroring CostsPanel.
+ const curOf = (i: any) => i.currency || trip.currency || base
const grouped = (budget || []).reduce((g: any, i: any) => { const c = i.category || t('shared.other'); (g[c] = g[c] || []).push(i); return g }, {})
- const total = (budget || []).reduce((s: number, i: any) => s + (parseFloat(i.total_price) || 0), 0)
+ const sumIn = (items: any[]) => items.reduce((s: number, i: any) => s + convert(parseFloat(i.total_price) || 0, curOf(i)), 0)
+ const total = sumIn(budget || [])
return (
{/* Total card */}
{t('shared.totalBudget')}
-
{total.toLocaleString(locale, { minimumFractionDigits: 2 })} {trip.currency || 'EUR'}
+
{total.toLocaleString(locale, { minimumFractionDigits: 2 })} {base}
{/* By category */}
{Object.entries(grouped).map(([cat, items]: [string, any]) => (
{cat}
- {items.reduce((s: number, i: any) => s + (parseFloat(i.total_price) || 0), 0).toLocaleString(locale, { minimumFractionDigits: 2 })} {trip.currency || ''}
+ {sumIn(items).toLocaleString(locale, { minimumFractionDigits: 2 })} {base}
{items.map((item: any) => (
{item.name}
- {item.total_price ? Number(item.total_price).toLocaleString(locale, { minimumFractionDigits: 2 }) : '—'}
+ {item.total_price ? `${convert(parseFloat(item.total_price) || 0, curOf(item)).toLocaleString(locale, { minimumFractionDigits: 2 })} ${base}` : '—'}
))}
diff --git a/client/src/pages/sharedTrip/useSharedTrip.ts b/client/src/pages/sharedTrip/useSharedTrip.ts
index ef682714..59990e99 100644
--- a/client/src/pages/sharedTrip/useSharedTrip.ts
+++ b/client/src/pages/sharedTrip/useSharedTrip.ts
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { shareApi } from '../../api/client'
+import { useExchangeRates } from '../../hooks/useExchangeRates'
/**
* Shared-trip (public) data hook — owns the token lookup, the read-only share
@@ -24,5 +25,12 @@ export function useSharedTrip() {
shareApi.getSharedTrip(token).then(setData).catch(() => setError(true))
}, [token])
- return { data, error, selectedDay, setSelectedDay, activeTab, setActiveTab, showLangPicker, setShowLangPicker }
+ // Budget display currency = what the share owner sees in Costs (embedded in the
+ // payload as baseCurrency), falling back to the trip's own currency, then EUR.
+ // Convert every expense into it via live FX, mirroring CostsPanel — a public
+ // viewer has no settings store, so the base comes from the payload (#1361).
+ const base = String(data?.baseCurrency || data?.trip?.currency || 'EUR').toUpperCase()
+ const { convert } = useExchangeRates(base)
+
+ return { data, error, base, convert, selectedDay, setSelectedDay, activeTab, setActiveTab, showLangPicker, setShowLangPicker }
}
diff --git a/server/src/services/shareService.ts b/server/src/services/shareService.ts
index 7021ce60..75ec76bf 100644
--- a/server/src/services/shareService.ts
+++ b/server/src/services/shareService.ts
@@ -2,6 +2,7 @@ import { db, canAccessTrip } from '../db/database';
import crypto from 'crypto';
import { loadTagsByPlaceIds } from './queryHelpers';
import { serveFilePath } from './placePhotoCache';
+import { getUserSettings } from './settingsService';
const PLACE_PHOTO_PROXY_PREFIX = '/api/maps/place-photo/';
@@ -219,8 +220,23 @@ export function getSharedTripData(token: string): Record
| null {
? db.prepare('SELECT m.*, u.username, u.avatar FROM collab_messages m JOIN users u ON m.user_id = u.id WHERE m.trip_id = ? AND m.deleted = 0 ORDER BY m.created_at').all(tripId)
: [];
+ // Display currency the share owner sees in their Costs view. A public viewer has
+ // no logged-in user, so the owner's per-user `default_currency` (with the admin
+ // instance default already merged in by getUserSettings) is embedded in the
+ // payload and used by the client to convert every expense — otherwise guests
+ // fall back to the trip's base currency and see the wrong totals (#1361).
+ // getUserSettings merges admin defaults under the user's own settings, so this
+ // honours per-user → admin-default; we then fall back to trip currency → EUR.
+ let baseCurrency = (trip as { currency?: string }).currency || 'EUR';
+ if (shareRow.created_by != null) {
+ const ownerDefault = getUserSettings(shareRow.created_by)['default_currency'];
+ if (typeof ownerDefault === 'string' && ownerDefault.trim()) {
+ baseCurrency = ownerDefault.trim();
+ }
+ }
+
return {
- trip, days, assignments, dayNotes, places, categories, permissions,
+ trip, baseCurrency, days, assignments, dayNotes, places, categories, permissions,
reservations: permissions.share_bookings ? reservations : [],
accommodations: permissions.share_bookings ? accommodations : [],
packing: permissions.share_packing ? packing : [],
diff --git a/server/tests/integration/share.test.ts b/server/tests/integration/share.test.ts
index 794a53f6..0dfd67c8 100644
--- a/server/tests/integration/share.test.ts
+++ b/server/tests/integration/share.test.ts
@@ -354,6 +354,56 @@ describe('Shared trip — ordering parity (issue #981)', () => {
});
});
+describe('Shared trip — display currency (issue #1361)', () => {
+ it('SHARE-021 — baseCurrency resolves from the share owner\'s default_currency setting', async () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id);
+ // Trip keeps the EUR default; the owner's Costs display currency is CAD.
+ testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'default_currency', ?)")
+ .run(user.id, JSON.stringify('CAD'));
+
+ const { body: { token } } = await request(app)
+ .post(`/api/trips/${trip.id}/share-link`)
+ .set('Cookie', authCookie(user.id))
+ .send({ share_budget: true });
+
+ const res = await request(app).get(`/api/shared/${token}`);
+ expect(res.status).toBe(200);
+ expect(res.body.baseCurrency).toBe('CAD');
+ });
+
+ it('SHARE-022 — baseCurrency falls back to the trip currency when the owner has no setting', async () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id);
+ testDb.prepare('UPDATE trips SET currency = ? WHERE id = ?').run('GBP', trip.id);
+
+ const { body: { token } } = await request(app)
+ .post(`/api/trips/${trip.id}/share-link`)
+ .set('Cookie', authCookie(user.id))
+ .send({ share_budget: true });
+
+ const res = await request(app).get(`/api/shared/${token}`);
+ expect(res.status).toBe(200);
+ expect(res.body.baseCurrency).toBe('GBP');
+ });
+
+ it('SHARE-023 — baseCurrency uses the admin instance default when the owner has no per-user setting', async () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id); // EUR trip default, no user setting
+ testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('default_user_setting_default_currency', ?)")
+ .run(JSON.stringify('USD'));
+
+ const { body: { token } } = await request(app)
+ .post(`/api/trips/${trip.id}/share-link`)
+ .set('Cookie', authCookie(user.id))
+ .send({ share_budget: true });
+
+ const res = await request(app).get(`/api/shared/${token}`);
+ expect(res.status).toBe(200);
+ expect(res.body.baseCurrency).toBe('USD');
+ });
+});
+
describe('Shared trip — place photos in shared links (issue #1100)', () => {
const PLACE_ID = 'ChIJsharedPhoto1100';
const PROXY_URL = `/api/maps/place-photo/${encodeURIComponent(PLACE_ID)}/bytes`;