mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-12 23:31:02 +00:00
fix(costs): keep convertBooked at five parameters and update the tests to as-typed display rows
The display-currency check is its own helper (inDisplayCurrency), used by the Costs model and the shared trip page. The tests that pinned #2525's reading of a display-currency bill through the trip currency now pin the as-typed reading, which the balances use too; read in the trip currency, such a bill and its repayment may stay a euro cent apart.
This commit is contained in:
@@ -2301,15 +2301,12 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
|
||||
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()
|
||||
// A bill in the display currency counts as typed (#2965), as the balances net it:
|
||||
// row total and payer chip read the dollars entered, nothing to explain under it,
|
||||
// and what I lent is half of them, which settle-up offers too.
|
||||
expect(screen.queryByText(/^\$801\.76 → /)).toBeNull()
|
||||
expect(screen.getAllByText('$801.76')).toHaveLength(2)
|
||||
expect(screen.getByText('you lent $400.88')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-087: a bill booked at today\'s rate reads exactly as typed everywhere on the page', async () => {
|
||||
@@ -2344,7 +2341,8 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
|
||||
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;$/)
|
||||
// The trip-currency figure it was booked at, then the dollars as typed (#2965).
|
||||
expect(lines[1]).toMatch(/;801\.76;USD;685\.26;801\.76;$/)
|
||||
} finally {
|
||||
createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore()
|
||||
}
|
||||
@@ -2357,13 +2355,13 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
|
||||
])
|
||||
|
||||
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()
|
||||
// Both in the display currency, both as typed (#2965): $801.76 and $120.00 make
|
||||
// $921.76, on the day header and on the total spend card.
|
||||
expect(screen.queryByText(/^\$120\.00 → /)).toBeNull()
|
||||
expect(screen.getByText('$921.76 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')
|
||||
expect(card.textContent).toContain('$921.76')
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-084: the totals follow the rates when they arrive after the expenses', async () => {
|
||||
@@ -2399,7 +2397,7 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
|
||||
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 () => {
|
||||
it('FE-W5COSTS-082: a payment recorded in dollars reads as entered, a legacy one too (#2965)', 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' },
|
||||
@@ -2410,29 +2408,26 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
|
||||
})
|
||||
|
||||
await screen.findByText('Aparthotel Silver')
|
||||
expect(await screen.findByText(/\$400\.88 → 342,63\s€$/)).toBeInTheDocument()
|
||||
// The hotel's split chips can carry the same figure, so look past them to the payment.
|
||||
const outsideChips = (text: string) => screen.queryAllByText(text).filter(el => !el.closest('[data-testid="split-chip"]'))
|
||||
expect(outsideChips('$395.77')).toHaveLength(1)
|
||||
expect(await screen.findByText('$30.00')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/\$400\.88 → /)).toBeNull()
|
||||
expect(outsideChips('$400.88')).toHaveLength(1)
|
||||
expect(outsideChips('$30.00')).toHaveLength(1)
|
||||
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 () => {
|
||||
it('FE-W5COSTS-085: the edit dialog has nothing to convert for a bill in the display currency (#2965)', async () => {
|
||||
const user = userEvent.setup()
|
||||
mount([hotel()])
|
||||
|
||||
await screen.findByText('Aparthotel Silver')
|
||||
await user.click(screen.getByRole('button', { name: /^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(/\$400\.88 → \$395\.77 per person/)).toBeInTheDocument()
|
||||
// The list reads the dollars typed, so there is no conversion to preview.
|
||||
expect(screen.queryByText(/live rate/)).toBeNull()
|
||||
expect(screen.getByText(/\$400\.88 per person/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/\$400\.88 → /)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-086: the final budget names the bill by what was entered beside its booked figure', async () => {
|
||||
|
||||
@@ -89,7 +89,8 @@ describe('costsModel: recorded payments', () => {
|
||||
|
||||
it('FE-BUDGET-COSTSMODEL-005: a legacy payment without a currency was entered in the display currency', () => {
|
||||
const inUsd: CostsCtx = { ...ctx, displayCurrency: 'USD' };
|
||||
expect(paymentAmount(payment({ amount: 10, currency: null }), inUsd)).toBe(5);
|
||||
// Entered in the display currency, it reads as entered (#2965).
|
||||
expect(paymentAmount(payment({ amount: 10, currency: null }), inUsd)).toBe(10);
|
||||
expect(paymentAmount(payment({ amount: 10, currency: null }), ctx)).toBe(10);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { BudgetParticipantFinal, BudgetUnconverted, CostCategory } from '@trek/shared';
|
||||
import { convertBooked, convertedLine, tripAmountOf } from '../../hooks/useExchangeRates';
|
||||
import { convertBooked, convertedLine, inDisplayCurrency, tripAmountOf } from '../../hooks/useExchangeRates';
|
||||
import type { BudgetItem } from '../../types';
|
||||
import { currencyDecimals } from '../../utils/formatters';
|
||||
import { currenciesWith, SYMBOLS } from './BudgetPanel.constants';
|
||||
@@ -30,9 +30,13 @@ export function currencyOf(e: BudgetItem, ctx: CostsCtx): string {
|
||||
return (e.currency || ctx.tripCurrency).toUpperCase();
|
||||
}
|
||||
|
||||
/** An amount of this expense in the display currency, at the rate the expense was booked at. */
|
||||
/**
|
||||
* An amount of this expense in the display currency, at the rate the expense was booked at;
|
||||
* as typed when it was entered in the display currency (#2965).
|
||||
*/
|
||||
export function booked(amount: number, e: BudgetItem, ctx: CostsCtx): number {
|
||||
return convertBooked(amount, e.currency, e.exchange_rate, ctx.tripCurrency, ctx.convert, ctx.displayCurrency);
|
||||
if (inDisplayCurrency(e.currency, ctx.tripCurrency, ctx.displayCurrency)) return amount;
|
||||
return convertBooked(amount, e.currency, e.exchange_rate, ctx.tripCurrency, ctx.convert);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,16 +134,14 @@ function paymentCurrency(s: CostsSettlement, ctx: CostsCtx): string {
|
||||
return (s.currency || ctx.displayCurrency).toUpperCase();
|
||||
}
|
||||
|
||||
/** A recorded payment in the display currency, at the rate frozen when it was settled (#1445). */
|
||||
/**
|
||||
* A recorded payment in the display currency, at the rate frozen when it was settled (#1445);
|
||||
* as entered when it was made in the display currency (#2965).
|
||||
*/
|
||||
export function paymentAmount(s: CostsSettlement, ctx: CostsCtx): number {
|
||||
return convertBooked(
|
||||
s.amount,
|
||||
paymentCurrency(s, ctx),
|
||||
s.exchange_rate,
|
||||
ctx.tripCurrency,
|
||||
ctx.convert,
|
||||
ctx.displayCurrency
|
||||
);
|
||||
const currency = paymentCurrency(s, ctx);
|
||||
if (inDisplayCurrency(currency, ctx.tripCurrency, ctx.displayCurrency)) return s.amount;
|
||||
return convertBooked(s.amount, currency, s.exchange_rate, ctx.tripCurrency, ctx.convert);
|
||||
}
|
||||
|
||||
/** The line under a payment shown converted, explained the way an expense's is (#2525). */
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../../tests/helpers/msw/server'
|
||||
import { bookedInTrip, convertBooked, convertedLine, crossRate, fetchExchangeRates, fetchFreshRates, freshRates, clearExchangeRateCache, tripAmountOf, useExchangeRates, withFallbackFx } from './useExchangeRates'
|
||||
import { bookedInTrip, convertBooked, convertedLine, inDisplayCurrency, crossRate, fetchExchangeRates, fetchFreshRates, freshRates, clearExchangeRateCache, tripAmountOf, useExchangeRates, withFallbackFx } from './useExchangeRates'
|
||||
|
||||
const FX_URL = 'https://api.frankfurter.dev/v2/rates'
|
||||
|
||||
@@ -198,16 +198,11 @@ describe('convertBooked', () => {
|
||||
})
|
||||
|
||||
// #2965: read in dollars, a bill entered in dollars counts as typed, as the balances net it.
|
||||
it('shows a row in the display currency as typed, whatever rate it was frozen at', () => {
|
||||
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
|
||||
}
|
||||
expect(convertBooked(801.76, 'USD', 1.17, 'EUR', liveUsd, 'USD')).toBe(801.76)
|
||||
expect(convertBooked(801.76, 'usd', 1.17, 'EUR', liveUsd, 'USD')).toBe(801.76)
|
||||
// Another currency still goes through the trip currency.
|
||||
expect(convertBooked(801.76, 'USD', 1.17, 'EUR', liveUsd, 'GBP')).toBeCloseTo((801.76 / 1.17) * 1.1398, 6)
|
||||
it('knows a row entered in the display currency, a NULL currency being the trip one', () => {
|
||||
expect(inDisplayCurrency('usd', 'EUR', 'USD')).toBe(true)
|
||||
expect(inDisplayCurrency(null, 'EUR', 'eur')).toBe(true)
|
||||
expect(inDisplayCurrency(null, 'EUR', 'USD')).toBe(false)
|
||||
expect(inDisplayCurrency('USD', 'EUR', 'GBP')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -133,19 +133,27 @@ export function convertBooked(
|
||||
frozenRate: number | null | undefined,
|
||||
tripCurrency: string,
|
||||
convertLive: (amount: number, from: string | null | undefined) => number,
|
||||
displayCurrency?: string,
|
||||
): number {
|
||||
const trip = (tripCurrency || 'EUR').toUpperCase()
|
||||
// A row entered in the display currency is shown as typed (#2965): through the
|
||||
// trip currency at its frozen rate and back at today's, it drifted off the
|
||||
// amount entered, and the balances (which net such rows as typed) disagreed.
|
||||
if (displayCurrency && (rowCurrency || trip).toUpperCase() === displayCurrency.toUpperCase()) return amount
|
||||
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())
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a row was entered in the display currency (#2965). Such a row counts as typed:
|
||||
* through the trip currency at its frozen rate and back at today's it drifted off the
|
||||
* amount entered, and the balances, which net such rows as typed, would disagree.
|
||||
*/
|
||||
export function inDisplayCurrency(
|
||||
rowCurrency: string | null | undefined,
|
||||
tripCurrency: string,
|
||||
displayCurrency: string,
|
||||
): boolean {
|
||||
return ((rowCurrency || tripCurrency) || '').toUpperCase() === (displayCurrency || '').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
|
||||
|
||||
@@ -664,10 +664,10 @@ describe('SharedTripPage', () => {
|
||||
await waitFor(() => expect(screen.getAllByText('12,345.67 USD')).toHaveLength(4));
|
||||
});
|
||||
|
||||
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).
|
||||
it('reads a bill in the display currency as typed, as Costs does (#2965)', async () => {
|
||||
// The owner reads in USD on a CHF trip. The USD bill reads as the 801.76 USD
|
||||
// typed, as Costs and its balances count it; 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 }) => {
|
||||
@@ -692,9 +692,9 @@ describe('SharedTripPage', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /costs/i }));
|
||||
|
||||
await waitFor(() => expect(screen.getAllByText('113.95 USD')).toHaveLength(3));
|
||||
expect(screen.getAllByText('780.86 USD')).toHaveLength(3);
|
||||
expect(screen.getByText('894.81 USD')).toBeInTheDocument();
|
||||
expect(screen.getByText('801.76 USD')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('801.76 USD').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('780.86 USD')).toBeNull();
|
||||
expect(screen.getByText('915.71 USD')).toBeInTheDocument();
|
||||
expect(screen.getByText('100.00 CHF')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { catMeta } from '../../components/Budget/costsCategories'
|
||||
import { fs, NEUTRAL_TINT } from '../../components/shared/DialogShell'
|
||||
import { CountPill } from '../../components/Planner/bookings/bookingParts'
|
||||
import { TripMemberAvatar } from '../../components/Trips/TripMemberAvatar'
|
||||
import { convertBooked, convertedLine } from '../../hooks/useExchangeRates'
|
||||
import { convertBooked, convertedLine, inDisplayCurrency } from '../../hooks/useExchangeRates'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { avatarSrc } from '../../utils/avatarSrc'
|
||||
import { currencyDecimals } from '../../utils/formatters'
|
||||
@@ -92,7 +92,11 @@ export function SharedCostsView({ items, base, tripCurrency, convert }: CostsPro
|
||||
const { t, locale } = useTranslation()
|
||||
if (items.length === 0) return <EmptySection icon={<Wallet size={22} />} text={t('shared.emptyCosts')} />
|
||||
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, base)
|
||||
// A bill in the owner's display currency reads as typed, as in Costs (#2965).
|
||||
const valueOf = (i: Expense) =>
|
||||
inDisplayCurrency(i.currency, tripCurrency, base)
|
||||
? amountOf(i)
|
||||
: convertBooked(amountOf(i), i.currency, i.exchange_rate, tripCurrency, convert)
|
||||
// In the reader's own number format with the currency's code: a public page has
|
||||
// no idea where its reader is, and "$" alone does not say which dollar.
|
||||
// Whole cents: a converted amount otherwise printed a third decimal.
|
||||
|
||||
@@ -681,7 +681,7 @@ describe('MCostSheet', () => {
|
||||
})))
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-036: editing a booked bill previews the rate the save keeps (#2525)', () => {
|
||||
it('FE-MOB-COSTSH-036: editing a bill in the display currency has nothing to preview (#2965)', () => {
|
||||
// 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.
|
||||
@@ -695,12 +695,9 @@ describe('MCostSheet', () => {
|
||||
})
|
||||
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()
|
||||
// The list reads the dollars typed, so the sheet has no conversion to show.
|
||||
expect(screen.queryByText(/live rate/)).toBeNull()
|
||||
expect(screen.getByText(/Split 2 ways · \$400\.88 each/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-037: a bill booked at today\'s rate has nothing to preview (#2525)', () => {
|
||||
|
||||
@@ -764,7 +764,7 @@ describe('MCostsTab', () => {
|
||||
expect(update).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-045: a bill whose rate moved shows what was entered and what it was booked at (#2525)', async () => {
|
||||
it('FE-MOB-COSTT-045: a bill whose rate moved still reads as the dollars typed (#2965)', 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
|
||||
@@ -793,17 +793,16 @@ describe('MCostsTab', () => {
|
||||
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(within(card).queryByText(/^\$801\.76 → /)).toBeNull()
|
||||
expect(within(card).getByText('$801.76')).toBeInTheDocument()
|
||||
// Each share is half of the dollars typed, the figure the balances net (#2965).
|
||||
expect(within(card).getAllByText('$400.88')).toHaveLength(2)
|
||||
expect(up(screen.getByText('costs.totalSpend'), 1).textContent).toContain('$801.76')
|
||||
expect(screen.getByText('costs.spent:$801.76')).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(paid).getByText('$400.88')).toBeInTheDocument()
|
||||
expect(within(up(screen.getByText('Bob → costs.you'), 2)).getByText('$30.00')).toBeInTheDocument()
|
||||
expect(screen.queryByText('$34.65')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -325,17 +325,14 @@ describe('costsModel: a bill entered in the display currency (#2525)', () => {
|
||||
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-038: a bill booked at a rate that has moved since still counts as the dollars typed (#2965)', () => {
|
||||
// The balances net it as typed too, so the row, the payer's figure and the share
|
||||
// read the dollars entered, and paying back half of them squares the trip.
|
||||
expect(baseTotal(hotel(), usdCtx)).toBe(801.76);
|
||||
expect(myPaidOf(hotel(), usdCtx)).toBe(801.76);
|
||||
expect(myShareOf(hotel(), usdCtx)).toBe(400.88);
|
||||
// Nothing to explain under it.
|
||||
expect(lineOf(801.76, hotel(), usdCtx, baseTotal(hotel(), usdCtx))).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MOB-CMOD-039: booked at today\'s rate, the bill reads exactly as typed and needs no line', () => {
|
||||
@@ -380,7 +377,7 @@ describe('costsModel: a bill entered in the display currency (#2525)', () => {
|
||||
].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;$/);
|
||||
expect(inUsd[1]).toMatch(/;801\.76;USD;685\.26;801\.76;$/);
|
||||
// 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');
|
||||
|
||||
@@ -1583,9 +1583,9 @@ describe('calculateSettlement: bills in the display currency (#2965)', () => {
|
||||
const [alice, bob] = result.balances.map((b) => b.balance);
|
||||
expect([540.54, 540.55]).toContain(alice);
|
||||
expect(bob).toBe(-alice);
|
||||
const fronted = result.finalBudgets.find((f) => f.user_id === 1)!;
|
||||
expect(fronted.expenses).toBe(1081.09);
|
||||
expect(fronted.sources.fronted).toEqual([{ item_id: 1, cents: 108109 }]);
|
||||
const fronted = result.finalBudgets.find((f) => f.user_id === 1);
|
||||
expect(fronted?.expenses).toBe(1081.09);
|
||||
expect(fronted?.sources.fronted).toEqual([{ item_id: 1, cents: 108109 }]);
|
||||
});
|
||||
|
||||
it('a repayment of exactly what is owed in euros squares the trip, frozen at any rate', async () => {
|
||||
@@ -1593,8 +1593,12 @@ describe('calculateSettlement: bills in the display currency (#2965)', () => {
|
||||
const owed = (await budget.calculateSettlement(1, today)).flows[0].amount;
|
||||
// Bob pays the euros the flow names, frozen at today's yen rate.
|
||||
await setupDb(flight(), pair(), paid(), [makeSettlementRow(5, 2, 1, owed, 'EUR', 1 / 176.73)]);
|
||||
for (const eurPerYen of [1 / 176.73, 1 / 170, 1 / 185]) {
|
||||
const result = await budget.calculateSettlement(1, { ...today, rates: { JPY: 1, EUR: eurPerYen } });
|
||||
const results = await Promise.all(
|
||||
[1 / 176.73, 1 / 170, 1 / 185].map((eurPerYen) =>
|
||||
budget.calculateSettlement(1, { ...today, rates: { JPY: 1, EUR: eurPerYen } }),
|
||||
),
|
||||
);
|
||||
for (const result of results) {
|
||||
expect(result.balances.map((b) => b.balance)).toEqual([0, 0]);
|
||||
expect(result.flows).toEqual([]);
|
||||
for (const f of result.finalBudgets) expect(f.final).toBeCloseTo(f.user_id === 1 ? 1081.09 - owed : owed, 2);
|
||||
|
||||
@@ -1807,14 +1807,20 @@ describe('the settlement converts with the quote the entry rate was frozen from
|
||||
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]);
|
||||
const offered = before.flows.map(f => f.amount);
|
||||
expect(offered).toHaveLength(1);
|
||||
expect([6172.83, 6172.84]).toContain(offered[0]);
|
||||
|
||||
// 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);
|
||||
await budget.createSettlement(trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: offered[0], 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]);
|
||||
// Read in the trip currency both go through the euro, each rounded to a euro cent
|
||||
// on its own, so at most that cent stays apart (#2965); it still adds up to zero.
|
||||
const inEur = (await budget.settlement(trip.id, 'EUR', 'EUR')).balances.map(b => b.balance);
|
||||
expect(inEur.every(b => Math.abs(b) <= 0.01)).toBe(true);
|
||||
expect(Math.round(inEur[0] * 100) + Math.round(inEur[1] * 100)).toBe(0);
|
||||
});
|
||||
|
||||
it('BUDGET-SVC-DB-083: the final budget lists a bill at what was typed even where its trip cent rounds away', async () => {
|
||||
|
||||
Reference in New Issue
Block a user