mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
fix(costs): read an expense entered in the display currency as it was typed (#2525)
A euro trip read in dollars books a bill entered in dollars at the rate of the day, from the euro's quote: 12,345.67 USD at 1.1398 is 10,831.44 EUR. The Costs screen and the settlement then turned those euros back into dollars with the dollar's own quote, 1 / 0.87732. Frankfurter rounds the two quotes separately, so they are not exact inverses, and the bill read $12,346.05 the moment it was saved, a $250.00 one $250.01. The error grows with the amount, which is why the large rows showed it first. Paying back what settle-up offered for such a bill left the trip open with the debt flipped the other way. Both sides now convert trip money to the display currency with the trip currency's own quote, the one every entry rate is frozen from. On the server, settlement() fetches that quote for the REST route and for the MCP get_settlement_summary tool, which now makes the same call, and a legacy transfer without a currency reads back through the same factor. On the client, useExchangeRates takes the trip currency as an anchor and converts through its cached quote, falling back to the display currency's while it is missing, so it works offline from cache and needs no change to the settlement response. The final budget builds each row from the unrounded trip amount, because a euro cent is worth more than a dollar cent and 10,831.44 EUR came back as 12,345.68; the balances and settle-up still net whole trip cents. A same-day bill in the display currency now reads exactly as typed on the row, the payer chip, the day header, the totals, Outstanding and the final budget, on both shells and on the shared trip page, and paying the amount settle-up offers leaves both balances at zero. When the rate really moved, the row keeps the value the totals count, with what was entered and the euros it was booked at underneath, "$801.76 -> 685,26 EUR". One helper builds that line for the desktop list, the phone list, payments, the final budget breakdown and the shared page; it prints nothing for an amount of 0 or a bill that reads as typed. The expense editors share one module for their preview. The split summary puts the share the row lends or borrows next to the entered one, "$400.88 -> $390.53 each", and an expense saved without a currency still opens in the trip currency. Both CSV exports add the amount in the trip currency when it differs from the display currency. Kept from before: the desktop totals recompute when rates arrive, a booking's linked costs are labelled in the trip currency (every linked row, the link picker and the phone's linked costs), a legacy transfer is read in the display currency, the shared page reads the frozen rate in whole cents, a booking import keeps the currency it was quoted in at a rate frozen before the write (REST and plugin RPC), and the MCP trip summary, the budget-overview prompt and the per-person summary add every row up in trip cents at its frozen rate. The packing-list prompt reads the trip title directly instead of building the whole summary, which could wait on a rates fetch.
This commit is contained in:
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user