Files
TREK/client/src/components/Budget/expenseFx.test.ts
T
Maurice a37a9c0fa8 fix(costs): count an expense in the display currency as typed
On a yen trip read in euros, an expense entered in euros was booked through the yen at the rate frozen on entry and shown back at today's rate, so 1,081.09 EUR read 1,078.86 EUR a day later, and repaying exactly what was typed left 2.23 EUR owed.

Rows in the display currency now net apart in whole display cents, as entered, and the lists show them as typed. A payment in the display currency first pays what is owed between the two in such rows; only the rest goes through the trip currency at its frozen rate, so a trip-currency debt settled in the display currency stays settled when rates move (#1445). Every other row still nets in trip cents and converts once (#1382), so the balances add up to zero in both halves.

Closes #2965
2026-11-03 22:16:41 +00:00

72 lines
4.0 KiB
TypeScript

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', base: 'GBP' })!
expect(fx.inTrip).toBeCloseTo(685.26, 2)
expect(fx.shown).toBeCloseTo(convert(801.76 / 1.17, 'EUR'), 2)
})
it('has nothing to preview for a bill in the display currency, which counts as typed (#2965)', () => {
// Frozen at 1.17 while a euro buys 1.1551 today: still the dollars typed.
expect(expenseFxPreview({ ...base, total: 801.76, currency: 'USD', base: 'USD' })).toBeNull()
})
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', () => {
it('prints the entered share, then the share the row lends or borrows', () => {
const fx = expenseFxPreview({ ...base, total: 1700, currency: 'JPY', editing: null })
expect(splitShareLabel(850, 'JPY', fx, 2, 'USD', 'en-US')).toBe(`${formatMoney(850, 'JPY', 'en-US')} → ${formatMoney(convert(1700, 'JPY') / 2, 'USD', 'en-US')}`)
// 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', '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', 'en-US')).toBe('$125.00')
expect(splitShareLabel(0, 'USD', { inTrip: null, shown: 10 }, 0, 'USD', 'en-US')).toBe('$0.00')
})
it('prints a share the way the list prints its currency, grouped and with its own decimals (#2604)', () => {
expect(splitShareLabel(15000, 'KRW', null, 4, 'KRW', 'en-US')).toBe('₩15,000')
expect(splitShareLabel(1.5, 'KWD', null, 2, 'KWD', 'en-US')).toBe(formatMoney(1.5, 'KWD', 'en-US'))
})
})