mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
fix(costs): read an expense entered in the display currency as it was typed (#2525)
A euro trip read in dollars books a bill entered in dollars at the rate of the day, from the euro's quote: 12,345.67 USD at 1.1398 is 10,831.44 EUR. The Costs screen and the settlement then turned those euros back into dollars with the dollar's own quote, 1 / 0.87732. Frankfurter rounds the two quotes separately, so they are not exact inverses, and the bill read $12,346.05 the moment it was saved, a $250.00 one $250.01. The error grows with the amount, which is why the large rows showed it first. Paying back what settle-up offered for such a bill left the trip open with the debt flipped the other way. Both sides now convert trip money to the display currency with the trip currency's own quote, the one every entry rate is frozen from. On the server, settlement() fetches that quote for the REST route and for the MCP get_settlement_summary tool, which now makes the same call, and a legacy transfer without a currency reads back through the same factor. On the client, useExchangeRates takes the trip currency as an anchor and converts through its cached quote, falling back to the display currency's while it is missing, so it works offline from cache and needs no change to the settlement response. The final budget builds each row from the unrounded trip amount, because a euro cent is worth more than a dollar cent and 10,831.44 EUR came back as 12,345.68; the balances and settle-up still net whole trip cents. A same-day bill in the display currency now reads exactly as typed on the row, the payer chip, the day header, the totals, Outstanding and the final budget, on both shells and on the shared trip page, and paying the amount settle-up offers leaves both balances at zero. When the rate really moved, the row keeps the value the totals count, with what was entered and the euros it was booked at underneath, "$801.76 -> 685,26 EUR". One helper builds that line for the desktop list, the phone list, payments, the final budget breakdown and the shared page; it prints nothing for an amount of 0 or a bill that reads as typed. The expense editors share one module for their preview. The split summary puts the share the row lends or borrows next to the entered one, "$400.88 -> $390.53 each", and an expense saved without a currency still opens in the trip currency. Both CSV exports add the amount in the trip currency when it differs from the display currency. Kept from before: the desktop totals recompute when rates arrive, a booking's linked costs are labelled in the trip currency (every linked row, the link picker and the phone's linked costs), a legacy transfer is read in the display currency, the shared page reads the frozen rate in whole cents, a booking import keeps the currency it was quoted in at a rate frozen before the write (REST and plugin RPC), and the MCP trip summary, the budget-overview prompt and the per-person summary add every row up in trip cents at its frozen rate. The packing-list prompt reads the trip title directly instead of building the whole summary, which could wait on a rates fetch.
This commit is contained in:
@@ -875,10 +875,11 @@ describe('CostsPanel — overview', () => {
|
||||
const alice = within(card).getByRole('button', { name: /You/ })
|
||||
await user.click(alice)
|
||||
expect(alice).toHaveAttribute('aria-expanded', 'true')
|
||||
// The dinner is the server's 92 €, once as the line and once as its only row;
|
||||
// the raw 100 USD never shows up in the card.
|
||||
// The dinner is the server's 92 €, once as the line and once as its only row; the
|
||||
// 100 USD is never converted again here. What was entered labels the row (#2525).
|
||||
expect(within(card).getAllByText('+92,00 €')).toHaveLength(2)
|
||||
expect(within(card).queryByText(/100,00/)).toBeNull()
|
||||
expect(within(card).getByText('· $100.00')).toBeInTheDocument()
|
||||
// Received and still pending both lower her cost, each line with its one row.
|
||||
expect(within(card).getAllByText('−15,00 €')).toHaveLength(2)
|
||||
expect(within(card).getAllByText('−16,00 €')).toHaveLength(2)
|
||||
@@ -2293,3 +2294,234 @@ describe('CostsPanel — expense modal in another language', () => {
|
||||
expect(screen.queryByText('Excluded')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ── #2525: a bill entered in the display currency on a trip kept in another ───
|
||||
describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
|
||||
// The reporter's setup: a euro trip, read in dollars, with the bill entered in
|
||||
// dollars. It was booked at 1.17 dollars to the euro, 685.26 EUR, and that is what
|
||||
// the balances and settle-up net. Today the euro buys 1.1551 dollars, so those
|
||||
// euros are $791.55. The dollar's own quote is rounded separately (0.865706 per
|
||||
// dollar is 1.155126 per euro), which is why the panel converts with the euro's.
|
||||
beforeEach(() => {
|
||||
seedAlice()
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'USD' } })
|
||||
clearExchangeRateCache()
|
||||
localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, EUR: 0.865706 }, ts: Date.now() }))
|
||||
localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 1.1551, SEK: 10 }, ts: Date.now() }))
|
||||
})
|
||||
afterEach(clearExchangeRateCache)
|
||||
|
||||
const hotel = (over: Partial<BudgetItem> = {}) => expense({
|
||||
id: 401, name: 'Aparthotel Silver', category: 'accommodation', total_price: 801.76, currency: 'USD', exchange_rate: 1.17,
|
||||
expense_date: '2026-08-06',
|
||||
payers: [{ user_id: 1, amount: 801.76 }],
|
||||
members: [{ user_id: 1, username: 'alice' }, { user_id: 2, username: 'bob' }],
|
||||
...over,
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-079: a bill whose rate moved shows what was entered and what it was booked at', async () => {
|
||||
mount([hotel()])
|
||||
|
||||
await screen.findByText('Aparthotel Silver')
|
||||
// The entered amount was nowhere on the row, so $791.57 read as a wrong copy of
|
||||
// the 801.76 in the dialog. The pill beside the line is the last step.
|
||||
expect(screen.getByText(/^\$801\.76 → 685,26\s€$/)).toBeInTheDocument()
|
||||
// Row total and payer chip stay the booked euros at today's rate, and so does what
|
||||
// I lent: half of 685.26 EUR, the figure settle-up offers. Printing $400.88 here
|
||||
// had Bob pay back more than he owed and reopened the trip.
|
||||
expect(screen.getAllByText('$791.55')).toHaveLength(2)
|
||||
expect(screen.getByText('you lent $395.77')).toBeInTheDocument()
|
||||
expect(screen.queryByText('you lent $400.88')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-087: a bill booked at today\'s rate reads exactly as typed everywhere on the page', async () => {
|
||||
mount([
|
||||
hotel({ id: 407, name: 'Villa', total_price: 12345.67, exchange_rate: 1.1551, payers: [{ user_id: 1, amount: 12345.67 }] }),
|
||||
hotel({ id: 408, name: 'Unpaid deposit', total_price: 250, exchange_rate: 1.1551, payers: [], members: [] }),
|
||||
])
|
||||
|
||||
await screen.findByText('Villa')
|
||||
// Pill and payer chip; converting back with the dollar's own quote gave $12,346.05.
|
||||
expect(screen.getAllByText('$12,345.67')).toHaveLength(2)
|
||||
expect(screen.queryByText(/12,346\.0[56]/)).toBeNull()
|
||||
expect(screen.getByText('$12,595.67 spent')).toBeInTheDocument()
|
||||
const total = screen.getByText('Total trip spend').parentElement!.parentElement!.parentElement!
|
||||
expect(total.textContent).toContain('$12,595.67')
|
||||
const outstanding = screen.getByText('Outstanding amount').parentElement!.parentElement!.parentElement!
|
||||
expect(outstanding.textContent).toContain('$250.00')
|
||||
// Nothing to explain under either row.
|
||||
expect(screen.queryByText(/^\$12,345\.67 →/)).toBeNull()
|
||||
expect(screen.queryByText(/^\$250\.00 →/)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-088: the CSV export adds the trip-currency amount every sum is built from', async () => {
|
||||
let exported: Blob | null = null
|
||||
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockImplementation(b => { exported = b as Blob; return 'blob:mock' })
|
||||
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
try {
|
||||
mount([hotel()])
|
||||
await screen.findByText('Aparthotel Silver')
|
||||
fireEvent.click(screen.getByTitle('Export CSV'))
|
||||
await waitFor(() => expect(exported).toBeTruthy())
|
||||
const lines = (await exported!.text()).replace(/^\uFEFF/, '').split('\r\n')
|
||||
expect(lines[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Amount (USD);Note')
|
||||
expect(lines[1]).toMatch(/;801\.76;USD;685\.26;791\.55;$/)
|
||||
} finally {
|
||||
createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-080: the totals add up the figures the rows show', async () => {
|
||||
mount([
|
||||
hotel(),
|
||||
hotel({ id: 402, name: 'Dinner', category: 'food', total_price: 120, payers: [{ user_id: 2, amount: 120 }] }),
|
||||
])
|
||||
|
||||
await screen.findByText('Aparthotel Silver')
|
||||
// 120 USD at 1.17 is 102.56 EUR, $118.47 today; with the hotel's $791.55 that is
|
||||
// $910.02, on the day header and on the total spend card.
|
||||
expect(screen.getByText(/^\$120\.00 → 102,56\s€$/)).toBeInTheDocument()
|
||||
expect(screen.getByText('$910.02 spent')).toBeInTheDocument()
|
||||
// Label, its text block, the header row, then the card itself.
|
||||
const card = screen.getByText('Total trip spend').parentElement!.parentElement!.parentElement!
|
||||
expect(card.textContent).toContain('$910.02')
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-084: the totals follow the rates when they arrive after the expenses', async () => {
|
||||
// Nothing cached: the rates land after the expenses and the settlement, and the
|
||||
// totals used to keep the figure they were first added up with.
|
||||
clearExchangeRateCache()
|
||||
server.use(http.get('https://api.frankfurter.dev/v2/rates', async () => {
|
||||
await new Promise(r => setTimeout(r, 150))
|
||||
return HttpResponse.json([{ quote: 'EUR', rate: 0.8 }])
|
||||
}))
|
||||
mount([hotel({ id: 406, name: 'Museum', category: 'activities', total_price: 100, currency: 'EUR', exchange_rate: 1, payers: [{ user_id: 1, amount: 100 }] })])
|
||||
|
||||
// 100 euro at 0.8 euro to the dollar.
|
||||
expect(await screen.findByText(/100,00\s€ → \$125\.00/)).toBeInTheDocument()
|
||||
const card = screen.getByText('Total trip spend').parentElement!.parentElement!.parentElement!
|
||||
await waitFor(() => expect(card.textContent).toContain('$125.00'))
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-081: a euro row converts live, a third currency shows the euros it was booked at', async () => {
|
||||
mount([
|
||||
hotel({ id: 403, name: 'Museum', category: 'activities', total_price: 100, currency: 'EUR', exchange_rate: 1, payers: [{ user_id: 1, amount: 100 }] }),
|
||||
hotel({ id: 404, name: 'Ferry', category: 'transport', total_price: 1000, currency: 'SEK', exchange_rate: 10, payers: [{ user_id: 1, amount: 1000 }] }),
|
||||
// Written before the freeze existed: nothing was booked, so dollars stay dollars.
|
||||
hotel({ id: 405, name: 'Old taxi', category: 'transport', total_price: 50, currency: 'USD', exchange_rate: 1, payers: [{ user_id: 1, amount: 50 }] }),
|
||||
])
|
||||
|
||||
await screen.findByText('Museum')
|
||||
// The trip currency has no frozen rate of its own and goes to dollars live.
|
||||
expect(screen.getByText(/^100,00\s€ → \$115\.51$/)).toBeInTheDocument()
|
||||
// 1000 SEK at 10 per euro is 100 euro, then live to dollars in the pill.
|
||||
expect(screen.getByText(/^1\s000,00\skr → 100,00\s€$/)).toBeInTheDocument()
|
||||
expect(screen.getAllByText('$50.00').length).toBeGreaterThanOrEqual(2)
|
||||
expect(screen.queryByText(/^\$50\.00 →/)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-082: a payment recorded in dollars is booked like an expense, a legacy one stays as entered', async () => {
|
||||
mount([hotel()], {
|
||||
settlements: [
|
||||
{ id: 21, from_user_id: 2, to_user_id: 1, amount: 400.88, currency: 'USD', exchange_rate: 1.17, created_at: '2026-08-07 09:00:00' },
|
||||
// Rows from before transfers had a currency were entered in the display
|
||||
// currency, which is how the server settles them.
|
||||
{ id: 22, from_user_id: 2, to_user_id: 1, amount: 30, currency: null, created_at: '2026-08-08 09:00:00' },
|
||||
],
|
||||
})
|
||||
|
||||
await screen.findByText('Aparthotel Silver')
|
||||
expect(screen.getByText(/\$400\.88 → 342,63\s€$/)).toBeInTheDocument()
|
||||
expect(screen.getByText('$395.77')).toBeInTheDocument()
|
||||
expect(screen.getByText('$30.00')).toBeInTheDocument()
|
||||
expect(screen.queryByText('$34.65')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-085: the edit dialog previews the rate the save keeps, the one the row was booked at', async () => {
|
||||
const user = userEvent.setup()
|
||||
mount([hotel()])
|
||||
|
||||
await screen.findByText('Aparthotel Silver')
|
||||
await user.click(screen.getByTitle('Edit'))
|
||||
expect(await screen.findByDisplayValue('801.76')).toBeInTheDocument()
|
||||
// Same currency as the list, so the dialog said nothing, and 801.76 there beside
|
||||
// $791.57 in the list looked like two different expenses.
|
||||
const hint = screen.getByText(/live rate/).parentElement as HTMLElement
|
||||
expect(within(hint).getByText('$801.76')).toBeInTheDocument()
|
||||
expect(within(hint).getByText(/^685,26\s€$/)).toBeInTheDocument()
|
||||
expect(within(hint).getByText('$791.55')).toBeInTheDocument()
|
||||
// Each share next to what it counts as, the figure the row's "you lent" is made of.
|
||||
expect(screen.getByText(/Split 2 ways · \$400\.88 → \$395\.77 each/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-086: the final budget names the bill by what was entered beside its booked figure', async () => {
|
||||
const user = userEvent.setup()
|
||||
mount([hotel()], {
|
||||
balances: [
|
||||
{ user_id: 1, username: 'alice', avatar_url: null, balance: 395.78 },
|
||||
{ user_id: 2, username: 'bob', avatar_url: null, balance: -395.78 },
|
||||
],
|
||||
flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 395.78 }],
|
||||
finalBudgets: [
|
||||
{
|
||||
user_id: 1, username: 'alice', avatar_url: null, expenses: 791.55, reimbursed: 0, pending: 395.78, final: 395.77,
|
||||
sources: { fronted: [{ item_id: 401, cents: 79155 }], moved: [], outstanding: [{ from_user_id: 2, to_user_id: 1, cents: 39578 }] },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const card = (await screen.findByText('Final budget')).parentElement as HTMLElement
|
||||
await user.click(await within(card).findByRole('button', { name: /You/ }))
|
||||
expect(within(card).getAllByText('+$791.55')).toHaveLength(2)
|
||||
expect(within(card).getByText('Aparthotel Silver')).toBeInTheDocument()
|
||||
expect(within(card).getByText('· $801.76')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-089: the final budget lists a bill that reads as typed without a second amount', async () => {
|
||||
const user = userEvent.setup()
|
||||
mount([hotel({ exchange_rate: 1.1551 })], {
|
||||
balances: [
|
||||
{ user_id: 1, username: 'alice', avatar_url: null, balance: 400.88 },
|
||||
{ user_id: 2, username: 'bob', avatar_url: null, balance: -400.88 },
|
||||
],
|
||||
flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 400.88 }],
|
||||
finalBudgets: [
|
||||
{
|
||||
user_id: 1, username: 'alice', avatar_url: null, expenses: 801.76, reimbursed: 0, pending: 400.88, final: 400.88,
|
||||
sources: { fronted: [{ item_id: 401, cents: 80176 }], moved: [], outstanding: [{ from_user_id: 2, to_user_id: 1, cents: 40088 }] },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const card = (await screen.findByText('Final budget')).parentElement as HTMLElement
|
||||
await user.click(await within(card).findByRole('button', { name: /You/ }))
|
||||
expect(within(card).getAllByText('+$801.76')).toHaveLength(2)
|
||||
expect(within(card).queryByText('· $801.76')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-083: an expense saved without a currency opens in the trip currency, not the display one', async () => {
|
||||
const user = userEvent.setup()
|
||||
let put: Record<string, unknown> | null = null
|
||||
server.use(http.put('/api/trips/1/budget/405', async ({ request }) => {
|
||||
put = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ item: hotel({ id: 405, currency: 'EUR' }) })
|
||||
}))
|
||||
mount([hotel({ id: 405, name: 'Tram pass', category: 'transport', total_price: 100, currency: null, exchange_rate: 1, payers: [{ user_id: 1, amount: 100 }] })])
|
||||
|
||||
await screen.findByText('Tram pass')
|
||||
// The list reads the missing currency as the trip's own.
|
||||
expect(screen.getByText(/100,00\s€ → \$115\.51/)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByTitle('Edit'))
|
||||
expect(await screen.findByDisplayValue('100,00')).toBeInTheDocument()
|
||||
// Seeding the display currency here would label 100 euro as 100 dollars, and a
|
||||
// plain save would then store it that way.
|
||||
expect(screen.getByText(/^EUR/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/^USD/)).toBeNull()
|
||||
|
||||
// Saving without touching anything keeps the 100 euro.
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(put).toMatchObject({ total_price: 100, currency: 'EUR' }))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,8 @@ import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { budgetApi } from '../../api/client'
|
||||
import { saveWithReceipts } from './receiptUploads'
|
||||
import { convertBooked, useExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { convertBooked, convertedLine, tripAmountOf, useExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { splitShareLabel, useExpenseFx } from './expenseFx'
|
||||
import { useIsMobile } from '../../hooks/useIsMobile'
|
||||
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, amountToInputString } from '../../utils/formatters'
|
||||
import { downloadBlob, openFile } from '../../utils/fileDownload'
|
||||
@@ -86,7 +87,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
|
||||
// Pre-rework rows stored currency = NULL, meaning "the trip's own currency".
|
||||
const tripCurrency = (trip?.currency || base).toUpperCase()
|
||||
const { convert } = useExchangeRates(base)
|
||||
// Anchored on the trip currency's quote, the one the server books with (#2525).
|
||||
const { convert } = useExchangeRates(base, tripCurrency)
|
||||
const curOf = useCallback((e: BudgetItem) => (e.currency || tripCurrency), [tripCurrency])
|
||||
const [settlement, setSettlement] = useState<SettlementData | null>(null)
|
||||
// A failed settlement read leaves `settlement` null, which the empty views would
|
||||
@@ -146,12 +148,17 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
[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).
|
||||
// A transfer freezes its own rate at settle time, in its own table (#1445). One
|
||||
// without a currency predates that and was entered in the display currency, which
|
||||
// is how the server settles it too, not in the trip's.
|
||||
const settled = useCallback(
|
||||
(s: Settlement) => convertBooked(s.amount, s.currency, s.exchange_rate, tripCurrency, convert),
|
||||
[convert, tripCurrency],
|
||||
(s: Settlement) => convertBooked(s.amount, s.currency || base, s.exchange_rate, tripCurrency, convert),
|
||||
[convert, tripCurrency, base],
|
||||
)
|
||||
const myPaidOf = (e: BudgetItem) => booked(paidByUser(e, me), e)
|
||||
// The line under an amount shown converted: what was entered, then where it went (#2525).
|
||||
const lineOf = (amount: number, e: BudgetItem, shown: number) =>
|
||||
convertedLine(amount, e.currency, e.exchange_rate, tripCurrency, base, shown)
|
||||
// "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.
|
||||
@@ -170,6 +177,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
return booked(myShare, e)
|
||||
}
|
||||
|
||||
// `booked` carries the rates. They can land after the expenses, and without it in the
|
||||
// deps the cards kept the sums they were first added up with while the rows moved on.
|
||||
const totals = useMemo(() => {
|
||||
const totalSpend = budgetItems.reduce((a, e) => a + baseTotal(e), 0)
|
||||
const myPaid = budgetItems.reduce((a, e) => a + myPaidOf(e), 0)
|
||||
@@ -179,7 +188,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const outstanding = budgetItems.reduce((a, e) => (isUnfinished(e) ? a + baseTotal(e) : a), 0)
|
||||
const outstandingCount = budgetItems.filter(isUnfinished).length
|
||||
return { totalSpend, myPaid, myShare, owe, owed, outstanding, outstandingCount }
|
||||
}, [budgetItems, settlement, me])
|
||||
}, [budgetItems, settlement, me, booked])
|
||||
|
||||
// ── filtering + day grouping ────────────────────────────────────────────
|
||||
const filtered = useMemo(() => {
|
||||
@@ -193,7 +202,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const q = search.trim().toLowerCase()
|
||||
if (q) list = list.filter(e => e.name.toLowerCase().includes(q))
|
||||
return list
|
||||
}, [budgetItems, filter, search, catFilter, dayFilter, me])
|
||||
}, [budgetItems, filter, search, catFilter, dayFilter, me, booked])
|
||||
|
||||
// Settlements ("payments") shown inline in the ledger. They have no name, so a
|
||||
// text search hides them; they're excluded from the "owed" expense filter and,
|
||||
@@ -291,15 +300,20 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
}
|
||||
const fmtDate = (iso: string) => { if (!iso) return ''; try { return new Date(iso + 'T00:00:00Z').toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) } catch { return iso } }
|
||||
|
||||
const header = ['Date', 'Name', 'Category', 'Amount', 'Currency', 'Amount (' + base + ')', 'Note']
|
||||
// Read in another currency than the trip's, what each row counts as in the trip
|
||||
// currency too, the figure every sum is built from (#2525).
|
||||
const tripCol = tripCurrency !== base
|
||||
const header = ['Date', 'Name', 'Category', 'Amount', 'Currency', ...(tripCol ? ['Amount (' + tripCurrency + ')'] : []), 'Amount (' + base + ')', 'Note']
|
||||
const rows = [header.join(sep)]
|
||||
const items = budgetItems.slice().sort((a, b) => (a.expense_date || '').localeCompare(b.expense_date || ''))
|
||||
for (const e of items) {
|
||||
const cur = curOf(e)
|
||||
const note = readUserNote(e)
|
||||
const inTrip = tripAmountOf(e.total_price || 0, e.currency, e.exchange_rate, tripCurrency, convert)
|
||||
rows.push([
|
||||
esc(fmtDate(e.expense_date || '')), esc(e.name), esc(t(catMeta(e.category).labelKey)),
|
||||
(e.total_price || 0).toFixed(currencyDecimals(cur)), cur,
|
||||
...(tripCol ? [inTrip.toFixed(currencyDecimals(tripCurrency))] : []),
|
||||
baseTotal(e).toFixed(currencyDecimals(base)),
|
||||
esc(note),
|
||||
].join(sep))
|
||||
@@ -737,7 +751,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
function ExpenseRow({ e }: { e: BudgetItem }) {
|
||||
const c = catMeta(e.category)
|
||||
const Icon = c.Icon
|
||||
const cur = curOf(e)
|
||||
const line = lineOf(e.total_price || 0, e, baseTotal(e))
|
||||
const payers = (e.payers || []).filter(p => p.amount !== 0)
|
||||
const net = round2(myPaidOf(e) - myShareOf(e))
|
||||
const unfinished = isUnfinished(e)
|
||||
@@ -806,9 +820,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{cur !== base && (
|
||||
{line && (
|
||||
<div className="text-content-faint" style={{ marginTop: 4, fontSize: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{fmt(e.total_price, cur)} {'→'} {fmt(baseTotal(e))}
|
||||
{fmt(line.entered.amount, line.entered.currency)} {'→'} {fmt(line.into.amount, line.into.currency)}
|
||||
</div>
|
||||
)}
|
||||
{/* Under the name, because a chip reading "Y 122,00" says nothing on
|
||||
@@ -863,6 +877,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
function SettlementRow({ s }: { s: Settlement }) {
|
||||
// Legacy transfers carry no currency and were entered in the display base.
|
||||
const cur = (s.currency || base).toUpperCase()
|
||||
// Booked in the trip currency like an expense, so it is explained the same way.
|
||||
const line = convertedLine(s.amount, cur, s.exchange_rate, tripCurrency, base, settled(s))
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'stretch', gap: 8 }}>
|
||||
<div className="bg-surface-card border border-edge exp-row" style={{ flex: 1, minWidth: 0, display: 'grid', gridTemplateColumns: isMobile ? '46px 1fr auto' : '46px minmax(200px, 1fr) minmax(0, 1.5fr) auto', gap: isMobile ? 16 : 18, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
|
||||
@@ -870,7 +886,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, marginBottom: 6 }}>
|
||||
{t('costs.payment')}
|
||||
{cur !== base && <span className="text-content-faint" style={{ fontWeight: 400, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}> · {fmt(s.amount, cur)} → {fmt(settled(s))}</span>}
|
||||
{line && <span className="text-content-faint" style={{ fontWeight: 400, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}> · {fmt(line.entered.amount, line.entered.currency)} → {fmt(line.into.amount, line.into.currency)}</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }} title={`${personName(s.from_user_id)} → ${personName(s.to_user_id)}`}>
|
||||
<Avatar id={s.from_user_id} size={20} /><ArrowRight size={13} className="text-content-faint" /><Avatar id={s.to_user_id} size={20} />
|
||||
@@ -969,6 +985,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
// server's display cents, not a conversion of the expense list done here.
|
||||
const signed = (v: number) => (v < 0 ? '−' : '+') + fmt(Math.abs(v))
|
||||
const { fronted, moved, outstanding } = finalBudgetSources(row, budgetItems)
|
||||
// Beside a converted row's name, what was entered, so the breakdown names the bill
|
||||
// the way the list above it does rather than by a figure nobody typed.
|
||||
const enteredOf = (itemId: number, shown: number) => {
|
||||
const e = budgetItems.find(i => i.id === itemId)
|
||||
const line = e ? lineOf(paidByUser(e, row.user_id), e, shown) : null
|
||||
return line ? fmt(line.entered.amount, line.entered.currency) : null
|
||||
}
|
||||
const lineCls = { display: 'flex', alignItems: 'baseline', gap: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' } as const
|
||||
const detail = (label: string, value: string) => (
|
||||
<div style={lineCls}>
|
||||
@@ -991,12 +1014,17 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
<div>
|
||||
<div className={labelCls}>{t('costs.finalExpenses')}</div>
|
||||
<div style={listCls}>
|
||||
{fronted.map(r => (
|
||||
<div key={r.item_id} style={lineCls}>
|
||||
<span className="text-content-muted" style={{ minWidth: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.name}</span>
|
||||
<span className="text-content" style={{ whiteSpace: 'nowrap' }}>{signed(r.amount)}</span>
|
||||
</div>
|
||||
))}
|
||||
{fronted.map(r => {
|
||||
const entered = enteredOf(r.item_id, r.amount)
|
||||
return (
|
||||
<div key={r.item_id} style={lineCls}>
|
||||
<span className="text-content-muted" style={{ minWidth: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{r.name}{entered && <span className="text-content-faint"> · {entered}</span>}
|
||||
</span>
|
||||
<span className="text-content" style={{ whiteSpace: 'nowrap' }}>{signed(r.amount)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1206,12 +1234,13 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
const toast = useToast()
|
||||
const isMobile = useIsMobile()
|
||||
const { addBudgetItem, updateBudgetItem } = useTripStore()
|
||||
const { convert } = useExchangeRates(base)
|
||||
const sym = (c: string) => SYMBOLS[c] || (c + ' ')
|
||||
// A saved expense without a currency opens in the trip's own (#2525).
|
||||
const { tripCurrency: tripCur, editingCurrency, preview } = useExpenseFx(base, editing)
|
||||
|
||||
const [name, setName] = useState(editing?.name || prefill?.name || '')
|
||||
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
|
||||
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
|
||||
const [currency, setCurrency] = useState(editingCurrency)
|
||||
const [day, setDay] = useState(editing?.expense_date || localToday())
|
||||
const [note, setNote] = useState(() => readUserNote(editing))
|
||||
// Edit and prefill seeds are padded to the currency's decimals (#2175): the DB
|
||||
@@ -1219,7 +1248,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
// 5,00 as "5". A prefill has no currency of its own — it is read as `base`,
|
||||
// which is also what the currency field starts on.
|
||||
const [total, setTotal] = useState<string>(() => {
|
||||
if (editing) return editing.total_price ? amountToInputString(editing.total_price, (editing.currency || base).toUpperCase()) : ''
|
||||
if (editing) return editing.total_price ? amountToInputString(editing.total_price, editingCurrency) : ''
|
||||
if (prefill?.amount != null) return amountToInputString(prefill.amount, base)
|
||||
return ''
|
||||
})
|
||||
@@ -1303,6 +1332,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
}, [ticketItems])
|
||||
|
||||
const totalNum = isTicketMode ? ticketInfo.total : (Number.parseFloat(total) || 0)
|
||||
const fx = preview(totalNum, currency)
|
||||
const splitSum = [...participants].reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0)
|
||||
const customBalanced = Math.round(splitSum * 100) === Math.round(totalNum * 100)
|
||||
// How much is still to be handed out, read on the total's own side: on a refund
|
||||
@@ -1560,12 +1590,18 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currency !== base && totalNum !== 0 && (
|
||||
{fx && (
|
||||
<div className="bg-surface-secondary border border-edge text-content-muted" style={{ borderRadius: 10, padding: '10px 12px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>{formatMoney(totalNum, currency, locale)}</span>
|
||||
<span className="text-content-faint">≈</span>
|
||||
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(convert(totalNum, currency), base, locale)}</span>
|
||||
<span className="text-content-faint">· {t('costs.liveRate')}</span>
|
||||
{fx.inTrip != null && <>
|
||||
<span className="text-content-faint">→</span>
|
||||
<span className={fx.shown == null ? 'text-content' : undefined} style={fx.shown == null ? { fontWeight: 600 } : undefined}>{formatMoney(fx.inTrip, tripCur, locale)}</span>
|
||||
</>}
|
||||
{fx.shown != null && <>
|
||||
<span className="text-content-faint">≈</span>
|
||||
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(fx.shown, base, locale)}</span>
|
||||
<span className="text-content-faint">· {t('costs.liveRate')}</span>
|
||||
</>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1792,7 +1828,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
<div style={{ marginTop: 10, fontSize: 12.5, display: 'flex', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
|
||||
{splitMode === 'equally' ? (
|
||||
<span className="text-content-faint">
|
||||
{participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })}
|
||||
{participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: splitShareLabel(each, currency, fx, participants.size, base, sym, locale) })}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontWeight: 600, color: customBalanced ? '#16a34a' : '#dc2626' }}>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { formatMoney } from '../../utils/formatters'
|
||||
import { editingCurrencyOf, expenseFxPreview, splitShareLabel } from './expenseFx'
|
||||
|
||||
// A euro trip read in dollars, converted with the euro's own quote: one euro is 1.1551
|
||||
// dollars today, and 1.17 on the day the hotel was booked (#2525).
|
||||
const convert = (amount: number, from: string | null | undefined): number =>
|
||||
(amount / ({ EUR: 1, USD: 1.1551, JPY: 170 } as Record<string, number>)[(from || 'USD').toUpperCase()]) * 1.1551
|
||||
const hotel = { currency: 'USD', exchange_rate: 1.17 }
|
||||
const base = { editing: hotel, editingCurrency: 'USD', tripCurrency: 'EUR', base: 'USD', convert }
|
||||
|
||||
describe('editingCurrencyOf', () => {
|
||||
it('opens a saved expense without a currency in the trip currency', () => {
|
||||
expect(editingCurrencyOf({ currency: null }, 'eur', 'USD')).toBe('EUR')
|
||||
expect(editingCurrencyOf({ currency: 'jpy' }, 'EUR', 'USD')).toBe('JPY')
|
||||
})
|
||||
|
||||
it('starts a new expense, or one on a trip without a currency, in the display currency', () => {
|
||||
expect(editingCurrencyOf(null, 'EUR', 'usd')).toBe('USD')
|
||||
expect(editingCurrencyOf({ currency: null }, null, 'usd')).toBe('USD')
|
||||
})
|
||||
})
|
||||
|
||||
describe('expenseFxPreview', () => {
|
||||
it('previews an edit that keeps the currency at the rate frozen on entry', () => {
|
||||
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD' })!
|
||||
expect(fx.inTrip).toBeCloseTo(685.26, 2)
|
||||
expect(fx.shown).toBeCloseTo(791.55, 2)
|
||||
})
|
||||
|
||||
it('has nothing to preview for a bill in the display currency that reads back as typed', () => {
|
||||
expect(expenseFxPreview({ ...base, editing: { currency: 'USD', exchange_rate: 1.1551 }, total: 12345.67, currency: 'USD' })).toBeNull()
|
||||
expect(expenseFxPreview({ ...base, editing: null, total: 250, currency: 'USD' })).toBeNull()
|
||||
expect(expenseFxPreview({ ...base, total: 0, currency: 'USD' })).toBeNull()
|
||||
})
|
||||
|
||||
it('previews a new or re-currencied amount at today\'s rate', () => {
|
||||
expect(expenseFxPreview({ ...base, editing: null, total: 1700, currency: 'JPY' })).toEqual({ inTrip: null, shown: convert(1700, 'JPY') })
|
||||
// Switching the hotel to yen re-freezes on save, so the old rate does not apply.
|
||||
expect(expenseFxPreview({ ...base, total: 1700, currency: 'JPY' })?.inTrip).toBeNull()
|
||||
})
|
||||
|
||||
it('stops at the trip amount when the viewer reads the trip currency', () => {
|
||||
expect(expenseFxPreview({ ...base, base: 'EUR', total: 801.76, currency: 'USD' })).toEqual({ inTrip: 801.76 / 1.17, shown: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitShareLabel', () => {
|
||||
const sym = (c: string) => (c === 'USD' ? '$' : c + ' ')
|
||||
|
||||
it('prints the entered share, then the share the row lends or borrows', () => {
|
||||
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD' })
|
||||
expect(splitShareLabel(400.88, 'USD', fx, 2, 'USD', sym, 'en-US')).toBe('$400.88 → $395.77')
|
||||
// Read in the trip currency, the share is the booked euros.
|
||||
const inEur = expenseFxPreview({ ...base, base: 'EUR', total: 801.76, currency: 'USD' })
|
||||
expect(splitShareLabel(400.88, 'USD', inEur, 2, 'EUR', sym, 'en-US')).toBe(`$400.88 → ${formatMoney(342.63, 'EUR', 'en-US')}`)
|
||||
})
|
||||
|
||||
it('prints the entered share alone when nothing is converted', () => {
|
||||
expect(splitShareLabel(125, 'USD', null, 2, 'USD', sym, 'en-US')).toBe('$125.00')
|
||||
expect(splitShareLabel(0, 'USD', { inTrip: null, shown: 10 }, 0, 'USD', sym, 'en-US')).toBe('$0.00')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import { bookedInTrip, useExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { currencyDecimals, formatMoney } from '../../utils/formatters'
|
||||
|
||||
/**
|
||||
* The conversion preview of the expense editors, shared by the desktop ExpenseModal and the
|
||||
* phone MCostSheet so the two can never preview the same save differently (#2525). The
|
||||
* editors only lay it out.
|
||||
*/
|
||||
|
||||
interface EditedExpense {
|
||||
currency?: string | null
|
||||
exchange_rate?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The currency an edited expense opens in. A saved expense without a currency is in the
|
||||
* trip's own, which is how the lists read it; opening it in the display currency relabelled
|
||||
* the amount, and a save then stored it in that currency.
|
||||
*/
|
||||
export function editingCurrencyOf(editing: EditedExpense | null | undefined, tripCurrency: string | null | undefined, base: string): string {
|
||||
return ((editing && (editing.currency || tripCurrency)) || base).toUpperCase()
|
||||
}
|
||||
|
||||
export interface ExpenseFx {
|
||||
/** The amount in the trip currency, when the save keeps a rate frozen on entry. */
|
||||
inTrip: number | null
|
||||
/** The amount in the display currency at today's rate; null when `inTrip` already is. */
|
||||
shown: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* What the amount being edited will count as. An edit that keeps the currency keeps the rate
|
||||
* frozen when the expense was entered (the server re-freezes only when the currency changes),
|
||||
* so it is previewed at that rate, the way the list will show it after the save. Anything
|
||||
* else is previewed at today's rate. Null when there is nothing to convert, including a bill
|
||||
* in the display currency that still reads back as typed.
|
||||
*/
|
||||
export function expenseFxPreview(args: {
|
||||
total: number
|
||||
currency: string
|
||||
editing: EditedExpense | null | undefined
|
||||
editingCurrency: string
|
||||
tripCurrency: string
|
||||
base: string
|
||||
convert: (amount: number, from: string | null | undefined) => number
|
||||
}): ExpenseFx | null {
|
||||
const { total, currency, editing, editingCurrency, tripCurrency, base, convert } = args
|
||||
if (total === 0) return null
|
||||
const inTrip = editing && currency === editingCurrency ? bookedInTrip(total, currency, editing.exchange_rate, tripCurrency) : null
|
||||
if (currency === base && inTrip == null) return null
|
||||
const shown = inTrip == null ? convert(total, currency) : convert(inTrip, tripCurrency)
|
||||
const unit = 10 ** currencyDecimals(base)
|
||||
if (currency === base && Math.round(shown * unit) === Math.round(total * unit)) return null
|
||||
return { inTrip, shown: inTrip != null && tripCurrency === base ? null : shown }
|
||||
}
|
||||
|
||||
/**
|
||||
* One equal share as the split summary prints it, "$400.88", followed by what that share
|
||||
* counts as when the amount is converted, "$400.88 → $390.54". That second figure is the
|
||||
* one the row's "you lent" is made of, so the editor and the row cannot disagree.
|
||||
*/
|
||||
export function splitShareLabel(
|
||||
each: number,
|
||||
currency: string,
|
||||
fx: ExpenseFx | null,
|
||||
participants: number,
|
||||
base: string,
|
||||
sym: (currency: string) => string,
|
||||
locale: string,
|
||||
): string {
|
||||
const entered = sym(currency) + each.toFixed(2)
|
||||
const whole = fx ? (fx.shown ?? fx.inTrip) : null
|
||||
if (whole == null || participants <= 0) return entered
|
||||
return `${entered} → ${formatMoney(whole / participants, base, locale)}`
|
||||
}
|
||||
|
||||
/** Everything an expense editor needs to preview a conversion, from the trip in the store. */
|
||||
export function useExpenseFx(base: string, editing: EditedExpense | null | undefined) {
|
||||
const storedTripCurrency = useTripStore(s => s.trip?.currency)
|
||||
const tripCurrency = (storedTripCurrency || base).toUpperCase()
|
||||
const { convert } = useExchangeRates(base, tripCurrency)
|
||||
const editingCurrency = editingCurrencyOf(editing, storedTripCurrency, base)
|
||||
const preview = (total: number, currency: string) =>
|
||||
expenseFxPreview({ total, currency, editing, editingCurrency, tripCurrency, base, convert })
|
||||
return { tripCurrency, editingCurrency, preview }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-COSTSEC-001 to FE-PLANNER-COSTSEC-018
|
||||
// FE-PLANNER-COSTSEC-001 to FE-PLANNER-COSTSEC-019
|
||||
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -206,13 +206,17 @@ describe('BookingCostsSection', () => {
|
||||
expect(screen.queryByRole('button', { name: /Create expense/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-COSTSEC-016: amounts without a currency use the display currency, then the trip currency', () => {
|
||||
it('FE-PLANNER-COSTSEC-016: an amount without a currency is in the trip currency, whatever the display currency (#2525)', () => {
|
||||
// The booking saves its cost without a currency, which the server and Costs
|
||||
// read as the trip's. Previewing it in the display currency said 12 GBP for
|
||||
// what was then stored as 12 JPY.
|
||||
seedStore(useTripStore, { budgetItems: [], trip: buildTrip({ id: 1, currency: 'JPY' }) });
|
||||
seedStore(useSettingsStore, { settings: { default_currency: 'gbp' } });
|
||||
const { unmount } = render(
|
||||
<BookingCostsSection reservationId={null} pendingExpense={{ total_price: 12, category: 'food' }} onCreate={vi.fn()} onEdit={vi.fn()} onRemove={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByText(money(12, 'GBP'))).toBeInTheDocument();
|
||||
expect(screen.getByText(money(12, 'JPY'))).toBeInTheDocument();
|
||||
expect(screen.queryByText(money(12, 'GBP'))).not.toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
seedStore(useSettingsStore, { settings: { default_currency: '' } });
|
||||
@@ -235,4 +239,18 @@ describe('BookingCostsSection', () => {
|
||||
expect(screen.getByText('Linked expenses')).toBeInTheDocument();
|
||||
expect(screen.getByText('Flight LH 400')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-COSTSEC-019: linked and offered expenses without a currency read in the trip currency (#2525)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const deposit = buildBudgetItem({ id: 17, trip_id: 1, name: 'Hotel deposit', total_price: 120, currency: null, category: 'accommodation', reservation_id: 9 });
|
||||
const tram = buildBudgetItem({ id: 18, trip_id: 1, name: 'Tram pass', total_price: 9, currency: null, category: 'transport' });
|
||||
seedStore(useTripStore, { budgetItems: [deposit, tram] });
|
||||
seedStore(useSettingsStore, { settings: { default_currency: 'USD' } });
|
||||
renderSection();
|
||||
|
||||
expect(within(rowOf('Hotel deposit')).getByText(money(120, 'EUR'))).toBeInTheDocument();
|
||||
expect(screen.queryByText(money(120, 'USD'))).not.toBeInTheDocument();
|
||||
await user.click(linkSelect());
|
||||
expect(screen.getByRole('button', { name: /Tram pass/ })).toHaveTextContent(money(9, 'EUR'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,10 @@ export function BookingCostsSection({ reservationId, placeId = null, hintKey = '
|
||||
const tripCurrency = useTripStore(s => s.trip?.currency)
|
||||
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
|
||||
const base = (displayCurrency || tripCurrency || 'EUR').toUpperCase()
|
||||
// An amount is printed in its own currency, unconverted. One saved without a
|
||||
// currency is in the trip's own (#2525), which is how Costs reads it; labelling
|
||||
// it with the display currency turned a 120 EUR deposit into $120.00.
|
||||
const ownCurrency = (currency: string | null | undefined) => currency || tripCurrency || base
|
||||
const { targetId, linked, unlinked, link, unlink } = useExpenseLinks(reservationId, placeId)
|
||||
|
||||
const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
|
||||
@@ -60,7 +64,7 @@ export function BookingCostsSection({ reservationId, placeId = null, hintKey = '
|
||||
<div className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t(meta.labelKey)}</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t(hintKey)}</div>
|
||||
</div>
|
||||
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{formatMoney(pendingExpense.total_price, pendingExpense.currency || base, locale)}</span>
|
||||
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{formatMoney(pendingExpense.total_price, ownCurrency(pendingExpense.currency), locale)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -82,7 +86,7 @@ export function BookingCostsSection({ reservationId, placeId = null, hintKey = '
|
||||
<div className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.name}</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t(meta.labelKey)}</div>
|
||||
</div>
|
||||
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{formatMoney(item.total_price, item.currency || base, locale)}</span>
|
||||
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{formatMoney(item.total_price, ownCurrency(item.currency), locale)}</span>
|
||||
<button type="button" onClick={() => onEdit(item)} title={t('common.edit')} aria-label={t('common.edit')} className={iconButton} style={iconButtonStyle}><Pencil size={13} /></button>
|
||||
<button type="button" onClick={() => void unlink(item)} title={t('reservations.unlinkExpense')} aria-label={t('reservations.unlinkExpense')} className={iconButton} style={iconButtonStyle}><Link2Off size={13} /></button>
|
||||
<button type="button" onClick={() => onRemove(item)} title={t('reservations.removeExpense')} aria-label={t('reservations.removeExpense')} className={deleteButton} style={iconButtonStyle}><Trash2 size={13} /></button>
|
||||
@@ -114,7 +118,7 @@ export function BookingCostsSection({ reservationId, placeId = null, hintKey = '
|
||||
return {
|
||||
value: i.id,
|
||||
label: i.name,
|
||||
badge: formatMoney(i.total_price, i.currency || base, locale),
|
||||
badge: formatMoney(i.total_price, ownCurrency(i.currency), locale),
|
||||
icon: <Icon size={13} style={{ color: meta.color }} />,
|
||||
}
|
||||
})}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-102
|
||||
// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-105
|
||||
import { render, screen, waitFor, fireEvent, within, act } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -1049,7 +1049,7 @@ describe('ReservationModal', () => {
|
||||
expect(screen.getByText('Linked expense')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 240, category: 'accommodation' });
|
||||
expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 240, category: 'accommodation', currency: 'EUR' });
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-061: a prefill without a price creates no cost entry', async () => {
|
||||
@@ -1360,6 +1360,55 @@ describe('ReservationModal', () => {
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-103: a linked cost saved without a currency is shown in the trip currency, not the display one (#2525)', () => {
|
||||
// A booking's own cost entry is written without a currency, which means the
|
||||
// trip's. Reading in USD on a EUR trip must not turn the 120 EUR into $120.00.
|
||||
budgetEnabled();
|
||||
seedStore(useSettingsStore, { settings: { default_currency: 'USD' } });
|
||||
seedStore(useTripStore, {
|
||||
trip: buildTrip({ id: 1, currency: 'EUR' }),
|
||||
budgetItems: [
|
||||
{ id: 7, trip_id: 1, name: 'Hotel deposit', total_price: 120, currency: null, category: 'accommodation', reservation_id: 9, members: [], payers: [], persons: 1, expense_date: null, paid_by_user_id: null },
|
||||
],
|
||||
});
|
||||
render(
|
||||
<ReservationModal {...defaultProps} reservation={buildReservation({ id: 9, type: 'hotel', title: 'Hotel Paris' })} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Hotel deposit')).toBeInTheDocument();
|
||||
expect(screen.getByText('120,00 €')).toBeInTheDocument();
|
||||
expect(screen.queryByText('$120.00')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-104: an imported price keeps the currency it was quoted in, in the preview and on save (#2525)', async () => {
|
||||
// A euro trip, a confirmation priced in dollars. The preview said $801.76 and the
|
||||
// save sent the amount alone, which the server stored as 801.76 EUR.
|
||||
budgetEnabled();
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) });
|
||||
const onSave = vi.fn().mockResolvedValue({ id: 82 });
|
||||
const prefill = hotelPrefill({ metadata: { price: 801.76, priceCurrency: 'usd' } });
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} prefill={prefill} days={reviewDays()} />);
|
||||
|
||||
expect(screen.getByText('$801.76')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 801.76, category: 'accommodation', currency: 'USD' });
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-105: a parsed currency that is not a code previews and saves in the trip currency', async () => {
|
||||
budgetEnabled();
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) });
|
||||
const onSave = vi.fn().mockResolvedValue({ id: 83 });
|
||||
const prefill = hotelPrefill({ metadata: { price: 50, priceCurrency: 'dollars' } });
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} prefill={prefill} days={reviewDays()} />);
|
||||
|
||||
// The server drops such a currency too, so the preview names the one it will store.
|
||||
expect(screen.getByText('50,00 €')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 50, category: 'accommodation' });
|
||||
});
|
||||
|
||||
// ── File error paths ────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-RESMODAL-077: a cancelled file dialog changes nothing', () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation,
|
||||
import { BookingCostsSection } from './BookingCostsSection'
|
||||
import { BookingLinkAndFiles } from './BookingLinkAndFiles'
|
||||
import { BookingTypeSelect } from './BookingTypeSelect'
|
||||
import { importedPriceEntry } from './importedPrice'
|
||||
import { TravelerPicker } from './TravelerPicker'
|
||||
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
|
||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||
@@ -290,11 +291,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
// Imported booking → auto-create the linked cost from the parsed price (what the
|
||||
// old direct import did). Only on create (not edit) and only when there's a price.
|
||||
if (!reservation && prefill && isBudgetEnabled) {
|
||||
const pmeta = prefill.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record<string, unknown>) : {}
|
||||
const price = Number(pmeta.price)
|
||||
if (Number.isFinite(price) && price > 0) {
|
||||
saveData.create_budget_entry = { total_price: price, category: typeToCostCategory(form.type) }
|
||||
}
|
||||
const entry = importedPriceEntry(prefill.metadata, form.type)
|
||||
if (entry) saveData.create_budget_entry = entry
|
||||
}
|
||||
const saved = await onSave(saveData)
|
||||
// Persist the traveler assignment once we have the reservation id (create → save
|
||||
@@ -336,12 +334,10 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) }
|
||||
}
|
||||
|
||||
// On an import review (not yet saved), preview the parsed price as the cost that will be linked.
|
||||
const prefillMeta = prefill?.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record<string, unknown>) : null
|
||||
const prefillPrice = Number(prefillMeta?.price)
|
||||
const pendingExpense = !reservation && Number.isFinite(prefillPrice) && prefillPrice > 0
|
||||
? { total_price: prefillPrice, currency: (prefillMeta?.priceCurrency as string | null) ?? null, category: typeToCostCategory(form.type) }
|
||||
: null
|
||||
// On an import review (not yet saved), preview the parsed price as the cost that will be
|
||||
// linked: the same entry the save sends, so the two cannot name different currencies.
|
||||
const importedEntry = !reservation && prefill ? importedPriceEntry(prefill.metadata, form.type) : null
|
||||
const pendingExpense = importedEntry ? { ...importedEntry, currency: importedEntry.currency ?? null } : null
|
||||
|
||||
const handleFileChange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
|
||||
@@ -1144,7 +1144,9 @@ describe('TransportModal', () => {
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 189.5, category: 'flights' });
|
||||
// The parsed currency travels with the price (#2525), or the server would store
|
||||
// it in whatever the trip currency is.
|
||||
expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 189.5, category: 'flights', currency: 'EUR' });
|
||||
});
|
||||
|
||||
// ── File edge cases ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -19,6 +19,7 @@ import { parseReservationMetadata, orderedEndpoints, stripAirportCode } from '..
|
||||
import { BookingCostsSection } from './BookingCostsSection'
|
||||
import { BookingLinkAndFiles } from './BookingLinkAndFiles'
|
||||
import { BookingTypeSelect } from './BookingTypeSelect'
|
||||
import { importedPriceEntry } from './importedPrice'
|
||||
import { TravelerPicker } from './TravelerPicker'
|
||||
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
|
||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||
@@ -603,11 +604,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
// Imported booking → auto-create the linked cost from the parsed price (what the
|
||||
// old direct import did). Only on create (not edit) and only when there's a price.
|
||||
if (!reservation && prefill && isBudgetEnabled) {
|
||||
const pmeta = prefill.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record<string, unknown>) : {}
|
||||
const price = Number(pmeta.price)
|
||||
if (Number.isFinite(price) && price > 0) {
|
||||
;(payload as Record<string, unknown>).create_budget_entry = { total_price: price, category: typeToCostCategory(form.type) }
|
||||
}
|
||||
const entry = importedPriceEntry(prefill.metadata, form.type)
|
||||
if (entry) (payload as Record<string, unknown>).create_budget_entry = entry
|
||||
}
|
||||
const saved = await onSave(payload)
|
||||
// Persist the traveler assignment once we have the reservation id (create → save
|
||||
@@ -651,12 +649,10 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) }
|
||||
}
|
||||
|
||||
// On an import review (not yet saved), preview the parsed price as the cost that will be linked.
|
||||
const prefillMeta = prefill?.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record<string, unknown>) : null
|
||||
const prefillPrice = Number(prefillMeta?.price)
|
||||
const pendingExpense = !reservation && Number.isFinite(prefillPrice) && prefillPrice > 0
|
||||
? { total_price: prefillPrice, currency: (prefillMeta?.priceCurrency as string | null) ?? null, category: typeToCostCategory(form.type) }
|
||||
: null
|
||||
// On an import review (not yet saved), preview the parsed price as the cost that will be
|
||||
// linked: the same entry the save sends, so the two cannot name different currencies.
|
||||
const importedEntry = !reservation && prefill ? importedPriceEntry(prefill.metadata, form.type) : null
|
||||
const pendingExpense = importedEntry ? { ...importedEntry, currency: importedEntry.currency ?? null } : null
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { typeToCostCategory } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The linked cost an imported booking's parsed price becomes, or null when it has none.
|
||||
*
|
||||
* The price keeps the currency it was quoted in (#2525). The forms used to send the
|
||||
* amount alone, so an imported $801.76 was stored as 801.76 in the trip's own currency
|
||||
* while the form had just previewed it in dollars. A currency that is not a three-letter
|
||||
* code is left out, which is what the server does with one too, so the preview and the
|
||||
* saved cost read the same either way.
|
||||
*/
|
||||
export function importedPriceEntry(
|
||||
metadata: unknown,
|
||||
type: string,
|
||||
): { total_price: number; category: string; currency?: string } | null {
|
||||
const meta = metadata && typeof metadata === 'object' ? (metadata as Record<string, unknown>) : {}
|
||||
const price = Number(meta.price)
|
||||
if (!Number.isFinite(price) || price <= 0) return null
|
||||
const code = typeof meta.priceCurrency === 'string' ? meta.priceCurrency.trim().toUpperCase() : ''
|
||||
return {
|
||||
total_price: price,
|
||||
category: typeToCostCategory(type),
|
||||
...(/^[A-Z]{3}$/.test(code) ? { currency: code } : {}),
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../../tests/helpers/msw/server'
|
||||
import { convertBooked, fetchExchangeRates, clearExchangeRateCache } from './useExchangeRates'
|
||||
import { bookedInTrip, convertBooked, convertedLine, crossRate, fetchExchangeRates, clearExchangeRateCache, tripAmountOf, useExchangeRates } from './useExchangeRates'
|
||||
|
||||
const FX_URL = 'https://api.frankfurter.dev/v2/rates'
|
||||
|
||||
@@ -92,4 +93,145 @@ describe('convertBooked', () => {
|
||||
expect(convertBooked(120, 'usd', 1.2, 'eur', live)).toBe(100)
|
||||
expect(convertBooked(100, 'eur', 1, 'EUR', live)).toBe(100)
|
||||
})
|
||||
|
||||
// #2525: a euro trip read in dollars, the bill entered in dollars. It was booked at 685.26
|
||||
// EUR, and that is what the balances and settle-up net and then show at today's rate. The
|
||||
// row has to show the same figure, or paying back the "you lent" it printed reopens the trip.
|
||||
it('reads an amount in the display currency through the trip currency too', () => {
|
||||
const liveUsd = (amount: number, from: string | null | undefined): number => {
|
||||
const rates: Record<string, number> = { USD: 1, EUR: 1 / 1.1398 }
|
||||
const r = rates[(from || 'USD').toUpperCase()]
|
||||
return r && r > 0 ? amount / r : amount
|
||||
}
|
||||
// 685.26 EUR, then at today's 1.1398 dollars to the euro.
|
||||
expect(convertBooked(801.76, 'USD', 1.17, 'EUR', liveUsd)).toBeCloseTo((801.76 / 1.17) * 1.1398, 6)
|
||||
expect(convertBooked(801.76, 'USD', 1.17, 'EUR', liveUsd)).not.toBeCloseTo(801.76, 1)
|
||||
// Without a booked rate there is nothing to go through, and dollars stay dollars.
|
||||
expect(convertBooked(801.76, 'USD', 1, 'EUR', liveUsd)).toBe(801.76)
|
||||
})
|
||||
})
|
||||
|
||||
describe('bookedInTrip', () => {
|
||||
it('gives the trip-currency amount a foreign row was booked at', () => {
|
||||
expect(bookedInTrip(801.76, 'USD', 1.17, 'EUR')).toBeCloseTo(685.26, 2)
|
||||
expect(bookedInTrip(12000, 'jpy', 170, 'eur')).toBeCloseTo(70.59, 2)
|
||||
})
|
||||
|
||||
it('has nothing to give for a row in the trip currency or one that never froze a rate', () => {
|
||||
expect(bookedInTrip(100, 'EUR', 1.17, 'EUR')).toBeNull()
|
||||
expect(bookedInTrip(100, null, 1.17, 'EUR')).toBeNull()
|
||||
expect(bookedInTrip(100, 'USD', 1, 'EUR')).toBeNull()
|
||||
expect(bookedInTrip(100, 'USD', null, 'EUR')).toBeNull()
|
||||
expect(bookedInTrip(100, 'USD', 0, 'EUR')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// #2525: the server freezes an entry rate from the trip currency's quote and converts the
|
||||
// settlement back with it. Read through the display currency's own quote, a separately
|
||||
// rounded figure, a same-day 12,345.67 USD bill on a euro trip came back as $12,346.05.
|
||||
describe('useExchangeRates anchored on the trip currency (#2525)', () => {
|
||||
beforeEach(() => {
|
||||
clearExchangeRateCache()
|
||||
})
|
||||
|
||||
const quotes = (): void => {
|
||||
server.use(http.get(FX_URL, ({ request }) => {
|
||||
const base = new URL(request.url).searchParams.get('base')
|
||||
if (base === 'EUR') return HttpResponse.json([{ quote: 'USD', rate: 1.1398 }, { quote: 'JPY', rate: 170 }])
|
||||
if (base === 'USD') return HttpResponse.json([{ quote: 'EUR', rate: 0.87732 }, { quote: 'JPY', rate: 149.1 }])
|
||||
return HttpResponse.error()
|
||||
}))
|
||||
}
|
||||
|
||||
it('converts the trip currency with its own quote, so a same-day bill reads as typed', async () => {
|
||||
quotes()
|
||||
const { result } = renderHook(() => useExchangeRates('usd', 'eur'))
|
||||
await waitFor(() => expect(result.current.convert(1, 'EUR')).toBe(1.1398))
|
||||
const shown = convertBooked(12345.67, 'USD', 1.1398, 'EUR', result.current.convert)
|
||||
expect(Math.round(shown * 100)).toBe(1234567)
|
||||
expect(Math.round(convertBooked(250, 'USD', 1.1398, 'EUR', result.current.convert) * 100)).toBe(25000)
|
||||
// A foreign row that never froze a rate goes through the same quote as well.
|
||||
expect(result.current.convert(170, 'JPY')).toBeCloseTo(1.1398, 10)
|
||||
// The display currency's own rates stay what the hook hands out.
|
||||
expect(result.current.rates).toEqual({ USD: 1, EUR: 0.87732, JPY: 149.1 })
|
||||
})
|
||||
|
||||
it('falls back to the display currency\'s quote while the trip\'s is missing', async () => {
|
||||
server.use(http.get(FX_URL, ({ request }) => (new URL(request.url).searchParams.get('base') === 'USD'
|
||||
? HttpResponse.json([{ quote: 'EUR', rate: 0.8 }])
|
||||
: HttpResponse.error())))
|
||||
const { result } = renderHook(() => useExchangeRates('USD', 'EUR'))
|
||||
await waitFor(() => expect(result.current.convert(8, 'EUR')).toBe(10))
|
||||
expect(result.current.convert(5, 'USD')).toBe(5)
|
||||
})
|
||||
|
||||
it('fetches one set of rates when the trip is in the display currency', async () => {
|
||||
let calls = 0
|
||||
server.use(http.get(FX_URL, () => { calls++; return HttpResponse.json([{ quote: 'USD', rate: 1.1398 }]) }))
|
||||
const { result } = renderHook(() => useExchangeRates('EUR', 'EUR'))
|
||||
await waitFor(() => expect(result.current.convert(1.1398, 'USD')).toBe(1))
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('crossRate', () => {
|
||||
it('reads units of one currency per another from rates on any base', () => {
|
||||
expect(crossRate({ EUR: 1, USD: 1.25, GBP: 0.8 }, 'USD', 'GBP')).toBe(1.5625)
|
||||
expect(crossRate({ EUR: 1, USD: 1.25 }, 'USD', 'EUR')).toBe(1.25)
|
||||
})
|
||||
|
||||
it('has nothing to say without both quotes', () => {
|
||||
expect(crossRate(null, 'USD', 'EUR')).toBeNull()
|
||||
expect(crossRate({ EUR: 1 }, 'USD', 'EUR')).toBeNull()
|
||||
expect(crossRate({ EUR: 0, USD: 1 }, 'USD', 'EUR')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('tripAmountOf', () => {
|
||||
// Display USD, trip EUR: one euro buys 1.25 dollars and 100 yen.
|
||||
const live = (amount: number, from: string | null | undefined): number =>
|
||||
amount * ({ EUR: 1.25, USD: 1, JPY: 0.0125 } as Record<string, number>)[(from || 'USD').toUpperCase()]
|
||||
|
||||
it('is the booked amount for a row booked through the trip currency', () => {
|
||||
expect(tripAmountOf(801.76, 'USD', 1.17, 'EUR', live)).toBeCloseTo(685.26, 2)
|
||||
})
|
||||
|
||||
it('is the amount itself for a row in the trip currency', () => {
|
||||
expect(tripAmountOf(100, null, null, 'eur', live)).toBe(100)
|
||||
expect(tripAmountOf(100, 'EUR', 1, 'EUR', live)).toBe(100)
|
||||
})
|
||||
|
||||
it('counts a row that never froze a rate at today\'s', () => {
|
||||
expect(tripAmountOf(1000, 'JPY', 1, 'EUR', live)).toBeCloseTo(10, 10)
|
||||
// Without rates the conversion is the identity, as it is on the server.
|
||||
expect(tripAmountOf(1000, 'JPY', null, 'EUR', (a: number) => a)).toBe(1000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertedLine', () => {
|
||||
it('shows the entered amount and the booked trip amount for a bill booked through the trip currency', () => {
|
||||
expect(convertedLine(801.76, 'usd', 1.17, 'EUR', 'USD', 791.55)).toEqual({
|
||||
entered: { amount: 801.76, currency: 'USD' },
|
||||
into: { amount: 801.76 / 1.17, currency: 'EUR' },
|
||||
})
|
||||
expect(convertedLine(12000, 'JPY', 170, 'EUR', 'USD', 80.46)?.into).toEqual({ amount: 12000 / 170, currency: 'EUR' })
|
||||
})
|
||||
|
||||
it('has no line for a bill in the display currency that still reads as typed', () => {
|
||||
expect(convertedLine(12345.67, 'USD', 1.1398, 'EUR', 'USD', 12345.670000000002)).toBeNull()
|
||||
// Rounded to the display currency's own decimals.
|
||||
expect(convertedLine(1500, 'JPY', 0.0125, 'EUR', 'JPY', 1500.2)).toBeNull()
|
||||
})
|
||||
|
||||
it('has no line for an amount of 0 or a row that was never converted', () => {
|
||||
expect(convertedLine(0, 'USD', 1.17, 'EUR', 'USD', 0)).toBeNull()
|
||||
expect(convertedLine(0, 'JPY', 170, 'EUR', 'USD', 0)).toEqual({ entered: { amount: 0, currency: 'JPY' }, into: { amount: 0, currency: 'USD' } })
|
||||
expect(convertedLine(50, 'USD', 1, 'EUR', 'USD', 50)).toBeNull()
|
||||
expect(convertedLine(50, null, null, 'USD', '', 50)).toBeNull()
|
||||
})
|
||||
|
||||
it('goes straight to the display value for a row in the trip currency, or when the viewer reads the trip currency', () => {
|
||||
expect(convertedLine(100, null, null, 'EUR', 'USD', 113.98)).toEqual({ entered: { amount: 100, currency: 'EUR' }, into: { amount: 113.98, currency: 'USD' } })
|
||||
expect(convertedLine(801.76, 'USD', 1.17, 'EUR', 'EUR', 685.26)).toEqual({ entered: { amount: 801.76, currency: 'USD' }, into: { amount: 685.26, currency: 'EUR' } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { currencyDecimals } from '../utils/formatters'
|
||||
|
||||
/**
|
||||
* Live FX rates for the Costs panel, used to convert every amount into the user's
|
||||
@@ -59,6 +60,13 @@ export async function fetchExchangeRates(base: string): Promise<Record<string, n
|
||||
* "units of the row's currency per 1 *trip* currency", which is why the amount goes to the
|
||||
* trip currency first and only then, live, to whatever currency the viewer reads in.
|
||||
*
|
||||
* That holds for an amount in the viewer's own currency too. A bill of 801.76 USD on a euro
|
||||
* trip is booked at 685.26 EUR, and a viewer reading in dollars sees those euros at today's
|
||||
* rate, which is what the balances and settle-up offer them as well (#2525). With the
|
||||
* conversion anchored on the trip currency (useExchangeRates' second argument) that is
|
||||
* the bill exactly as typed until the rate moves; after that it is what the bill counts
|
||||
* as, and convertedLine explains the difference.
|
||||
*
|
||||
* `convertLive` is the hook's own `convert`. Passing it in keeps this a plain function that
|
||||
* both shells and the PDF can share, rather than three copies of the same three branches.
|
||||
*/
|
||||
@@ -70,13 +78,89 @@ export function convertBooked(
|
||||
convertLive: (amount: number, from: string | null | undefined) => number,
|
||||
): number {
|
||||
const trip = (tripCurrency || 'EUR').toUpperCase()
|
||||
const inTrip = bookedInTrip(amount, rowCurrency, frozenRate, trip)
|
||||
if (inTrip != null) return convertLive(inTrip, trip)
|
||||
// A NULL currency means the trip's own, and then there was never anything to freeze.
|
||||
return convertLive(amount, (rowCurrency || trip).toUpperCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* The amount a row was booked at in the trip currency, when it was booked through one: a
|
||||
* foreign currency with a frozen rate. Null for a row in the trip currency, and for one
|
||||
* written before the freeze existed. A rate of exactly 1 is the column default, not a booked
|
||||
* rate, and those rows still convert live, as they always did.
|
||||
*
|
||||
* The lists print it between the amount entered and the amount shown, so a row whose value
|
||||
* moved with the rate shows where the move came from instead of a figure nobody typed.
|
||||
*/
|
||||
export function bookedInTrip(
|
||||
amount: number,
|
||||
rowCurrency: string | null | undefined,
|
||||
frozenRate: number | null | undefined,
|
||||
tripCurrency: string,
|
||||
): number | null {
|
||||
const trip = (tripCurrency || 'EUR').toUpperCase()
|
||||
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)
|
||||
if (cur === trip) return null
|
||||
if (frozenRate != null && frozenRate > 0 && frozenRate !== 1) return amount / frozenRate
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* What an amount of a row counts as in the trip currency, the figure the settlement nets:
|
||||
* the booked amount for a row booked through the trip currency, the amount itself for one
|
||||
* in it, and today's rate for a foreign row that never froze one, the way the server
|
||||
* counts that row too.
|
||||
*/
|
||||
export function tripAmountOf(
|
||||
amount: number,
|
||||
rowCurrency: string | null | undefined,
|
||||
frozenRate: number | null | undefined,
|
||||
tripCurrency: string,
|
||||
convertLive: (amount: number, from: string | null | undefined) => number,
|
||||
): number {
|
||||
const trip = (tripCurrency || 'EUR').toUpperCase()
|
||||
const inTrip = bookedInTrip(amount, rowCurrency, frozenRate, trip)
|
||||
if (inTrip != null) return inTrip
|
||||
const cur = (rowCurrency || trip).toUpperCase()
|
||||
if (cur === trip) return amount
|
||||
const perTrip = convertLive(1, trip)
|
||||
return perTrip > 0 ? convertLive(amount, cur) / perTrip : amount
|
||||
}
|
||||
|
||||
/** An amount and the currency it is in. */
|
||||
export interface MoneyPart { amount: number; currency: string }
|
||||
|
||||
/**
|
||||
* The line under an amount the lists show converted (#2525): what was entered, then where
|
||||
* it went. For a row booked through the trip currency that is the booked trip amount,
|
||||
* "$801.76 -> 685,26 EUR", since the display value beside it already shows the last step.
|
||||
* Any other foreign row goes straight to the display value, "100,00 EUR -> $113.98".
|
||||
*
|
||||
* Null when there is nothing to explain: a row in the display currency that still reads
|
||||
* as typed, or an amount of 0. `shown` is the display value printed beside the line.
|
||||
* Every list uses it, so the desktop and phone shells and the shared page can never
|
||||
* explain the same row two ways.
|
||||
*/
|
||||
export function convertedLine(
|
||||
amount: number,
|
||||
rowCurrency: string | null | undefined,
|
||||
frozenRate: number | null | undefined,
|
||||
tripCurrency: string,
|
||||
displayCurrency: string,
|
||||
shown: number,
|
||||
): { entered: MoneyPart; into: MoneyPart } | null {
|
||||
const trip = (tripCurrency || 'EUR').toUpperCase()
|
||||
const display = (displayCurrency || trip).toUpperCase()
|
||||
const cur = (rowCurrency || trip).toUpperCase()
|
||||
const entered = { amount, currency: cur }
|
||||
const inTrip = display !== trip && amount !== 0 ? bookedInTrip(amount, cur, frozenRate, trip) : null
|
||||
if (inTrip != null) {
|
||||
const unit = 10 ** currencyDecimals(display)
|
||||
if (cur === display && Math.round(shown * unit) === Math.round(amount * unit)) return null
|
||||
return { entered, into: { amount: inTrip, currency: trip } }
|
||||
}
|
||||
return cur === display ? null : { entered, into: { amount: shown, currency: display } }
|
||||
}
|
||||
|
||||
/** Test-only: the module-level cache outlives a vitest file's individual tests. */
|
||||
@@ -89,29 +173,60 @@ export function clearExchangeRateCache(): void {
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function useExchangeRates(base: string) {
|
||||
const upper = (base || 'EUR').toUpperCase()
|
||||
const [rates, setRates] = useState<Record<string, number> | null>(() => readCache(upper)?.rates ?? null)
|
||||
/** Rates for one base, from the cache first and then the network; null for no base. */
|
||||
function useRatesFor(base: string | null): Record<string, number> | null {
|
||||
const [rates, setRates] = useState<Record<string, number> | null>(() => (base ? readCache(base)?.rates ?? null : null))
|
||||
|
||||
useEffect(() => {
|
||||
const cached = readCache(upper)
|
||||
if (!base) { setRates(null); return }
|
||||
const cached = readCache(base)
|
||||
if (cached) setRates(cached.rates)
|
||||
if (cached && Date.now() - cached.ts < TTL_MS) return
|
||||
let cancelled = false
|
||||
fetchExchangeRates(upper).then(r => {
|
||||
fetchExchangeRates(base).then(r => {
|
||||
if (!cancelled && r) setRates(r)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [upper])
|
||||
}, [base])
|
||||
|
||||
return rates
|
||||
}
|
||||
|
||||
/**
|
||||
* Units of `to` per 1 `from`, from rates quoted against any base (each rate is units of
|
||||
* that currency per 1 base). Null when either quote is missing.
|
||||
*/
|
||||
export function crossRate(rates: Record<string, number> | null | undefined, to: string, from: string): number | null {
|
||||
const rTo = rates?.[to]
|
||||
const rFrom = rates?.[from]
|
||||
return rTo && rTo > 0 && rFrom && rFrom > 0 ? rTo / rFrom : null
|
||||
}
|
||||
|
||||
/**
|
||||
* `tripCurrency`, when given, anchors the conversion on the trip currency's own quote
|
||||
* (#2525). That is the quote the server froze every entry rate from and converts the
|
||||
* settlement with, so an amount booked through the trip currency comes back exactly as
|
||||
* entered while the rate has not moved. The display currency's own quote is a separately
|
||||
* rounded figure, not the exact inverse: through it a same-day 12,345.67 USD bill on a
|
||||
* euro trip read as $12,346.05. Both quotes are cached like any other, so the anchor works
|
||||
* offline too; the display quote only stands in while the trip's is missing.
|
||||
*/
|
||||
export function useExchangeRates(base: string, tripCurrency?: string | null) {
|
||||
const upper = (base || 'EUR').toUpperCase()
|
||||
const anchor = (tripCurrency || upper).toUpperCase()
|
||||
const rates = useRatesFor(upper)
|
||||
const anchorRates = useRatesFor(anchor === upper ? null : anchor)
|
||||
|
||||
const convert = useCallback(
|
||||
(amount: number, from: string | null | undefined): number => {
|
||||
const f = (from || upper).toUpperCase()
|
||||
if (f === upper || !rates) return amount
|
||||
const r = rates[f]
|
||||
if (f === upper) return amount
|
||||
const viaAnchor = crossRate(anchorRates, upper, f)
|
||||
if (viaAnchor != null) return amount * viaAnchor
|
||||
const r = rates?.[f]
|
||||
return r && r > 0 ? amount / r : amount
|
||||
},
|
||||
[rates, upper],
|
||||
[rates, anchorRates, upper],
|
||||
)
|
||||
|
||||
return { rates, convert }
|
||||
|
||||
@@ -8,10 +8,10 @@ import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader }
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import { useToast } from '../../../../components/shared/Toast'
|
||||
import { useTripStore } from '../../../../store/tripStore'
|
||||
import { useExchangeRates } from '../../../../hooks/useExchangeRates'
|
||||
import { formatMoney, localizeAmountInput, amountToInputString } from '../../../../utils/formatters'
|
||||
import { openFile } from '../../../../utils/fileDownload'
|
||||
import { saveWithReceipts } from '../../../../components/Budget/receiptUploads'
|
||||
import { splitShareLabel, useExpenseFx } from '../../../../components/Budget/expenseFx'
|
||||
import { SYMBOLS, SPLIT_COLORS, currenciesWith } from '../../../../components/Budget/BudgetPanel.constants'
|
||||
import { COST_CATEGORY_LIST, catMeta } from '../../../../components/Budget/costsCategories'
|
||||
import { localToday } from '../../../../components/Planner/today'
|
||||
@@ -58,8 +58,9 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const { addBudgetItem, updateBudgetItem, deleteBudgetItem } = useTripStore()
|
||||
const { convert } = useExchangeRates(base)
|
||||
const sym = (c: string) => SYMBOLS[c] || (c + ' ')
|
||||
// A saved expense without a currency opens in the trip's own (#2525), as on desktop.
|
||||
const { tripCurrency: tripCur, editingCurrency, preview } = useExpenseFx(base, editing)
|
||||
|
||||
// Internal open flag so the exit animation still plays even though the parent
|
||||
// unmounts us on close.
|
||||
@@ -75,13 +76,13 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
|
||||
const [catOpen, setCatOpen] = useState(false)
|
||||
const [note, setNote] = useState(() => readUserNote(editing))
|
||||
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
|
||||
const [currency, setCurrency] = useState(editingCurrency)
|
||||
const [day, setDay] = useState(editing?.expense_date || localToday())
|
||||
// Edit and prefill seeds are padded to the currency's decimals (#2175), same
|
||||
// as the desktop modal: a saved 4,90 must reopen as "4,90", not "4,9". A
|
||||
// prefill has no currency of its own and is read as `base`.
|
||||
const [total, setTotal] = useState<string>(() => {
|
||||
if (editing) return editing.total_price ? amountToInputString(editing.total_price, (editing.currency || base).toUpperCase()) : ''
|
||||
if (editing) return editing.total_price ? amountToInputString(editing.total_price, editingCurrency) : ''
|
||||
if (prefill?.amount != null) return amountToInputString(prefill.amount, base)
|
||||
return ''
|
||||
})
|
||||
@@ -151,6 +152,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
const ticketInfo = useMemo(() => calculateTicketShares(ticketItems), [ticketItems])
|
||||
|
||||
const totalNum = isTicketMode ? ticketInfo.total : (Number.parseFloat(total) || 0)
|
||||
const fx = preview(totalNum, currency)
|
||||
const splitSum = [...participants].reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0)
|
||||
const customBalanced = Math.round(splitSum * 100) === Math.round(totalNum * 100)
|
||||
const each = participants.size > 0 ? totalNum / participants.size : 0
|
||||
@@ -404,12 +406,18 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
</div>
|
||||
|
||||
{/* CONVERSION HINT */}
|
||||
{currency !== base && totalNum !== 0 && (
|
||||
{fx && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 py-[9px] text-[0.71875rem] text-m-muted">
|
||||
<span>{formatMoney(totalNum, currency, locale)}</span>
|
||||
<span className="text-m-faint">≈</span>
|
||||
<span className="font-semibold text-m-ink">{formatMoney(convert(totalNum, currency), base, locale)}</span>
|
||||
<span className="text-m-faint">· {t('costs.liveRate')}</span>
|
||||
{fx.inTrip != null && <>
|
||||
<span className="text-m-faint">→</span>
|
||||
<span className={fx.shown == null ? 'font-semibold text-m-ink' : undefined}>{formatMoney(fx.inTrip, tripCur, locale)}</span>
|
||||
</>}
|
||||
{fx.shown != null && <>
|
||||
<span className="text-m-faint">≈</span>
|
||||
<span className="font-semibold text-m-ink">{formatMoney(fx.shown, base, locale)}</span>
|
||||
<span className="text-m-faint">· {t('costs.liveRate')}</span>
|
||||
</>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -646,12 +654,12 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
<div className="mt-2 text-[0.71875rem]">
|
||||
{splitMode === 'equally' ? (
|
||||
<span className="text-m-faint">
|
||||
{participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })}
|
||||
{participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: splitShareLabel(each, currency, fx, participants.size, base, sym, locale) })}
|
||||
</span>
|
||||
) : (
|
||||
<span className={`font-semibold ${customBalanced ? 'text-[color:var(--m-st-confirmed)]' : 'text-[color:var(--m-st-danger)]'}`}>
|
||||
{customBalanced
|
||||
? t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })
|
||||
? t('costs.splitSummary', { count: participants.size, amount: splitShareLabel(each, currency, fx, participants.size, base, sym, locale) })
|
||||
: `${sym(currency)}${splitSum.toFixed(2)} / ${sym(currency)}${totalNum.toFixed(2)}`}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -45,7 +45,8 @@ export default function MLinkedCosts({ reservationId = null, placeId = null, hin
|
||||
const [picking, setPicking] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const money = (item: BudgetItem) => formatMoney(item.total_price, item.currency || base, locale)
|
||||
// In the expense's own currency; one saved without a currency is in the trip's (#2525).
|
||||
const money = (item: BudgetItem) => formatMoney(item.total_price, item.currency || tripCurrency || base, locale)
|
||||
const remove = async (item: BudgetItem) => {
|
||||
if (!tripId) return
|
||||
try { await deleteBudgetItem(tripId, item.id) } catch { toast.error(t('common.unknownError')) }
|
||||
|
||||
@@ -12,6 +12,7 @@ import { BookingCodeInput } from '../../../../components/shared/BookingCode'
|
||||
import AirportSelect, { type Airport } from '../../../../components/Planner/AirportSelect'
|
||||
import LocationSelect, { type LocationPoint } from '../../../../components/Planner/LocationSelect'
|
||||
import { toLocationPicks } from '../../../../components/Planner/locationPicks'
|
||||
import { importedPriceEntry } from '../../../../components/Planner/importedPrice'
|
||||
import TransitSearchPanel from '../../../../components/Planner/TransitSearchPanel'
|
||||
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
|
||||
import MBookingFilesCosts from './MBookingFilesCosts'
|
||||
@@ -572,11 +573,8 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
|
||||
// Imported booking → auto-create the linked cost from the parsed price
|
||||
// (only on create and only when a price is present).
|
||||
if (!res && prefill && isBudgetEnabled) {
|
||||
const pmeta = prefill.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record<string, unknown>) : {}
|
||||
const price = Number(pmeta.price)
|
||||
if (Number.isFinite(price) && price > 0) {
|
||||
payload.create_budget_entry = { total_price: price, category: typeToCostCategory(form.type) }
|
||||
}
|
||||
const entry = importedPriceEntry(prefill.metadata, form.type)
|
||||
if (entry) payload.create_budget_entry = entry
|
||||
}
|
||||
const saved = await saveTransport(payload)
|
||||
// Persist the traveler assignment once we have the reservation id (from the
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { convertBooked } from '../../../../hooks/useExchangeRates'
|
||||
import { convertBooked, convertedLine } from '../../../../hooks/useExchangeRates'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
AlertCircle, ArrowDown, ArrowLeftRight, ArrowRight, ArrowUp, Check, ChevronDown, ChevronUp,
|
||||
@@ -14,7 +14,7 @@ import { downloadBlob, openFile } from '../../../../utils/fileDownload'
|
||||
import { budgetApi } from '../../../../api/client'
|
||||
import MCostSheet from '../sheets/MCostSheet'
|
||||
import { ReceiptPreviewModal } from '../../../../components/Budget/ReceiptPreviewModal'
|
||||
import { finalBudgetFor, finalBudgetSources, readUserNote, settlementDate } from '../../../../components/Budget/CostsPanel.helpers'
|
||||
import { finalBudgetFor, finalBudgetSources, paidByUser, readUserNote, settlementDate } from '../../../../components/Budget/CostsPanel.helpers'
|
||||
import { catMeta, COST_CAT_META } from '../../../../components/Budget/costsCategories'
|
||||
import CustomSelect from '../../../../components/shared/CustomSelect'
|
||||
import { CustomDatePicker } from '../../../../components/shared/CustomDateTimePicker'
|
||||
@@ -27,8 +27,8 @@ import { Eyebrow, FIELD_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/
|
||||
import { CountPill, TabScroller } from './tabChrome'
|
||||
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
|
||||
import {
|
||||
baseTotal, buildCostsCsv, categoryBreakdown, categoryFilterKeys, computeTotals, currencyOf,
|
||||
dayFilterKeys, filterBudgetItems, filterSettlements, groupLedgerByDay, isUnfinished, memberShareOf, tint,
|
||||
baseTotal, buildCostsCsv, categoryBreakdown, categoryFilterKeys, computeTotals,
|
||||
dayFilterKeys, filterBudgetItems, filterSettlements, groupLedgerByDay, isUnfinished, lineOf, memberShareOf, tint,
|
||||
type CostsCtx, type CostsSegment, type CostsSettlement, type CostsSettlementResponse,
|
||||
} from './costsModel'
|
||||
import type { BudgetParticipantFinal } from '@trek/shared'
|
||||
@@ -55,8 +55,9 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
|
||||
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
|
||||
const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
|
||||
const tripCurrency = (trip?.currency || base).toUpperCase()
|
||||
const { convert } = useExchangeRates(base)
|
||||
const ctx: CostsCtx = useMemo(() => ({ me, tripCurrency, convert }), [me, tripCurrency, convert])
|
||||
// Anchored on the trip currency's quote, the one the server books with (#2525).
|
||||
const { convert } = useExchangeRates(base, tripCurrency)
|
||||
const ctx: CostsCtx = useMemo(() => ({ me, tripCurrency, displayCurrency: base, convert }), [me, tripCurrency, base, convert])
|
||||
|
||||
const [settlement, setSettlement] = useState<CostsSettlementResponse | null>(null)
|
||||
// A failed settlement read leaves `settlement` null, and the final budget would
|
||||
@@ -339,7 +340,7 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
|
||||
: <ChevronDown size={13} strokeWidth={2.2} className="flex-none text-m-faint" />}
|
||||
</button>
|
||||
{open && (
|
||||
<FinalBudgetBreakdown row={row} items={budgetItems} base={base} locale={locale} t={t} personName={personName} />
|
||||
<FinalBudgetBreakdown row={row} items={budgetItems} ctx={ctx} base={base} locale={locale} t={t} personName={personName} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -622,8 +623,8 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT
|
||||
}) {
|
||||
const meta = catMeta(item.category)
|
||||
const Icon = meta.Icon
|
||||
const cur = currencyOf(item, ctx)
|
||||
const total = baseTotal(item, ctx)
|
||||
const line = lineOf(item.total_price || 0, item, ctx, total)
|
||||
const unfinished = isUnfinished(item, ctx)
|
||||
const borderColor = tint(meta.color, 0.55)
|
||||
const members = item.members || []
|
||||
@@ -670,9 +671,9 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{cur !== base && (
|
||||
{line && (
|
||||
<div className="mt-[1px] truncate font-geist text-[0.59375rem] text-m-faint">
|
||||
{formatMoney(item.total_price, cur, locale)} {'→'} {formatMoney(total, base, locale)}
|
||||
{formatMoney(line.entered.amount, line.entered.currency, locale)} {'→'} {formatMoney(line.into.amount, line.into.currency, locale)}
|
||||
</div>
|
||||
)}
|
||||
{members.length > 0 && (
|
||||
@@ -756,7 +757,9 @@ function PaymentRow({ settlement, ctx, base, locale, t, personName, canEdit, onE
|
||||
}) {
|
||||
const cur = (settlement.currency || base).toUpperCase()
|
||||
// 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)
|
||||
// A transfer without a currency was entered in the display currency, not the trip's.
|
||||
const amount = convertBooked(settlement.amount, cur, settlement.exchange_rate, ctx.tripCurrency, ctx.convert)
|
||||
const line = convertedLine(settlement.amount, cur, settlement.exchange_rate, ctx.tripCurrency, base, amount)
|
||||
return (
|
||||
<div className="mt-2 flex items-center gap-[6px]">
|
||||
<div className="relative min-w-0 flex-1 rounded-2xl border border-[color:var(--m-rowbr)] bg-m-card px-3 py-[12px]">
|
||||
@@ -767,9 +770,9 @@ function PaymentRow({ settlement, ctx, base, locale, t, personName, canEdit, onE
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[0.8125rem] font-bold text-m-ink">{t('costs.payment')}</div>
|
||||
<div className="truncate font-geist text-[0.65625rem] text-m-faint">{personName(settlement.from_user_id)} → {personName(settlement.to_user_id)}</div>
|
||||
{cur !== base && (
|
||||
{line && (
|
||||
<div className="mt-[1px] truncate font-geist text-[0.59375rem] text-m-faint">
|
||||
{formatMoney(settlement.amount, cur, locale)} {'→'} {formatMoney(amount, base, locale)}
|
||||
{formatMoney(line.entered.amount, line.entered.currency, locale)} {'→'} {formatMoney(line.into.amount, line.into.currency, locale)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -799,9 +802,10 @@ function PaymentRow({ settlement, ctx, base, locale, t, personName, canEdit, onE
|
||||
* with the balances; each line is signed by what it does to the final, so the
|
||||
* column reads as a subtraction and the rows add up to the line above them.
|
||||
*/
|
||||
function FinalBudgetBreakdown({ row, items, base, locale, t, personName }: {
|
||||
function FinalBudgetBreakdown({ row, items, ctx, base, locale, t, personName }: {
|
||||
row: BudgetParticipantFinal
|
||||
items: BudgetItem[]
|
||||
ctx: CostsCtx
|
||||
base: string
|
||||
locale: string
|
||||
t: TFn
|
||||
@@ -810,6 +814,12 @@ function FinalBudgetBreakdown({ row, items, base, locale, t, personName }: {
|
||||
const signed = (v: number) => (v < 0 ? '−' : '+') + formatMoney(Math.abs(v), base, locale)
|
||||
const { fronted, moved, outstanding } = finalBudgetSources(row, items)
|
||||
const transfer = (fromId: number, toId: number) => `${personName(fromId)} → ${personName(toId)}`
|
||||
// Beside a converted row's name, what was entered, as the list above names it (#2525).
|
||||
const nameOf = (itemId: number, name: string, shown: number) => {
|
||||
const e = items.find(i => i.id === itemId)
|
||||
const entered = e ? lineOf(paidByUser(e, row.user_id), e, ctx, shown)?.entered : null
|
||||
return entered ? `${name} · ${formatMoney(entered.amount, entered.currency, locale)}` : name
|
||||
}
|
||||
const line = (key: string, label: string, value: string) => (
|
||||
<div key={key} className="flex items-baseline gap-2 py-[2px] font-geist text-[0.6875rem]">
|
||||
<span className="min-w-0 flex-1 truncate text-m-muted">{label}</span>
|
||||
@@ -828,7 +838,7 @@ function FinalBudgetBreakdown({ row, items, base, locale, t, personName }: {
|
||||
{line('expenses', t('costs.finalExpenses'), signed(row.expenses))}
|
||||
{line('reimbursed', t('costs.finalReimbursed'), signed(-row.reimbursed))}
|
||||
{line('pending', t('costs.finalPending'), signed(-row.pending))}
|
||||
{section(t('costs.finalExpenses'), fronted.map(r => line(`e${r.item_id}`, r.name, signed(r.amount))))}
|
||||
{section(t('costs.finalExpenses'), fronted.map(r => line(`e${r.item_id}`, nameOf(r.item_id, r.name, r.amount), signed(r.amount))))}
|
||||
{section(t('costs.finalReimbursed'), moved.map(r => line(`s${r.settlement_id}`, transfer(r.from_user_id, r.to_user_id), signed(-r.amount))))}
|
||||
{section(t('costs.finalPending'), outstanding.map((r, i) => line(`f${i}`, transfer(r.from_user_id, r.to_user_id), signed(-r.amount))))}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +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 { convertBooked, convertedLine, tripAmountOf } from '../../../../hooks/useExchangeRates'
|
||||
import { currencyDecimals } from '../../../../utils/formatters'
|
||||
import type { BudgetItem } from '../../../../types'
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface CostsCtx {
|
||||
me: number
|
||||
/** The trip's own currency — what a NULL `budget_items.currency` means. */
|
||||
tripCurrency: string
|
||||
/** The currency everything is shown in, the base `convert` converts to. */
|
||||
displayCurrency: string
|
||||
convert: (amount: number, currency: string | null | undefined) => number
|
||||
}
|
||||
|
||||
@@ -32,6 +34,15 @@ export function booked(amount: number, e: BudgetItem, ctx: CostsCtx): number {
|
||||
return convertBooked(amount, e.currency, e.exchange_rate, ctx.tripCurrency, ctx.convert)
|
||||
}
|
||||
|
||||
/**
|
||||
* The line under an amount of this expense the list shows converted, what was entered and
|
||||
* where it went (#2525); null when there is nothing to explain. `shown` is the display
|
||||
* value printed beside it.
|
||||
*/
|
||||
export function lineOf(amount: number, e: BudgetItem, ctx: CostsCtx, shown: number) {
|
||||
return convertedLine(amount, e.currency, e.exchange_rate, ctx.tripCurrency, ctx.displayCurrency, shown)
|
||||
}
|
||||
|
||||
/** Expense total converted to the display/base currency, at its booked rate. */
|
||||
export function baseTotal(e: BudgetItem, ctx: CostsCtx): number {
|
||||
return booked(e.total_price || 0, e, ctx)
|
||||
@@ -315,12 +326,17 @@ export function buildCostsCsv(items: BudgetItem[], opts: CsvBuildOptions): { fil
|
||||
}
|
||||
}
|
||||
|
||||
const header = ['Date', 'Name', 'Category', 'Amount', 'Currency', `Amount (${opts.base})`, 'Note']
|
||||
// Read in another currency than the trip's, what each row counts as in the trip currency
|
||||
// too, the figure every sum is built from (#2525). Same columns as the desktop export.
|
||||
const trip = opts.ctx.tripCurrency.toUpperCase()
|
||||
const tripCol = trip !== opts.base.toUpperCase()
|
||||
const header = ['Date', 'Name', 'Category', 'Amount', 'Currency', ...(tripCol ? [`Amount (${trip})`] : []), `Amount (${opts.base})`, 'Note']
|
||||
const rows = [header.join(sep)]
|
||||
const sorted = items.slice().sort((a, b) => (a.expense_date || '').localeCompare(b.expense_date || ''))
|
||||
for (const e of sorted) {
|
||||
const cur = currencyOf(e, opts.ctx)
|
||||
const note = readUserNote(e)
|
||||
const inTrip = tripAmountOf(e.total_price || 0, e.currency, e.exchange_rate, trip, opts.ctx.convert)
|
||||
rows.push(
|
||||
[
|
||||
esc(fmtDate(e.expense_date || '')),
|
||||
@@ -328,6 +344,7 @@ export function buildCostsCsv(items: BudgetItem[], opts: CsvBuildOptions): { fil
|
||||
esc(opts.t(catMeta(e.category).labelKey)),
|
||||
(e.total_price || 0).toFixed(currencyDecimals(cur)),
|
||||
cur,
|
||||
...(tripCol ? [inTrip.toFixed(currencyDecimals(trip))] : []),
|
||||
baseTotal(e, opts.ctx).toFixed(currencyDecimals(opts.base)),
|
||||
esc(note),
|
||||
].join(sep),
|
||||
|
||||
@@ -597,6 +597,112 @@ describe('SharedTripPage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-SHARED-043: a foreign expense reads at the rate it was booked at, as in Costs (#2525)', () => {
|
||||
it('shows the frozen-rate value in whole cents, not today\'s rate', async () => {
|
||||
// CHF so no earlier test has cached rates for this base. Today 1 CHF buys 1.1395
|
||||
// USD; the expense froze 1.17 when it was entered. Costs reads 801.76 / 1.17 =
|
||||
// 685.26 CHF. Today's rate alone gives 703.61, printed as "703.607".
|
||||
server.use(
|
||||
http.get('https://api.frankfurter.dev/v2/rates', () => HttpResponse.json([{ quote: 'USD', rate: 1.1395 }])),
|
||||
http.get('/api/shared/:token', ({ params }) => {
|
||||
if (params.token !== 'booked-token') return;
|
||||
return HttpResponse.json({
|
||||
trip: { id: 1, title: 'Shared Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05', currency: 'CHF' },
|
||||
baseCurrency: 'CHF',
|
||||
days: [], assignments: {}, dayNotes: {}, places: [], reservations: [], accommodations: [], packing: [],
|
||||
budget: [
|
||||
{ id: 1, name: 'Aparthotel Silver', total_price: 801.76, category: 'Accommodation', currency: 'USD', exchange_rate: 1.17 },
|
||||
{ id: 2, name: 'Tram pass', total_price: 100, category: 'Transport', currency: null, exchange_rate: 1 },
|
||||
],
|
||||
categories: [],
|
||||
permissions: { share_bookings: false, share_packing: false, share_budget: true, share_collab: false },
|
||||
collab: [],
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
renderSharedTrip('booked-token');
|
||||
await waitFor(() => expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole('button', { name: /costs/i }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Aparthotel Silver')).toBeInTheDocument());
|
||||
// Row and its category both read the booked value; the trip total adds the
|
||||
// 100 CHF tram pass to it.
|
||||
await waitFor(() => expect(screen.getAllByText('685.26 CHF')).toHaveLength(2));
|
||||
expect(screen.getByText('785.26 CHF')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/703\.6/)).toBeNull();
|
||||
// What was entered stays beside the converted row, as the Costs list shows it.
|
||||
expect(screen.getByText('· 801.76 USD')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reads a same-day bill in the display currency exactly as typed, with the trip currency\'s quote', async () => {
|
||||
// ZAR trip read in USD (ZAR so no earlier test has cached its rates). The bill froze
|
||||
// 0.05911 USD per rand, today's ZAR quote. The USD quote, 16.918 rand per dollar, is
|
||||
// rounded on its own and is not its inverse.
|
||||
server.use(
|
||||
http.get('https://api.frankfurter.dev/v2/rates', ({ request }) => {
|
||||
const base = new URL(request.url).searchParams.get('base');
|
||||
return HttpResponse.json(base === 'ZAR' ? [{ quote: 'USD', rate: 0.05911 }] : [{ quote: 'ZAR', rate: 16.918 }]);
|
||||
}),
|
||||
http.get('/api/shared/:token', ({ params }) => {
|
||||
if (params.token !== 'same-day-token') return;
|
||||
return HttpResponse.json({
|
||||
trip: { id: 1, title: 'Shared Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05', currency: 'ZAR' },
|
||||
baseCurrency: 'USD',
|
||||
days: [], assignments: {}, dayNotes: {}, places: [], reservations: [], accommodations: [], packing: [],
|
||||
budget: [
|
||||
{ id: 1, name: 'Villa', total_price: 12345.67, category: 'Accommodation', currency: 'USD', exchange_rate: 0.05911 },
|
||||
],
|
||||
categories: [],
|
||||
permissions: { share_bookings: false, share_packing: false, share_budget: true, share_collab: false },
|
||||
collab: [],
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
renderSharedTrip('same-day-token');
|
||||
await waitFor(() => expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole('button', { name: /costs/i }));
|
||||
|
||||
await waitFor(() => expect(screen.getAllByText('12,345.67 USD')).toHaveLength(3));
|
||||
expect(screen.queryByText(/^· /)).toBeNull();
|
||||
});
|
||||
|
||||
it('reads a bill in the display currency through the trip currency too, beside what was entered', async () => {
|
||||
// The owner reads in USD on a CHF trip. The USD bill was booked at 685.26 CHF,
|
||||
// which today is 780.86 USD, the figure Costs and its balances use; the CHF one
|
||||
// converts at today's rate (100 CHF = 113.95 USD).
|
||||
server.use(
|
||||
http.get('https://api.frankfurter.dev/v2/rates', () => HttpResponse.json([{ quote: 'CHF', rate: 1 / 1.1395 }])),
|
||||
http.get('/api/shared/:token', ({ params }) => {
|
||||
if (params.token !== 'display-token') return;
|
||||
return HttpResponse.json({
|
||||
trip: { id: 1, title: 'Shared Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05', currency: 'CHF' },
|
||||
baseCurrency: 'USD',
|
||||
days: [], assignments: {}, dayNotes: {}, places: [], reservations: [], accommodations: [], packing: [],
|
||||
budget: [
|
||||
{ id: 1, name: 'Aparthotel Silver', total_price: 801.76, category: 'Accommodation', currency: 'USD', exchange_rate: 1.17 },
|
||||
{ id: 2, name: 'Tram pass', total_price: 100, category: 'Transport', currency: null, exchange_rate: 1 },
|
||||
],
|
||||
categories: [],
|
||||
permissions: { share_bookings: false, share_packing: false, share_budget: true, share_collab: false },
|
||||
collab: [],
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
renderSharedTrip('display-token');
|
||||
await waitFor(() => expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole('button', { name: /costs/i }));
|
||||
|
||||
await waitFor(() => expect(screen.getAllByText('113.95 USD')).toHaveLength(2));
|
||||
expect(screen.getAllByText('780.86 USD')).toHaveLength(2);
|
||||
expect(screen.getByText('894.81 USD')).toBeInTheDocument();
|
||||
expect(screen.getByText('· 801.76 USD')).toBeInTheDocument();
|
||||
expect(screen.getByText('· 100.00 CHF')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// FE-PAGE-SHARED-021 to FE-PAGE-SHARED-037 drive the remaining render branches of
|
||||
// the page: header variants, the permission-driven tab strip, and every item kind
|
||||
// the day timeline, bookings, packing, costs and chat sections can produce.
|
||||
|
||||
@@ -27,13 +27,14 @@ import { getCategoryIcon } from '../components/shared/categoryIcons';
|
||||
import PublicLanguagePicker from '../components/shared/PublicLanguagePicker';
|
||||
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, attributionForTile } from '../constants/mapDefaults';
|
||||
import VectorBasemap from '../components/Map/VectorBasemap';
|
||||
import { convertBooked, convertedLine } from '../hooks/useExchangeRates';
|
||||
import { useTranslation } from '../i18n';
|
||||
import { avatarSrc } from '../utils/avatarSrc';
|
||||
import { safeHexColor } from '../utils/safeColor';
|
||||
import { getMergedItems, getTransportForDay, hidesOnMiddleDay } from '../utils/dayMerge';
|
||||
import { isDayInAccommodationRange } from '../utils/dayOrder';
|
||||
import { getFlightLegs, getTrainLegs } from '../utils/flightLegs';
|
||||
import { splitReservationDateTime } from '../utils/formatters';
|
||||
import { currencyDecimals, splitReservationDateTime } from '../utils/formatters';
|
||||
import { computeMapViewport, TILE_SIZE_RASTER } from '../utils/mapViewport';
|
||||
import { resolveBasemap } from '../utils/tileUrl';
|
||||
import { useSharedTrip } from './sharedTrip/useSharedTrip';
|
||||
@@ -981,16 +982,31 @@ export default function SharedTripPage() {
|
||||
{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;
|
||||
// Pre-rework rows store currency = NULL ("the trip's own currency"). Each
|
||||
// expense is read the way CostsPanel reads it (#2525): at the rate frozen when
|
||||
// it was entered, into the trip currency, then into the owner's display base.
|
||||
// Reading it at today's rate alone made the shared page disagree with the
|
||||
// trip's own Costs tab.
|
||||
type Expense = { total_price?: number | string | null; currency?: string | null; exchange_rate?: number | null };
|
||||
const tripCurrency = String(trip.currency || base).toUpperCase();
|
||||
const amountOf = (i: Expense) => Number.parseFloat(String(i.total_price ?? '')) || 0;
|
||||
const valueOf = (i: Expense) => convertBooked(amountOf(i), i.currency, i.exchange_rate, tripCurrency, convert);
|
||||
// Whole cents: a converted amount otherwise printed a third decimal.
|
||||
const money = (v: number) =>
|
||||
v.toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
// What was entered, beside a row shown converted, as the Costs list prints it.
|
||||
const enteredOf = (i: Expense): string | null => {
|
||||
const entered = convertedLine(amountOf(i), i.currency, i.exchange_rate, tripCurrency, base, valueOf(i))?.entered;
|
||||
if (!entered) return null;
|
||||
const d = currencyDecimals(entered.currency);
|
||||
return `${entered.amount.toLocaleString(locale, { minimumFractionDigits: d, maximumFractionDigits: d })} ${entered.currency}`;
|
||||
};
|
||||
const grouped = (budget || []).reduce((g: any, i: any) => {
|
||||
const c = i.category || t('shared.other');
|
||||
(g[c] = g[c] || []).push(i);
|
||||
return g;
|
||||
}, {});
|
||||
const sumIn = (items: any[]) =>
|
||||
items.reduce((s: number, i: any) => s + convert(Number.parseFloat(i.total_price) || 0, curOf(i)), 0);
|
||||
const sumIn = (items: any[]) => items.reduce((s: number, i: any) => s + valueOf(i), 0);
|
||||
const total = sumIn(budget || []);
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
@@ -1015,7 +1031,7 @@ export default function SharedTripPage() {
|
||||
{t('shared.totalBudget')}
|
||||
</div>
|
||||
<div style={{ fontSize: 'calc(28px * var(--fs-scale-title, 1))', fontWeight: 700, marginTop: 4 }}>
|
||||
{total.toLocaleString(locale, { minimumFractionDigits: 2 })} {base}
|
||||
{money(total)} {base}
|
||||
</div>
|
||||
</div>
|
||||
{/* By category */}
|
||||
@@ -1045,7 +1061,7 @@ export default function SharedTripPage() {
|
||||
className="text-[#6b7280]"
|
||||
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600 }}
|
||||
>
|
||||
{sumIn(items).toLocaleString(locale, { minimumFractionDigits: 2 })} {base}
|
||||
{money(sumIn(items))} {base}
|
||||
</span>
|
||||
</div>
|
||||
{items.map((item: any) => (
|
||||
@@ -1061,13 +1077,16 @@ export default function SharedTripPage() {
|
||||
>
|
||||
<span className="text-[#111827]" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
|
||||
{item.name}
|
||||
{item.total_price && enteredOf(item) ? (
|
||||
<span className="text-content-faint"> · {enteredOf(item)}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span
|
||||
className="text-[#111827]"
|
||||
style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}
|
||||
>
|
||||
{item.total_price
|
||||
? `${convert(Number.parseFloat(item.total_price) || 0, curOf(item)).toLocaleString(locale, { minimumFractionDigits: 2 })} ${base}`
|
||||
? `${money(valueOf(item))} ${base}`
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +77,8 @@ export function useSharedTrip() {
|
||||
// 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)
|
||||
// Anchored on the trip currency's quote, as the Costs tab converts (#2525).
|
||||
const { convert } = useExchangeRates(base, data?.trip?.currency)
|
||||
|
||||
return {
|
||||
data,
|
||||
|
||||
Reference in New Issue
Block a user