mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +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 } : {}),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user