mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +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 cost is labelled in the trip currency, 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'
|
||||
@@ -85,7 +86,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
|
||||
@@ -145,12 +147,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.
|
||||
@@ -169,6 +176,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)
|
||||
@@ -178,7 +187,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(() => {
|
||||
@@ -192,7 +201,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,
|
||||
@@ -290,15 +299,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))
|
||||
@@ -768,7 +782,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)
|
||||
@@ -837,9 +851,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
|
||||
@@ -894,6 +908,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' }}>
|
||||
@@ -901,7 +917,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} />
|
||||
@@ -1000,6 +1016,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}>
|
||||
@@ -1022,12 +1045,17 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
<div>
|
||||
<div className={labelCls}>{t('costs.finalExpenses')}</div>
|
||||
<div style={listCls}>
|
||||
{fronted.map(r => (
|
||||
{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}</span>
|
||||
<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>
|
||||
)}
|
||||
@@ -1237,12 +1265,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
|
||||
@@ -1250,7 +1279,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 ''
|
||||
})
|
||||
@@ -1334,6 +1363,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
|
||||
@@ -1591,12 +1621,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>
|
||||
{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(convert(totalNum, currency), base, locale)}</span>
|
||||
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(fx.shown, base, locale)}</span>
|
||||
<span className="text-content-faint">· {t('costs.liveRate')}</span>
|
||||
</>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1823,7 +1859,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 }
|
||||
}
|
||||
@@ -33,6 +33,10 @@ export function BookingCostsSection({ reservationId, placeId = null, hintKey = '
|
||||
const trip = useTripStore(s => s.trip)
|
||||
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
|
||||
const base = (displayCurrency || trip?.currency || '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 || trip?.currency || base
|
||||
const linked = reservationId
|
||||
? budgetItems.find(i => i.reservation_id === reservationId)
|
||||
: placeId
|
||||
@@ -54,7 +58,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>
|
||||
)
|
||||
@@ -72,7 +76,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' }}>{linked.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(linked.total_price, linked.currency || base, locale)}</span>
|
||||
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{formatMoney(linked.total_price, ownCurrency(linked.currency), locale)}</span>
|
||||
<button type="button" onClick={() => onEdit(linked)} title={t('common.edit')} className="text-content-muted border border-edge bg-surface-card" style={{ display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' }}><Pencil size={13} /></button>
|
||||
<button type="button" onClick={() => onRemove(linked)} title={t('reservations.removeExpense')} className="text-content-muted border border-edge bg-surface-card" style={{ display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' }}><Trash2 size={13} /></button>
|
||||
</div>
|
||||
|
||||
@@ -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';
|
||||
@@ -1026,7 +1026,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 () => {
|
||||
@@ -1336,6 +1336,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 { parseReservationMetadata } from '../../utils/flightLegs'
|
||||
import { resolveDayId } from '../../utils/formatters'
|
||||
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, BudgetItem } from '../../types'
|
||||
import { BookingCostsSection } from './BookingCostsSection'
|
||||
import { importedPriceEntry } from './importedPrice'
|
||||
import { TravelerPicker } from './TravelerPicker'
|
||||
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
|
||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||
@@ -306,11 +307,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
|
||||
@@ -352,12 +350,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]
|
||||
|
||||
@@ -1090,7 +1090,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 ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -18,6 +18,7 @@ import apiClient from '../../api/client'
|
||||
import type { Day, Place, Accommodation, Reservation, ReservationEndpoint, TripFile, BudgetItem, AssignmentsMap } from '../../types'
|
||||
import { parseReservationMetadata, orderedEndpoints, stripAirportCode } from '../../utils/flightLegs'
|
||||
import { BookingCostsSection } from './BookingCostsSection'
|
||||
import { importedPriceEntry } from './importedPrice'
|
||||
import { TravelerPicker } from './TravelerPicker'
|
||||
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
|
||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||
@@ -612,11 +613,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
|
||||
@@ -660,12 +658,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>
|
||||
{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(convert(totalNum, currency), base, locale)}</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>
|
||||
)}
|
||||
|
||||
@@ -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 PlFileAttach from './PlFileAttach'
|
||||
@@ -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,
|
||||
|
||||
@@ -4,10 +4,10 @@ import type { TripMember } from '../../../../src/components/Budget/BudgetPanelMe
|
||||
import { clearExchangeRateCache } from '../../../../src/hooks/useExchangeRates'
|
||||
import { useTripStore, type TripStoreState } from '../../../../src/store/tripStore'
|
||||
import type { BudgetItem, BudgetItemMember } from '../../../../src/types'
|
||||
import { buildBudgetItem } from '../../../helpers/factories'
|
||||
import { buildBudgetItem, buildTrip } from '../../../helpers/factories'
|
||||
import { localToday } from '../../../../src/components/Planner/today'
|
||||
import { resetAllStores } from '../../../helpers/store'
|
||||
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-COSTSH-001 to FE-MOB-COSTSH-030
|
||||
// The sheet reads its copy from useTranslation(), so assertions are English.
|
||||
@@ -648,6 +648,66 @@ describe('MCostSheet', () => {
|
||||
})))
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-035: an expense saved without a currency reopens in the trip currency (#2525)', async () => {
|
||||
// No currency on the row means the trip's own. Seeding the display currency
|
||||
// instead labelled 100 euro as 100 dollars, and a plain save stored it so.
|
||||
localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, EUR: 0.8 }, ts: Date.now() }))
|
||||
useTripStore.setState({ trip: buildTrip({ id: 1, currency: 'EUR' }) } as unknown as Partial<TripStoreState>)
|
||||
const editing = buildBudgetItem({
|
||||
id: 12, name: 'Tram pass', category: 'transport', currency: null, total_price: 100,
|
||||
members: [member(1, null)],
|
||||
payers: [{ user_id: 1, amount: 100 }],
|
||||
})
|
||||
renderSheet({ base: 'USD', editing })
|
||||
|
||||
expect(totalField()).toHaveValue('100,00')
|
||||
expect(screen.getByRole('button', { name: 'EUR €' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'USD $' })).toBeNull()
|
||||
|
||||
fireEvent.click(saveBtn())
|
||||
await waitFor(() => expect(updateBudgetItem).toHaveBeenCalledWith(1, 12, expect.objectContaining({
|
||||
total_price: 100,
|
||||
currency: 'EUR',
|
||||
})))
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-036: editing a booked bill previews the rate the save keeps (#2525)', () => {
|
||||
// A euro trip read in dollars, the bill entered in dollars at 1.17 to the euro.
|
||||
// Same currency as the list, so the sheet said nothing, while the list showed the
|
||||
// booked euros at today's rate beside the 801.76 typed here.
|
||||
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 }, ts: Date.now() }))
|
||||
useTripStore.setState({ trip: buildTrip({ id: 1, currency: 'EUR' }) } as unknown as Partial<TripStoreState>)
|
||||
const editing = buildBudgetItem({
|
||||
id: 13, name: 'Aparthotel Silver', category: 'accommodation', currency: 'USD', exchange_rate: 1.17, total_price: 801.76,
|
||||
members: [member(1, null), member(2, null)],
|
||||
payers: [{ user_id: 1, amount: 801.76 }],
|
||||
})
|
||||
renderSheet({ base: 'USD', editing })
|
||||
|
||||
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-MOB-COSTSH-037: a bill booked at today\'s rate has nothing to preview (#2525)', () => {
|
||||
localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, EUR: 0.87732 }, ts: Date.now() }))
|
||||
localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 1.1398 }, ts: Date.now() }))
|
||||
useTripStore.setState({ trip: buildTrip({ id: 1, currency: 'EUR' }) } as unknown as Partial<TripStoreState>)
|
||||
const editing = buildBudgetItem({
|
||||
id: 14, name: 'Villa', category: 'accommodation', currency: 'USD', exchange_rate: 1.1398, total_price: 12345.67,
|
||||
members: [member(1, null), member(2, null)],
|
||||
payers: [{ user_id: 1, amount: 12345.67 }],
|
||||
})
|
||||
renderSheet({ base: 'USD', editing })
|
||||
|
||||
expect(screen.queryByText(/live rate/)).toBeNull()
|
||||
expect(screen.queryByText(/→/)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-030: a currency without a known symbol falls back to its code', () => {
|
||||
localStorage.setItem('trek_fx_XTS', JSON.stringify({ rates: { XTS: 1 }, ts: Date.now() }))
|
||||
renderSheet({ base: 'XTS' })
|
||||
|
||||
@@ -639,7 +639,8 @@ describe('MCostsTab', () => {
|
||||
expect(mine).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(within(card).getAllByText('+$58.00')).toHaveLength(2)
|
||||
expect(within(card).queryByText(/60\.00/)).not.toBeInTheDocument()
|
||||
expect(within(card).getByText('Museum')).toBeInTheDocument()
|
||||
// Named by what Bob entered, as the list names it (#2525), never re-converted.
|
||||
expect(within(card).getByText('Museum · £30.00')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(bob)
|
||||
expect(bob).toHaveAttribute('aria-expanded', 'false')
|
||||
@@ -725,6 +726,74 @@ describe('MCostsTab', () => {
|
||||
expect(update).toHaveBeenCalledWith(7, 505, { from_user_id: 1, to_user_id: 2, amount: 10, currency: 'GBP', settled_at: '2026-04-28' })
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-045: a bill whose rate moved shows what was entered and what it was booked at (#2525)', async () => {
|
||||
// A euro trip read in dollars, the bill entered in dollars at a frozen 1.17 to the
|
||||
// euro while today the euro buys 1.1551. It is 685.26 EUR of trip money, $791.55
|
||||
// today, and that is what settle-up offers too; the row now says where the
|
||||
// 801.76 that was entered went instead of hiding it. Converted with the euro's own
|
||||
// quote, not the dollar's separately rounded one.
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'USD' } })
|
||||
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 }, ts: Date.now() }))
|
||||
const hotel = {
|
||||
id: 21, trip_id: 7, name: 'Aparthotel Silver', category: 'accommodation', total_price: 801.76, currency: 'USD', exchange_rate: 1.17,
|
||||
expense_date: '2026-05-01', note: null,
|
||||
members: [
|
||||
{ user_id: 1, paid: 1, username: 'Me', amount: null, avatar_url: null },
|
||||
{ user_id: 2, paid: 0, username: 'Ada', amount: null, avatar_url: null },
|
||||
],
|
||||
payers: [{ user_id: 1, amount: 801.76, username: 'Me' }],
|
||||
} as unknown as BudgetItem
|
||||
serveSettlement({
|
||||
...SETTLEMENT,
|
||||
settlements: [
|
||||
{ id: 601, from_user_id: 2, to_user_id: 1, amount: 400.88, currency: 'USD', exchange_rate: 1.17, settled_at: '2026-05-02', created_at: '2026-05-02T09:00:00Z' },
|
||||
// A transfer from before they carried a currency was entered in the display currency.
|
||||
{ id: 602, from_user_id: 3, to_user_id: 1, amount: 30, currency: null, settled_at: '2026-05-03', created_at: '2026-05-03T09:00:00Z' },
|
||||
],
|
||||
})
|
||||
await renderTab(planner({ trip: { ...TRIP, currency: 'EUR' } as Trip, budgetItems: [hotel] }))
|
||||
|
||||
const card = cardOf('Aparthotel Silver')
|
||||
expect(within(card).getByText(/^\$801\.76 → 685,26\s€$/)).toBeInTheDocument()
|
||||
expect(within(card).getByText('$791.55')).toBeInTheDocument()
|
||||
// Each share is half of the booked euros, the figure the balances net.
|
||||
expect(within(card).getAllByText('$395.77')).toHaveLength(2)
|
||||
expect(within(card).queryByText('$400.88')).toBeNull()
|
||||
expect(up(screen.getByText('costs.totalSpend'), 1).textContent).toContain('$791.55')
|
||||
expect(screen.getByText('costs.spent:$791.55')).toBeInTheDocument()
|
||||
|
||||
expect(screen.getAllByText('costs.payment')).toHaveLength(2)
|
||||
const paid = up(screen.getByText('Ada → costs.you'), 2)
|
||||
expect(within(paid).getByText(/^\$400\.88 → 342,63\s€$/)).toBeInTheDocument()
|
||||
expect(within(up(screen.getByText('Bob → costs.you'), 2)).getByText('$30.00')).toBeInTheDocument()
|
||||
expect(screen.queryByText('$34.65')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-046: a bill booked at today\'s rate reads exactly as typed (#2525)', async () => {
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'USD' } })
|
||||
localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, EUR: 0.87732 }, ts: Date.now() }))
|
||||
localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 1.1398 }, ts: Date.now() }))
|
||||
const villa = {
|
||||
id: 22, trip_id: 7, name: 'Villa', category: 'accommodation', total_price: 12345.67, currency: 'USD', exchange_rate: 1.1398,
|
||||
expense_date: '2026-05-01', note: null,
|
||||
members: [
|
||||
{ user_id: 1, paid: 1, username: 'Me', amount: null, avatar_url: null },
|
||||
{ user_id: 2, paid: 0, username: 'Ada', amount: null, avatar_url: null },
|
||||
],
|
||||
payers: [{ user_id: 1, amount: 12345.67, username: 'Me' }],
|
||||
} as unknown as BudgetItem
|
||||
serveSettlement(SETTLEMENT)
|
||||
await renderTab(planner({ trip: { ...TRIP, currency: 'EUR' } as Trip, budgetItems: [villa] }))
|
||||
|
||||
const card = cardOf('Villa')
|
||||
// Through the dollar's own quote it read $12,346.05.
|
||||
expect(within(card).getByText('$12,345.67')).toBeInTheDocument()
|
||||
expect(within(card).queryByText(/^\$12,345\.67 →/)).toBeNull()
|
||||
expect(screen.getByText('costs.spent:$12,345.67')).toBeInTheDocument()
|
||||
expect(up(screen.getByText('costs.totalSpend'), 1).textContent).toContain('$12,345.67')
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-044: a payment cannot be saved without a day', async () => {
|
||||
// Cleared, the server would store NULL and the ledger would quietly fall
|
||||
// back to the day the payment was recorded on.
|
||||
|
||||
@@ -826,6 +826,18 @@ describe('MTransportFormSheet', () => {
|
||||
expect((addFile.mock.calls[0][1] as FormData).get('description')).toBe('Rental car')
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-029b: a parsed price keeps the currency it was quoted in (#2525)', async () => {
|
||||
const handleSaveTransport = makeSave()
|
||||
const planner = makePlanner({
|
||||
transportPrefill: { type: 'train', title: 'Amtrak', metadata: { price: 117, priceCurrency: 'USD' }, endpoints: [] },
|
||||
handleSaveTransport,
|
||||
})
|
||||
renderSheet(planner)
|
||||
await submit()
|
||||
// Sent without it, the server stored 117 in the trip currency.
|
||||
expect(handleSaveTransport.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 117, category: 'transport', currency: 'USD' })
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-030: a prefill without a price and with the budget addon off skips the cost entry', async () => {
|
||||
seedStore(useAddonStore, { addons: [] })
|
||||
const handleSaveTransport = makeSave()
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
groupByDay,
|
||||
groupLedgerByDay,
|
||||
isUnfinished,
|
||||
lineOf,
|
||||
memberShareOf,
|
||||
myPaidOf,
|
||||
myShareOf,
|
||||
@@ -32,6 +33,7 @@ const ctx: CostsCtx = {
|
||||
me: 1,
|
||||
// lower case on purpose — currencyOf has to normalise it
|
||||
tripCurrency: 'eur',
|
||||
displayCurrency: 'EUR',
|
||||
convert: (amount, currency) => amount * (RATES[(currency || 'EUR').toUpperCase()] ?? 1),
|
||||
};
|
||||
|
||||
@@ -306,6 +308,86 @@ describe('costsModel — list filters and grouping', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('costsModel: a bill entered in the display currency (#2525)', () => {
|
||||
// A euro trip read in dollars, converted with the euro's own quote the way
|
||||
// useExchangeRates(base, tripCurrency) converts: one euro buys 1.1551 dollars today.
|
||||
const usdCtx: CostsCtx = {
|
||||
me: 1,
|
||||
tripCurrency: 'EUR',
|
||||
displayCurrency: 'usd',
|
||||
convert: (amount, currency) => {
|
||||
const perEuro = ({ USD: 1.1551, EUR: 1, SEK: 10 } as Record<string, number>)[(currency || 'USD').toUpperCase()] ?? 1
|
||||
return (amount / perEuro) * 1.1551
|
||||
},
|
||||
};
|
||||
const hotel = (rate = 1.17) => expense({
|
||||
total_price: 801.76, currency: 'USD', exchange_rate: rate,
|
||||
payers: [payer(1, 801.76)], members: [member(1), member(2)],
|
||||
});
|
||||
|
||||
it('FE-MOB-CMOD-038: a bill booked at a rate that has moved since counts as its euros at today\'s rate', () => {
|
||||
// 685.26 EUR, which the balances net and settle-up offers; the row, the payer's
|
||||
// figure and the share read the same, or a payback of the row's half reopens the trip.
|
||||
expect(baseTotal(hotel(), usdCtx)).toBeCloseTo(791.55, 2);
|
||||
expect(myPaidOf(hotel(), usdCtx)).toBeCloseTo(791.55, 2);
|
||||
expect(myShareOf(hotel(), usdCtx)).toBeCloseTo(395.77, 2);
|
||||
// Under it, what was entered and the euros it was booked at, no third step.
|
||||
const line = lineOf(801.76, hotel(), usdCtx, baseTotal(hotel(), usdCtx))!;
|
||||
expect(line.entered).toEqual({ amount: 801.76, currency: 'USD' });
|
||||
expect(line.into.currency).toBe('EUR');
|
||||
expect(line.into.amount).toBeCloseTo(685.26, 2);
|
||||
});
|
||||
|
||||
it('FE-MOB-CMOD-039: booked at today\'s rate, the bill reads exactly as typed and needs no line', () => {
|
||||
const big = expense({ total_price: 12345.67, currency: 'USD', exchange_rate: 1.1551, payers: [payer(1, 12345.67)], members: [member(1), member(2)] });
|
||||
const unpaid = expense({ total_price: 250, currency: 'USD', exchange_rate: 1.1551 });
|
||||
expect(Math.round(baseTotal(big, usdCtx) * 100)).toBe(1234567);
|
||||
expect(Math.round(baseTotal(unpaid, usdCtx) * 100)).toBe(25000);
|
||||
expect(lineOf(12345.67, big, usdCtx, baseTotal(big, usdCtx))).toBeNull();
|
||||
const t = computeTotals([big, unpaid], [], usdCtx);
|
||||
expect(Math.round(t.totalSpend * 100)).toBe(1259567);
|
||||
expect(Math.round(t.outstanding * 100)).toBe(25000);
|
||||
});
|
||||
|
||||
it('FE-MOB-CMOD-040: other currencies go through the trip currency at their booked rate', () => {
|
||||
// 1000 SEK booked at 10 per euro is 100 euro, and 100 euro is 115.51 dollars today.
|
||||
const sek = expense({ total_price: 1000, currency: 'SEK', exchange_rate: 10 })
|
||||
expect(baseTotal(sek, usdCtx)).toBeCloseTo(115.51, 2);
|
||||
expect(lineOf(1000, sek, usdCtx, baseTotal(sek, usdCtx))).toEqual({ entered: { amount: 1000, currency: 'SEK' }, into: { amount: 100, currency: 'EUR' } });
|
||||
// A row in the trip currency goes straight to the dollars.
|
||||
const eur = expense({ total_price: 100, currency: null })
|
||||
expect(baseTotal(eur, usdCtx)).toBeCloseTo(115.51, 2);
|
||||
expect(lineOf(100, eur, usdCtx, 115.51)).toEqual({ entered: { amount: 100, currency: 'EUR' }, into: { amount: 115.51, currency: 'USD' } });
|
||||
});
|
||||
|
||||
it('FE-MOB-CMOD-041: there is nothing to explain when nothing was converted', () => {
|
||||
// An amount of 0, a row written before the freeze, and any row when the viewer
|
||||
// reads the trip currency itself.
|
||||
const zero = expense({ total_price: 0, currency: 'USD', exchange_rate: 1.17 });
|
||||
expect(lineOf(0, zero, usdCtx, 0)).toBeNull();
|
||||
const legacy = expense({ total_price: 50, currency: 'USD', exchange_rate: 1 });
|
||||
expect(baseTotal(legacy, usdCtx)).toBe(50);
|
||||
expect(lineOf(50, legacy, usdCtx, 50)).toBeNull();
|
||||
const inEur = { ...usdCtx, displayCurrency: 'EUR', convert: (a: number) => a };
|
||||
expect(lineOf(801.76, hotel(), inEur, 685.26)).toEqual({ entered: { amount: 801.76, currency: 'USD' }, into: { amount: 685.26, currency: 'EUR' } });
|
||||
});
|
||||
|
||||
it('FE-MOB-CMOD-042: the CSV adds what each row counts as in the trip currency', () => {
|
||||
const t = ((key: string) => key) as unknown as Parameters<typeof buildCostsCsv>[1]['t'];
|
||||
const items = [
|
||||
hotel(),
|
||||
expense({ name: 'Old taxi', total_price: 115.51, currency: 'USD', exchange_rate: 1, expense_date: '2026-07-20' }),
|
||||
].map((e, i) => ({ ...e, name: e.name || 'Hotel', expense_date: e.expense_date || '2026-07-19', id: i + 1 }));
|
||||
const inUsd = buildCostsCsv(items, { base: 'USD', ctx: usdCtx, locale: 'en-US', tripTitle: 'Trip', t }).content.split('\r\n');
|
||||
expect(inUsd[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Amount (USD);Note');
|
||||
expect(inUsd[1]).toMatch(/;801\.76;USD;685\.26;791\.55;$/);
|
||||
// Never booked, so counted at today's rate, as the server counts it.
|
||||
expect(inUsd[2]).toMatch(/;115\.51;USD;100\.00;115\.51;$/);
|
||||
const inEur = buildCostsCsv(items, { base: 'EUR', ctx: { ...usdCtx, displayCurrency: 'EUR' }, locale: 'en-US', tripTitle: 'Trip', t }).content.split('\r\n');
|
||||
expect(inEur[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Note');
|
||||
});
|
||||
});
|
||||
|
||||
describe('costsModel — ledger grouping (expenses + settlement payments)', () => {
|
||||
it('FE-MOB-CMOD-032: merges expenses and payments into shared day groups, newest first, no-date last', () => {
|
||||
const e = expense({ id: 50, expense_date: '2026-07-02' });
|
||||
|
||||
@@ -62,8 +62,8 @@ export class BudgetController {
|
||||
}
|
||||
|
||||
@Get('summary/per-person')
|
||||
perPerson(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
return { summary: this.budget.perPersonSummary(tripId) };
|
||||
async perPerson(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
return { summary: await this.budget.perPersonSummary(tripId) };
|
||||
}
|
||||
|
||||
@Get('settlement')
|
||||
|
||||
@@ -402,10 +402,9 @@ export class BudgetMcp {
|
||||
async getSettlementSummary({ tripId, base }: { tripId: number; base?: string }, ctx: McpContext) {
|
||||
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
const trip = this.db.get<{ currency?: string }>('SELECT currency FROM trips WHERE id = ?', tripId);
|
||||
const tripCurrency = trip?.currency || 'EUR';
|
||||
const effectiveBase = (base || tripCurrency).toUpperCase();
|
||||
const rates = await this.exchangeRates.getRates(effectiveBase);
|
||||
const summary = this.budget.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency });
|
||||
// The same call the REST route makes, so both convert with the trip currency's own
|
||||
// quote and an amount entered in `base` reads back to the cent (#2525).
|
||||
const summary = await this.budget.settlement(tripId, base, trip?.currency || 'EUR');
|
||||
return ok({ summary });
|
||||
}
|
||||
|
||||
@@ -556,7 +555,8 @@ export class BudgetMcp {
|
||||
}],
|
||||
};
|
||||
}
|
||||
const summary = this.budget.getPerPersonSummary(id);
|
||||
// In the trip currency, each share at the rate its expense was booked at (#2525).
|
||||
const summary = await this.budget.perPersonSummary(id);
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
|
||||
@@ -88,6 +88,44 @@ function allocateDisplayCents(cents: number[], factor: number, total = Math.roun
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* An amount of a row, in the row's own currency, in the trip currency. The rate frozen
|
||||
* when the row was entered wins (#1335), so a booked expense keeps the value it was
|
||||
* booked at. A row written before the freeze existed (rate 1 or none) converts through
|
||||
* today's `rates`, units of each currency per 1 of their base, and without them stays
|
||||
* as it is. Pre-rework rows store currency = NULL, which means the trip's own.
|
||||
*
|
||||
* The settlement nets with it and every trip total adds up with it, so the two can never
|
||||
* read the same rows differently.
|
||||
*/
|
||||
function toTripAmount(
|
||||
amount: number,
|
||||
itemCurrency: string | null | undefined,
|
||||
itemRate: number | null | undefined,
|
||||
tripCurrency: string,
|
||||
rates: Record<string, number> | null,
|
||||
): number {
|
||||
const cur = (itemCurrency || tripCurrency).toUpperCase();
|
||||
if (cur === tripCurrency) return amount;
|
||||
if (itemRate != null && itemRate > 0 && itemRate !== 1) return amount / itemRate;
|
||||
if (!rates) return amount;
|
||||
const rCur = rates[cur];
|
||||
const rTrip = rates[tripCurrency];
|
||||
if (rCur && rCur > 0 && rTrip && rTrip > 0) return (amount / rCur) * rTrip;
|
||||
return amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Units of `to` per 1 `from`, from a set of rates quoted against any base (each rate is
|
||||
* units of that currency per 1 base). 1 when either quote is missing, which leaves an
|
||||
* amount as it is, the way every conversion here degrades without rates.
|
||||
*/
|
||||
function quoteRatio(rates: Record<string, number> | null, to: string, from: string): number {
|
||||
const rTo = rates?.[to];
|
||||
const rFrom = rates?.[from];
|
||||
return rTo && rTo > 0 && rFrom && rFrom > 0 ? rTo / rFrom : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Budget domain service — owns the budget SQL (moved from the legacy
|
||||
* services/budgetService.ts: identical statements, the `||` falsy-coercion
|
||||
@@ -541,7 +579,7 @@ export class BudgetService {
|
||||
linkBudgetItemToReservation(
|
||||
tripId: string | number,
|
||||
reservationId: number,
|
||||
data: { name: string; category?: string; total_price: number },
|
||||
data: { name: string; category?: string; total_price: number; currency?: string | null; exchange_rate?: number },
|
||||
) {
|
||||
// createBudgetItem accepts reservation_id directly — the legacy separate
|
||||
// UPDATE after the insert was redundant (and non-atomic).
|
||||
@@ -814,20 +852,101 @@ export class BudgetService {
|
||||
// Per-person summary
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
getPerPersonSummary(tripId: string | number) {
|
||||
const summary = this.db.all<{ user_id: number; username: string; avatar: string | null; total_assigned: number; total_paid: number; items_count: number }>(`
|
||||
SELECT bm.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar,
|
||||
SUM(COALESCE(bm.amount, bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id))) as total_assigned,
|
||||
SUM(CASE WHEN bm.paid = 1 THEN COALESCE(bm.amount, bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id)) ELSE 0 END) as total_paid,
|
||||
COUNT(bi.id) as items_count
|
||||
/**
|
||||
* What each member is down for across the trip (`total_assigned`), and how much of
|
||||
* that is marked paid (`total_paid`), in the trip currency.
|
||||
*
|
||||
* Each share is converted to trip cents the way the settlement converts it, at the
|
||||
* rate frozen when the expense was entered, and an equal split is a largest-remainder
|
||||
* split of those cents (#2525). The SQL this replaces summed raw amounts across
|
||||
* currencies and divided them in floats, so a bill of 801.76 USD counted as 801.76 of
|
||||
* the trip's euros and a third of 100 came out as 33.333…
|
||||
*/
|
||||
getPerPersonSummary(tripId: string | number, rates: Record<string, number> | null = null) {
|
||||
const trip = this.db.get<{ currency?: string | null }>('SELECT currency FROM trips WHERE id = ?', tripId);
|
||||
const tripCurrency = (trip?.currency || 'EUR').toUpperCase();
|
||||
const items = this.db.all<{ id: number; total_price: number | null; currency: string | null; exchange_rate: number | null }>(
|
||||
'SELECT id, total_price, currency, exchange_rate FROM budget_items WHERE trip_id = ?', tripId,
|
||||
);
|
||||
const members = this.db.all<{ budget_item_id: number; user_id: number; amount: number | null; paid: number | null; username: string; avatar: string | null }>(`
|
||||
SELECT bm.budget_item_id, bm.user_id, bm.amount, bm.paid, COALESCE(u.display_name, u.username) AS username, u.avatar
|
||||
FROM budget_item_members bm
|
||||
JOIN budget_items bi ON bm.budget_item_id = bi.id
|
||||
JOIN users u ON bm.user_id = u.id
|
||||
WHERE bi.trip_id = ?
|
||||
GROUP BY bm.user_id
|
||||
`, tripId);
|
||||
const toTripCents = (amount: number, item: { currency: string | null; exchange_rate: number | null }) =>
|
||||
Math.round(toTripAmount(amount, item.currency, item.exchange_rate, tripCurrency, rates) * 100);
|
||||
|
||||
return summary.map(s => ({ ...s, avatar_url: avatarUrl(s) }));
|
||||
const people = new Map<number, { user_id: number; username: string; avatar: string | null; assigned: number; paid: number; items_count: number }>();
|
||||
for (const item of items) {
|
||||
const own = members.filter(m => m.budget_item_id === item.id);
|
||||
if (own.length === 0) continue;
|
||||
// A member with an amount of their own owes exactly that; the rest split the
|
||||
// total evenly, as the query this replaces did.
|
||||
const equal = this.splitEqualShares(toTripCents(item.total_price || 0, item), own, item.id);
|
||||
for (const m of own) {
|
||||
const share = m.amount !== null && m.amount !== undefined ? toTripCents(m.amount, item) : (equal[m.user_id] || 0);
|
||||
let p = people.get(m.user_id);
|
||||
if (!p) {
|
||||
p = { user_id: m.user_id, username: m.username, avatar: m.avatar, assigned: 0, paid: 0, items_count: 0 };
|
||||
people.set(m.user_id, p);
|
||||
}
|
||||
p.assigned += share;
|
||||
if (m.paid === 1) p.paid += share;
|
||||
p.items_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return [...people.values()]
|
||||
.sort((a, b) => a.user_id - b.user_id)
|
||||
.map(p => ({
|
||||
user_id: p.user_id, username: p.username, avatar: p.avatar,
|
||||
total_assigned: p.assigned / 100, total_paid: p.paid / 100, items_count: p.items_count,
|
||||
currency: tripCurrency,
|
||||
avatar_url: avatarUrl(p),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* What the trip's expenses add up to in the trip currency, overall and per category
|
||||
* (#2525). Every row is converted once, the way the settlement converts it, and
|
||||
* rounded to a whole cent before anything is added. Summing total_price as stored adds
|
||||
* dollars to euros and labels the result with the trip currency.
|
||||
*/
|
||||
tripTotals(tripId: string | number, tripCurrency: string, rates: Record<string, number> | null = null): { total: number; byCategory: Record<string, number> } {
|
||||
const trip = (tripCurrency || 'EUR').toUpperCase();
|
||||
const rows = this.db.all<{ category: string | null; total_price: number | null; currency: string | null; exchange_rate: number | null }>(
|
||||
'SELECT category, total_price, currency, exchange_rate FROM budget_items WHERE trip_id = ? ORDER BY id', tripId,
|
||||
);
|
||||
let total = 0;
|
||||
const byCategory: Record<string, number> = {};
|
||||
for (const r of rows) {
|
||||
const cents = Math.round(toTripAmount(r.total_price || 0, r.currency, r.exchange_rate, trip, rates) * 100);
|
||||
total += cents;
|
||||
const cat = r.category || '';
|
||||
byCategory[cat] = (byCategory[cat] || 0) + cents;
|
||||
}
|
||||
return {
|
||||
total: total / 100,
|
||||
byCategory: Object.fromEntries(Object.entries(byCategory).map(([cat, cents]) => [cat, cents / 100])),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Today's rates against the trip currency, for the totals above. Fetched only when a
|
||||
* row in another currency never froze a rate of its own: every other row converts
|
||||
* without them, so a trip whose rows were all booked never waits on the network.
|
||||
*/
|
||||
async ratesForTripTotals(tripId: string | number, tripCurrency: string): Promise<Record<string, number> | null> {
|
||||
const trip = (tripCurrency || 'EUR').toUpperCase();
|
||||
const unbooked = this.db.get(`
|
||||
SELECT 1 FROM budget_items
|
||||
WHERE trip_id = ? AND currency IS NOT NULL AND currency != '' AND UPPER(currency) != ?
|
||||
AND (exchange_rate IS NULL OR exchange_rate <= 0 OR exchange_rate = 1)
|
||||
LIMIT 1
|
||||
`, tripId, trip);
|
||||
return unbooked ? this.exchangeRates.getRates(trip) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -895,26 +1014,23 @@ export class BudgetService {
|
||||
// is the identity, so behaviour is unchanged.
|
||||
// rates[X] = units of X per 1 base; the frozen exchange_rate is units of item-currency
|
||||
// per 1 trip-currency. Pre-rework rows store currency = NULL = "the trip's own currency".
|
||||
const toTrip = (amount: number, itemCurrency: string | null | undefined, itemRate?: number | null): number => {
|
||||
const cur = (itemCurrency || tripCurrency).toUpperCase();
|
||||
if (cur === tripCurrency) return amount;
|
||||
// Prefer the FX rate frozen at entry time (#1335): a settled expense keeps the rate
|
||||
// it was booked at, so a later live-rate drift doesn't re-open it with a residual.
|
||||
if (itemRate != null && itemRate > 0 && itemRate !== 1) return amount / itemRate;
|
||||
// Legacy rows without a frozen rate: convert via base with live rates.
|
||||
if (!rates) return amount;
|
||||
const rCur = rates[cur];
|
||||
const rTrip = rates[tripCurrency];
|
||||
if (rCur && rCur > 0 && rTrip && rTrip > 0) return (amount / rCur) * rTrip;
|
||||
return amount;
|
||||
};
|
||||
// Legacy rows without a frozen rate convert via base with live rates.
|
||||
const toTrip = (amount: number, itemCurrency: string | null | undefined, itemRate?: number | null): number =>
|
||||
toTripAmount(amount, itemCurrency, itemRate, tripCurrency, rates);
|
||||
// trip-currency → display currency, applied once to the final netted totals.
|
||||
// Held as a plain factor so it is exactly linear: the balances are converted as
|
||||
// one set (allocateDisplayCents) rather than one at a time, which is what keeps
|
||||
// them adding up to zero in whatever currency the viewer picked (#1382).
|
||||
const displayFactor = base === tripCurrency
|
||||
? 1
|
||||
: (rates && rates[tripCurrency] > 0 ? 1 / rates[tripCurrency] : 1);
|
||||
//
|
||||
// `rates` may be quoted against any base; the ratio of the two quotes is the
|
||||
// factor either way. Callers pass the trip currency's own quote (see settlement()),
|
||||
// the one freezeForeignRate froze every entry rate from, so a bill entered today in
|
||||
// the display currency converts back to exactly what was typed (#2525). Taken the
|
||||
// other way round, from the display currency's quote, the two quotes are not exact
|
||||
// inverses and 12,345.67 USD came back as 12,346.06.
|
||||
const displayFactor = base === tripCurrency ? 1 : quoteRatio(rates, base, tripCurrency);
|
||||
// A recorded settle-up amount is entered in whatever display currency the payer
|
||||
// was viewing. New rows capture that currency and the rate frozen at settle time
|
||||
// (#1445), so a settled position stays balanced when live rates drift — mirroring
|
||||
@@ -934,7 +1050,8 @@ export class BudgetService {
|
||||
}
|
||||
return amount;
|
||||
}
|
||||
return base === tripCurrency ? amount : (rates && rates[tripCurrency] > 0 ? amount * rates[tripCurrency] : amount);
|
||||
// The inverse of the display factor, so such a transfer reads back as entered.
|
||||
return amount / displayFactor;
|
||||
};
|
||||
|
||||
const items = this.db.all<BudgetItem>('SELECT * FROM budget_items WHERE trip_id = ?', tripId);
|
||||
@@ -974,6 +1091,12 @@ export class BudgetService {
|
||||
// cents rather than converting the expense list a second time on the client.
|
||||
const frontedRows: Record<number, { item_id: number; cents: number }[]> = {};
|
||||
const movedRows: Record<number, { settlement_id: number; from_user_id: number; to_user_id: number; cents: number }[]> = {};
|
||||
// Read in the trip currency, those figures are the ledger's own trip cents. Read in
|
||||
// another one, they are built from each row's unrounded trip amount instead: a trip
|
||||
// cent is worth more than a dollar cent on a euro trip, so a bill of 12,345.67 USD
|
||||
// rounded to 10,831.44 EUR first came back as 12,345.68 (#2525). The balances, and
|
||||
// with them settle-up, still net whole trip cents.
|
||||
const finalUnit = (exactTripCents: number) => (displayFactor === 1 ? Math.round(exactTripCents) : exactTripCents);
|
||||
|
||||
for (const item of items) {
|
||||
const members = allMembers.filter(m => m.budget_item_id === item.id);
|
||||
@@ -1003,10 +1126,11 @@ export class BudgetService {
|
||||
// refund (#2176) — is debited by the same arithmetic: their credit is negative.
|
||||
let creditCents = 0;
|
||||
for (const p of payers) {
|
||||
const paid = toTripCents(p.amount, item.currency, item.exchange_rate);
|
||||
const exact = toTrip(p.amount, item.currency, item.exchange_rate) * 100;
|
||||
const paid = Math.round(exact);
|
||||
ensure(p.user_id, p).cents += paid;
|
||||
frontedCents[p.user_id] = (frontedCents[p.user_id] || 0) + paid;
|
||||
if (paid !== 0) (frontedRows[p.user_id] ??= []).push({ item_id: item.id, cents: paid });
|
||||
frontedCents[p.user_id] = (frontedCents[p.user_id] || 0) + finalUnit(exact);
|
||||
if (paid !== 0) (frontedRows[p.user_id] ??= []).push({ item_id: item.id, cents: finalUnit(exact) });
|
||||
creditCents += paid;
|
||||
}
|
||||
// …and each split participant owes their share — a custom per-member amount
|
||||
@@ -1043,17 +1167,19 @@ export class BudgetService {
|
||||
for (const s of settlements) {
|
||||
// Rounded to a trip cent per transfer, so recording one in a display currency
|
||||
// can't leave a sliver of a cent behind to accumulate over a trip's lifetime.
|
||||
const inTrip = Math.round(settleToTrip(s.amount, s.currency, s.exchange_rate) * 100);
|
||||
const exact = settleToTrip(s.amount, s.currency, s.exchange_rate) * 100;
|
||||
const inTrip = Math.round(exact);
|
||||
ensureSettled(s.from_user_id, s.from_username, s.from_avatar_url).cents += inTrip;
|
||||
ensureSettled(s.to_user_id, s.to_username, s.to_avatar_url).cents -= inTrip;
|
||||
// Net of the transfers in both directions: sending one back is a reimbursement
|
||||
// received in reverse, and netting them is what keeps the final budget's
|
||||
// subtraction equal to the balance it is taken from.
|
||||
reimbursedCents[s.to_user_id] = (reimbursedCents[s.to_user_id] || 0) + inTrip;
|
||||
reimbursedCents[s.from_user_id] = (reimbursedCents[s.from_user_id] || 0) - inTrip;
|
||||
const shown = finalUnit(exact);
|
||||
reimbursedCents[s.to_user_id] = (reimbursedCents[s.to_user_id] || 0) + shown;
|
||||
reimbursedCents[s.from_user_id] = (reimbursedCents[s.from_user_id] || 0) - shown;
|
||||
const moved = { settlement_id: s.id, from_user_id: s.from_user_id, to_user_id: s.to_user_id };
|
||||
(movedRows[s.to_user_id] ??= []).push({ ...moved, cents: inTrip });
|
||||
(movedRows[s.from_user_id] ??= []).push({ ...moved, cents: -inTrip });
|
||||
(movedRows[s.to_user_id] ??= []).push({ ...moved, cents: shown });
|
||||
(movedRows[s.from_user_id] ??= []).push({ ...moved, cents: -shown });
|
||||
}
|
||||
|
||||
// Into the display currency as one set, then simplify — balances and flows are
|
||||
@@ -1237,14 +1363,24 @@ export class BudgetService {
|
||||
return this.listBudgetItems(tripId);
|
||||
}
|
||||
|
||||
perPersonSummary(tripId: string) {
|
||||
return this.getPerPersonSummary(tripId);
|
||||
async perPersonSummary(tripId: string | number) {
|
||||
const trip = this.db.get<{ currency?: string | null }>('SELECT currency FROM trips WHERE id = ?', tripId);
|
||||
return this.getPerPersonSummary(tripId, await this.ratesForTripTotals(tripId, trip?.currency || 'EUR'));
|
||||
}
|
||||
|
||||
async settlement(tripId: string, base: string | undefined, tripCurrency: string) {
|
||||
const effectiveBase = (base || tripCurrency || 'EUR').toUpperCase();
|
||||
const rates = await this.exchangeRates.getRates(effectiveBase);
|
||||
return this.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency });
|
||||
/**
|
||||
* The settlement in the viewer's currency, for REST and MCP alike. Converted with the
|
||||
* trip currency's own quote, the one every entry rate was frozen from, so a same-day
|
||||
* amount in the display currency round-trips to the cent and paying what settle-up
|
||||
* offers closes the balance (#2525). The display currency's quote only stands in when
|
||||
* the trip's cannot be fetched.
|
||||
*/
|
||||
async settlement(tripId: string | number, base: string | undefined, tripCurrency: string) {
|
||||
const trip = (tripCurrency || 'EUR').toUpperCase();
|
||||
const effectiveBase = (base || trip).toUpperCase();
|
||||
const rates = (await this.exchangeRates.getRates(trip))
|
||||
?? (effectiveBase === trip ? null : await this.exchangeRates.getRates(effectiveBase));
|
||||
return this.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency: trip });
|
||||
}
|
||||
|
||||
async create(tripId: string, data: Parameters<BudgetService['createBudgetItem']>[1]) {
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
type ReservationBody = Record<string, unknown> & {
|
||||
title?: string;
|
||||
type?: string;
|
||||
create_budget_entry?: { total_price?: number; category?: string };
|
||||
create_budget_entry?: { total_price?: number; category?: string; currency?: string | null };
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -61,7 +61,7 @@ export class ReservationsController {
|
||||
|
||||
@RequirePermission('reservation_edit')
|
||||
@Post()
|
||||
create(
|
||||
async create(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() rawBody: ReservationCreateDto,
|
||||
@@ -69,11 +69,14 @@ export class ReservationsController {
|
||||
) {
|
||||
const body = rawBody as ReservationBody & { title: string };
|
||||
this.rejectForeignReferences(tripId, body);
|
||||
// Before the synchronous writes: the price keeps the currency it was quoted in,
|
||||
// at a rate frozen now (#2525).
|
||||
const budgetEntry = await this.reservations.withFrozenRate(tripId, body.create_budget_entry);
|
||||
const { reservation, accommodationCreated } = this.reservations.create(tripId, body as never);
|
||||
if (accommodationCreated) {
|
||||
this.reservations.broadcast(tripId, 'accommodation:created', {}, socketId);
|
||||
}
|
||||
this.reservations.syncBudgetOnCreate(tripId, reservation.id, body.title, body.type, body.create_budget_entry, socketId);
|
||||
this.reservations.syncBudgetOnCreate(tripId, reservation.id, body.title, body.type, budgetEntry, socketId);
|
||||
this.reservations.broadcast(tripId, 'reservation:created', { reservation }, socketId);
|
||||
this.reservations.notifyBookingChange(tripId, user.id, body.title, body.type ?? '');
|
||||
return { reservation };
|
||||
|
||||
@@ -30,7 +30,7 @@ export class ReservationsRpc {
|
||||
) {}
|
||||
|
||||
@PluginMethod('reservations.create', { permission: 'db:write:reservations' })
|
||||
create(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async create(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const tripId = num(params.tripId, 'tripId');
|
||||
const actor = this.guards.requireActor(ctx, 'reservation');
|
||||
const parsed = reservationCreateRequestSchema.safeParse(params.input);
|
||||
@@ -39,10 +39,12 @@ export class ReservationsRpc {
|
||||
this.requireValidEndpoints(input.endpoints);
|
||||
this.guards.requireTripEdit(tripId, actor, RESERVATION_EDIT_ACTION);
|
||||
this.requireOwnReferences(tripId, input);
|
||||
const i = input as { title?: string; type?: string; create_budget_entry?: unknown };
|
||||
// Same as the REST route: the price keeps its currency, at a rate frozen now (#2525).
|
||||
const budgetEntry = await this.reservations.withFrozenRate(tripId, i.create_budget_entry as never);
|
||||
const { reservation, accommodationCreated } = this.reservations.create(String(tripId), input as never);
|
||||
if (accommodationCreated) this.realtime.broadcast(tripId, 'accommodation:created', {}, undefined);
|
||||
const i = input as { title?: string; type?: string; create_budget_entry?: unknown };
|
||||
this.reservations.syncBudgetOnCreate(String(tripId), reservation.id, i.title ?? '', i.type, i.create_budget_entry as never, undefined);
|
||||
this.reservations.syncBudgetOnCreate(String(tripId), reservation.id, i.title ?? '', i.type, budgetEntry, undefined);
|
||||
this.realtime.broadcast(tripId, 'reservation:created', { reservation }, undefined);
|
||||
this.notifyBooking(actor, tripId, i.title ?? '', i.type ?? '');
|
||||
return reservation;
|
||||
|
||||
@@ -12,7 +12,7 @@ import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { AccommodationsService, noStayMirror, type AccommodationMirror } from '../accommodations/accommodations.service';
|
||||
|
||||
type Trip = TripAccess;
|
||||
type BudgetEntry = { total_price?: number; category?: string } | undefined;
|
||||
type BudgetEntry = { total_price?: number; category?: string; currency?: string | null; exchange_rate?: number } | undefined;
|
||||
|
||||
export interface ReservationEndpoint {
|
||||
id?: number;
|
||||
@@ -1028,6 +1028,25 @@ export class ReservationsService {
|
||||
return answer;
|
||||
}
|
||||
|
||||
/**
|
||||
* The linked cost a new booking's price becomes, in the currency the price was quoted
|
||||
* in and at the rate frozen for it now (#2525). An imported booking previewed its
|
||||
* $801.76 in dollars, then stored 801.76 in the trip's own currency, because only the
|
||||
* amount travelled. The rate is resolved here, before the synchronous writes, the way
|
||||
* the direct booking import and the Costs routes resolve it. A currency that is not a
|
||||
* three-letter code is dropped, which leaves the price in the trip currency as before,
|
||||
* and a rate is never taken from the caller.
|
||||
*/
|
||||
async withFrozenRate(tripId: string | number, entry: BudgetEntry): Promise<BudgetEntry> {
|
||||
if (!entry || typeof entry !== 'object') return entry;
|
||||
const { currency: rawCurrency, exchange_rate: _callerRate, ...rest } = entry;
|
||||
const currency = typeof rawCurrency === 'string' ? rawCurrency.trim().toUpperCase() : '';
|
||||
if (!/^[A-Z]{3}$/.test(currency)) return rest;
|
||||
const priced: { currency?: string | null; exchange_rate?: number } = { currency };
|
||||
await this.budget.freezeForeignRate(tripId, priced);
|
||||
return { ...rest, currency, ...(priced.exchange_rate != null ? { exchange_rate: priced.exchange_rate } : {}) };
|
||||
}
|
||||
|
||||
/** POST side effect: auto-create a linked budget item when a price is provided. */
|
||||
syncBudgetOnCreate(tripId: string, reservationId: number, title: string, type: string | undefined, entry: BudgetEntry, socketId: string | undefined): void {
|
||||
if (!entry || !(Number(entry.total_price) > 0)) return;
|
||||
@@ -1036,6 +1055,8 @@ export class ReservationsService {
|
||||
name: title,
|
||||
category: entry.category || type || 'Other',
|
||||
total_price: entry.total_price!,
|
||||
...(entry.currency ? { currency: entry.currency } : {}),
|
||||
...(entry.exchange_rate != null ? { exchange_rate: entry.exchange_rate } : {}),
|
||||
});
|
||||
this.realtime.broadcast(tripId, 'budget:created', { item }, socketId);
|
||||
} catch (err) {
|
||||
|
||||
@@ -47,7 +47,7 @@ export class TripReadModelService {
|
||||
|
||||
// ── Trip summary (used by MCP get_trip_summary tool) ──────────────────────
|
||||
|
||||
getTripSummary(tripId: number, viewerUserId?: number) {
|
||||
async getTripSummary(tripId: number, viewerUserId?: number) {
|
||||
const trip = withoutFeedToken(
|
||||
this.db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Record<string, unknown> | undefined,
|
||||
);
|
||||
@@ -63,10 +63,15 @@ export class TripReadModelService {
|
||||
const accommodations = this.accommodations.list(tripId);
|
||||
|
||||
const budgetItems = this.budget.listBudgetItems(tripId);
|
||||
// In the trip currency, each row at the rate it was booked at (#2525). A raw sum of
|
||||
// total_price added a dollar bill to the euros and called the result euros.
|
||||
const tripCurrency = String(trip.currency || 'EUR');
|
||||
const totals = this.budget.tripTotals(tripId, tripCurrency, await this.budget.ratesForTripTotals(tripId, tripCurrency));
|
||||
const budget = {
|
||||
items: budgetItems,
|
||||
item_count: budgetItems.length,
|
||||
total: budgetItems.reduce((sum, i) => sum + (i.total_price || 0), 0),
|
||||
total: totals.total,
|
||||
by_category: totals.byCategory,
|
||||
currency: trip.currency,
|
||||
};
|
||||
|
||||
|
||||
@@ -43,18 +43,20 @@ export class TripPromptsMcp {
|
||||
if (!this.trips.canAccessTrip(tripId, ctx.userId)) {
|
||||
return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found or access denied.' } }] };
|
||||
}
|
||||
const summary = this.readModel.getTripSummary(tripId, ctx.userId);
|
||||
const summary = await this.readModel.getTripSummary(tripId, ctx.userId);
|
||||
if (!summary) {
|
||||
return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found.' } }] };
|
||||
}
|
||||
const { trip, budget } = summary;
|
||||
const currency = trip?.currency || 'EUR';
|
||||
const byCategory = (budget?.items || []).reduce((acc: Record<string, number>, item: { category?: string; total_price?: number }) => {
|
||||
const cat = item.category || 'Uncategorized';
|
||||
acc[cat] = (acc[cat] || 0) + (item.total_price || 0);
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
const total = Object.values(byCategory).reduce((sum, v) => sum + v, 0);
|
||||
// The summary's own totals, each row in the trip currency at the rate it was booked
|
||||
// at (#2525). Adding total_price up here printed a dollar bill as that many euros.
|
||||
const byCategory: Record<string, number> = {};
|
||||
for (const [cat, amount] of Object.entries(budget?.by_category || {})) {
|
||||
const key = cat || 'Uncategorized';
|
||||
byCategory[key] = Math.round(((byCategory[key] || 0) + amount) * 100) / 100;
|
||||
}
|
||||
const total = budget?.total || 0;
|
||||
const lines = Object.entries(byCategory)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([cat, amount]) => `- ${cat}: ${amount} ${currency}`)
|
||||
@@ -94,7 +96,9 @@ export class TripPromptsMcp {
|
||||
const lines = Object.entries(grouped).map(([cat, catItems]) =>
|
||||
`## ${cat}\n${(catItems as { checked?: unknown; name?: string }[]).map((i) => `- [${i.checked ? 'x' : ' '}] ${i.name}`).join('\n')}`
|
||||
).join('\n\n');
|
||||
const { trip } = this.readModel.getTripSummary(tripId, ctx.userId) || {};
|
||||
// Only the title is needed. The whole summary also adds up the budget, which can
|
||||
// wait on a rates fetch for a row that never froze its rate.
|
||||
const trip = this.trips.getRaw(tripId);
|
||||
return {
|
||||
description: `Packing list for "${trip?.title || tripId}"`,
|
||||
messages: [{ role: 'user' as const, content: { type: 'text' as const, text: `# Packing List: ${trip?.title || 'Trip'}\n\n${lines}\n\n_${items.length} items across ${Object.keys(grouped).length} categories_` } }],
|
||||
|
||||
@@ -265,7 +265,7 @@ export class TripsMcp {
|
||||
})
|
||||
async getTripSummary({ tripId }: { tripId: number }, ctx: McpContext) {
|
||||
if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
const summary = this.readModel.getTripSummary(tripId, ctx.userId);
|
||||
const summary = await this.readModel.getTripSummary(tripId, ctx.userId);
|
||||
if (!summary) return noAccess();
|
||||
const R = canReadTrips(ctx.scopes);
|
||||
// Addon availability gates
|
||||
@@ -599,7 +599,7 @@ export class TripsMcp {
|
||||
if (!this.trips.canAccessTrip(tripId, ctx.userId)) {
|
||||
return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found or access denied.' } }] };
|
||||
}
|
||||
const summary = this.readModel.getTripSummary(tripId, ctx.userId);
|
||||
const summary = await this.readModel.getTripSummary(tripId, ctx.userId);
|
||||
if (!summary) {
|
||||
return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found.' } }] };
|
||||
}
|
||||
|
||||
@@ -228,6 +228,27 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('GET /summary/per-person reads a dollar bill on a euro trip at its booked rate (#2525)', async () => {
|
||||
const hotel = await request(server)
|
||||
.post(`/api/trips/${tripId}/budget`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ name: 'Aparthotel Silver', currency: 'USD', exchange_rate: 1.17, payers: [{ user_id: 1, amount: 801.76 }], member_ids: [1, 2] });
|
||||
expect(hotel.status).toBe(201);
|
||||
|
||||
const res = await request(server)
|
||||
.get(`/api/trips/${tripId}/budget/summary/per-person`)
|
||||
.set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
// 801.76 USD at 1.17 is 685.26 EUR, half of it each. The summary used to put
|
||||
// 400.88 on both and leave the reader to guess the currency.
|
||||
const row = (uid: number) => res.body.summary.find((r: { user_id: number }) => r.user_id === uid);
|
||||
expect(row(1)).toMatchObject({ total_assigned: 342.63, total_paid: 0, items_count: 1, currency: 'EUR' });
|
||||
expect(row(2)).toMatchObject({ total_assigned: 342.63, total_paid: 0, items_count: 1, currency: 'EUR' });
|
||||
|
||||
const del = await request(server).delete(`/api/trips/${tripId}/budget/${hotel.body.item.id}`).set('Cookie', sessionCookie(1));
|
||||
expect(del.status).toBe(200);
|
||||
});
|
||||
|
||||
it('200 on settlement update with permission, persisting the new amount and day', async () => {
|
||||
const created = await request(server)
|
||||
.post(`/api/trips/${tripId}/budget/settlements`)
|
||||
|
||||
@@ -57,6 +57,7 @@ let checkPermission: MockInstance;
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { NotificationsService } from '../../src/nest/notifications/notifications.service';
|
||||
import { ExchangeRatesService } from '../../src/nest/budget/exchange-rates.service';
|
||||
|
||||
describe('Reservations + accommodations e2e (real auth guard + temp SQLite, real reservation SQL)', () => {
|
||||
let server: Server;
|
||||
@@ -67,6 +68,10 @@ describe('Reservations + accommodations e2e (real auth guard + temp SQLite, real
|
||||
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, ReservationsModule, AccommodationsModule] })
|
||||
.overrideProvider(NotificationsService)
|
||||
.useValue({ send: notificationSend })
|
||||
// A price quoted in a foreign currency freezes the rate of the day; this is that
|
||||
// day's table, so no case ever reaches the network.
|
||||
.overrideProvider(ExchangeRatesService)
|
||||
.useValue({ getRates: async (base: string) => (base.toUpperCase() === 'EUR' ? { EUR: 1, USD: 1.17 } : null) })
|
||||
.compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
@@ -133,6 +138,31 @@ describe('Reservations + accommodations e2e (real auth guard + temp SQLite, real
|
||||
expect(res.body).toEqual({ error: 'Trip not found' });
|
||||
});
|
||||
|
||||
it('201 create keeps an imported price in the currency it was quoted in, at a frozen rate (#2525)', async () => {
|
||||
const res = await request(server)
|
||||
.post(`/api/trips/${tripId}/reservations`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({
|
||||
title: 'Aparthotel Silver', type: 'hotel',
|
||||
metadata: { price: '801.76', priceCurrency: 'USD' },
|
||||
create_budget_entry: { total_price: 801.76, category: 'accommodation', currency: 'USD' },
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
// It used to land as 801.76 with no currency, which is 801.76 of the trip's euros.
|
||||
const item = db.prepare('SELECT total_price, currency, exchange_rate FROM budget_items WHERE reservation_id = ?').get(res.body.reservation.id);
|
||||
expect(item).toEqual({ total_price: 801.76, currency: 'USD', exchange_rate: 1.17 });
|
||||
});
|
||||
|
||||
it('201 create leaves a price without a currency in the trip currency, as before', async () => {
|
||||
const res = await request(server)
|
||||
.post(`/api/trips/${tripId}/reservations`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ title: 'Museum', type: 'event', create_budget_entry: { total_price: 20, currency: 'not a code' } });
|
||||
expect(res.status).toBe(201);
|
||||
const item = db.prepare('SELECT total_price, currency, exchange_rate FROM budget_items WHERE reservation_id = ?').get(res.body.reservation.id);
|
||||
expect(item).toEqual({ total_price: 20, currency: null, exchange_rate: 1 });
|
||||
});
|
||||
|
||||
it('201 create reservation (real insert + booking notification), 400 without title', async () => {
|
||||
const ok = await request(server)
|
||||
.post(`/api/trips/${tripId}/reservations`)
|
||||
|
||||
@@ -374,6 +374,46 @@ describe('Settlement tools', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('get_settlement_summary reads a same-day bill in the viewer\'s currency to the cent, like REST (#2525)', async () => {
|
||||
// Frankfurter's two quotes for the pair, each rounded, so not exact inverses. The entry
|
||||
// rate is frozen from the euro's, and converting back with the dollar's read the
|
||||
// 12,345.67 dollars as 12,346.06.
|
||||
const quotes: Record<string, Record<string, number>> = { EUR: { EUR: 1, USD: 1.1398 }, USD: { USD: 1, EUR: 0.87732 } };
|
||||
const spy = vi.spyOn(ExchangeRatesService.prototype, 'getRates').mockImplementation(async (b: string) => quotes[b] ?? null);
|
||||
try {
|
||||
const { user, other, trip } = tripWithTwo();
|
||||
testDb.prepare("UPDATE trips SET currency = 'EUR' WHERE id = ?").run(trip.id);
|
||||
const item = createBudgetItem(testDb, trip.id, { total_price: 12345.67 });
|
||||
testDb.prepare("UPDATE budget_items SET currency = 'USD', exchange_rate = 1.1398 WHERE id = ?").run(item.id);
|
||||
testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0), (?, ?, 0)')
|
||||
.run(item.id, user.id, item.id, other.id);
|
||||
testDb.prepare('INSERT INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?, ?, ?)')
|
||||
.run(item.id, user.id, 12345.67);
|
||||
|
||||
const dbService = new DatabaseService(testDb);
|
||||
const controller = new BudgetController(
|
||||
new BudgetService(dbService, new PermissionsService(dbService), new ExchangeRatesService(), new RealtimeService()),
|
||||
);
|
||||
const rest = await controller.settlement(
|
||||
{ id: user.id } as User,
|
||||
{ id: trip.id, user_id: user.id, currency: 'EUR' } as TripAccess,
|
||||
String(trip.id),
|
||||
'USD',
|
||||
);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_settlement_summary', arguments: { tripId: trip.id, base: 'USD' } });
|
||||
const data = parseToolResult(result) as { summary: typeof rest };
|
||||
expect(data.summary).toEqual(rest);
|
||||
const mine = data.summary.finalBudgets.find(f => f.user_id === user.id)!;
|
||||
expect(mine.expenses).toBe(12345.67);
|
||||
expect(mine.sources.fronted).toEqual([{ item_id: item.id, cents: 1234567 }]);
|
||||
expect(data.summary.flows.map(f => f.amount)).toEqual([6172.84]);
|
||||
});
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('get_settlement_summary returns balances and flows', async () => {
|
||||
try {
|
||||
const { user, other, trip } = tripWithTwo();
|
||||
|
||||
@@ -93,6 +93,7 @@ import { notificationsStub } from '../../helpers/notifications';
|
||||
// getTripSummary is the same controllable mock the legacy path used.
|
||||
const tripsStub = {
|
||||
canAccessTrip: (tripId: number, userId: number) => dbMock.canAccessTrip(tripId, userId),
|
||||
getRaw: (tripId: number) => testDb.prepare('SELECT * FROM trips WHERE id = ?').get(tripId),
|
||||
} as unknown as TripsService;
|
||||
// getTripSummary moved to TripReadModelService with the trip split; the mock is
|
||||
// the same controllable one, one constructor slot further along.
|
||||
@@ -119,8 +120,9 @@ const promptDbs = () => new DatabaseService(testDb);
|
||||
const authStub = { isDemoUser: () => false } as unknown as AuthService;
|
||||
const promptPackingService = new PackingService(promptDbs(), new PermissionsService(promptDbs()), new RealtimeService(), notificationsStub());
|
||||
const packingMcp = new PackingMcp(promptPackingService, authStub, addonsStub, promptGuards);
|
||||
const promptBudget = new BudgetService(promptDbs(), new PermissionsService(promptDbs()), new ExchangeRatesService(), new RealtimeService());
|
||||
const budgetMcp = new BudgetMcp(
|
||||
new BudgetService(promptDbs(), new PermissionsService(promptDbs()), new ExchangeRatesService(), new RealtimeService()),
|
||||
promptBudget,
|
||||
new ExchangeRatesService(),
|
||||
promptDbs(),
|
||||
new RuntimeEnvService(),
|
||||
@@ -158,6 +160,8 @@ beforeEach(() => {
|
||||
`).all(tripId) as any[];
|
||||
const budgetRows = testDb.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId) as any[];
|
||||
const packingRows = testDb.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(tripId) as any[];
|
||||
// The totals come from the same BudgetService.tripTotals the real summary uses.
|
||||
const totals = promptBudget.tripTotals(tripId, trip.currency || 'EUR');
|
||||
return {
|
||||
trip,
|
||||
days: [],
|
||||
@@ -165,7 +169,8 @@ beforeEach(() => {
|
||||
budget: {
|
||||
items: budgetRows,
|
||||
item_count: budgetRows.length,
|
||||
total: budgetRows.reduce((sum, i) => sum + (i.total_price || 0), 0),
|
||||
total: totals.total,
|
||||
by_category: totals.byCategory,
|
||||
currency: trip.currency,
|
||||
},
|
||||
packing: packingRows, // array shape; packing prompt tolerates it
|
||||
@@ -418,19 +423,18 @@ describe('Prompt: packing-list', () => {
|
||||
expect(text).toMatch(/\[[ x]\]/);
|
||||
});
|
||||
|
||||
it('uses tripId as title fallback when getTripSummary returns null (covers || {} branch)', async () => {
|
||||
it('reads the trip title without building the whole trip summary (#2525)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Null Trip' });
|
||||
createPackingItem(testDb, trip.id, { name: 'Toothbrush', category: 'Hygiene' });
|
||||
|
||||
// Null out the getTripSummary call inside packing-list (line 94: || {})
|
||||
mockGetTripSummary.mockReturnValueOnce(null);
|
||||
mockGetTripSummary.mockClear();
|
||||
|
||||
const client = await buildServer(user.id);
|
||||
const text = await invokePromptText(client, 'packing-list', { tripId: trip.id });
|
||||
expect(text).toContain('Toothbrush');
|
||||
// Falls back to 'Trip' literal since trip?.title is undefined (getTripSummary null → || {})
|
||||
expect(text).toContain('Packing List: Trip');
|
||||
expect(text).toContain('Packing List: Null Trip');
|
||||
// The summary adds the budget up, and that can wait on a rates fetch.
|
||||
expect(mockGetTripSummary).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -513,6 +517,24 @@ describe('Prompt: budget-overview', () => {
|
||||
expect(text).toContain('300'); // Accommodation total
|
||||
});
|
||||
|
||||
it('prints a foreign-currency bill at the rate it was booked at, not as that many euros (#2525)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Dollar Trip' });
|
||||
promptBudget.createBudgetItem(trip.id, {
|
||||
name: 'Aparthotel Silver', category: 'Accommodation', currency: 'USD', exchange_rate: 1.17,
|
||||
payers: [{ user_id: user.id, amount: 801.76 }], members: [{ user_id: user.id }],
|
||||
});
|
||||
createBudgetItem(testDb, trip.id, { name: 'Dinner', category: 'Food', total_price: 100 });
|
||||
|
||||
const client = await buildServer(user.id);
|
||||
const text = await invokePromptText(client, 'budget-overview', { tripId: trip.id });
|
||||
// 801.76 USD at 1.17 is 685.26 EUR. Summed as stored it read "Total: 901.76 EUR".
|
||||
expect(text).toContain('Total: 785.26 EUR');
|
||||
expect(text).toContain('- Accommodation: 685.26 EUR');
|
||||
expect(text).toContain('- Food: 100 EUR');
|
||||
expect(text).not.toContain('801.76');
|
||||
});
|
||||
|
||||
it('renders "No expenses recorded." when budget array is empty', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Empty Budget' });
|
||||
|
||||
@@ -49,13 +49,13 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou
|
||||
expect(new BudgetController(svc).list(user, '5')).toEqual({ items: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
it('GET /summary/per-person + /settlement delegate', () => {
|
||||
it('GET /summary/per-person + /settlement delegate', async () => {
|
||||
const settlement = vi.fn().mockReturnValue({ transfers: [] });
|
||||
const svc = makeService({
|
||||
perPersonSummary: vi.fn().mockReturnValue([{ userId: 1, owes: 10 }]),
|
||||
settlement,
|
||||
} as Partial<BudgetService>);
|
||||
expect(new BudgetController(svc).perPerson(user, '5')).toEqual({ summary: [{ userId: 1, owes: 10 }] });
|
||||
expect(await new BudgetController(svc).perPerson(user, '5')).toEqual({ summary: [{ userId: 1, owes: 10 }] });
|
||||
// A trip with no currency set falls back to EUR rather than passing undefined on.
|
||||
expect(new BudgetController(svc).settlement(user, { id: 5, user_id: 42 } as never, '5')).toEqual({ transfers: [] });
|
||||
expect(settlement).toHaveBeenLastCalledWith('5', undefined, 'EUR');
|
||||
|
||||
@@ -39,6 +39,9 @@ const { RATES } = vi.hoisted(() => ({
|
||||
RATES: {
|
||||
RUB: { RUB: 1, USD: 0.013042, EUR: 0.011412 },
|
||||
EUR: { EUR: 1, USD: 1.1429, RUB: 87.63 },
|
||||
// The dollar's own quote, as Frankfurter prints it: rounded, so not the exact
|
||||
// inverse of the euro's 1.1429 (1 / 0.87497 = 1.142896...).
|
||||
USD: { USD: 1, EUR: 0.87497 },
|
||||
} as Record<string, Record<string, number>>,
|
||||
}));
|
||||
// Constructor-injected since the fold; the class is mocked at the module path
|
||||
@@ -1156,3 +1159,157 @@ describe('an expense whose split leaves a remainder', () => {
|
||||
expect(after.id).toBe(before.id);
|
||||
});
|
||||
});
|
||||
|
||||
// #2525: a bill entered in dollars on a euro trip was booked at the rate of the day. The
|
||||
// totals the MCP summary, the budget prompt and the per-person resource hand out added its
|
||||
// dollars to the euros as they stood and called the sum euros.
|
||||
describe('trip totals read every row in the trip currency (#2525)', () => {
|
||||
function seedDollarBill() {
|
||||
const { user: me } = createUser(testDb);
|
||||
const { user: bob } = createUser(testDb);
|
||||
const trip = createTrip(testDb, me.id);
|
||||
addTripMember(testDb, trip.id, bob.id);
|
||||
const members = [{ user_id: me.id }, { user_id: bob.id }];
|
||||
const hotel = budget.createBudgetItem(trip.id, {
|
||||
name: 'Aparthotel Silver', category: 'accommodation', currency: 'USD', exchange_rate: 1.17,
|
||||
payers: [{ user_id: me.id, amount: 801.76 }], members,
|
||||
});
|
||||
budget.createBudgetItem(trip.id, {
|
||||
name: 'Dinner', category: 'food', currency: 'EUR',
|
||||
payers: [{ user_id: bob.id, amount: 100 }], members,
|
||||
});
|
||||
return { trip, me, bob, hotel };
|
||||
}
|
||||
|
||||
it('BUDGET-SVC-DB-044: adds each row up at the rate it was booked at, in whole cents', async () => {
|
||||
const { trip } = seedDollarBill();
|
||||
// 801.76 USD at 1.17 is 685.26 EUR, the figure the settlement nets as well.
|
||||
expect(budget.tripTotals(trip.id, 'EUR')).toEqual({
|
||||
total: 785.26,
|
||||
byCategory: { accommodation: 685.26, food: 100 },
|
||||
});
|
||||
// Both rows carry what they need, so nothing waits on today's rates.
|
||||
expect(await budget.ratesForTripTotals(trip.id, 'EUR')).toBeNull();
|
||||
});
|
||||
|
||||
it('BUDGET-SVC-DB-045: fetches today\'s rate only for a row that never froze one', async () => {
|
||||
const { user: me } = createUser(testDb);
|
||||
const trip = createTrip(testDb, me.id);
|
||||
// Written before the freeze existed: the column default of 1 is not a booked rate.
|
||||
budget.createBudgetItem(trip.id, { name: 'Old taxi', category: 'transport', total_price: 114.29, currency: 'USD' });
|
||||
const rates = await budget.ratesForTripTotals(trip.id, 'EUR');
|
||||
expect(rates).toEqual(RATES.EUR);
|
||||
// 114.29 USD at 1.1429 per euro, the same way the settlement reads such a row.
|
||||
expect(budget.tripTotals(trip.id, 'EUR', rates).total).toBe(100);
|
||||
});
|
||||
|
||||
it('BUDGET-SVC-DB-046: the per-person summary splits trip cents, not raw amounts', async () => {
|
||||
const { trip, me, bob, hotel } = seedDollarBill();
|
||||
budget.toggleMemberPaid(hotel.id, trip.id, bob.id, true);
|
||||
|
||||
const summary = await budget.perPersonSummary(trip.id);
|
||||
const of = (id: number) => summary.find(s => s.user_id === id)!;
|
||||
// Half of 685.26 EUR plus half of 100 EUR each. The query this replaced put
|
||||
// 450.88 on both, half the dollars counted as euros, and never said which.
|
||||
expect(of(me.id)).toMatchObject({ total_assigned: 392.63, total_paid: 0, items_count: 2, currency: 'EUR' });
|
||||
expect(of(bob.id)).toMatchObject({ total_assigned: 392.63, total_paid: 342.63, items_count: 2, currency: 'EUR' });
|
||||
});
|
||||
|
||||
it('BUDGET-SVC-DB-047: an equal split hands out the odd cent instead of a float tail', async () => {
|
||||
const { user: a } = createUser(testDb);
|
||||
const { user: b } = createUser(testDb);
|
||||
const { user: c } = createUser(testDb);
|
||||
const trip = createTrip(testDb, a.id);
|
||||
addTripMember(testDb, trip.id, b.id);
|
||||
addTripMember(testDb, trip.id, c.id);
|
||||
budget.createBudgetItem(trip.id, {
|
||||
name: 'Boat', currency: 'EUR', payers: [{ user_id: a.id, amount: 100 }],
|
||||
members: [{ user_id: a.id }, { user_id: b.id }, { user_id: c.id }],
|
||||
});
|
||||
|
||||
const shares = (await budget.perPersonSummary(trip.id)).map(s => Math.round(s.total_assigned * 100));
|
||||
expect(shares.slice().sort((x, y) => x - y)).toEqual([3333, 3333, 3334]);
|
||||
expect(shares.reduce((x, y) => x + y, 0)).toBe(10000);
|
||||
});
|
||||
});
|
||||
|
||||
// #2525: a euro trip read in dollars froze a dollar bill's rate from the euro's quote and
|
||||
// converted the ledger back with the dollar's. The two quotes are not exact inverses, so a
|
||||
// bill entered today read a few cents off and paying what settle-up offered left a balance.
|
||||
describe('the settlement converts with the quote the entry rate was frozen from (#2525)', () => {
|
||||
function seedSameDayBill() {
|
||||
const { user: me } = createUser(testDb);
|
||||
const { user: bob } = createUser(testDb);
|
||||
const trip = createTrip(testDb, me.id);
|
||||
addTripMember(testDb, trip.id, bob.id);
|
||||
return { trip, me, bob };
|
||||
}
|
||||
|
||||
it('BUDGET-SVC-DB-048: a same-day bill in the display currency reads as typed and settles to zero', async () => {
|
||||
const { trip, me, bob } = seedSameDayBill();
|
||||
// Frozen now, from the euro's quote: 1.1429.
|
||||
const bill = await budget.create(String(trip.id), {
|
||||
name: 'Villa', category: 'accommodation', currency: 'USD', total_price: 12345.67,
|
||||
payers: [{ user_id: me.id, amount: 12345.67 }], members: [{ user_id: me.id }, { user_id: bob.id }],
|
||||
}) as { id: number; exchange_rate: number };
|
||||
expect(bill.exchange_rate).toBe(1.1429);
|
||||
|
||||
const before = await budget.settlement(trip.id, 'USD', 'EUR');
|
||||
const mine = before.finalBudgets.find(f => f.user_id === me.id)!;
|
||||
expect(mine.expenses).toBe(12345.67);
|
||||
expect(mine.sources.fronted).toEqual([{ item_id: bill.id, cents: 1234567 }]);
|
||||
expect(before.flows.map(f => f.amount)).toEqual([6172.84]);
|
||||
|
||||
// Bob pays exactly what settle-up offers, in dollars, as the Costs screen records it.
|
||||
await budget.createSettlement(trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: 6172.84, currency: 'USD' }, me.id);
|
||||
const after = await budget.settlement(trip.id, 'USD', 'EUR');
|
||||
expect(after.balances.map(b => b.balance)).toEqual([0, 0]);
|
||||
expect(after.flows).toEqual([]);
|
||||
expect((await budget.settlement(trip.id, 'EUR', 'EUR')).balances.map(b => b.balance)).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
it('BUDGET-SVC-DB-051: the final budget lists a bill at what was typed even where its trip cent rounds away', async () => {
|
||||
const { trip, me, bob } = seedSameDayBill();
|
||||
// 123.45 USD is 108.0147 EUR. As a whole trip cent, 108.01 EUR, it came back as 123.44.
|
||||
const bill = await budget.create(String(trip.id), {
|
||||
name: 'Taxi', currency: 'USD', total_price: 123.45,
|
||||
payers: [{ user_id: me.id, amount: 123.45 }], members: [{ user_id: me.id }, { user_id: bob.id }],
|
||||
}) as { id: number };
|
||||
const s = await budget.settlement(trip.id, 'USD', 'EUR');
|
||||
const mine = s.finalBudgets.find(f => f.user_id === me.id)!;
|
||||
const bobs = s.finalBudgets.find(f => f.user_id === bob.id)!;
|
||||
expect(mine.sources.fronted).toEqual([{ item_id: bill.id, cents: 12345 }]);
|
||||
expect(mine.expenses).toBe(123.45);
|
||||
// The two shares still add up to the bill, and the ledger in euros is untouched.
|
||||
expect(Math.round((mine.final + bobs.final) * 100)).toBe(12345);
|
||||
const inEur = await budget.settlement(trip.id, 'EUR', 'EUR');
|
||||
expect(inEur.finalBudgets.find(f => f.user_id === me.id)!.sources.fronted).toEqual([{ item_id: bill.id, cents: 10801 }]);
|
||||
});
|
||||
|
||||
it('BUDGET-SVC-DB-049: a transfer saved without a currency reads back in the display currency as entered', async () => {
|
||||
const { trip, me, bob } = seedSameDayBill();
|
||||
await budget.createSettlement(trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: 30 }, me.id);
|
||||
const s = await budget.settlement(trip.id, 'USD', 'EUR');
|
||||
const mine = s.finalBudgets.find(f => f.user_id === me.id)!;
|
||||
expect(mine.reimbursed).toBe(30);
|
||||
expect(s.balances.find(b => b.user_id === me.id)!.balance).toBe(-30);
|
||||
});
|
||||
|
||||
it('BUDGET-SVC-DB-050: falls back to the display currency\'s quote when the trip\'s is unavailable', async () => {
|
||||
const { trip, me, bob } = seedSameDayBill();
|
||||
budget.createBudgetItem(trip.id, {
|
||||
name: 'Dinner', currency: 'EUR', payers: [{ user_id: me.id, amount: 100 }],
|
||||
members: [{ user_id: me.id }, { user_id: bob.id }],
|
||||
});
|
||||
const rates = (budget as unknown as { exchangeRates: ExchangeRatesService }).exchangeRates;
|
||||
const spy = vi.spyOn(rates, 'getRates').mockImplementation(async (base: string) => (base === 'EUR' ? null : RATES[base] ?? null));
|
||||
try {
|
||||
const s = await budget.settlement(trip.id, 'USD', 'EUR');
|
||||
expect(spy.mock.calls.map(c => c[0])).toEqual(['EUR', 'USD']);
|
||||
// 50 EUR at 1 / 0.87497 USD per euro.
|
||||
expect(s.balances.find(b => b.user_id === me.id)!.balance).toBe(57.14);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,24 +73,36 @@ describe('BudgetService', () => {
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:created', { item: { id: 1 } }, 'sock');
|
||||
});
|
||||
|
||||
it('list / perPersonSummary resolve through the folded SQL methods', () => {
|
||||
it('list / perPersonSummary resolve through the folded SQL methods', async () => {
|
||||
const s = svc();
|
||||
const listSpy = vi.spyOn(s, 'listBudgetItems').mockReturnValue([{ id: 1 }] as never);
|
||||
expect(s.list('5')).toEqual([{ id: 1 }]);
|
||||
expect(listSpy).toHaveBeenCalledWith('5');
|
||||
const summarySpy = vi.spyOn(s, 'getPerPersonSummary').mockReturnValue([{ userId: 1 }] as never);
|
||||
expect(s.perPersonSummary('5')).toEqual([{ userId: 1 }]);
|
||||
expect(summarySpy).toHaveBeenCalledWith('5');
|
||||
expect(await s.perPersonSummary('5')).toEqual([{ userId: 1 }]);
|
||||
// No row is waiting on today's rates, so none are fetched for it.
|
||||
expect(summarySpy).toHaveBeenCalledWith('5', null);
|
||||
expect(getRates).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('settlement', () => {
|
||||
it('upper-cases the explicit base and forwards the rates', async () => {
|
||||
it('upper-cases the explicit base and converts with the trip currency\'s own quote (#2525)', async () => {
|
||||
const s = svc();
|
||||
const calcSpy = vi.spyOn(s, 'calculateSettlement').mockReturnValue({ transfers: [] } as never);
|
||||
getRates.mockResolvedValue({ USD: 1.1 });
|
||||
await s.settlement('5', 'usd', 'EUR');
|
||||
expect(getRates).toHaveBeenCalledWith('USD');
|
||||
expect(calcSpy).toHaveBeenCalledWith('5', { base: 'USD', rates: { USD: 1.1 }, tripCurrency: 'EUR' });
|
||||
getRates.mockResolvedValue({ EUR: 1, USD: 1.1 });
|
||||
await s.settlement('5', 'usd', 'eur');
|
||||
// The quote every entry rate was frozen from; the dollar's is not its exact inverse.
|
||||
expect(getRates.mock.calls).toEqual([['EUR']]);
|
||||
expect(calcSpy).toHaveBeenCalledWith('5', { base: 'USD', rates: { EUR: 1, USD: 1.1 }, tripCurrency: 'EUR' });
|
||||
});
|
||||
|
||||
it('stands in the display currency\'s quote when the trip\'s cannot be fetched', async () => {
|
||||
const s = svc();
|
||||
const calcSpy = vi.spyOn(s, 'calculateSettlement').mockReturnValue({ transfers: [] } as never);
|
||||
getRates.mockImplementation(async (b: string) => (b === 'USD' ? { USD: 1, EUR: 0.9 } : null));
|
||||
await s.settlement('5', 'USD', 'EUR');
|
||||
expect(getRates.mock.calls).toEqual([['EUR'], ['USD']]);
|
||||
expect(calcSpy).toHaveBeenCalledWith('5', { base: 'USD', rates: { USD: 1, EUR: 0.9 }, tripCurrency: 'EUR' });
|
||||
});
|
||||
|
||||
it('falls back to the trip currency when no base is given', async () => {
|
||||
@@ -98,8 +110,8 @@ describe('BudgetService', () => {
|
||||
const calcSpy = vi.spyOn(s, 'calculateSettlement').mockReturnValue({ transfers: [] } as never);
|
||||
getRates.mockResolvedValue(null);
|
||||
await s.settlement('5', undefined, 'gbp');
|
||||
expect(getRates).toHaveBeenCalledWith('GBP');
|
||||
expect(calcSpy).toHaveBeenCalledWith('5', { base: 'GBP', rates: null, tripCurrency: 'gbp' });
|
||||
expect(getRates.mock.calls).toEqual([['GBP']]);
|
||||
expect(calcSpy).toHaveBeenCalledWith('5', { base: 'GBP', rates: null, tripCurrency: 'GBP' });
|
||||
});
|
||||
|
||||
it('falls back to EUR when neither base nor trip currency is present', async () => {
|
||||
@@ -108,7 +120,7 @@ describe('BudgetService', () => {
|
||||
getRates.mockResolvedValue(null);
|
||||
await s.settlement('5', undefined, '');
|
||||
expect(getRates).toHaveBeenCalledWith('EUR');
|
||||
expect(calcSpy).toHaveBeenCalledWith('5', { base: 'EUR', rates: null, tripCurrency: '' });
|
||||
expect(calcSpy).toHaveBeenCalledWith('5', { base: 'EUR', rates: null, tripCurrency: 'EUR' });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -21,10 +21,22 @@ function makeService(overrides: Partial<ReservationsService> = {}): Reservations
|
||||
syncBudgetOnCreate: vi.fn(),
|
||||
syncBudgetOnUpdate: vi.fn(),
|
||||
notifyBookingChange: vi.fn(),
|
||||
// Hands the entry back as it came: what freezing does to it is the service's test.
|
||||
withFrozenRate: vi.fn(async (_tripId: string, entry: unknown) => entry),
|
||||
...overrides,
|
||||
} as unknown as ReservationsService;
|
||||
}
|
||||
|
||||
/** The create route awaits the price's rate, so its 400s arrive as a rejection. */
|
||||
async function rejected(promise: Promise<unknown>): Promise<{ status: number; body: unknown }> {
|
||||
try { await promise; } catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
return { status: e.getStatus(), body: e.getResponse() };
|
||||
}
|
||||
throw new Error('expected throw');
|
||||
}
|
||||
|
||||
function thrown(fn: () => unknown): { status: number; body: unknown } {
|
||||
try { fn(); } catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
@@ -46,31 +58,31 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese
|
||||
// The bespoke 'Title is required' 400 moved to the global ZodValidationPipe
|
||||
// (ReservationCreateDto) — covered by the e2e suite.
|
||||
|
||||
it('creates, runs budget sync, broadcasts accommodation + reservation, notifies', () => {
|
||||
it('creates, runs budget sync, broadcasts accommodation + reservation, notifies', async () => {
|
||||
const create = vi.fn().mockReturnValue({ reservation: { id: 9 }, accommodationCreated: true });
|
||||
const broadcast = vi.fn(); const syncBudgetOnCreate = vi.fn(); const notifyBookingChange = vi.fn();
|
||||
const svc = makeService({ create, broadcast, syncBudgetOnCreate, notifyBookingChange } as Partial<ReservationsService>);
|
||||
const body = { title: 'Hotel', type: 'lodging', create_budget_entry: { total_price: 200 } };
|
||||
expect(new ReservationsController(svc, airtrailLink).create(user, '5', body, 'sock')).toEqual({ reservation: { id: 9 } });
|
||||
expect(await new ReservationsController(svc, airtrailLink).create(user, '5', body, 'sock')).toEqual({ reservation: { id: 9 } });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:created', {}, 'sock');
|
||||
expect(syncBudgetOnCreate).toHaveBeenCalledWith('5', 9, 'Hotel', 'lodging', { total_price: 200 }, 'sock');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'reservation:created', { reservation: { id: 9 } }, 'sock');
|
||||
expect(notifyBookingChange).toHaveBeenCalledWith('5', user.id, 'Hotel', 'lodging');
|
||||
});
|
||||
|
||||
it('400s on a body id belonging to another trip, without writing', () => {
|
||||
it('400s on a body id belonging to another trip, without writing', async () => {
|
||||
const create = vi.fn();
|
||||
const svc = makeService({
|
||||
create,
|
||||
referencesOutsideTrip: vi.fn().mockReturnValue(['accommodation_id']),
|
||||
} as Partial<ReservationsService>);
|
||||
const body = { title: 'Hotel', accommodation_id: 4711 };
|
||||
expect(thrown(() => new ReservationsController(svc, airtrailLink).create(user, '5', body)))
|
||||
expect(await rejected(new ReservationsController(svc, airtrailLink).create(user, '5', body)))
|
||||
.toEqual({ status: 400, body: { error: 'Not part of this trip: accommodation_id' } });
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('400s on a body id that exists nowhere, in its own words, without writing', () => {
|
||||
it('400s on a body id that exists nowhere, in its own words, without writing', async () => {
|
||||
const create = vi.fn();
|
||||
const svc = makeService({
|
||||
create,
|
||||
@@ -79,20 +91,36 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese
|
||||
const body = { title: 'Hotel', place_id: 4711 };
|
||||
// Not 'Not part of this trip': an id that is part of nothing would send
|
||||
// the caller looking for it on another trip.
|
||||
expect(thrown(() => new ReservationsController(svc, airtrailLink).create(user, '5', body)))
|
||||
expect(await rejected(new ReservationsController(svc, airtrailLink).create(user, '5', body)))
|
||||
.toEqual({ status: 400, body: { error: 'Unknown reference: place_id' } });
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers a foreign id with the older message when it is both', () => {
|
||||
it('answers a foreign id with the older message when it is both', async () => {
|
||||
const svc = makeService({
|
||||
create: vi.fn(),
|
||||
referencesOutsideTrip: vi.fn().mockReturnValue(['place_id']),
|
||||
unresolvedReferences: vi.fn().mockReturnValue(['place_id']),
|
||||
} as Partial<ReservationsService>);
|
||||
expect(thrown(() => new ReservationsController(svc, airtrailLink).create(user, '5', { title: 'Hotel', place_id: 4711 })))
|
||||
expect(await rejected(new ReservationsController(svc, airtrailLink).create(user, '5', { title: 'Hotel', place_id: 4711 })))
|
||||
.toEqual({ status: 400, body: { error: 'Not part of this trip: place_id' } });
|
||||
});
|
||||
|
||||
// #2525: an imported booking quoted in dollars. The price has to reach the linked
|
||||
// cost in dollars, at a rate frozen before anything is written.
|
||||
it('hands the budget sync the entry with its currency and frozen rate', async () => {
|
||||
const create = vi.fn().mockReturnValue({ reservation: { id: 9 }, accommodationCreated: false });
|
||||
const syncBudgetOnCreate = vi.fn();
|
||||
const withFrozenRate = vi.fn(async () => {
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
return { total_price: 801.76, currency: 'USD', exchange_rate: 1.17 };
|
||||
});
|
||||
const svc = makeService({ create, syncBudgetOnCreate, withFrozenRate } as Partial<ReservationsService>);
|
||||
const body = { title: 'Aparthotel Silver', type: 'hotel', create_budget_entry: { total_price: 801.76, currency: 'usd' } };
|
||||
await new ReservationsController(svc, airtrailLink).create(user, '5', body, 'sock');
|
||||
expect(withFrozenRate).toHaveBeenCalledWith('5', { total_price: 801.76, currency: 'usd' });
|
||||
expect(syncBudgetOnCreate).toHaveBeenCalledWith('5', 9, 'Aparthotel Silver', 'hotel', { total_price: 801.76, currency: 'USD', exchange_rate: 1.17 }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /positions', () => {
|
||||
|
||||
@@ -39,7 +39,7 @@ const permissionsStub = { checkPermission } as unknown as PermissionsService;
|
||||
|
||||
// Constructor-injected since the budget fold (was a path mock of the deleted
|
||||
// services/budgetService).
|
||||
const budget = { createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(), deleteBudgetItem: vi.fn(), linkBudgetItemToReservation: vi.fn() };
|
||||
const budget = { createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(), deleteBudgetItem: vi.fn(), linkBudgetItemToReservation: vi.fn(), freezeForeignRate: vi.fn() };
|
||||
|
||||
const { notif } = vi.hoisted(() => ({ notif: { send: vi.fn().mockResolvedValue(undefined) } }));
|
||||
|
||||
@@ -532,6 +532,37 @@ describe('ReservationsService (DI-native, real SQL)', () => {
|
||||
budget.linkBudgetItemToReservation.mockImplementation(() => { throw new Error('boom'); });
|
||||
expect(() => svc.syncBudgetOnCreate('5', 9, 'Hotel', undefined, { total_price: 50 }, 'sock')).not.toThrow();
|
||||
});
|
||||
|
||||
// #2525: an imported booking quoted in dollars became a cost of that many euros.
|
||||
it('links the cost in the currency and at the rate it arrives with', () => {
|
||||
budget.linkBudgetItemToReservation.mockReturnValue({ id: 7 });
|
||||
svc.syncBudgetOnCreate('5', 9, 'Hotel', 'hotel', { total_price: 801.76, currency: 'USD', exchange_rate: 1.17 }, 'sock');
|
||||
expect(budget.linkBudgetItemToReservation).toHaveBeenCalledWith('5', 9, {
|
||||
name: 'Hotel', category: 'hotel', total_price: 801.76, currency: 'USD', exchange_rate: 1.17,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('withFrozenRate (#2525)', () => {
|
||||
it('keeps a quoted currency and freezes a rate for it', async () => {
|
||||
budget.freezeForeignRate.mockImplementation(async (_tripId: unknown, data: { exchange_rate?: number }) => { data.exchange_rate = 1.17; });
|
||||
expect(await svc.withFrozenRate('5', { total_price: 801.76, category: 'hotel', currency: ' usd ' })).toEqual({
|
||||
total_price: 801.76, category: 'hotel', currency: 'USD', exchange_rate: 1.17,
|
||||
});
|
||||
expect(budget.freezeForeignRate).toHaveBeenCalledWith('5', { currency: 'USD', exchange_rate: 1.17 });
|
||||
});
|
||||
|
||||
it('leaves the rate off when none could be frozen, so the cost converts live', async () => {
|
||||
budget.freezeForeignRate.mockResolvedValue(undefined);
|
||||
expect(await svc.withFrozenRate('5', { total_price: 10, currency: 'EUR' })).toEqual({ total_price: 10, currency: 'EUR' });
|
||||
});
|
||||
|
||||
it('drops a currency that is not a code, and never takes a rate from the caller', async () => {
|
||||
expect(await svc.withFrozenRate('5', { total_price: 10, currency: 'dollars', exchange_rate: 99 })).toEqual({ total_price: 10 });
|
||||
expect(await svc.withFrozenRate('5', { total_price: 10, exchange_rate: 99 })).toEqual({ total_price: 10 });
|
||||
expect(budget.freezeForeignRate).not.toHaveBeenCalled();
|
||||
expect(await svc.withFrozenRate('5', undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncBudgetOnUpdate', () => {
|
||||
|
||||
@@ -158,31 +158,31 @@ function ownerlessDbs(): DatabaseService {
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getTripSummary guards', () => {
|
||||
it('TRIP-READ-001: returns null for a missing trip instead of throwing', () => {
|
||||
it('TRIP-READ-001: returns null for a missing trip instead of throwing', async () => {
|
||||
// The MCP get_trip_summary tool hands over whatever id the model produced, so
|
||||
// an unknown id has to come back as an empty answer; a throw there surfaces as
|
||||
// a tool error rather than "no such trip".
|
||||
expect(svc.getTripSummary(99999)).toBeNull();
|
||||
expect(svc.getTripSummary(99999, 1)).toBeNull();
|
||||
expect(await svc.getTripSummary(99999)).toBeNull();
|
||||
expect(await svc.getTripSummary(99999, 1)).toBeNull();
|
||||
});
|
||||
|
||||
it('TRIP-READ-002: returns null when the owner row cannot be read', () => {
|
||||
it('TRIP-READ-002: returns null when the owner row cannot be read', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
|
||||
// Trip row and owner id are two separate SELECTs; if the second one comes back
|
||||
// empty (trip deleted in between) the guard has to stop. Without it listMembers
|
||||
// runs with an undefined owner id and the summary reports an ownerless trip.
|
||||
expect(buildReadModel(ownerlessDbs()).getTripSummary(trip.id, owner.id)).toBeNull();
|
||||
expect(await buildReadModel(ownerlessDbs()).getTripSummary(trip.id, owner.id)).toBeNull();
|
||||
|
||||
// Same trip through the real connection still aggregates — the null above is
|
||||
// the missing owner row, not a broken fixture.
|
||||
expect(svc.getTripSummary(trip.id, owner.id)).not.toBeNull();
|
||||
expect(await svc.getTripSummary(trip.id, owner.id)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTripSummary shaping', () => {
|
||||
it('TRIP-READ-003: folds a falsy total_price into the budget total instead of poisoning it', () => {
|
||||
it('TRIP-READ-003: folds a falsy total_price into the budget total instead of poisoning it', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addBudgetItem(trip.id, 'Dinner', 40);
|
||||
@@ -193,13 +193,43 @@ describe('getTripSummary shaping', () => {
|
||||
// the offline clients both render as an empty budget.
|
||||
addBudgetItem(trip.id, 'Free walking tour', 0);
|
||||
|
||||
const summary = svc.getTripSummary(trip.id, owner.id)!;
|
||||
const summary = (await svc.getTripSummary(trip.id, owner.id))!;
|
||||
expect(summary.budget.item_count).toBe(2);
|
||||
expect(summary.budget.total).toBe(40);
|
||||
expect(summary.budget.currency).toBe('EUR');
|
||||
});
|
||||
|
||||
it('TRIP-READ-004: counts only checked packing items, not the whole list', () => {
|
||||
it('TRIP-READ-008: totals a foreign-currency bill in the trip currency, at its booked rate (#2525)', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addBudgetItem(trip.id, 'Dinner', 100);
|
||||
// 801.76 USD booked when a euro bought 1.17 dollars: 685.26 EUR of trip money. The
|
||||
// summary used to add the 801.76 to the euros and report 901.76 EUR.
|
||||
testDb.prepare("INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate) VALUES (?, 'accommodation', 'Aparthotel Silver', 801.76, 'USD', 1.17)")
|
||||
.run(trip.id);
|
||||
|
||||
const summary = (await svc.getTripSummary(trip.id, owner.id))!;
|
||||
expect(summary.budget.total).toBe(785.26);
|
||||
expect(summary.budget.by_category).toEqual({ food: 100, accommodation: 685.26 });
|
||||
expect(summary.budget.currency).toBe('EUR');
|
||||
});
|
||||
|
||||
it('TRIP-READ-009: totals a trip saved without a currency in euros, the default the rest of the app reads it in (#2525)', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
testDb.prepare('UPDATE trips SET currency = NULL WHERE id = ?').run(trip.id);
|
||||
addBudgetItem(trip.id, 'Dinner', 100);
|
||||
// 117.33 USD booked at 1.1733 dollars to the euro is 100 EUR of trip money.
|
||||
testDb.prepare("INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate) VALUES (?, 'transport', 'Taxi', 117.33, 'USD', 1.1733)")
|
||||
.run(trip.id);
|
||||
|
||||
const summary = (await svc.getTripSummary(trip.id, owner.id))!;
|
||||
expect(summary.budget.total).toBe(200);
|
||||
expect(summary.budget.by_category).toEqual({ food: 100, transport: 100 });
|
||||
expect(summary.budget.currency).toBeNull();
|
||||
});
|
||||
|
||||
it('TRIP-READ-004: counts only checked packing items, not the whole list', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addPackingItem(trip.id, 'Socks', 1);
|
||||
@@ -208,7 +238,7 @@ describe('getTripSummary shaping', () => {
|
||||
|
||||
// total and checked come from the same array; if the filter is ever widened the
|
||||
// packing progress the summary reports jumps to 100% while items are still open.
|
||||
const summary = svc.getTripSummary(trip.id, owner.id)!;
|
||||
const summary = (await svc.getTripSummary(trip.id, owner.id))!;
|
||||
expect(summary.packing.total).toBe(3);
|
||||
expect(summary.packing.checked).toBe(2);
|
||||
});
|
||||
@@ -254,7 +284,7 @@ describe('bundle shaping', () => {
|
||||
});
|
||||
|
||||
describe('private packing items stay viewer-scoped (#858)', () => {
|
||||
it("TRIP-READ-007: neither summary nor bundle leaks another member's private item", () => {
|
||||
it("TRIP-READ-007: neither summary nor bundle leaks another member's private item", async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: viewer } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { start_date: '2025-06-01', end_date: '2025-06-02' });
|
||||
@@ -267,7 +297,7 @@ describe('private packing items stay viewer-scoped (#858)', () => {
|
||||
// ONLY thing filtering the list. If either call site loses it, listItems falls
|
||||
// back to the unfiltered query and the surprise the owner is carrying shows up
|
||||
// in the other member's MCP summary and in their offline cache.
|
||||
const asViewer = svc.getTripSummary(trip.id, viewer.id)!;
|
||||
const asViewer = (await svc.getTripSummary(trip.id, viewer.id))!;
|
||||
expect(asViewer.packing.items.map((i: any) => i.name)).toEqual(['Tent']);
|
||||
expect(asViewer.packing.total).toBe(1);
|
||||
|
||||
@@ -276,7 +306,7 @@ describe('private packing items stay viewer-scoped (#858)', () => {
|
||||
|
||||
// The owner still sees their own private item through both paths, so the
|
||||
// assertions above are the filter working, not an empty fixture.
|
||||
expect(svc.getTripSummary(trip.id, owner.id)!.packing.items.map((i: any) => i.name)).toEqual(['Ring', 'Tent']);
|
||||
expect((await svc.getTripSummary(trip.id, owner.id))!.packing.items.map((i: any) => i.name)).toEqual(['Ring', 'Tent']);
|
||||
expect((svc.bundle(String(trip.id), { user_id: owner.id }, owner.id) as any)
|
||||
.packingItems.map((i: any) => i.name)).toEqual(['Ring', 'Tent']);
|
||||
});
|
||||
|
||||
@@ -928,7 +928,7 @@ describe('guest members (#1362)', () => {
|
||||
// ── Folded CRUD SQL (summary / list / create / delete / copy) ─────────────────
|
||||
|
||||
describe('folded trip CRUD', () => {
|
||||
it('TRIP-SVC-042: getTripSummary aggregates members, days, budget, packing and reservations', () => {
|
||||
it('TRIP-SVC-042: getTripSummary aggregates members, days, budget, packing and reservations', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { start_date: '2025-06-01', end_date: '2025-06-02' });
|
||||
@@ -936,7 +936,7 @@ describe('folded trip CRUD', () => {
|
||||
testDb.prepare("INSERT INTO budget_items (trip_id, category, name, total_price) VALUES (?, 'food', 'Dinner', 40)").run(trip.id);
|
||||
testDb.prepare("INSERT INTO packing_items (trip_id, name, checked) VALUES (?, 'Socks', 1)").run(trip.id);
|
||||
|
||||
const summary = readModelSvc.getTripSummary(trip.id, owner.id)!;
|
||||
const summary = (await readModelSvc.getTripSummary(trip.id, owner.id))!;
|
||||
expect(summary).toBeTruthy();
|
||||
expect((summary.trip as any).id).toBe(trip.id);
|
||||
expect(summary.members.owner.id).toBe(owner.id);
|
||||
@@ -950,7 +950,7 @@ describe('folded trip CRUD', () => {
|
||||
expect(summary.collab_notes).toEqual([]);
|
||||
|
||||
// Missing trips return null instead of throwing.
|
||||
expect(readModelSvc.getTripSummary(99999)).toBeNull();
|
||||
expect(await readModelSvc.getTripSummary(99999)).toBeNull();
|
||||
});
|
||||
|
||||
it('TRIP-SVC-043: list returns owned + shared trips with is_owner, honoring the archived filter', () => {
|
||||
|
||||
@@ -43,6 +43,8 @@ function build(opts: { canEdit?: boolean; cascade?: boolean; stop?: boolean; see
|
||||
),
|
||||
syncBudgetOnCreate: vi.fn(),
|
||||
syncBudgetOnUpdate: vi.fn(),
|
||||
// The price's currency and rate are the service's to resolve; here it passes through.
|
||||
withFrozenRate: vi.fn(async (_tripId: number, entry: unknown) => entry),
|
||||
notifyBookingChange: vi.fn(),
|
||||
// Both guards run before a write now, so the fixture answers for them.
|
||||
referencesOutsideTrip: vi.fn(() => opts.foreign ?? []),
|
||||
|
||||
Reference in New Issue
Block a user