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