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:
Maurice
2026-09-25 19:48:12 +02:00
parent 5f77c2cee9
commit 06bee95ae3
44 changed files with 1900 additions and 227 deletions
+21
View File
@@ -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`)
+30
View File
@@ -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();
+30 -8
View File
@@ -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();
}
});
});
+23 -11
View File
@@ -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']);
});
+3 -3
View File
@@ -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 ?? []),