From 7acd0a64371eeabb8c830f594c68c3144c037fb2 Mon Sep 17 00:00:00 2001 From: jubnl Date: Mon, 29 Jun 2026 13:18:21 +0200 Subject: [PATCH] fix(share): show user currency instead of the default euro in the share page --- client/src/pages/SharedTripPage.test.tsx | 63 ++++++++++++++++++++ client/src/pages/SharedTripPage.tsx | 14 +++-- client/src/pages/sharedTrip/useSharedTrip.ts | 10 +++- server/src/services/shareService.ts | 18 +++++- server/tests/integration/share.test.ts | 50 ++++++++++++++++ 5 files changed, 148 insertions(+), 7 deletions(-) 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`;