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:
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user