Files
TREK/server/tests/e2e/budget.e2e.test.ts
T
Maurice 06bee95ae3 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.
2026-09-26 04:22:50 +00:00

298 lines
14 KiB
TypeScript

/**
* Budget module e2e — exercises the migrated /api/trips/:tripId/budget endpoints
* through the real JwtAuthGuard against a temp SQLite db carrying the full real
* schema (createTables + runMigrations), so the folded BudgetService runs its
* real SQL. Only the db singleton (trip access) and the WebSocket broadcast are
* mocked; the permission check is a spy on the container's PermissionsService.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { DatabaseModule } from '../../src/nest/database/database.module';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { Test } from '@nestjs/testing';
import { sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec('PRAGMA foreign_keys = ON');
return { db: tmp };
});
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
vi.mock('../../src/db/database', () => ({
db,
closeDb: () => {},
reinitialize: () => {},
canAccessTrip,
getPlaceWithTags: () => null,
isOwner: () => false,
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
// Since the permissions DI migration, the check is a spy on the container's
// PermissionsService singleton (created in beforeAll, after build()).
let checkPermission: MockInstance;
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { BudgetModule } from '../../src/nest/budget/budget.module';
import { ExchangeRatesService } from '../../src/nest/budget/exchange-rates.service';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
let tripId: number;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, BudgetModule] })
// The settlement read awaits live FX rates; the trip here is all-EUR, so a
// null result is the identity — and the test never touches the network.
.overrideProvider(ExchangeRatesService)
.useValue({ getRates: async () => null })
.compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
nest.useGlobalPipes(new ZodValidationPipe());
await nest.init();
return nest;
}
beforeAll(async () => {
createTables(db);
runMigrations(db);
// The temp db carries the real schema (password_hash NOT NULL), so seed the
// auth users directly instead of via the trimmed-DDL seedUser helper.
db.prepare(
"INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES (1, 'e2e-user', 'e2e@example.test', 'x', 'user', 0)",
).run();
db.prepare(
"INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES (2, 'e2e-peer', 'peer@example.test', 'x', 'user', 0)",
).run();
tripId = Number(db.prepare("INSERT INTO trips (user_id, title, currency) VALUES (1, 'E2E Trip', 'EUR')").run().lastInsertRowid);
// The peer settles up with the owner below, so they have to be on the trip:
// a settlement between people who do not share one is refused.
db.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, 2)').run(tripId);
app = await build();
checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission');
server = app.getHttpServer();
});
beforeEach(() => {
canAccessTrip.mockReturnValue({ id: tripId, user_id: 1, currency: 'EUR' });
checkPermission.mockReturnValue(true);
});
afterAll(async () => {
await app.close();
});
it('401 without a session cookie', async () => {
const res = await request(server).get(`/api/trips/${tripId}/budget`);
expect(res.status).toBe(401);
});
it('404 when the trip is not accessible', async () => {
canAccessTrip.mockReturnValue(undefined);
const res = await request(server).get(`/api/trips/${tripId}/budget`).set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
});
it('201 on create with permission, then 200 list returns the stored row', async () => {
const created = await request(server)
.post(`/api/trips/${tripId}/budget`)
.set('Cookie', sessionCookie(1))
.send({ name: 'Hotel', total_price: 200 });
expect(created.status).toBe(201);
expect(created.body.item).toMatchObject({ name: 'Hotel', total_price: 200, category: 'other', members: [], payers: [] });
const row = db.prepare('SELECT name, total_price FROM budget_items WHERE id = ?').get(created.body.item.id);
expect(row).toEqual({ name: 'Hotel', total_price: 200 });
const list = await request(server).get(`/api/trips/${tripId}/budget`).set('Cookie', sessionCookie(1));
expect(list.status).toBe(200);
expect(list.body.items.map((i: { id: number }) => i.id)).toContain(created.body.item.id);
});
it('403 on create without permission', async () => {
checkPermission.mockReturnValue(false);
const res = await request(server)
.post(`/api/trips/${tripId}/budget`)
.set('Cookie', sessionCookie(1))
.send({ name: 'Hotel' });
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: 'No permission' });
});
it('400 on member update with a non-array user_ids (Zod pipe envelope)', async () => {
const res = await request(server)
.put(`/api/trips/${tripId}/budget/9/members`)
.set('Cookie', sessionCookie(1))
.send({ user_ids: 'no' });
expect(res.status).toBe(400);
expect(res.body.error).toContain('user_ids');
});
it('400 on create without a name (Zod pipe envelope)', async () => {
const res = await request(server)
.post(`/api/trips/${tripId}/budget`)
.set('Cookie', sessionCookie(1))
.send({ total_price: 10 });
expect(res.status).toBe(400);
expect(res.body.error.toLowerCase()).toContain('name');
});
it('201 on a negative expense (partial reimbursement) and the settlement nets it out (#2176)', async () => {
// The dinner: user 1 fronts 90, split with user 2 → user 2 owes 45.
const dinner = await request(server)
.post(`/api/trips/${tripId}/budget`)
.set('Cookie', sessionCookie(1))
.send({ name: 'Dinner', payers: [{ user_id: 1, amount: 90 }], member_ids: [1, 2] });
expect(dinner.status).toBe(201);
// The refund: -30 lands with user 1, shared with user 2 → user 2's debt
// drops by 15. The write path used to null this to a payer-less 0 € row.
const refund = await request(server)
.post(`/api/trips/${tripId}/budget`)
.set('Cookie', sessionCookie(1))
.send({ name: 'Hotel partial refund', total_price: -30, payers: [{ user_id: 1, amount: -30 }], member_ids: [1, 2] });
expect(refund.status).toBe(201);
expect(refund.body.item).toMatchObject({ total_price: -30 });
expect(refund.body.item.payers).toEqual([expect.objectContaining({ user_id: 1, amount: -30 })]);
const row = db.prepare('SELECT total_price FROM budget_items WHERE id = ?').get(refund.body.item.id) as { total_price: number };
expect(row.total_price).toBe(-30);
const settlement = await request(server)
.get(`/api/trips/${tripId}/budget/settlement`)
.set('Cookie', sessionCookie(1));
expect(settlement.status).toBe(200);
const balances = settlement.body.balances as { user_id: number; balance: number }[];
const balance = (uid: number) => balances.find(b => b.user_id === uid)!.balance;
// 45 owed from the dinner minus the 15 refund share = 30, and Σ balances = 0.
expect(balance(2)).toBe(-30);
expect(balance(1)).toBe(30);
expect(balances.reduce((a, b) => a + Math.round(b.balance * 100), 0)).toBe(0);
// Clean up so the ledger tests below start from an empty trip.
for (const id of [dinner.body.item.id, refund.body.item.id]) {
const del = await request(server).delete(`/api/trips/${tripId}/budget/${id}`).set('Cookie', sessionCookie(1));
expect(del.status).toBe(200);
}
});
it('an expense with no payer creates no debt and no flow (#2225)', async () => {
// The shape from the report: one bill user 1 fronted for both, plus a row
// saved with "No one paid yet" that only user 2 is on. The taxi used to be
// debited with no credit behind it and its whole 60 offered to user 1.
const dinner = await request(server)
.post(`/api/trips/${tripId}/budget`)
.set('Cookie', sessionCookie(1))
.send({ name: 'Dinner', payers: [{ user_id: 1, amount: 100 }], member_ids: [1, 2] });
expect(dinner.status).toBe(201);
const unpaid = await request(server)
.post(`/api/trips/${tripId}/budget`)
.set('Cookie', sessionCookie(1))
.send({ name: 'Taxi', total_price: 60, payers: [], member_ids: [2] });
expect(unpaid.status).toBe(201);
expect(unpaid.body.item).toMatchObject({ total_price: 60, payers: [] });
const settlement = await request(server)
.get(`/api/trips/${tripId}/budget/settlement`)
.set('Cookie', sessionCookie(1));
expect(settlement.status).toBe(200);
const balances = settlement.body.balances as { user_id: number; balance: number }[];
const balance = (uid: number) => balances.find(b => b.user_id === uid)!.balance;
// Only the dinner settles: user 2 owes half of it and not a cent of the taxi.
expect(balance(1)).toBe(50);
expect(balance(2)).toBe(-50);
expect(balances.reduce((a, b) => a + Math.round(b.balance * 100), 0)).toBe(0);
expect(settlement.body.flows).toEqual([
expect.objectContaining({ amount: 50, from: expect.objectContaining({ user_id: 2 }), to: expect.objectContaining({ user_id: 1 }) }),
]);
// Clean up so the ledger tests below start from an empty trip.
for (const id of [dinner.body.item.id, unpaid.body.item.id]) {
const del = await request(server).delete(`/api/trips/${tripId}/budget/${id}`).set('Cookie', sessionCookie(1));
expect(del.status).toBe(200);
}
});
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`)
.set('Cookie', sessionCookie(1))
.send({ from_user_id: 2, to_user_id: 1, amount: 10, settled_at: '2026-01-05' });
expect(created.status).toBe(201);
expect(created.body.settlement.settled_at).toBe('2026-01-05');
const res = await request(server)
.put(`/api/trips/${tripId}/budget/settlements/${created.body.settlement.id}`)
.set('Cookie', sessionCookie(1))
.send({ from_user_id: 2, to_user_id: 1, amount: 15, settled_at: '2026-01-09' });
expect(res.status).toBe(200);
expect(res.body.settlement).toMatchObject({ id: created.body.settlement.id, from_user_id: 2, to_user_id: 1, amount: 15, settled_at: '2026-01-09' });
const row = db.prepare('SELECT amount, settled_at FROM budget_settlements WHERE id = ?').get(created.body.settlement.id);
expect(row).toEqual({ amount: 15, settled_at: '2026-01-09' });
});
it('200 on settlement update that clears the day, leaving NULL in the column', async () => {
const created = await request(server)
.post(`/api/trips/${tripId}/budget/settlements`)
.set('Cookie', sessionCookie(1))
.send({ from_user_id: 2, to_user_id: 1, amount: 10, settled_at: '2026-01-05' });
expect(created.status).toBe(201);
const res = await request(server)
.put(`/api/trips/${tripId}/budget/settlements/${created.body.settlement.id}`)
.set('Cookie', sessionCookie(1))
.send({ from_user_id: 2, to_user_id: 1, amount: 10, settled_at: null });
expect(res.status).toBe(200);
expect(res.body.settlement.settled_at).toBeNull();
const row = db.prepare('SELECT settled_at FROM budget_settlements WHERE id = ?').get(created.body.settlement.id);
expect(row).toEqual({ settled_at: null });
});
it('404 on settlement update when it does not exist', async () => {
const res = await request(server)
.put(`/api/trips/${tripId}/budget/settlements/424242`)
.set('Cookie', sessionCookie(1))
.send({ from_user_id: 2, to_user_id: 1, amount: 15 });
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Settlement not found' });
});
});