/** * Budget module e2e — exercises the migrated /api/trips/:tripId/budget endpoints * through the real JwtAuthGuard against a temp SQLite db carrying the full real, * migrated schema (createSnapshotTestDb), 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. * ReservationsModule is mounted beside it because an expense can be linked to a * booking (#2084), and deleting that booking has to take the expense with it. */ 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 { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { Test } from '@nestjs/testing'; import { sessionCookie } from './harness'; vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb } = await import('../helpers/db-mock'); const db = createSnapshotTestDb(); // Trip access reads through TripsRepository now; the module only hands out the handle. return { db, closeDb: () => {}, reinitialize: () => {} }; }); 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 { db } from '../../src/db/database'; import { BudgetModule } from '../../src/nest/budget/budget.module'; import { ReservationsModule } from '../../src/nest/reservations/reservations.module'; import { NotificationsService } from '../../src/nest/notifications/notifications.service'; 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'; import { TestUnitOfWorkModule } from '../helpers/test-uow'; import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; import { countRows, deleteRows, findRow, insertRow } from '../helpers/factories/rows'; import { makeUser } from '../helpers/factories/users'; import { addTripMember, makeTrip } from '../helpers/factories/trips'; import { makePlace } from '../helpers/factories/places'; import { makeReservation } from '../helpers/factories/reservations'; import { BudgetItems } from '../../src/db/entities/BudgetItems.entity'; import { BudgetSettlements } from '../../src/db/entities/BudgetSettlements.entity'; import { Reservations } from '../../src/db/entities/Reservations.entity'; import { Trips } from '../../src/db/entities/Trips.entity'; let orm: TestOrm; /** The stored expense; fails the case when it is gone. */ async function budgetRow(id: number) { const row = await findRow(orm, BudgetItems, { id }); if (!row) throw new Error(`no budget item ${id}`); return row; } /** The stored settlement; fails the case when it is gone. */ async function settlementRow(id: number) { const row = await findRow(orm, BudgetSettlements, { id }); if (!row) throw new Error(`no settlement ${id}`); return row; } describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => { let server: Server; let app: Awaited>; let tripId: number; async function build() { const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), RealtimeModule, BudgetModule, ReservationsModule] }) // 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 }) // A booking delete notifies the trip; nothing here is listening. .overrideProvider(NotificationsService) .useValue({ send: vi.fn().mockResolvedValue(undefined) }) .compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); nest.useGlobalPipes(new ZodValidationPipe()); await nest.init(); return nest; } beforeAll(async () => { // The temp db carries the real schema, so the auth users go in through the // factories, pinned to the ids the session cookies are signed for. orm = await createTestOrm(db); await makeUser(orm, { id: 1, username: 'e2e-user', email: 'e2e@example.test', role: 'user', password_version: 0 }); await makeUser(orm, { id: 2, username: 'e2e-peer', email: 'peer@example.test', role: 'user', password_version: 0 }); tripId = (await makeTrip(orm, 1, { title: 'E2E Trip', currency: 'EUR' })).id; // 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. await addTripMember(orm, tripId, 2); app = await build(); checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission'); server = app.getHttpServer(); }); beforeEach(() => { // 0b review L2 / security review F-B7: `canAccessTrip` is dead mock // scaffolding — `TripAccessGuard` reads `TripsRepository.findAccessible` // directly now (Plan 3c Task 0b), so `db/database`'s `canAccessTrip` // property is never imported by production code; this line used to be a // no-op that read as if it still controlled access. checkPermission.mockReturnValue(true); }); afterAll(async () => { await app.close(); await orm.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 () => { // Plan 3c Task 0b: TripAccessGuard reads TripsRepository.findAccessible // directly now, a real query — `canAccessTrip.mockReturnValue(...)` no // longer intercepts it. The trip row is seeded once in `beforeAll` (not // re-seeded per test), so it is removed and restored around this one // assertion instead. await deleteRows(orm, Trips, { id: tripId }); try { 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' }); } finally { await insertRow(orm, Trips, { id: tripId, user: 1, title: 'E2E Trip', currency: 'EUR' }); await addTripMember(orm, tripId, 2); } }); 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 = await budgetRow(created.body.item.id); expect({ name: row.name, total_price: row.total_price }).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 })]); expect((await budgetRow(refund.body.item.id)).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 = await settlementRow(created.body.settlement.id); expect({ amount: row.amount, settled_at: row.settled_at }).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(); expect((await settlementRow(created.body.settlement.id)).settled_at).toBeNull(); }); it('keeps a note on a settlement, leaves it alone when omitted and clears it on blank (#2340)', 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, note: ' Cash at the airport ' }); expect(created.status).toBe(201); expect(created.body.settlement.note).toBe('Cash at the airport'); const url = `/api/trips/${tripId}/budget/settlements/${created.body.settlement.id}`; const kept = await request(server).put(url).set('Cookie', sessionCookie(1)).send({ from_user_id: 2, to_user_id: 1, amount: 12 }); expect(kept.body.settlement.note).toBe('Cash at the airport'); const cleared = await request(server).put(url).set('Cookie', sessionCookie(1)).send({ from_user_id: 2, to_user_id: 1, amount: 12, note: ' ' }); expect(cleared.body.settlement.note).toBeNull(); const tooLong = await request(server).put(url).set('Cookie', sessionCookie(1)).send({ from_user_id: 2, to_user_id: 1, amount: 12, note: 'x'.repeat(501) }); expect(tooLong.status).toBe(400); }); 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' }); }); // Plan 4 Task 8b (U6) — :id is now parsed ONCE at the controller gate // (toRowId), so a non-numeric id 404s cleanly through that guard instead // of falling through to the repository and depending on SQLite's // column-affinity CAST to simply not match (the legacy outcome was also a // 404, same status — this pins the gate itself, not just the status). it('404 (not 500) on a budget item update with a non-numeric :id', async () => { const res = await request(server) .put(`/api/trips/${tripId}/budget/abc`) .set('Cookie', sessionCookie(1)) .send({ name: 'X' }); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Budget item not found' }); }); it('404 (not 500) on a budget item delete with a non-numeric :id', async () => { const res = await request(server) .delete(`/api/trips/${tripId}/budget/abc`) .set('Cookie', sessionCookie(1)); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Budget item not found' }); }); // #2084: an expense that already exists can be linked to a booking of its trip // (several per booking), let go of again, and goes with the booking it is on. describe('linking expenses to bookings (#2084)', () => { let bookingId: number; let foreignBookingId: number; let foreignPlaceId: number; let fareId: number; let luggageId: number; let seatId: number; const metadataOf = async (id: number) => { const row = await findRow(orm, Reservations, { id }); if (!row) throw new Error(`no reservation ${id}`); return row.metadata ? JSON.parse(row.metadata) : null; }; /** The expense's booking link, or undefined once the expense is gone. */ const linkOf = async (id: number) => { const row = await findRow(orm, BudgetItems, { id }); return row ? { reservation_id: row.reservation_id ?? null } : undefined; }; const put = (id: number, body: Record) => request(server).put(`/api/trips/${tripId}/budget/${id}`).set('Cookie', sessionCookie(1)).send(body); const create = (body: Record) => request(server).post(`/api/trips/${tripId}/budget`).set('Cookie', sessionCookie(1)).send(body); beforeAll(async () => { bookingId = (await makeReservation(orm, tripId, { title: 'Flight', type: 'flight', metadata: JSON.stringify({ seat: '12A' }) })).id; const otherTripId = (await makeTrip(orm, 1, { title: 'Other Trip', currency: 'EUR' })).id; foreignBookingId = (await makeReservation(orm, otherTripId, { title: 'Elsewhere', type: 'flight' })).id; foreignPlaceId = (await makePlace(orm, otherTripId, { name: 'Elsewhere', lat: null, lng: null, category: null })).id; }); it('200 on linking two expenses to one booking, which then mirrors their sum', async () => { const fare = await create({ name: 'Fare', total_price: 120 }); const luggage = await create({ name: 'Luggage', total_price: 30.5 }); expect(fare.status).toBe(201); expect(luggage.status).toBe(201); fareId = fare.body.item.id; luggageId = luggage.body.item.id; const first = await put(fareId, { reservation_id: bookingId }); expect(first.status).toBe(200); expect(first.body.item).toMatchObject({ id: fareId, reservation_id: bookingId, total_price: 120 }); expect(await metadataOf(bookingId)).toEqual({ seat: '12A', price: '120' }); const second = await put(luggageId, { reservation_id: bookingId }); expect(second.status).toBe(200); expect(await metadataOf(bookingId)).toEqual({ seat: '12A', price: '150.5' }); const list = await request(server).get(`/api/trips/${tripId}/budget`).set('Cookie', sessionCookie(1)); const linked = (list.body.items as { id: number; reservation_id: number | null }[]) .filter(i => i.reservation_id === bookingId).map(i => i.id); expect(linked).toEqual([fareId, luggageId]); }); it('400 on a booking or a place from another trip, on update and on create, writing nothing', async () => { const relink = await put(fareId, { reservation_id: foreignBookingId, name: 'Hijacked' }); expect(relink.status).toBe(400); expect(relink.body).toEqual({ error: 'reservation_id does not belong to this trip.' }); expect(await linkOf(fareId)).toEqual({ reservation_id: bookingId }); expect((await budgetRow(fareId)).name).toBe('Fare'); expect(await metadataOf(foreignBookingId)).toBeNull(); const place = await put(fareId, { place_id: foreignPlaceId }); expect(place.status).toBe(400); expect(place.body).toEqual({ error: 'place_id does not belong to this trip.' }); const before = await countRows(orm, BudgetItems); const created = await create({ name: 'Smuggled', total_price: 5, reservation_id: foreignBookingId }); expect(created.status).toBe(400); expect(created.body).toEqual({ error: 'reservation_id does not belong to this trip.' }); expect(await countRows(orm, BudgetItems)).toBe(before); }); it('400 on a reservation_id that is not a positive integer (Zod pipe envelope)', async () => { const res = await put(fareId, { reservation_id: 0 }); expect(res.status).toBe(400); expect(res.body.error).toContain('reservation_id'); expect(await linkOf(fareId)).toEqual({ reservation_id: bookingId }); }); it('200 on unlinking: the expense stays and the booking price is worked out again', async () => { const res = await put(luggageId, { reservation_id: null }); expect(res.status).toBe(200); expect(res.body.item).toMatchObject({ id: luggageId, reservation_id: null, total_price: 30.5 }); expect(await linkOf(luggageId)).toEqual({ reservation_id: null }); expect(await metadataOf(bookingId)).toEqual({ seat: '12A', price: '120' }); }); it('201 on a second expense created straight onto the booking, which raises its price to the sum', async () => { const seat = await create({ name: 'Seat', total_price: 15, reservation_id: bookingId }); expect(seat.status).toBe(201); expect(seat.body.item.reservation_id).toBe(bookingId); seatId = seat.body.item.id; expect(await metadataOf(bookingId)).toEqual({ seat: '12A', price: '135' }); }); it('200 on new payers for a linked expense, whose derived total the booking price follows', async () => { const res = await request(server) .put(`/api/trips/${tripId}/budget/${seatId}/payers`) .set('Cookie', sessionCookie(1)) .send({ payers: [{ user_id: 1, amount: 12 }, { user_id: 2, amount: 6 }] }); expect(res.status).toBe(200); expect(res.body.item).toMatchObject({ id: seatId, total_price: 18 }); expect(await metadataOf(bookingId)).toEqual({ seat: '12A', price: '138' }); }); it('200 on a currency-only change of a linked expense, which re-names the booking price', async () => { const trainId = (await makeReservation(orm, tripId, { title: 'Train', type: 'train' })).id; const ticket = await create({ name: 'Ticket', total_price: 60, reservation_id: trainId }); expect(ticket.status).toBe(201); // In the trip currency, so the price carries no currency of its own. expect(await metadataOf(trainId)).toEqual({ price: '60' }); const chf = await put(ticket.body.item.id, { currency: 'CHF' }); expect(chf.status).toBe(200); expect(chf.body.item).toMatchObject({ currency: 'CHF', total_price: 60 }); expect(await metadataOf(trainId)).toEqual({ price: '60', priceCurrency: 'CHF' }); const back = await put(ticket.body.item.id, { currency: null }); expect(back.status).toBe(200); expect(await metadataOf(trainId)).toEqual({ price: '60' }); // An expense with no currency and one naming the trip's own (EUR, in any // case) are in the same money, so the booking shows their sum. const bike = await create({ name: 'Bike ticket', total_price: 9.5, currency: 'eur', reservation_id: trainId }); expect(bike.status).toBe(201); expect(await metadataOf(trainId)).toEqual({ price: '69.5', priceCurrency: 'EUR' }); }); it('deleting the booking removes every expense linked to it, and only those', async () => { const unlinkedId = luggageId; const res = await request(server) .delete(`/api/trips/${tripId}/reservations/${bookingId}`) .set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); expect(res.body).toEqual({ success: true }); expect(await findRow(orm, Reservations, { id: bookingId })).toBeNull(); expect(await linkOf(fareId)).toBeUndefined(); expect(await linkOf(seatId)).toBeUndefined(); expect(await linkOf(unlinkedId)).toEqual({ reservation_id: null }); const list = await request(server).get(`/api/trips/${tripId}/budget`).set('Cookie', sessionCookie(1)); const ids = (list.body.items as { id: number }[]).map(i => i.id); expect(ids).not.toContain(fareId); expect(ids).not.toContain(seatId); expect(ids).toContain(unlinkedId); }); }); // The VND/AUD report. The rates override above answers null for every base, which is // exactly that server: it never reached the rates provider, so the only rates there // are the ones the browser lends. describe('rows no rate can convert', () => { /** A fresh AUD trip the owner shares with user 2, so these cases never meet the ledger above. */ async function audTrip(): Promise { const id = (await makeTrip(orm, 1, { title: 'AUD Trip', currency: 'AUD' })).id; await addTripMember(orm, id, 2); return id; } const balanceOf = (body: { balances: { user_id: number; balance: number }[] }, uid: number) => body.balances.find(b => b.user_id === uid)!.balance; it('VND/AUD regression: unfrozen VND bill is listed as unconverted, freeze-rates heals it from the browser quote, settlement nets 489.00, second freeze heals nothing', async () => { const trip = await audTrip(); const bill = await request(server) .post(`/api/trips/${trip}/budget`) .set('Cookie', sessionCookie(1)) .send({ name: 'Pho', currency: 'VND', payers: [{ user_id: 1, amount: 8920000 }], member_ids: [1, 2] }); expect(bill.status).toBe(201); expect(bill.body.item.exchange_rate).toBe(1); // Left out whole before anything is written: no balance in VND's order of magnitude. const before = await request(server).get(`/api/trips/${trip}/budget/settlement`).set('Cookie', sessionCookie(1)); expect(before.status).toBe(200); expect(before.body.currency).toBe('AUD'); expect(before.body.unconverted).toEqual({ item_ids: [bill.body.item.id], settlement_ids: [], currencies: ['VND'] }); expect(before.body.balances).toEqual([]); const fallback_fx = { base: 'AUD', rates: { VND: 18241.3, EUR: 0.61 } }; const healed = await request(server) .post(`/api/trips/${trip}/budget/freeze-rates`) .set('Cookie', sessionCookie(1)) .send({ fallback_fx }); expect(healed.status).toBe(200); expect(healed.body.items.map((i: { id: number; exchange_rate: number }) => [i.id, i.exchange_rate])).toEqual([[bill.body.item.id, 18241.3]]); expect(healed.body).toMatchObject({ settlements: [], unresolved: [] }); const after = await request(server).get(`/api/trips/${trip}/budget/settlement`).set('Cookie', sessionCookie(1)); expect(after.body.unconverted).toEqual({ item_ids: [], settlement_ids: [], currencies: [] }); // 8,920,000 VND at 18,241.3 per dollar is 489.00 AUD, half of it user 2's. expect(after.body.finalBudgets.find((f: { user_id: number }) => f.user_id === 1).expenses).toBe(489); expect(balanceOf(after.body, 1)).toBe(244.5); expect(balanceOf(after.body, 2)).toBe(-244.5); const again = await request(server) .post(`/api/trips/${trip}/budget/freeze-rates`) .set('Cookie', sessionCookie(1)) .send({ fallback_fx: { base: 'AUD', rates: { VND: 25000 } } }); expect(again.status).toBe(200); expect(again.body).toEqual({ items: [], settlements: [], unresolved: [] }); expect((await budgetRow(bill.body.item.id)).exchange_rate).toBe(18241.3); }); it('POST with fallback_fx freezes at entry', async () => { const trip = await audTrip(); const res = await request(server) .post(`/api/trips/${trip}/budget`) .set('Cookie', sessionCookie(1)) .send({ name: 'Pho', currency: 'VND', total_price: 100000, fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } }); expect(res.status).toBe(201); expect(res.body.item.exchange_rate).toBe(18241.3); expect(res.body.item).not.toHaveProperty('fallback_fx'); }); it('POST /settlements in EUR with fallback_fx freezes the transfer', async () => { const trip = await audTrip(); const res = await request(server) .post(`/api/trips/${trip}/budget/settlements`) .set('Cookie', sessionCookie(1)) .send({ from_user_id: 2, to_user_id: 1, amount: 30.5, currency: 'EUR', fallback_fx: { base: 'AUD', rates: { EUR: 0.61 } } }); expect(res.status).toBe(201); expect(res.body.settlement).toMatchObject({ currency: 'EUR', exchange_rate: 0.61 }); // Read back in euros at the same browser quote, the transfer is what was typed. const s = await request(server).get(`/api/trips/${trip}/budget/settlement?base=EUR&base_rate=0.61`).set('Cookie', sessionCookie(1)); expect(s.body.currency).toBe('EUR'); expect(s.body.unconverted.settlement_ids).toEqual([]); expect(balanceOf(s.body, 2)).toBe(30.5); expect(balanceOf(s.body, 1)).toBe(-30.5); }); it('GET /settlement?base=EUR answers in AUD without base_rate and in EUR with it', async () => { const trip = await audTrip(); const dinner = await request(server) .post(`/api/trips/${trip}/budget`) .set('Cookie', sessionCookie(1)) .send({ name: 'Dinner', payers: [{ user_id: 1, amount: 100 }], member_ids: [1, 2] }); expect(dinner.status).toBe(201); const plain = await request(server).get(`/api/trips/${trip}/budget/settlement?base=EUR`).set('Cookie', sessionCookie(1)); expect(plain.status).toBe(200); // Trip dollars, labelled as such rather than printed as euros. expect(plain.body.currency).toBe('AUD'); expect(balanceOf(plain.body, 2)).toBe(-50); const quoted = await request(server).get(`/api/trips/${trip}/budget/settlement?base=EUR&base_rate=0.61`).set('Cookie', sessionCookie(1)); expect(quoted.status).toBe(200); expect(quoted.body.currency).toBe('EUR'); expect(balanceOf(quoted.body, 2)).toBe(-30.5); }); it('PUT currency change to VND without a rate stores 1 instead of the USD rate', async () => { const trip = await audTrip(); const created = await request(server) .post(`/api/trips/${trip}/budget`) .set('Cookie', sessionCookie(1)) .send({ name: 'Taxi', currency: 'USD', exchange_rate: 0.65, total_price: 20 }); expect(created.body.item.exchange_rate).toBe(0.65); const res = await request(server) .put(`/api/trips/${trip}/budget/${created.body.item.id}`) .set('Cookie', sessionCookie(1)) .send({ currency: 'VND' }); expect(res.status).toBe(200); expect(res.body.item).toMatchObject({ currency: 'VND', exchange_rate: 1 }); const stored = await budgetRow(created.body.item.id); expect({ currency: stored.currency, exchange_rate: stored.exchange_rate }) .toEqual({ currency: 'VND', exchange_rate: 1 }); }); it('freeze-rates 403 without budget_edit, 400 on malformed fallback_fx, other base heals nothing (unresolved VND)', async () => { const trip = await audTrip(); const bill = await request(server) .post(`/api/trips/${trip}/budget`) .set('Cookie', sessionCookie(1)) .send({ name: 'Pho', currency: 'VND', total_price: 100000 }); expect(bill.status).toBe(201); const freeze = (body: object) => request(server).post(`/api/trips/${trip}/budget/freeze-rates`).set('Cookie', sessionCookie(1)).send(body); checkPermission.mockReturnValue(false); const denied = await freeze({ fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } }); expect(denied.status).toBe(403); expect(denied.body).toEqual({ error: 'No permission' }); checkPermission.mockReturnValue(true); for (const fallback_fx of [{ base: 'aud', rates: { VND: 18241.3 } }, { base: 'AUD', rates: { VND: 0 } }, { base: 'AUD', rates: {} }]) { const malformed = await freeze({ fallback_fx }); expect(malformed.status).toBe(400); expect(malformed.body.error).toContain('fallback_fx'); } // A table against euros would freeze the bill against the wrong currency. const otherBase = await freeze({ fallback_fx: { base: 'EUR', rates: { VND: 27000 } } }); expect(otherBase.status).toBe(200); expect(otherBase.body).toEqual({ items: [], settlements: [], unresolved: ['VND'] }); expect((await budgetRow(bill.body.item.id)).exchange_rate).toBe(1); }); it('400 on base_rate=abc', async () => { const res = await request(server).get(`/api/trips/${tripId}/budget/settlement?base=EUR&base_rate=abc`).set('Cookie', sessionCookie(1)); expect(res.status).toBe(400); expect(res.body.error).toContain('base_rate'); }); }); });