diff --git a/client/src/components/Budget/CostsPanel.test.tsx b/client/src/components/Budget/CostsPanel.test.tsx
index 577982115..f7d5b5ab5 100644
--- a/client/src/components/Budget/CostsPanel.test.tsx
+++ b/client/src/components/Budget/CostsPanel.test.tsx
@@ -1185,6 +1185,26 @@ describe('CostsPanel — expense rows', () => {
expect(screen.getByText(/\$20\.00 → 10,00 €/)).toBeInTheDocument()
})
+ it('FE-W5COSTS-078: a booked rate outlives the live one, for the expense and for the transfer', async () => {
+ // What a tester hit after settling up: the euro moved, and so did a bill that had
+ // already been paid. A cost is money that changed hands at a rate that was true that
+ // day, so the frozen rate wins over whatever the market says this morning.
+ localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 2 }, ts: Date.now() }))
+ mount([expense({
+ id: 131, name: 'Diner', category: 'food', total_price: 120, currency: 'USD', exchange_rate: 1.2,
+ expense_date: '2025-06-15',
+ payers: [{ user_id: 1, amount: 120 }],
+ members: [{ user_id: 1, username: 'alice' }, { user_id: 2, username: 'bob' }],
+ })], { settlements: [{ id: 9, from_user_id: 2, to_user_id: 1, amount: 24, currency: 'USD', exchange_rate: 1.2, created_at: '2025-06-15 09:00:00' }] })
+
+ await screen.findByText('Diner')
+ // 120 USD at the booked 1.2 per euro is 100 euro. At today's 2 it would read 60,00 euro.
+ expect(screen.getByText(/\$120\.00 → 100,00 €/)).toBeInTheDocument()
+ expect(screen.queryByText(/60,00 €/)).toBeNull()
+ // The settled transfer is read back the same way: 24 USD booked at 1.2 is 20 euro.
+ expect(screen.getByText(/\$24\.00 → 20,00 €/)).toBeInTheDocument()
+ })
+
it('FE-W5COSTS-022: deleting an expense removes it, and a failure is reported', async () => {
const addToast = vi.fn()
window.__addToast = addToast as unknown as typeof window.__addToast
diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx
index c27e5aba8..95e2d5850 100644
--- a/client/src/components/Budget/CostsPanel.tsx
+++ b/client/src/components/Budget/CostsPanel.tsx
@@ -9,7 +9,7 @@ import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { budgetApi } from '../../api/client'
import { saveWithReceipts } from './receiptUploads'
-import { useExchangeRates } from '../../hooks/useExchangeRates'
+import { convertBooked, useExchangeRates } from '../../hooks/useExchangeRates'
import { useIsMobile } from '../../hooks/useIsMobile'
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, amountToInputString } from '../../utils/formatters'
import { downloadBlob, openFile } from '../../utils/fileDownload'
@@ -41,6 +41,9 @@ interface Settlement {
// The currency the transfer was entered in. Legacy rows predate it (null) and are
// read as the display currency, which is what the server assumes for them too.
currency?: string | null
+ // The rate frozen when the transfer was settled, in units of `currency` per 1 trip
+ // currency (#1445). Absent, or exactly 1, on rows written before the freeze existed.
+ exchange_rate?: number
created_at?: string
// The day the transfer actually happened; editable, unlike created_at (when it
// was recorded). Null/absent on rows predating this field — settlementDate()
@@ -135,8 +138,19 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
}, [searchParams])
// ── derived expense maths (everything converted to the base currency) ────
- const baseTotal = (e: BudgetItem) => convert(e.total_price || 0, curOf(e))
- const myPaidOf = (e: BudgetItem) => convert(paidByUser(e, me), curOf(e))
+ // Booked, not live: an expense entered in a foreign currency keeps the rate it was
+ // entered at, which is the same rule the server settles by (#1335).
+ const booked = useCallback(
+ (amount: number, e: BudgetItem) => convertBooked(amount, e.currency, e.exchange_rate, tripCurrency, convert),
+ [convert, tripCurrency],
+ )
+ const baseTotal = (e: BudgetItem) => booked(e.total_price || 0, e)
+ // A transfer freezes its own rate at settle time, in its own table (#1445).
+ const settled = useCallback(
+ (s: Settlement) => convertBooked(s.amount, s.currency, s.exchange_rate, tripCurrency, convert),
+ [convert, tripCurrency],
+ )
+ const myPaidOf = (e: BudgetItem) => booked(paidByUser(e, me), e)
// "Unfinished": a recorded total nobody has paid yet — counts toward the trip
// total but stays out of settlements until who-paid is filled in. A negative
// total (a refund, #2176) is just as unfinished until its recipient is named.
@@ -148,11 +162,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const myMember = (e.members || []).find(m => m.user_id === me)
if (!myMember) return 0
if (myMember.amount !== null && myMember.amount !== undefined) {
- return convert(myMember.amount, curOf(e))
+ return booked(myMember.amount, e)
}
const shares = splitEqualShares(e.total_price || 0, e.members || [], e.id)
const myShare = shares[me] || 0
- return convert(myShare, curOf(e))
+ return booked(myShare, e)
}
const totals = useMemo(() => {
@@ -835,7 +849,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{payers.map(p => (
- {fmt(convert(p.amount, cur))}
+ {fmt(booked(p.amount, e))}
))}
@@ -887,7 +901,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{t('costs.payment')}
- {cur !== base && · {fmt(s.amount, cur)} → {fmt(convert(s.amount, cur))}}
+ {cur !== base && · {fmt(s.amount, cur)} → {fmt(settled(s))}}
@@ -898,7 +912,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
on the same axis as every expense above it. */}
{!isMobile &&
}
- {fmt(convert(s.amount, cur))}
+ {fmt(settled(s))}
{canEdit && (
diff --git a/client/src/hooks/useExchangeRates.test.ts b/client/src/hooks/useExchangeRates.test.ts
index de6d3904c..45eaac209 100644
--- a/client/src/hooks/useExchangeRates.test.ts
+++ b/client/src/hooks/useExchangeRates.test.ts
@@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../../tests/helpers/msw/server'
-import { fetchExchangeRates, clearExchangeRateCache } from './useExchangeRates'
+import { convertBooked, fetchExchangeRates, clearExchangeRateCache } from './useExchangeRates'
const FX_URL = 'https://api.frankfurter.dev/v2/rates'
@@ -45,3 +45,51 @@ describe('fetchExchangeRates (#1561)', () => {
expect(await fetchExchangeRates('NOK')).toEqual({ NOK: 1, USD: 0.1 })
})
})
+
+// A cost entered in a foreign currency is money that was actually paid, at a rate that
+// was true that day. Reading it back at today's rate quietly rewrites history, which is
+// what a tester reported after settling up: the figure moved under him.
+describe('convertBooked', () => {
+ // Display currency is EUR, so rates are units per 1 EUR. The trip is booked in EUR too
+ // unless a test says otherwise.
+ const live = (amount: number, from: string | null | undefined): number => {
+ const rates: Record
= { EUR: 1, USD: 2, SEK: 10 }
+ const r = rates[(from || 'EUR').toUpperCase()]
+ return r && r > 0 ? amount / r : amount
+ }
+
+ it('leaves an amount already in the trip currency to the live step alone', () => {
+ // Nothing was ever frozen here, so this is just trip currency to display currency.
+ expect(convertBooked(100, null, 1, 'EUR', live)).toBe(100)
+ expect(convertBooked(100, 'EUR', 1, 'EUR', live)).toBe(100)
+ })
+
+ it('reads a foreign amount at the rate it was booked at, not at today s', () => {
+ // 120 USD booked when a euro bought 1.2 dollars is 100 EUR, and stays 100 EUR even
+ // though the live table above says a euro now buys 2.
+ expect(convertBooked(120, 'USD', 1.2, 'EUR', live)).toBe(100)
+ })
+
+ it('falls back to live rates for a row that never froze one', () => {
+ // Rate 1 is the column default, not a booked rate: rows predating the freeze carry it.
+ expect(convertBooked(120, 'USD', 1, 'EUR', live)).toBe(60)
+ expect(convertBooked(120, 'USD', undefined, 'EUR', live)).toBe(60)
+ expect(convertBooked(120, 'USD', null, 'EUR', live)).toBe(60)
+ // A rate that cannot divide is no rate at all.
+ expect(convertBooked(120, 'USD', 0, 'EUR', live)).toBe(60)
+ expect(convertBooked(120, 'USD', -2, 'EUR', live)).toBe(60)
+ })
+
+ it('goes through the trip currency when the reader displays a third one', () => {
+ // 200 SEK booked at 10 SEK per euro is 20 EUR of trip money, which the live step then
+ // takes to the display currency. The frozen rate is against the trip, never the display.
+ expect(convertBooked(200, 'SEK', 10, 'EUR', live)).toBe(20)
+ // Trip in USD, display in EUR: 200 SEK at 5 SEK per dollar is 40 USD, then 20 EUR.
+ expect(convertBooked(200, 'SEK', 5, 'USD', live)).toBe(20)
+ })
+
+ it('treats currency case as noise', () => {
+ expect(convertBooked(120, 'usd', 1.2, 'eur', live)).toBe(100)
+ expect(convertBooked(100, 'eur', 1, 'EUR', live)).toBe(100)
+ })
+})
diff --git a/client/src/hooks/useExchangeRates.ts b/client/src/hooks/useExchangeRates.ts
index 46a40ed93..2652312fa 100644
--- a/client/src/hooks/useExchangeRates.ts
+++ b/client/src/hooks/useExchangeRates.ts
@@ -52,6 +52,33 @@ export async function fetchExchangeRates(base: string): Promise number,
+): number {
+ const trip = (tripCurrency || 'EUR').toUpperCase()
+ // A NULL currency means the trip's own, and then there was never anything to freeze.
+ const cur = (rowCurrency || trip).toUpperCase()
+ if (cur === trip) return convertLive(amount, trip)
+ // A rate of exactly 1 is the column default, not a booked rate: rows written before the
+ // freeze existed carry it, and those still convert live, as they always did.
+ if (frozenRate != null && frozenRate > 0 && frozenRate !== 1) return convertLive(amount / frozenRate, trip)
+ return convertLive(amount, cur)
+}
+
/** Test-only: the module-level cache outlives a vitest file's individual tests. */
export function clearExchangeRateCache(): void {
mem.clear()
diff --git a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx
index dd28c4d11..3476410bd 100644
--- a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx
+++ b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx
@@ -1,3 +1,4 @@
+import { convertBooked } from '../../../../hooks/useExchangeRates'
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import {
AlertCircle, ArrowDown, ArrowLeftRight, ArrowRight, ArrowUp, Check, ChevronDown, ChevronUp,
@@ -754,7 +755,8 @@ function PaymentRow({ settlement, ctx, base, locale, t, personName, canEdit, onE
onUndo: () => void
}) {
const cur = (settlement.currency || base).toUpperCase()
- const amount = ctx.convert(settlement.amount, cur)
+ // At the rate it was settled at, not today's (#1445), matching the desktop ledger.
+ const amount = convertBooked(settlement.amount, settlement.currency, settlement.exchange_rate, ctx.tripCurrency, ctx.convert)
return (
diff --git a/client/src/mobile/screens/trip/tabs/costsModel.ts b/client/src/mobile/screens/trip/tabs/costsModel.ts
index 6ccbbfaf8..aa8c67962 100644
--- a/client/src/mobile/screens/trip/tabs/costsModel.ts
+++ b/client/src/mobile/screens/trip/tabs/costsModel.ts
@@ -1,6 +1,7 @@
import type { BudgetParticipantFinal, CostCategory } from '@trek/shared'
import { paidByUser, readUserNote, settlementDate, splitEqualShares } from '../../../../components/Budget/CostsPanel.helpers'
import { catMeta, COST_CATEGORY_LIST } from '../../../../components/Budget/costsCategories'
+import { convertBooked } from '../../../../hooks/useExchangeRates'
import { currencyDecimals } from '../../../../utils/formatters'
import type { BudgetItem } from '../../../../types'
@@ -26,14 +27,19 @@ export function currencyOf(e: BudgetItem, ctx: CostsCtx): string {
return (e.currency || ctx.tripCurrency).toUpperCase()
}
-/** Expense total converted to the display/base currency. */
+/** An amount of this expense in the display currency, at the rate the expense was booked at. */
+export function booked(amount: number, e: BudgetItem, ctx: CostsCtx): number {
+ return convertBooked(amount, e.currency, e.exchange_rate, ctx.tripCurrency, ctx.convert)
+}
+
+/** Expense total converted to the display/base currency, at its booked rate. */
export function baseTotal(e: BudgetItem, ctx: CostsCtx): number {
- return ctx.convert(e.total_price || 0, currencyOf(e, ctx))
+ return booked(e.total_price || 0, e, ctx)
}
/** How much `ctx.me` personally fronted for this expense, in the base currency. */
export function myPaidOf(e: BudgetItem, ctx: CostsCtx): number {
- return ctx.convert(paidByUser(e, ctx.me), currencyOf(e, ctx))
+ return booked(paidByUser(e, ctx.me), e, ctx)
}
/** A given member's share of this expense (explicit custom amount, else equal split), base currency. */
@@ -41,10 +47,10 @@ export function memberShareOf(e: BudgetItem, userId: number, ctx: CostsCtx): num
const member = (e.members || []).find(m => m.user_id === userId)
if (!member) return 0
if (member.amount !== null && member.amount !== undefined) {
- return ctx.convert(member.amount, currencyOf(e, ctx))
+ return booked(member.amount, e, ctx)
}
const shares = splitEqualShares(e.total_price || 0, e.members || [], e.id)
- return ctx.convert(shares[userId] || 0, currencyOf(e, ctx))
+ return booked(shares[userId] || 0, e, ctx)
}
/** `ctx.me`'s own share — the common case of {@link memberShareOf}. */
@@ -84,6 +90,9 @@ export interface CostsSettlement {
amount: number
// Legacy rows predate this column (null) and are read as the display currency.
currency?: string | null
+ // The rate frozen when the transfer was settled, units of `currency` per 1 trip
+ // currency (#1445). Absent, or exactly 1, on rows written before the freeze existed.
+ exchange_rate?: number
created_at?: string
// The day the transfer actually happened; editable, unlike created_at (when it
// was recorded). Null/absent on rows predating this field.