Files
TREK/server/tests/unit/nest/budget.service.db.test.ts
Maurice 48d573c60d refactor(trips): leaving a trip tidies its journal link in the roster's own repository
The departure port needed a global binding that every suite building TripMembersModule without AppModule lacked. The roster's repository now deletes the leaver's row on the trip's journal link itself (leaveJournalLink), inside the transaction that removes the member: no edge into journey, no DI, no test stub, and the eight suites the stub touched are back as they were.

The schema inventory tests count the two new tables and their NOT NULL columns, the legacy step map lists the migration as post-legacy, and the TJL scenes give their users numbered names so cases sharing one database do not collide.
2026-10-18 20:02:09 +02:00

2115 lines
110 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* DB-backed unit tests for BudgetService trip-scoping (BUDGET-SVC-DB-001+).
* Uses a real in-memory SQLite DB so the SQL WHERE clauses are exercised.
* BUDGET-SVC-DB-001 through 014 moved 1:1 from the legacy
* tests/unit/services/budgetServiceDb.test.ts; 015–018 pinned the deleted
* budget.bridge's delegation and now pin the same paths on the service;
* 019–020 pin the post-fold quirk fixes (COALESCE(display_name) on
* settlements, transactional multi-statement writes).
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
const db = createSnapshotTestDb();
// Trip access and place reads go through the repositories now; the module only hands out the handle.
return { db, closeDb: () => {}, reinitialize: () => {} };
});
// Frozen snapshot of the rates in play in #1543 (rates[X] = units of X per 1 base).
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
// so every separately-constructed instance sees the same deterministic rates
// as the SUT.
vi.mock('../../../src/nest/budget/exchange-rates.service', () => ({
ExchangeRatesService: class {
async getRates(base: string) {
return RATES[base.toUpperCase()] ?? null;
}
},
}));
import { db as testDb } from '../../../src/db/database';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, addTripMember } from '../../helpers/factories';
import { BudgetService } from '../../../src/nest/budget/budget.service';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
import { UserCleanupService } from '../../../src/nest/auth/user-cleanup.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { TripMembersService } from '../../../src/nest/trip-members/trip-members.service';
import { TodoService } from '../../../src/nest/todo/todo.service';
import { PackingService } from '../../../src/nest/packing/packing.service';
import { FilesService } from '../../../src/nest/files/files.service';
import { ReservationsService } from '../../../src/nest/reservations/reservations.service';
import { DaysService } from '../../../src/nest/days/days.service';
import { CollabService } from '../../../src/nest/collab/collab.service';
import { VacayService } from '../../../src/nest/vacay/vacay.service';
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
import { notificationsStub } from '../../helpers/notifications';
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, sharedTestOrm, createTestTripsRepo, createTestTripMembersRepo } from '../../helpers/test-uow';
import { budgetRepoArgs, createTestBudgetItemMembersRepo, createTestBudgetItemPayersRepo, createTestBudgetSettlementsRepo } from '../../helpers/budget-repos';
import { createTestShareTokensRepo, createTestPluginsRepo, createTestPluginUserErasureQueueRepo } from '../../helpers/share-repos';
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
import { createTestJourneysRepo, createTestJourneyEntriesRepo, createTestJourneyContributorsRepo } from '../../helpers/journey-repos';
import { createTestJourneyShareTokensRepo } from '../../helpers/journey-share-repos';
import { BudgetItemMembers } from '../../../src/db/entities/BudgetItemMembers.entity';
import { MaintenanceRepository } from '../../../src/db/repositories/MaintenanceRepository';
import { BudgetCategoryOrder } from '../../../src/db/entities/BudgetCategoryOrder.entity';
import { BudgetItemPayers } from '../../../src/db/entities/BudgetItemPayers.entity';
import { BudgetItems } from '../../../src/db/entities/BudgetItems.entity';
import { BudgetSettlements } from '../../../src/db/entities/BudgetSettlements.entity';
import { FileLinks } from '../../../src/db/entities/FileLinks.entity';
import { Places } from '../../../src/db/entities/Places.entity';
import { Reservations } from '../../../src/db/entities/Reservations.entity';
import { TripFiles } from '../../../src/db/entities/TripFiles.entity';
import { Trips } from '../../../src/db/entities/Trips.entity';
import { Users } from '../../../src/db/entities/Users.entity';
import { countRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows';
const orm = () => sharedTestOrm(testDb);
async function setTrip(id: number, data: { currency: string | null }): Promise<void> {
await updateRows(await orm(), Trips, { id }, data);
}
/** Hangs the given expenses on the booking. */
async function linkItems(reservationId: number, itemIds: number[]): Promise<void> {
await updateRows(await orm(), BudgetItems, { id: { $in: itemIds } }, { reservation: reservationId });
}
/** The stored expense; fails the case when it is gone. */
async function budgetItemRow(id: number) {
const row = await findRow(await orm(), BudgetItems, { id });
if (!row) throw new Error(`no budget item ${id}`);
return row;
}
/** The stored expense's currency and frozen rate, the two columns a rebase writes. */
async function budgetItemFx(id: number): Promise<{ currency: string | null | undefined; exchange_rate: number }> {
const { currency, exchange_rate } = await budgetItemRow(id);
return { currency, exchange_rate };
}
async function addPlace(tripId: number, name: string): Promise<number> {
return insertRow(await orm(), Places, { trip: tripId, name });
}
/** A trip file under the same name on disk and on screen. */
async function addFile(tripId: number, name: string): Promise<number> {
return insertRow(await orm(), TripFiles, { trip: tripId, filename: name, original_name: name });
}
async function fileRow(id: number) {
const row = await findRow(await orm(), TripFiles, { id });
if (!row) throw new Error(`no trip file ${id}`);
return row;
}
/** The file's one link row; fails the case when it has none. */
async function linkOfFile(fileId: number) {
const row = await findRow(await orm(), FileLinks, { file: fileId });
if (!row) throw new Error(`no link for file ${fileId}`);
return row;
}
// Guest fixtures come from TripMembersService since the trip split (they were on
// TripsService before, and on the deleted services/tripService before that);
// deleteGuest routes through the SAME BudgetService domain SQL
// (removeUserFromBudgetItems) under test.
//
// Plan 3c Task 0b: `dbsEm` is resolved once, at the top of the `beforeAll`
// below — `canAccessTrip`/`isOwner`/`rosterUserIds`/`getPlaceWithTags`
// resolve `TripsRepository`/`TripMembersRepository`/`PlacesRepository`
// through it. Plan 4 Task 4: `DatabaseService` itself is gone —
// `UserCleanupService` now takes this `EntityManager` directly.
let dbsEm: import('@mikro-orm/core').EntityManager | undefined;
let budget: BudgetService;
let membersSvc: TripMembersService;
let createGuest: typeof membersSvc.createGuest;
let deleteGuest: typeof membersSvc.deleteGuest;
let budgetItemsRepoDirect: Awaited<ReturnType<typeof createTestBudgetItemsRepo>>;
let budgetItemMembersRepoDirect: Awaited<ReturnType<typeof createTestBudgetItemMembersRepo>>;
let budgetItemPayersRepoDirect: Awaited<ReturnType<typeof createTestBudgetItemPayersRepo>>;
let budgetSettlementsRepoDirect: Awaited<ReturnType<typeof createTestBudgetSettlementsRepo>>;
beforeAll(async () => {
dbsEm = (await sharedTestOrm(testDb)).em;
budgetItemsRepoDirect = await createTestBudgetItemsRepo(testDb);
budgetItemMembersRepoDirect = await createTestBudgetItemMembersRepo(testDb);
budgetItemPayersRepoDirect = await createTestBudgetItemPayersRepo(testDb);
budgetSettlementsRepoDirect = await createTestBudgetSettlementsRepo(testDb);
budget = new BudgetService(
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
new ExchangeRatesService(),
new RealtimeService(),
await createTestUnitOfWork(testDb),
...(await budgetRepoArgs(testDb)),
);
membersSvc = new TripMembersService(
budget,
new UserCleanupService(new MaintenanceRepository(dbsEm!), budget, await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestTripMembersRepo(testDb), await createTestBudgetItemsRepo(testDb), await createTestBudgetSettlementsRepo(testDb), await createTestJourneyShareTokensRepo(testDb), await createTestJourneysRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestJourneyContributorsRepo(testDb), await createTestShareTokensRepo(testDb), await createTestPluginsRepo(testDb), await createTestPluginUserErasureQueueRepo(testDb)),
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
new RealtimeService(),
notificationsStub(),
await createTestUnitOfWork(testDb),
await createTestTripsRepo(testDb),
await createTestTripMembersRepo(testDb),
await createTestUsersRepo(testDb),
);
createGuest = (...args) => membersSvc.createGuest(...args);
deleteGuest = (...args) => membersSvc.deleteGuest(...args);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
async function paidFlag(itemId: number, memberId: number): Promise<number | undefined> {
const row = await findRow(await orm(), BudgetItemMembers, { budgetItem: itemId, user: memberId });
return row?.paid;
}
describe('deleting a member re-splits their expenses (#1553)', () => {
async function personsOf(itemId: number): Promise<number | null | undefined> {
return (await budgetItemRow(itemId)).persons;
}
async function memberCount(itemId: number): Promise<number> {
return countRows(await orm(), BudgetItemMembers, { budgetItem: itemId });
}
it('BUDGET-SVC-DB-010: re-derives the persons divisor when a guest in the split is deleted', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const guests = await Promise.all(['G1', 'G2', 'G3'].map(async n => (await createGuest(trip.id, n, owner.id)).member));
const item = await budget.createBudgetItem(trip.id, {
name: 'Dinner', total_price: 400,
member_ids: [owner.id, ...guests.map(g => g.id)],
});
expect(await personsOf(item.id)).toBe(4);
await deleteGuest(trip.id, guests[0].id);
await deleteGuest(trip.id, guests[1].id);
// The member rows cascade with the users row; `persons` is denormalized and has to
// be re-derived, or the per-person column keeps dividing by the departed.
expect(await memberCount(item.id)).toBe(2);
expect(await personsOf(item.id)).toBe(2);
});
it('BUDGET-SVC-DB-011: leaves a manually entered persons count alone', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const guest = (await createGuest(trip.id, 'G1', owner.id)).member;
// No member rows — `persons` is just a number someone typed.
const item = await budget.createBudgetItem(trip.id, { name: 'Rental', total_price: 300, persons: 6 });
await deleteGuest(trip.id, guest.id);
expect(await personsOf(item.id)).toBe(6);
});
it('BUDGET-SVC-DB-012: drops the last member to a null divisor rather than zero', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const guest = (await createGuest(trip.id, 'G1', owner.id)).member;
const item = await budget.createBudgetItem(trip.id, { name: 'Taxi', total_price: 50, member_ids: [guest.id] });
await deleteGuest(trip.id, guest.id);
expect(await memberCount(item.id)).toBe(0);
expect(await personsOf(item.id)).toBeNull();
});
it('BUDGET-SVC-DB-013: saves a split from a stale client instead of failing on the users FK', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const guest = (await createGuest(trip.id, 'G1', owner.id)).member;
const item = await budget.createBudgetItem(trip.id, { name: 'Dinner', total_price: 200, member_ids: [owner.id, guest.id] });
await deleteGuest(trip.id, guest.id);
// A client that loaded before the deletion still sends the guest back (#1553).
const updated = await budget.updateBudgetItem(item.id, trip.id, { member_ids: [owner.id, guest.id] });
expect(updated!.members.map(m => m.user_id)).toEqual([owner.id]);
expect(await personsOf(item.id)).toBe(1);
});
it('BUDGET-SVC-DB-014: ignores a deleted member arriving through updateMembers', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const guest = (await createGuest(trip.id, 'G1', owner.id)).member;
const item = await budget.createBudgetItem(trip.id, { name: 'Drinks', total_price: 60, member_ids: [owner.id, guest.id] });
await deleteGuest(trip.id, guest.id);
const result = await budget.updateMembers(item.id, trip.id, [owner.id, guest.id]);
expect(result!.members.map(m => m.user_id)).toEqual([owner.id]);
expect(await personsOf(item.id)).toBe(1);
});
});
describe('toggleMemberPaid trip-scoping', () => {
it('BUDGET-SVC-DB-001: toggles paid for an item that belongs to the given trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip A' });
const item = await budget.createBudgetItem(trip.id, { name: 'Hotel', total_price: 100 });
await budget.updateMembers(item.id, trip.id, [user.id]);
const member = await budget.toggleMemberPaid(item.id, trip.id, user.id, true);
expect(member).not.toBeNull();
expect(await paidFlag(item.id, user.id)).toBe(1);
});
it('BUDGET-SVC-DB-002: refuses to toggle an item from a different trip (cross-trip IDOR)', async () => {
const { user } = createUser(testDb);
const tripA = createTrip(testDb, user.id, { title: 'Trip A' });
const tripB = createTrip(testDb, user.id, { title: 'Trip B' });
const itemB = await budget.createBudgetItem(tripB.id, { name: 'Foreign expense', total_price: 50 });
await budget.updateMembers(itemB.id, tripB.id, [user.id]);
// Caller passes a trip they can access (A) but the item lives in trip B.
const member = await budget.toggleMemberPaid(itemB.id, tripA.id, user.id, true);
expect(member).toBeNull();
expect(await paidFlag(itemB.id, user.id)).toBe(0); // unchanged
});
it('M2-BUDGET-001: getBudgetItem/deleteBudgetItem refuse an item from a different trip (BudgetItems.findInTrip / findForDelete trip-scoping)', async () => {
const { user } = createUser(testDb);
const tripA = createTrip(testDb, user.id, { title: 'Trip A' });
const tripB = createTrip(testDb, user.id, { title: 'Trip B' });
const itemB = await budget.createBudgetItem(tripB.id, { name: 'Foreign expense', total_price: 50 }) as { id: number };
// Caller passes a trip they can access (A) but the item lives in trip B —
// both methods must answer the legacy "not found" (null/false), not act
// on a row outside the trip the route claims to scope to.
expect(await budget.getBudgetItem(itemB.id, tripA.id)).toBeNull();
expect(await budget.deleteBudgetItem(itemB.id, tripA.id)).toBe(false);
// The item is untouched — proof the delete branch really refused, not
// just returned a stale read.
expect(await budget.getBudgetItem(itemB.id, tripB.id)).not.toBeNull();
});
it('BUDGET-SVC-DB-044: a forced mid-transaction failure after the write leaves no partial update (R2-class fix, §17 surprise 4)', async () => {
// Plan 3e Task 2: `toggleMemberPaid` used to run its trip-scoping guard
// (BG68) and its write (BG69) as two un-transacted statements. Wrapped
// in `uow.transactional` now — a failure anywhere in the block (here,
// the re-select AFTER the write) rolls the whole thing back, including
// the `setPaid` write that already ran.
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip A' });
const item = await budget.createBudgetItem(trip.id, { name: 'Hotel', total_price: 100 });
await budget.updateMembers(item.id, trip.id, [user.id]);
expect(await paidFlag(item.id, user.id)).toBe(0);
const membersRepo = (await sharedTestOrm(testDb)).repo(BudgetItemMembers);
const findSpy = vi.spyOn(membersRepo, 'findMemberWithUser').mockRejectedValueOnce(new Error('boom'));
try {
await expect(budget.toggleMemberPaid(item.id, trip.id, user.id, true)).rejects.toThrow('boom');
// The UPDATE ran before the forced failure — rolled back with it, so the flag is still 0.
expect(await paidFlag(item.id, user.id)).toBe(0);
} finally {
findSpy.mockRestore();
}
// The fix works: without the forced failure, the same write commits normally.
const member = await budget.toggleMemberPaid(item.id, trip.id, user.id, true);
expect(member).not.toBeNull();
expect(await paidFlag(item.id, user.id)).toBe(1);
});
});
describe('calculateSettlement custom splits', () => {
it('BUDGET-SVC-DB-003: settles by the custom per-member amounts, not the equal split (#1458)', async () => {
const { user: alice } = createUser(testDb, { username: 'alice' });
const { user: bob } = createUser(testDb, { username: 'bob' });
const trip = createTrip(testDb, alice.id, { title: 'Trip' });
addTripMember(testDb, trip.id, bob.id);
// 100 total, custom split: Alice owes 90, Bob owes 10. Alice paid the whole bill.
await budget.createBudgetItem(trip.id, {
name: 'Dinner',
payers: [{ user_id: alice.id, amount: 100 }],
members: [
{ user_id: alice.id, amount: 90 },
{ user_id: bob.id, amount: 10 },
],
});
const result = await budget.calculateSettlement(trip.id);
// Alice paid 100 but owes 90 → net +10 (creditor); Bob owes 10 → net -10 (debtor).
// With the equal-split bug both owe 50, so the flow would be 50 instead of 10.
expect(result.flows).toEqual([
expect.objectContaining({
from: expect.objectContaining({ user_id: bob.id }),
to: expect.objectContaining({ user_id: alice.id }),
amount: 10,
}),
]);
});
});
describe('calculateSettlement squares up to the cent (#1382)', () => {
it('BUDGET-SVC-DB-026: recording the offered flows clears the trip to exactly zero', async () => {
const { user: alice } = createUser(testDb, { username: 'alice' });
const { user: bob } = createUser(testDb, { username: 'bob' });
const { user: carol } = createUser(testDb, { username: 'carol' });
const trip = createTrip(testDb, alice.id, { title: 'Trip' });
addTripMember(testDb, trip.id, bob.id);
addTripMember(testDb, trip.id, carol.id);
const members = [{ user_id: alice.id }, { user_id: bob.id }, { user_id: carol.id }];
// Totals that never divide by three, so every expense leaves a remainder cent
// somewhere: 10.00, 100.01 and 0.01 (the smallest debt there is).
await budget.createBudgetItem(trip.id, { name: 'Coffee', payers: [{ user_id: alice.id, amount: 10 }], members });
await budget.createBudgetItem(trip.id, { name: 'Dinner', payers: [{ user_id: bob.id, amount: 100.01 }], members });
await budget.createBudgetItem(trip.id, { name: 'Stamp', payers: [{ user_id: carol.id, amount: 0.01 }], members });
const before = await budget.calculateSettlement(trip.id);
expect(before.flows.length).toBeGreaterThan(0);
for (const f of before.flows) {
await budget.insertSettlement(trip.id, { from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount }, f.from.user_id);
}
// Nothing left over, and nothing left to offer — the settle-up list and the
// balances agree instead of the balances holding a cent nobody can pay.
const after = await budget.calculateSettlement(trip.id);
expect(after.balances.map(b => b.balance)).toEqual([0, 0, 0]);
expect(after.flows).toEqual([]);
});
it('BUDGET-SVC-DB-027: the balances hold still while the live rate moves under them', async () => {
const { trip, me, danil, serega } = await seedIssue1543Trip('RUB');
// Nothing about the trip changes between the two reads; only the rate cache
// turned over. That was enough to shuffle cents between people (#1382).
const first = await budget.calculateSettlement(trip.id, { base: 'RUB', tripCurrency: 'RUB', rates: RATES.RUB });
const second = await budget.calculateSettlement(trip.id, {
base: 'RUB', tripCurrency: 'RUB',
rates: { RUB: 1, USD: 0.013042 * 1.03, EUR: 0.011412 * 0.97 },
});
for (const uid of [me.id, danil.id, serega.id]) {
expect(second.balances.find(b => b.user_id === uid)!.balance)
.toBe(first.balances.find(b => b.user_id === uid)!.balance);
}
});
});
/** The exact trip from #1543: RUB base, three members, one expense booked in USD. */
async function seedIssue1543Trip(tripCurrency: string) {
const { user: me } = createUser(testDb, { username: 'me' });
const { user: danil } = createUser(testDb, { username: 'danil' });
const { user: serega } = createUser(testDb, { username: 'serega' });
const trip = createTrip(testDb, me.id, { title: 'Trip' });
addTripMember(testDb, trip.id, danil.id);
addTripMember(testDb, trip.id, serega.id);
await setTrip(trip.id, { currency: tripCurrency });
const members = [{ user_id: me.id }, { user_id: danil.id }, { user_id: serega.id }];
// 9 000 ₽ nobody has paid yet (outstanding, settled by nobody, #2225), 9 000 ₽ paid
// by me, and $100 paid by me. The USD row carries the rate frozen at entry time:
// units of USD per 1 RUB.
await budget.createBudgetItem(trip.id, { name: 'Проезд обратно', total_price: 9000, currency: 'RUB', members });
await budget.createBudgetItem(trip.id, { name: 'Проезд туда', currency: 'RUB', payers: [{ user_id: me.id, amount: 9000 }], members });
await budget.createBudgetItem(trip.id, {
name: 'test', currency: 'USD', exchange_rate: 0.013042,
payers: [{ user_id: me.id, amount: 100 }], members,
});
return { trip, me, danil, serega };
}
describe('calculateSettlement with a foreign-currency expense (#1543)', () => {
it('BUDGET-SVC-DB-004: nets in the trip currency instead of inflating the foreign share ~27x', async () => {
const { trip, me, danil, serega } = await seedIssue1543Trip('RUB');
const result = await budget.calculateSettlement(trip.id, { base: 'RUB', tripCurrency: 'RUB', rates: RATES.RUB });
const balanceOf = (id: number) => result.balances.find(b => b.user_id === id)!.balance;
// What actually settles is 9 000 ₽ + $100 (≈7 668 ₽): the third expense is the
// 9 000 ₽ nobody has paid, which is outstanding rather than owed (#2225). Each of
// the three owes a third of that, and I am owed back everything I fronted beyond my
// own share. The bug divided the RUB shares by the USD rate and reported +451 092 /
// −230 080 / −230 012 instead. Tolerance is a rouble: the cent-rotation in
// splitEqualShares moves the odd cent of the $100 between members, which the USD
// rate magnifies ~77x.
const settledSpend = 9000 + 100 / RATES.RUB.USD;
const share = settledSpend / 3;
expect(balanceOf(me.id)).toBeCloseTo(settledSpend - share, -1);
expect(balanceOf(danil.id)).toBeCloseTo(-share, -1);
expect(balanceOf(serega.id)).toBeCloseTo(-share, -1);
// Nothing is left over: every rouble owed is a rouble somebody is owed.
expect(result.balances.reduce((a, b) => a + Math.round(b.balance * 100), 0)).toBe(0);
});
it('BUDGET-SVC-DB-005: reports the same balances when the display currency differs from the trip currency', async () => {
const { trip, danil } = await seedIssue1543Trip('RUB');
// Same trip, viewed in EUR: every balance is the RUB one converted once, at the end.
const inEur = await budget.calculateSettlement(trip.id, { base: 'EUR', tripCurrency: 'RUB', rates: RATES.EUR });
const danilEur = inEur.balances.find(b => b.user_id === danil.id)!.balance;
const shareRub = (9000 + 100 / RATES.RUB.USD) / 3;
expect(danilEur).toBeCloseTo(-shareRub / RATES.EUR.RUB, 0);
});
});
describe('rebaseTripCurrency', () => {
const itemRow = async (id: number) => {
const { currency, exchange_rate } = await budgetItemRow(id);
return { currency, exchange_rate };
};
it('BUDGET-SVC-DB-006: pins currency-less expenses to the outgoing currency and re-freezes the rest', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
await setTrip(trip.id, { currency: 'EUR' });
const members = [{ user_id: user.id }];
// An expense that inherits the trip's base (currency NULL), one booked in USD, and
// one already in the incoming currency.
const implicit = await budget.createBudgetItem(trip.id, { name: 'Implicit', total_price: 100, members }) as { id: number };
const usd = await budget.createBudgetItem(trip.id, { name: 'USD', total_price: 100, currency: 'USD', exchange_rate: 1.1429, members }) as { id: number };
const rub = await budget.createBudgetItem(trip.id, { name: 'RUB', total_price: 9000, currency: 'RUB', exchange_rate: 87.63, members }) as { id: number };
await budget.rebaseTripCurrency(trip.id, 'RUB');
// The implicit row really held euros, so it is stamped EUR rather than silently
// becoming 100 ₽, and every rate is re-anchored to the new base.
expect(await itemRow(implicit.id)).toEqual({ currency: 'EUR', exchange_rate: RATES.RUB.EUR });
expect(await itemRow(usd.id)).toEqual({ currency: 'USD', exchange_rate: RATES.RUB.USD });
// Already in the trip's new currency → no conversion left to freeze.
expect(await itemRow(rub.id)).toEqual({ currency: 'RUB', exchange_rate: 1 });
});
it('BUDGET-SVC-DB-007: keeps every balance at the same real-world value across the switch', async () => {
const { user: alice } = createUser(testDb, { username: 'alice' });
const { user: bob } = createUser(testDb, { username: 'bob' });
const trip = createTrip(testDb, alice.id, { title: 'Trip' });
addTripMember(testDb, trip.id, bob.id);
await setTrip(trip.id, { currency: 'EUR' });
const members = [{ user_id: alice.id }, { user_id: bob.id }];
await budget.createBudgetItem(trip.id, { name: 'Hotel', payers: [{ user_id: alice.id, amount: 100 }], members });
await budget.createBudgetItem(trip.id, { name: 'Dinner', currency: 'USD', exchange_rate: 1.1429, payers: [{ user_id: bob.id, amount: 60 }], members });
const before = await budget.calculateSettlement(trip.id, { base: 'EUR', tripCurrency: 'EUR', rates: RATES.EUR });
await budget.rebaseTripCurrency(trip.id, 'RUB');
await setTrip(trip.id, { currency: 'RUB' });
const after = await budget.calculateSettlement(trip.id, { base: 'RUB', tripCurrency: 'RUB', rates: RATES.RUB });
for (const b of before.balances) {
const rub = after.balances.find(x => x.user_id === b.user_id)!.balance;
expect(rub).toBeCloseTo(b.balance * 87.63, 0); // same money, different unit
}
});
it('BUDGET-SVC-DB-009: pins currency-less place prices to the outgoing currency', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
await setTrip(trip.id, { currency: 'EUR' });
const priced = async (price: number | null, currency: string | null) =>
insertRow(await orm(), Places, { trip: trip.id, name: 'Place', price, currency });
// A place that inherits the trip's base (currency NULL), one priced in its own
// currency, and one with no price at all.
const implicit = await priced(15, null);
const jpy = await priced(1500, 'JPY');
const free = await priced(null, null);
await budget.rebaseTripCurrency(trip.id, 'JPY');
const placeRow = async (id: number) => {
const place = await findRow(await orm(), Places, { id });
if (!place) throw new Error(`no place ${id}`);
return { price: place.price, currency: place.currency };
};
// The implicit place really held euros, so it is stamped EUR rather than silently
// becoming ¥15 — the amount the user typed is never rewritten.
expect(await placeRow(implicit)).toEqual({ price: 15, currency: 'EUR' });
expect(await placeRow(jpy)).toEqual({ price: 1500, currency: 'JPY' });
// Nothing to denominate without a price: leave it inheriting the trip's currency.
expect(await placeRow(free)).toEqual({ price: null, currency: null });
});
it('BUDGET-SVC-DB-010: re-freezes a budget_settlements row against the new base (BudgetSettlements.listDistinctCurrencies / setExchangeRateForCurrency)', async () => {
const { user: alice } = createUser(testDb, { username: 'alice' });
const { user: bob } = createUser(testDb, { username: 'bob' });
const trip = createTrip(testDb, alice.id, { title: 'Trip' });
addTripMember(testDb, trip.id, bob.id);
await setTrip(trip.id, { currency: 'EUR' });
const settlement = await budget.createSettlement(trip.id, { from_user_id: bob.id, to_user_id: alice.id, amount: 50, currency: 'USD' }, alice.id) as { id: number };
const settlementRow = async () => {
const row = await findRow(await orm(), BudgetSettlements, { id: settlement.id });
if (!row) throw new Error(`no settlement ${settlement.id}`);
return { currency: row.currency, exchange_rate: row.exchange_rate };
};
expect((await settlementRow()).currency).toBe('USD');
await budget.rebaseTripCurrency(trip.id, 'RUB');
// USD isn't the incoming base, so the settlement's frozen rate is re-anchored
// against RUB rather than left pointing at the trip's old EUR base.
expect(await settlementRow()).toEqual({ currency: 'USD', exchange_rate: RATES.RUB.USD });
});
it('BUDGET-SVC-DB-008: is a no-op when the currency is unchanged', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
await setTrip(trip.id, { currency: 'EUR' });
const item = await budget.createBudgetItem(trip.id, { name: 'Implicit', total_price: 100, members: [{ user_id: user.id }] }) as { id: number };
await budget.rebaseTripCurrency(trip.id, 'EUR');
expect(await itemRow(item.id)).toEqual({ currency: null, exchange_rate: 1 });
});
});
describe('composite service paths (ex budget.bridge delegation)', () => {
// budget.bridge is deleted — UserCleanupService injects BudgetService now
// that BudgetModule no longer imports AuthModule. 015-018 kept their IDs and
// pin the same behavior directly on the service.
it('BUDGET-SVC-DB-015: listBudgetItems returns the hydrated list', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = await budget.createBudgetItem(trip.id, { name: 'Hotel', total_price: 100, member_ids: [user.id] });
const items = await budget.listBudgetItems(trip.id);
expect(items.map(i => i.id)).toEqual([item.id]);
expect(items[0].members.map(m => m.user_id)).toEqual([user.id]);
});
it('BUDGET-SVC-DB-016: removeUserFromBudgetItems re-derives persons', async () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb, { username: 'other' });
const trip = createTrip(testDb, owner.id);
const item = await budget.createBudgetItem(trip.id, { name: 'Dinner', total_price: 80, member_ids: [owner.id, other.id] });
await budget.removeUserFromBudgetItems(other.id);
const row = await budgetItemRow(item.id);
expect(row.persons).toBe(1);
});
it('BUDGET-SVC-DB-017: rebaseTripCurrency pins the implicit currency', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await setTrip(trip.id, { currency: 'EUR' });
const item = await budget.createBudgetItem(trip.id, { name: 'Implicit', total_price: 100, members: [{ user_id: user.id }] });
await budget.rebaseTripCurrency(trip.id, 'RUB');
expect(await budgetItemFx(item.id)).toEqual({ currency: 'EUR', exchange_rate: RATES.RUB.EUR });
});
it('BUDGET-SVC-DB-086: prepareCurrencyRebase writes nothing and applyCurrencyRebase reads the outgoing currency inside its write', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await setTrip(trip.id, { currency: 'EUR' });
const item = await budget.createBudgetItem(trip.id, { name: 'Implicit', total_price: 100, members: [{ user_id: user.id }] });
const row = () => budgetItemFx(item.id);
const before = await row();
expect(await budget.prepareCurrencyRebase(trip.id, 'eur')).toBeNull();
expect(await budget.prepareCurrencyRebase(trip.id, '')).toBeNull();
expect(await budget.prepareCurrencyRebase(999999, 'RUB')).toBeNull();
const plan = await budget.prepareCurrencyRebase(trip.id, 'rub');
expect(plan).toEqual({ next: 'RUB', rates: RATES.RUB });
expect(await row()).toEqual(before);
// The trip took the new currency in the meantime: nothing is left to rebase.
await setTrip(trip.id, { currency: 'RUB' });
await budget.applyCurrencyRebase(trip.id, plan!);
expect(await row()).toEqual(before);
await setTrip(trip.id, { currency: 'EUR' });
await budget.applyCurrencyRebase(trip.id, plan!);
expect(await row()).toEqual({ currency: 'EUR', exchange_rate: RATES.RUB.EUR });
});
it('BUDGET-SVC-DB-018: linkBudgetItemToReservation stamps the reservation id', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const reservationId = await insertRow(await orm(), Reservations, { trip: trip.id, title: 'Flight', type: 'flight' });
const item = await budget.linkBudgetItemToReservation(trip.id, reservationId, { name: 'Flight', total_price: 200 });
expect(item.reservation_id).toBe(reservationId);
const row = await budgetItemRow(item.id);
expect(row.reservation_id).toBe(reservationId);
});
it('BUDGET-SVC-DB-018b: an expense can be created against a place (#1298)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const placeId = await addPlace(trip.id, 'Louvre');
const item = await budget.createBudgetItem(trip.id, { name: 'Louvre tickets', total_price: 34, place_id: placeId });
expect(item.place_id).toBe(placeId);
expect(item.reservation_id).toBeNull();
const row = await budgetItemRow(item.id);
expect(row.place_id).toBe(placeId);
});
it('BUDGET-SVC-DB-018c: an expense without a link stores neither id', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = await budget.createBudgetItem(trip.id, { name: 'Coffee', total_price: 3 });
expect(item.place_id).toBeNull();
expect(item.reservation_id).toBeNull();
});
});
// A settlement names two parties and both columns are NOT NULL, so an id that is
// not on the trip cannot simply be dropped the way a split member can — the whole
// write is refused. Refusing also means the endpoint stops answering "does this
// user id exist", which it used to do by 500ing on the foreign key.
describe('settlement parties are confined to the trip', () => {
it('BUDGET-SVC-DB-028: refuses a payer who is not on the trip', async () => {
const { user: alice } = createUser(testDb, { username: 'alice' });
const { user: outsider } = createUser(testDb, { username: 'outsider' });
const trip = createTrip(testDb, alice.id);
const created = await budget.createSettlement(trip.id, { from_user_id: outsider.id, to_user_id: alice.id, amount: 10 }, alice.id);
expect(created).toBeNull();
expect(await budget.listSettlements(trip.id)).toEqual([]);
});
it('BUDGET-SVC-DB-029: answers a nonexistent user the same way, without throwing', async () => {
const { user: alice } = createUser(testDb, { username: 'alice' });
const trip = createTrip(testDb, alice.id);
await expect(budget.createSettlement(trip.id, { from_user_id: 999999, to_user_id: alice.id, amount: 10 }, alice.id))
.resolves.toBeNull();
});
it('BUDGET-SVC-DB-030: still records a settlement between the owner and a member', async () => {
const { user: alice } = createUser(testDb, { username: 'alice' });
const { user: bob } = createUser(testDb, { username: 'bob' });
const trip = createTrip(testDb, alice.id);
addTripMember(testDb, trip.id, bob.id);
const created = await budget.createSettlement(trip.id, { from_user_id: bob.id, to_user_id: alice.id, amount: 10 }, alice.id);
expect(created).toMatchObject({ from_user_id: bob.id, to_user_id: alice.id });
});
it('BUDGET-SVC-DB-030b: keeps a trimmed note on a payment and clears a blank one (#2340)', async () => {
const { user: alice } = createUser(testDb, { username: 'alice' });
const { user: bob } = createUser(testDb, { username: 'bob' });
const trip = createTrip(testDb, alice.id);
addTripMember(testDb, trip.id, bob.id);
const created = await budget.createSettlement(trip.id, { from_user_id: bob.id, to_user_id: alice.id, amount: 10, note: ' Bank transfer ' }, alice.id);
expect(created).toMatchObject({ note: 'Bank transfer' });
const kept = await budget.updateSettlement(created!.id, trip.id, { from_user_id: bob.id, to_user_id: alice.id, amount: 11 });
expect(kept).toMatchObject({ amount: 11, note: 'Bank transfer' });
const cleared = await budget.updateSettlement(created!.id, trip.id, { from_user_id: bob.id, to_user_id: alice.id, amount: 11, note: '' });
expect(cleared).toMatchObject({ note: null });
});
it('BUDGET-SVC-DB-031: drops an off-trip payer from an item instead of refusing it', async () => {
const { user: alice } = createUser(testDb, { username: 'alice' });
const { user: outsider } = createUser(testDb, { username: 'outsider' });
const trip = createTrip(testDb, alice.id);
const item = await budget.createBudgetItem(trip.id, {
name: 'Dinner',
payers: [{ user_id: alice.id, amount: 40 }, { user_id: outsider.id, amount: 60 }],
}) as { payers: { user_id: number }[]; total_price: number };
expect(item.payers.map(p => p.user_id)).toEqual([alice.id]);
// total_price is the sum of the payers that actually landed.
expect(item.total_price).toBe(40);
});
});
// ── Refunds persist as negative expenses (#2176) ─────────────────────────────
// The write path used to filter payers on amount > 0 and re-derive total_price
// from the survivors, silently turning a negative entry into a 0 € one with no
// payer row.
describe('negative amounts persist end-to-end (#2176)', () => {
it('BUDGET-SVC-DB-032: stores a negative payer row and the negative total', async () => {
const { user: alice } = createUser(testDb, { username: 'alice' });
const trip = createTrip(testDb, alice.id);
const item = await budget.createBudgetItem(trip.id, {
name: 'Hotel partial refund',
total_price: -100,
payers: [{ user_id: alice.id, amount: -100 }],
}) as { id: number; payers: { user_id: number; amount: number }[]; total_price: number };
expect(item.total_price).toBe(-100);
expect(item.payers).toEqual([expect.objectContaining({ user_id: alice.id, amount: -100 })]);
const row = await budgetItemRow(item.id);
expect(row.total_price).toBe(-100);
});
it('BUDGET-SVC-DB-033: an update keeps a negative payer instead of nulling the entry', async () => {
const { user: alice } = createUser(testDb, { username: 'alice' });
const { user: bob } = createUser(testDb, { username: 'bob' });
const trip = createTrip(testDb, alice.id);
addTripMember(testDb, trip.id, bob.id);
const item = await budget.createBudgetItem(trip.id, {
name: 'Refund', total_price: -60, payers: [{ user_id: alice.id, amount: -60 }],
}) as { id: number };
const updated = await budget.updateBudgetItem(item.id, trip.id, {
payers: [{ user_id: alice.id, amount: -40 }, { user_id: bob.id, amount: -20 }],
}) as { payers: { user_id: number; amount: number }[]; total_price: number };
expect(updated.total_price).toBe(-60);
expect(updated.payers).toHaveLength(2);
expect(updated.payers.map(p => p.amount).sort((a, b) => a - b)).toEqual([-40, -20]);
});
it('BUDGET-SVC-DB-034: mixed-sign payers derive the netted total', async () => {
// Alice fronted 100 but bob pocketed a 30 refund on the same receipt.
const { user: alice } = createUser(testDb, { username: 'alice' });
const { user: bob } = createUser(testDb, { username: 'bob' });
const trip = createTrip(testDb, alice.id);
addTripMember(testDb, trip.id, bob.id);
const item = await budget.createBudgetItem(trip.id, {
name: 'Tickets', payers: [{ user_id: alice.id, amount: 100 }, { user_id: bob.id, amount: -30 }],
}) as { payers: { user_id: number }[]; total_price: number };
expect(item.payers).toHaveLength(2);
expect(item.total_price).toBe(70);
});
it('BUDGET-SVC-DB-035: a zero-amount payer is still dropped', async () => {
const { user: alice } = createUser(testDb, { username: 'alice' });
const { user: bob } = createUser(testDb, { username: 'bob' });
const trip = createTrip(testDb, alice.id);
addTripMember(testDb, trip.id, bob.id);
const item = await budget.createBudgetItem(trip.id, {
name: 'Dinner', payers: [{ user_id: alice.id, amount: 90 }, { user_id: bob.id, amount: 0 }],
}) as { payers: { user_id: number }[]; total_price: number };
expect(item.payers.map(p => p.user_id)).toEqual([alice.id]);
expect(item.total_price).toBe(90);
});
it('BUDGET-SVC-DB-036: a negative expense settles against real rows — Σ balances = 0', async () => {
const { user: alice } = createUser(testDb, { username: 'alice' });
const { user: bob } = createUser(testDb, { username: 'bob' });
const trip = createTrip(testDb, alice.id);
addTripMember(testDb, trip.id, bob.id);
await budget.createBudgetItem(trip.id, {
name: 'Dinner', payers: [{ user_id: alice.id, amount: 90 }],
member_ids: [alice.id, bob.id],
});
await budget.createBudgetItem(trip.id, {
name: 'Refund', payers: [{ user_id: alice.id, amount: -30 }],
member_ids: [alice.id, bob.id],
});
const result = await budget.calculateSettlement(trip.id);
const balance = (uid: number) => result.balances.find(b => b.user_id === uid)!.balance;
// 90 out, 30 back, both split evenly: bob nets 45 - 15 = 30 owed to alice.
expect(balance(alice.id)).toBe(30);
expect(balance(bob.id)).toBe(-30);
expect(result.balances.reduce((a, b) => a + Math.round(b.balance * 100), 0)).toBe(0);
expect(result.flows).toEqual([
expect.objectContaining({ amount: 30, from: expect.objectContaining({ user_id: bob.id }), to: expect.objectContaining({ user_id: alice.id }) }),
]);
});
});
describe('deleting an expense takes its price off the booking (#2233)', () => {
it('BUDGET-SVC-DB-040: the mirrored price and currency are cleared, the rest of the metadata stays', async () => {
// The reservation update path keeps metadata.price across booking edits, so
// the expense side has to remove it when the expense itself goes away.
// Without this the card would show a price with nothing behind it, for good.
const { user } = createUser(testDb, { username: 'owner' });
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const reservationId = await insertRow(await orm(), Reservations, {
trip: trip.id, title: 'Flight', type: 'flight', metadata: JSON.stringify({ airline: 'CZ', seat: '12A', price: '2040', priceCurrency: 'CNY' }),
});
const item = await budget.createBudgetItem(trip.id, { name: 'Flight', total_price: 2040 });
await linkItems(reservationId, [item.id]);
expect(await budget.deleteBudgetItem(item.id, trip.id)).toBe(true);
const after = await findRow(await orm(), Reservations, { id: reservationId });
expect(JSON.parse(after!.metadata!)).toEqual({ airline: 'CZ', seat: '12A' });
});
it('BUDGET-SVC-DB-041: an expense that was never linked to a booking deletes as before', async () => {
const { user } = createUser(testDb, { username: 'owner' });
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const item = await budget.createBudgetItem(trip.id, { name: 'Coffee', total_price: 4 });
expect(await budget.deleteBudgetItem(item.id, trip.id)).toBe(true);
expect(await findRow(await orm(), BudgetItems, { id: item.id })).toBeNull();
});
});
describe('linking existing expenses to bookings and places (#2084)', () => {
async function tripWithBooking(metadata: Record<string, unknown> | null = null) {
const { user } = createUser(testDb, { username: 'owner' });
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const reservationId = await insertReservation(trip.id, 'Flight', metadata);
return { user, trip, reservationId };
}
async function insertReservation(tripId: number, title: string, metadata: Record<string, unknown> | null = null): Promise<number> {
return insertRow(await orm(), Reservations, { trip: tripId, title, type: 'flight', metadata: metadata ? JSON.stringify(metadata) : null });
}
async function insertPlace(tripId: number, name = 'Louvre'): Promise<number> {
return addPlace(tripId, name);
}
async function metadataOf(reservationId: number): Promise<Record<string, unknown> | null> {
const row = await findRow(await orm(), Reservations, { id: reservationId });
if (!row) throw new Error(`no reservation ${reservationId}`);
return row.metadata ? JSON.parse(row.metadata) : null;
}
async function linksOf(itemId: number) {
const { reservation_id, place_id } = await budgetItemRow(itemId);
return { reservation_id, place_id };
}
it('BUDGET-SVC-DB-070: an update links an existing expense, leaves the link alone when omitted, and null lets go of it', async () => {
const { trip, reservationId } = await tripWithBooking();
const placeId = await insertPlace(trip.id);
const item = await budget.createBudgetItem(trip.id, { name: 'Tickets', total_price: 34 });
const linked = await budget.updateBudgetItem(item.id, trip.id, { reservation_id: reservationId, place_id: placeId });
expect(linked).toMatchObject({ reservation_id: reservationId, place_id: placeId });
expect(await linksOf(item.id)).toEqual({ reservation_id: reservationId, place_id: placeId });
// An edit that does not name the links keeps both.
await budget.updateBudgetItem(item.id, trip.id, { name: 'Museum tickets' });
expect(await linksOf(item.id)).toEqual({ reservation_id: reservationId, place_id: placeId });
// null unlinks, one field at a time, and the expense itself stays.
await budget.updateBudgetItem(item.id, trip.id, { reservation_id: null });
expect(await linksOf(item.id)).toEqual({ reservation_id: null, place_id: placeId });
await budget.updateBudgetItem(item.id, trip.id, { place_id: null });
expect(await linksOf(item.id)).toEqual({ reservation_id: null, place_id: null });
const { name, total_price } = await budgetItemRow(item.id);
expect({ name, total_price }).toEqual({ name: 'Museum tickets', total_price: 34 });
});
it('BUDGET-SVC-DB-071: linkRefusal passes this trip\'s ids and names the foreign or missing one', async () => {
const { user, trip, reservationId } = await tripWithBooking();
const placeId = await insertPlace(trip.id);
const elsewhere = createTrip(testDb, user.id, { title: 'Elsewhere' });
const foreignReservation = await insertReservation(elsewhere.id, 'Other flight');
const foreignPlace = await insertPlace(elsewhere.id, 'Elsewhere');
expect(await budget.linkRefusal(trip.id, {})).toBeNull();
expect(await budget.linkRefusal(trip.id, { reservation_id: null, place_id: null })).toBeNull();
expect(await budget.linkRefusal(trip.id, { reservation_id: reservationId, place_id: placeId })).toBeNull();
// The route param arrives as a string; the lookup still matches.
expect(await budget.linkRefusal(String(trip.id), { reservation_id: reservationId })).toBeNull();
expect(await budget.linkRefusal(trip.id, { reservation_id: foreignReservation })).toBe('reservation_id does not belong to this trip.');
expect(await budget.linkRefusal(trip.id, { place_id: foreignPlace })).toBe('place_id does not belong to this trip.');
// An id that exists nowhere is refused the same way, never a foreign-key 500.
expect(await budget.linkRefusal(trip.id, { reservation_id: 999999 })).toBe('reservation_id does not belong to this trip.');
expect(await budget.linkRefusal(trip.id, { place_id: 999999 })).toBe('place_id does not belong to this trip.');
// Both wrong: the booking is named first.
expect(await budget.linkRefusal(trip.id, { reservation_id: foreignReservation, place_id: foreignPlace }))
.toBe('reservation_id does not belong to this trip.');
});
it('BUDGET-SVC-DB-072: the booking mirrors the sum of its expenses while they share one currency', async () => {
const { trip, reservationId } = await tripWithBooking({ seat: '12A' });
const first = await budget.createBudgetItem(trip.id, { name: 'Fare', total_price: 10.1, currency: 'usd' });
const second = await budget.createBudgetItem(trip.id, { name: 'Luggage', total_price: 20.2, currency: 'usd' });
// An expense on another booking of the same trip is not part of the sum.
const other = await insertReservation(trip.id, 'Return');
const unrelated = await budget.createBudgetItem(trip.id, { name: 'Return fare', total_price: 99, currency: 'usd' });
await linkItems(reservationId, [first.id, second.id]);
await linkItems(other, [unrelated.id]);
await budget.resyncReservationPrice(trip.id, reservationId);
// Cent-clean (10.1 + 20.2 is 30.299999... in floats), upper-cased currency,
// and the rest of the metadata untouched.
expect(await metadataOf(reservationId)).toEqual({ seat: '12A', price: '30.3', priceCurrency: 'USD' });
});
it('BUDGET-SVC-DB-072b: a booking whose metadata is not JSON gets the mirrored price instead of a logged failure', async () => {
const { trip, reservationId } = await tripWithBooking();
await updateRows(await orm(), Reservations, { id: reservationId }, { metadata: '{not json' });
const item = await budget.createBudgetItem(trip.id, { name: 'Fare', total_price: 42, currency: 'usd' });
await linkItems(reservationId, [item.id]);
await budget.resyncReservationPrice(trip.id, reservationId);
expect(await metadataOf(reservationId)).toEqual({ price: '42', priceCurrency: 'USD' });
});
it('BUDGET-SVC-DB-052: an expense without a currency and one in the trip currency by code add up', async () => {
// No currency means the trip's; the two rows are in the same money and the
// booking shows their sum, not the first one alone.
const { trip, reservationId } = await tripWithBooking();
await setTrip(trip.id, { currency: 'EUR' });
const implicit = await budget.createBudgetItem(trip.id, { name: 'Fare', total_price: 40 });
const explicit = await budget.createBudgetItem(trip.id, { name: 'Seat', total_price: 12, currency: 'eur' });
await linkItems(reservationId, [implicit.id, explicit.id]);
await budget.resyncReservationPrice(trip.id, reservationId);
// Named by its code once one of them names it.
expect(await metadataOf(reservationId)).toEqual({ price: '52', priceCurrency: 'EUR' });
});
it('BUDGET-SVC-DB-053: a currency code is the same currency whatever its case', async () => {
const { trip, reservationId } = await tripWithBooking();
const lower = await budget.createBudgetItem(trip.id, { name: 'Fare', total_price: 100, currency: 'usd' });
const upper = await budget.createBudgetItem(trip.id, { name: 'Seat', total_price: 25, currency: 'USD' });
await linkItems(reservationId, [lower.id, upper.id]);
await budget.resyncReservationPrice(trip.id, reservationId);
expect(await metadataOf(reservationId)).toEqual({ price: '125', priceCurrency: 'USD' });
});
it('BUDGET-SVC-DB-054: a first expense in the trip currency, beside a foreign one, is named by the trip code', async () => {
const { trip, reservationId } = await tripWithBooking();
await setTrip(trip.id, { currency: 'eur' });
const implicit = await budget.createBudgetItem(trip.id, { name: 'Fare', total_price: 100 });
const foreign = await budget.createBudgetItem(trip.id, { name: 'Seat', total_price: 20, currency: 'USD' });
await linkItems(reservationId, [implicit.id, foreign.id]);
await budget.resyncReservationPrice(trip.id, reservationId);
// The currencies differ, so the first expense stands alone, and its implicit
// currency is spelled out so the card does not read the figure as dollars.
expect(await metadataOf(reservationId)).toEqual({ price: '100', priceCurrency: 'EUR' });
// A trip with no currency at all leaves nothing to spell out.
await setTrip(trip.id, { currency: null });
await budget.resyncReservationPrice(trip.id, reservationId);
expect(await metadataOf(reservationId)).toEqual({ price: '100' });
});
it('BUDGET-SVC-DB-073: expenses in different currencies mirror the first one alone, in its currency', async () => {
const { trip, reservationId } = await tripWithBooking();
const first = await budget.createBudgetItem(trip.id, { name: 'Fare', total_price: 120, currency: 'CHF' });
const second = await budget.createBudgetItem(trip.id, { name: 'Seat', total_price: 15, currency: 'EUR' });
await linkItems(reservationId, [first.id, second.id]);
await budget.resyncReservationPrice(trip.id, reservationId);
expect(await metadataOf(reservationId)).toEqual({ price: '120', priceCurrency: 'CHF' });
});
it('BUDGET-SVC-DB-074: expenses in the trip currency sum up and carry no currency of their own', async () => {
// An imported booking can carry a currency stamped by the importer; once the
// mirror comes from expenses in the trip's own currency, that stamp goes.
const { trip, reservationId } = await tripWithBooking({ price: '5', priceCurrency: 'CNY' });
const first = await budget.createBudgetItem(trip.id, { name: 'Fare', total_price: 40 });
const second = await budget.createBudgetItem(trip.id, { name: 'Seat', total_price: 2.5 });
await linkItems(reservationId, [first.id, second.id]);
await budget.resyncReservationPrice(trip.id, reservationId);
expect(await metadataOf(reservationId)).toEqual({ price: '42.5' });
});
it('BUDGET-SVC-DB-075: a booking with nothing linked loses its price and currency, and nothing else', async () => {
const { trip, reservationId } = await tripWithBooking({ seat: '12A', price: '30', priceCurrency: 'USD' });
await budget.resyncReservationPrice(trip.id, reservationId);
expect(await metadataOf(reservationId)).toEqual({ seat: '12A' });
// A booking without a price, or without metadata at all, is not rewritten.
const bare = await insertReservation(trip.id, 'Bare');
await budget.resyncReservationPrice(trip.id, bare);
expect(await metadataOf(bare)).toBeNull();
});
it('BUDGET-SVC-DB-076: deleting one of two expenses leaves the booking with the other one\'s total', async () => {
const { trip, reservationId } = await tripWithBooking({ price: '30' });
const first = await budget.createBudgetItem(trip.id, { name: 'Fare', total_price: 25 });
const second = await budget.createBudgetItem(trip.id, { name: 'Seat', total_price: 5 });
await linkItems(reservationId, [first.id, second.id]);
expect(await budget.deleteBudgetItem(first.id, trip.id)).toBe(true);
// Before #2084 the price was dropped outright, though an expense still stood behind it.
expect(await metadataOf(reservationId)).toEqual({ price: '5' });
expect(await budget.deleteBudgetItem(second.id, trip.id)).toBe(true);
expect(await metadataOf(reservationId)).toEqual({});
});
it('BUDGET-SVC-DB-077: moving an expense to another booking moves its share of the price along', async () => {
const { trip, reservationId: from } = await tripWithBooking();
const to = await insertReservation(trip.id, 'Return');
const stays = await budget.createBudgetItem(trip.id, { name: 'Fare', total_price: 100 });
const moves = await budget.createBudgetItem(trip.id, { name: 'Seat', total_price: 20 });
await linkItems(from, [stays.id, moves.id]);
await budget.resyncReservationPrice(trip.id, from);
expect(await metadataOf(from)).toEqual({ price: '120' });
const before = (await budget.getBudgetItem(moves.id, trip.id))!;
const data = { reservation_id: to };
const updated = (await budget.updateBudgetItem(moves.id, trip.id, data))!;
await budget.resyncLinkedPrices(trip.id, before.reservation_id, updated, data);
expect(await metadataOf(from)).toEqual({ price: '100' });
expect(await metadataOf(to)).toEqual({ price: '20' });
// And letting go of the link takes the price off the booking it left.
const unlink = { reservation_id: null };
const unlinked = (await budget.updateBudgetItem(moves.id, trip.id, unlink))!;
await budget.resyncLinkedPrices(trip.id, to, unlinked, unlink);
expect(await metadataOf(to)).toEqual({});
expect((await findRow(await orm(), BudgetItems, { id: moves.id }))?.id).toBe(moves.id);
});
it('BUDGET-SVC-DB-055: an edit that only changes the currency of a linked expense re-names the booking price', async () => {
const { trip, reservationId } = await tripWithBooking();
const item = await budget.createBudgetItem(trip.id, { name: 'Fare', total_price: 80 });
await linkItems(reservationId, [item.id]);
await budget.resyncReservationPrice(trip.id, reservationId);
expect(await metadataOf(reservationId)).toEqual({ price: '80' });
// Shaped like the route body, which names neither the total nor the link here.
type Body = { currency: string | null; total_price?: number; reservation_id?: number | null };
const toChf: Body = { currency: 'chf' };
await budget.resyncLinkedPrices(trip.id, undefined, (await budget.updateBudgetItem(item.id, trip.id, toChf))!, toChf);
expect(await metadataOf(reservationId)).toEqual({ price: '80', priceCurrency: 'CHF' });
const back: Body = { currency: null };
await budget.resyncLinkedPrices(trip.id, undefined, (await budget.updateBudgetItem(item.id, trip.id, back))!, back);
expect(await metadataOf(reservationId)).toEqual({ price: '80' });
});
});
describe('an expense nobody paid stays out of the ledger (#2225)', () => {
it('BUDGET-SVC-DB-037: an unpaid expense moves neither the balances nor the offered flows', async () => {
const { user: alice } = createUser(testDb, { username: 'alice' });
const { user: bob } = createUser(testDb, { username: 'bob' });
const { user: carol } = createUser(testDb, { username: 'carol' });
const trip = createTrip(testDb, alice.id, { title: 'Trip' });
addTripMember(testDb, trip.id, bob.id);
addTripMember(testDb, trip.id, carol.id);
await budget.createBudgetItem(trip.id, {
name: 'Dinner', payers: [{ user_id: alice.id, amount: 120 }],
member_ids: [alice.id, bob.id, carol.id],
});
const before = await budget.calculateSettlement(trip.id);
// The row from the issue: a recorded total left on "No one paid yet", split
// between the two people who were actually there.
const unpaid = await budget.createBudgetItem(trip.id, {
name: 'Taxi', total_price: 161.57, payers: [], member_ids: [bob.id, carol.id],
}) as { id: number; payers: unknown[]; total_price: number };
expect(unpaid.payers).toEqual([]);
expect(unpaid.total_price).toBe(161.57);
// Re-saving it from the edit modal (payers cleared, total re-sent) must not
// let writeItemPayers derive the total back down to 0.
const resaved = await budget.updateBudgetItem(unpaid.id, trip.id, {
total_price: 161.57, payers: [], member_ids: [bob.id, carol.id],
}) as { payers: unknown[]; total_price: number };
expect(resaved.payers).toEqual([]);
expect(resaved.total_price).toBe(161.57);
expect(await countRows(await orm(), BudgetItemPayers, { budgetItem: unpaid.id })).toBe(0);
// 161.57 with no credit behind it used to push bob and carol 80.79/80.78 further
// into the red and hand the whole of it to alice, who never fronted a rouble of it.
const after = await budget.calculateSettlement(trip.id);
expect(after.balances).toEqual(before.balances);
expect(after.flows).toEqual(before.flows);
expect(after.balances.reduce((a, b) => a + Math.round(b.balance * 100), 0)).toBe(0);
});
});
describe('post-fold quirk fixes', () => {
it('BUDGET-SVC-DB-019: settlements prefer display_name over username (quirk fix)', async () => {
const { user: alice } = createUser(testDb, { username: 'alice' });
const { user: bob } = createUser(testDb, { username: 'bob' });
await updateRows(await orm(), Users, { id: alice.id }, { display_name: 'Alice Displayed' });
const trip = createTrip(testDb, alice.id);
const created = await budget.insertSettlement(trip.id, { from_user_id: alice.id, to_user_id: bob.id, amount: 10 }, alice.id);
expect(created!.from_username).toBe('Alice Displayed');
expect(created!.to_username).toBe('bob');
expect((await budget.listSettlements(trip.id))[0].from_username).toBe('Alice Displayed');
});
it('BUDGET-SVC-DB-042: a settle-up payment carries its own settled_at, independent of created_at', async () => {
const { user: alice } = createUser(testDb, { username: 'alice' });
const { user: bob } = createUser(testDb, { username: 'bob' });
const trip = createTrip(testDb, alice.id);
const noDate = await budget.insertSettlement(trip.id, { from_user_id: alice.id, to_user_id: bob.id, amount: 10 }, alice.id);
expect(noDate!.settled_at).toBeNull();
const dated = await budget.insertSettlement(trip.id, { from_user_id: alice.id, to_user_id: bob.id, amount: 20, settled_at: '2026-01-05' }, alice.id);
expect(dated!.settled_at).toBe('2026-01-05');
expect((await budget.getSettlement(dated!.id, trip.id))!.settled_at).toBe('2026-01-05');
const moved = await budget.applySettlementUpdate(dated!.id, trip.id, { from_user_id: alice.id, to_user_id: bob.id, amount: 20, settled_at: '2026-01-09' });
expect(moved!.settled_at).toBe('2026-01-09');
// An update that omits settled_at (undefined) leaves the stored day alone,
// the same CASE WHEN pattern currency/exchange_rate already follow.
const untouched = await budget.applySettlementUpdate(dated!.id, trip.id, { from_user_id: alice.id, to_user_id: bob.id, amount: 25 });
expect(untouched!.settled_at).toBe('2026-01-09');
});
it('BUDGET-SVC-DB-043: clearing settled_at stores NULL, whether it arrives as null or an empty string', async () => {
const { user: alice } = createUser(testDb, { username: 'alice' });
const { user: bob } = createUser(testDb, { username: 'bob' });
const trip = createTrip(testDb, alice.id);
const parties = { from_user_id: alice.id, to_user_id: bob.id, amount: 20 };
// The date picker's clear button sends '' and the contract allows null; both
// mean "no day of its own", never a stored empty string.
const blank = await budget.insertSettlement(trip.id, { ...parties, settled_at: '' }, alice.id);
expect(blank!.settled_at).toBeNull();
const dated = await budget.insertSettlement(trip.id, { ...parties, settled_at: '2026-01-05' }, alice.id);
expect((await budget.applySettlementUpdate(dated!.id, trip.id, { ...parties, settled_at: null }))!.settled_at).toBeNull();
await budget.applySettlementUpdate(dated!.id, trip.id, { ...parties, settled_at: '2026-01-05' });
expect((await budget.applySettlementUpdate(dated!.id, trip.id, { ...parties, settled_at: '' }))!.settled_at).toBeNull();
const row = (await findRow(await orm(), BudgetSettlements, { id: dated!.id }))!;
expect(row.settled_at).toBeNull();
});
// ── Notes vs. itemized receipts (#1658) ────────────────────────────────────
it('BUDGET-SVC-DB-021: a note and a receipt are stored in their own columns', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = await budget.createBudgetItem(trip.id, {
name: 'Groceries',
total_price: 40,
note: 'Lisa pays half of this back',
ticket_json: '{"items":[{"name":"Bread","price":"3","parts":[1]}]}',
});
const row = await budgetItemRow(item!.id);
expect(row.note).toBe('Lisa pays half of this back');
expect(JSON.parse(row.ticket_json!).items).toHaveLength(1);
});
it('BUDGET-SVC-DB-022: an update that omits note leaves the stored one alone', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = await budget.createBudgetItem(trip.id, { name: 'Dinner', total_price: 20, note: 'split with Ben' });
// What the mobile sheet sends: notes are written on desktop, so it never
// speaks about the field.
await budget.updateBudgetItem(item!.id, trip.id, { total_price: 25 });
const row = await budgetItemRow(item!.id);
expect(row.note).toBe('split with Ben');
});
it('BUDGET-SVC-DB-023: an explicit null clears the note', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = await budget.createBudgetItem(trip.id, { name: 'Taxi', total_price: 12, note: 'airport run' });
await budget.updateBudgetItem(item!.id, trip.id, { note: null });
const row = await budgetItemRow(item!.id);
expect(row.note).toBeNull();
});
it('BUDGET-SVC-DB-024: a pre-#1658 client sending the receipt as a note cannot erase the note', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = await budget.createBudgetItem(trip.id, { name: 'Supermarket', total_price: 30, note: 'reimburse from the kitty' });
// An old tab, still encoding the receipt into `note`.
await budget.updateBudgetItem(item!.id, trip.id, {
note: 'TICKETJSON:{"items":[{"name":"Milk","price":"2","parts":[1]}]}',
});
const row = await budgetItemRow(item!.id);
expect(row.note).toBe('reimburse from the kitty');
expect(JSON.parse(row.ticket_json!).items[0].name).toBe('Milk');
});
it('BUDGET-SVC-DB-025: the same legacy payload on create lands in the receipt column', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = await budget.createBudgetItem(trip.id, {
name: 'Market',
total_price: 9,
note: 'TICKETJSON:{"items":[{"name":"Cheese","price":"9","parts":[1]}]}',
});
const row = await budgetItemRow(item!.id);
expect(row.note).toBeNull();
expect(JSON.parse(row.ticket_json!).items[0].name).toBe('Cheese');
});
it('BUDGET-SVC-DB-020: a failing item insert rolls back the category-order side write (quirk fix)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// NOT NULL violation on name fires after the category-order upsert — the
// legacy non-transactional create leaked the budget_category_order row.
await expect(budget.createBudgetItem(trip.id, { name: null as unknown as string, category: 'atomic-test' })).rejects.toThrow();
expect(await findRow(await orm(), BudgetCategoryOrder, { trip: trip.id, category: 'atomic-test' })).toBeNull();
});
});
/**
* The total an uneven split writes back (#1964).
*
* The client splits in whole cents, which is right — 163.21 across two people
* is 81.61 and 81.60. The server then discarded the total it was sent and
* re-derived it by adding those two as doubles, which lands on
* 163.20999999999998, and that is what went into the row.
*
* Everything that reaches Intl was fine, which is why it looked like a display
* quirk in only some places. It was not: the stored number was wrong, and the
* expense form showed it back the moment the item was reopened.
*/
describe('an expense whose split leaves a remainder', () => {
const totalOf = async (itemId: number) => (await budgetItemRow(itemId)).total_price;
it('stores the total the parts add up to, not the float they land on', async () => {
const { user: alice } = createUser(testDb);
const { user: bob } = createUser(testDb);
const trip = createTrip(testDb, alice.id);
addTripMember(testDb, trip.id, bob.id);
const item = await budget.createBudgetItem(trip.id, {
name: 'Flight',
payers: [{ user_id: alice.id, amount: 81.61 }, { user_id: bob.id, amount: 81.60 }],
members: [{ user_id: alice.id }, { user_id: bob.id }],
});
// The assertion that fails on the old code, with exactly the reported number.
expect(await totalOf(item.id)).toBe(163.21);
expect(String(await totalOf(item.id))).toBe('163.21');
});
it('does the same when the payers are replaced on an update', async () => {
const { user: alice } = createUser(testDb);
const { user: bob } = createUser(testDb);
const trip = createTrip(testDb, alice.id);
addTripMember(testDb, trip.id, bob.id);
const item = await budget.createBudgetItem(trip.id, { name: 'Hotel', total_price: 10, members: [{ user_id: alice.id }] });
await budget.updateBudgetItem(item.id, trip.id, {
payers: [{ user_id: alice.id, amount: 81.61 }, { user_id: bob.id, amount: 81.60 }],
});
expect(await totalOf(item.id)).toBe(163.21);
});
/*
* Three ways is the harder case: 100.00 becomes 33.34 + 33.33 + 33.33, and
* two of those additions drift.
*/
it('holds across a three-way split too', async () => {
const { user: alice } = createUser(testDb);
const { user: bob } = createUser(testDb);
const { user: carol } = createUser(testDb);
const trip = createTrip(testDb, alice.id);
addTripMember(testDb, trip.id, bob.id);
addTripMember(testDb, trip.id, carol.id);
const item = await budget.createBudgetItem(trip.id, {
name: 'Dinner',
payers: [
{ user_id: alice.id, amount: 33.34 },
{ user_id: bob.id, amount: 33.33 },
{ user_id: carol.id, amount: 33.33 },
],
members: [{ user_id: alice.id }, { user_id: bob.id }, { user_id: carol.id }],
});
expect(await totalOf(item.id)).toBe(100);
});
it('leaves a total that needs no cleaning exactly as it was', async () => {
const { user: alice } = createUser(testDb);
const { user: bob } = createUser(testDb);
const trip = createTrip(testDb, alice.id);
addTripMember(testDb, trip.id, bob.id);
const item = await budget.createBudgetItem(trip.id, {
name: 'Taxi',
payers: [{ user_id: alice.id, amount: 12.5 }, { user_id: bob.id, amount: 12.5 }],
members: [{ user_id: alice.id }, { user_id: bob.id }],
});
expect(await totalOf(item.id)).toBe(25);
});
it('attaches receipts created with receipt_file_ids and lists them', async () => {
const { user: alice } = createUser(testDb);
const trip = createTrip(testDb, alice.id);
// Insert a file for this trip
const fileId = await insertRow(await orm(), TripFiles, {
trip: trip.id, filename: 'receipt-123.jpg', original_name: 'receipt.jpg', mime_type: 'image/jpeg', file_size: 1024,
});
const item = await budget.createBudgetItem(trip.id, {
name: 'Restaurant with receipt',
total_price: 45,
receipt_file_ids: [fileId],
});
expect(item.receipts).toBeDefined();
expect(item.receipts!.length).toBe(1);
expect(item.receipts![0].id).toBe(fileId);
expect(item.receipts![0].original_name).toBe('receipt.jpg');
const listed = await budget.listBudgetItems(trip.id);
const found = listed.find(i => i.id === item.id);
expect(found?.receipts?.length).toBe(1);
expect(found?.receipts?.[0].id).toBe(fileId);
});
it('updates receipts on updateBudgetItem without deleting any file', async () => {
const { user: alice } = createUser(testDb);
const trip = createTrip(testDb, alice.id);
// File 1: will be removed and is orphan -> should be trashed
const f1 = await addFile(trip.id, 'f1.jpg');
// File 2: will be kept
const f2 = await addFile(trip.id, 'f2.jpg');
// File 3: will be added
const f3 = await addFile(trip.id, 'f3.jpg');
// File 4: will be removed but is shared with another budget item -> should NOT be trashed
const f4 = await addFile(trip.id, 'f4.jpg');
await budget.createBudgetItem(trip.id, { name: 'Other', receipt_file_ids: [f4] });
const item = await budget.createBudgetItem(trip.id, { name: 'Dinner', receipt_file_ids: [f1, f2, f4] });
// Update item: remove f1 and f4, keep f2, add f3
const updated = await budget.updateBudgetItem(item.id, trip.id, { receipt_file_ids: [f2, f3] });
expect(updated?.receipts?.map(r => r.id).sort()).toEqual([f2, f3].sort());
// Removing a receipt removes the link and nothing else. The file stays on
// the trip: deleting it needs file_delete, which this path never checks.
for (const fid of [f1, f2, f3, f4]) {
const row = await fileRow(fid);
expect(row.deleted_at).toBeNull();
}
expect(await countRows(await orm(), FileLinks, { budgetItem: item.id })).toBe(2);
// f4 keeps the link it has to the other expense.
expect(await countRows(await orm(), FileLinks, { file: f4 })).toBe(1);
});
it('re-saving an expense whose receipt is also linked elsewhere twice does not 500', async () => {
// A file may carry one link row per place and one per booking, and the receipt
// link is written onto a spare one of those. On the second save the row kept
// from last time is skipped, so the next spare used to be adopted into a
// second (file, item) pair — refused by the unique index, thrown inside the
// transaction, and the whole expense edit rolled back. Every time, for good.
const { user: alice } = createUser(testDb);
const trip = createTrip(testDb, alice.id);
const file = await addFile(trip.id, 'r.jpg');
const place = await addPlace(trip.id, 'Osteria');
const reservation = await insertRow(await orm(), Reservations, { trip: trip.id, title: 'Table', type: 'restaurant' });
await insertRow(await orm(), FileLinks, { file, place });
await insertRow(await orm(), FileLinks, { file, reservation });
const item = await budget.createBudgetItem(trip.id, { name: 'Dinner', total_price: 40, receipt_file_ids: [file] });
const again = await budget.updateBudgetItem(item.id, trip.id, { total_price: 42, receipt_file_ids: [file] });
expect(again?.total_price).toBe(42);
expect(again?.receipts?.map(r => r.id)).toEqual([file]);
expect(await countRows(await orm(), FileLinks, { file: file, budgetItem: item.id })).toBe(1);
});
it('a receipt named twice in one save is linked once', async () => {
const { user: alice } = createUser(testDb);
const trip = createTrip(testDb, alice.id);
const file = await addFile(trip.id, 'dup.jpg');
const item = await budget.createBudgetItem(trip.id, { name: 'Taxi' });
const updated = await budget.updateBudgetItem(item.id, trip.id, { receipt_file_ids: [file, file] });
expect(updated?.receipts?.map(r => r.id)).toEqual([file]);
});
it('unlinks receipts on deleteBudgetItem and leaves every file in place', async () => {
const { user: alice } = createUser(testDb);
const trip = createTrip(testDb, alice.id);
// File 1: orphan receipt
const f1 = await addFile(trip.id, 'del1.jpg');
// File 2: linked to a place directly
const place = await addPlace(trip.id, 'Hotel');
const f2 = await insertRow(await orm(), TripFiles, { trip: trip.id, filename: 'del2.jpg', original_name: 'del2.jpg', place });
const item = await budget.createBudgetItem(trip.id, { name: 'Lunch', receipt_file_ids: [f1, f2] });
const deleted = await budget.deleteBudgetItem(item.id, trip.id);
expect(deleted).toBe(true);
// Neither file is touched; only the links to the deleted expense go.
for (const fid of [f1, f2]) {
const row = await fileRow(fid);
expect(row.deleted_at).toBeNull();
}
const linkRows = await findRows(await orm(), FileLinks, { budgetItem: item.id });
expect(linkRows).toHaveLength(0);
});
it('unlinks receipts without ever trashing the file itself', async () => {
const { user: alice } = createUser(testDb);
const trip = createTrip(testDb, alice.id);
// budget_edit and file_delete are separate permissions and a receipt id is
// any file on the trip, so the budget domain must never delete one.
const plain = await addFile(trip.id, 'r.pdf');
const item = await budget.createBudgetItem(trip.id, { name: 'Dinner', receipt_file_ids: [plain] });
await budget.deleteBudgetItem(item.id, trip.id);
expect((await fileRow(plain)).deleted_at).toBeNull();
// The link is gone, because it was all the row carried.
expect(await countRows(await orm(), FileLinks, { file: plain })).toBe(0);
});
it('keeps a place link on a row that also carried the receipt link', async () => {
const { user: alice } = createUser(testDb);
const trip = createTrip(testDb, alice.id);
const place = await addPlace(trip.id, 'Cafe');
const file = await addFile(trip.id, 'menu.pdf');
await insertRow(await orm(), FileLinks, { file, place });
const item = await budget.createBudgetItem(trip.id, { name: 'Lunch', receipt_file_ids: [file] });
await budget.deleteBudgetItem(item.id, trip.id);
const row = await linkOfFile(file);
expect(row.place_id).toBe(place);
expect(row.budget_item_id).toBeNull();
expect((await fileRow(file)).deleted_at).toBeNull();
});
it('BUDGET-SVC-DB-046: unlinking a receipt that also carries a place link clears only the budget_item_id (BudgetItems.clearLinkBudgetRef)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = await addPlace(trip.id, 'Cafe');
const file = await addFile(trip.id, 'receipt.pdf');
const item = await budget.createBudgetItem(trip.id, { name: 'Lunch' }) as { id: number };
// Seed a link row that carries BOTH the receipt tie and a place tie — the
// shape unlinkReceipts's clearLinkBudgetRef branch exists for (row also
// ties the file to a place, so it must survive with budget_item_id cleared
// rather than be deleted outright).
await insertRow(await orm(), FileLinks, { file, budgetItem: item.id, place });
await budget.deleteBudgetItem(item.id, trip.id);
const row = await linkOfFile(file);
expect(row.place_id).toBe(place);
expect(row.budget_item_id).toBeNull();
});
it('BUDGET-SVC-DB-047: updating receipts adopts a spare file_links row instead of inserting a duplicate (BudgetItems.adoptSpareLink)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const file = await addFile(trip.id, 'r.pdf');
const item = await budget.createBudgetItem(trip.id, { name: 'Lunch' }) as { id: number };
const linkId = await insertRow(await orm(), FileLinks, { file });
await budget.updateBudgetItem(item.id, trip.id, { receipt_file_ids: [file] });
const row = await linkOfFile(file);
// The pre-existing spare row is re-parented rather than left orphaned
// beside a freshly inserted duplicate.
expect(row.id).toBe(linkId);
expect(row.budget_item_id).toBe(item.id);
});
it('leaves a receipt already in the trash linked, so restoring it comes back attached', async () => {
const { user: alice } = createUser(testDb);
const trip = createTrip(testDb, alice.id);
const file = await addFile(trip.id, 'old.pdf');
const item = await budget.createBudgetItem(trip.id, { name: 'Taxi', receipt_file_ids: [file] });
await updateRows(await orm(), TripFiles, { id: file }, { deleted_at: new Date().toISOString().slice(0, 19).replace('T', ' ') });
// A save that no longer mentions the trashed receipt must not drop its link.
await budget.updateBudgetItem(item.id, trip.id, { receipt_file_ids: [] });
expect(await countRows(await orm(), FileLinks, { file: file, budgetItem: item.id })).toBe(1);
});
it('an edit that keeps a receipt does not churn its link row', async () => {
const { user: alice } = createUser(testDb);
const trip = createTrip(testDb, alice.id);
const file = await addFile(trip.id, 'keep.pdf');
const item = await budget.createBudgetItem(trip.id, { name: 'Hotel', receipt_file_ids: [file] });
const before = (await findRow(await orm(), FileLinks, { file, budgetItem: item.id }))!;
await budget.updateBudgetItem(item.id, trip.id, { name: 'Hotel 2', receipt_file_ids: [file] });
const after = (await findRow(await orm(), FileLinks, { file, budgetItem: item.id }))!;
expect(after.id).toBe(before.id);
});
});
describe('repository parity (rule 19 — full-key toEqual against the legacy statement run raw)', () => {
it('BUDGET-REPO-001: BudgetItemsRepository.listWithCategoryOrder matches BG10 (LEFT JOIN budget_category_order, COALESCE sort) run raw', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Two categories; only one has an explicit sort_order row, so the other
// falls back to the 999999 COALESCE default and sorts after it.
await insertRow(await orm(), BudgetCategoryOrder, { trip: trip.id, category: 'Food', sort_order: 0 });
// Creation order assigns bi.sort_order 0, 1, 2 in turn; 'Activities' has no
// budget_category_order row, so it falls back to the 999999 COALESCE
// default and sorts after every 'Food' item regardless of its own sort_order.
await budget.createBudgetItem(trip.id, { name: 'Ramen', category: 'Food', total_price: 12 });
await budget.createBudgetItem(trip.id, { name: 'Museum', category: 'Activities', total_price: 20 });
await budget.createBudgetItem(trip.id, { name: 'Coffee', category: 'Food', total_price: 4 });
const converted = await budgetItemsRepoDirect.listWithCategoryOrder(trip.id);
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
const legacy = testDb.prepare(`
SELECT bi.* FROM budget_items bi
LEFT JOIN budget_category_order bco ON bco.trip_id = bi.trip_id AND bco.category = bi.category
WHERE bi.trip_id = ?
ORDER BY COALESCE(bco.sort_order, 999999) ASC, bi.sort_order ASC
`).all(trip.id);
expect(converted).toEqual(legacy);
expect(converted).toHaveLength(3);
expect(converted.map(r => r.name)).toEqual(['Ramen', 'Coffee', 'Museum']);
});
it('BUDGET-REPO-002: BudgetItemMembersRepository/BudgetItemPayersRepository.listForItems and BudgetItemsRepository.listReceiptsForItems match BG11/BG12/BG13 run raw', async () => {
const { user: alice } = createUser(testDb, { username: 'alice' });
const { user: bob } = createUser(testDb, { username: 'bob' });
const trip = createTrip(testDb, alice.id);
addTripMember(testDb, trip.id, bob.id);
const file = await addFile(trip.id, 'r.pdf');
const item1 = await budget.createBudgetItem(trip.id, {
name: 'Dinner', total_price: 40, members: [{ user_id: alice.id }, { user_id: bob.id }],
payers: [{ user_id: alice.id, amount: 40 }], receipt_file_ids: [file],
}) as { id: number };
const item2 = await budget.createBudgetItem(trip.id, { name: 'Snack', total_price: 5, members: [{ user_id: bob.id }] }) as { id: number };
const itemIds = [item1.id, item2.id];
const convertedMembers = await budgetItemMembersRepoDirect.listForItems(itemIds);
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
const legacyMembers = testDb.prepare(`
SELECT bm.budget_item_id, bm.user_id, bm.paid, bm.amount, COALESCE(u.display_name, u.username) AS username, u.avatar
FROM budget_item_members bm JOIN users u ON u.id = bm.user_id
WHERE bm.budget_item_id IN (${itemIds.join(',')})
`).all();
const sortByUser = <T extends { user_id: number; budget_item_id: number }>(rows: T[]) => [...rows].sort((a, b) => a.budget_item_id - b.budget_item_id || a.user_id - b.user_id);
expect(sortByUser(convertedMembers)).toEqual(sortByUser(legacyMembers as typeof convertedMembers));
expect(convertedMembers).toHaveLength(3);
const convertedPayers = await budgetItemPayersRepoDirect.listForItems(itemIds);
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
const legacyPayers = testDb.prepare(`
SELECT bp.budget_item_id, bp.user_id, bp.amount, COALESCE(u.display_name, u.username) AS username, u.avatar
FROM budget_item_payers bp JOIN users u ON u.id = bp.user_id
WHERE bp.budget_item_id IN (${itemIds.join(',')})
`).all();
expect(sortByUser(convertedPayers)).toEqual(sortByUser(legacyPayers as typeof convertedPayers));
expect(convertedPayers).toHaveLength(1);
const convertedReceipts = await budgetItemsRepoDirect.listReceiptsForItems(itemIds);
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
const legacyReceipts = testDb.prepare(`
SELECT f.id, f.filename, f.original_name, f.file_size, f.mime_type, f.trip_id, fl.budget_item_id
FROM trip_files f JOIN file_links fl ON fl.file_id = f.id
WHERE f.deleted_at IS NULL AND fl.budget_item_id IN (${itemIds.join(',')})
ORDER BY f.created_at ASC
`).all();
expect(convertedReceipts).toEqual(legacyReceipts);
expect(convertedReceipts).toHaveLength(1);
});
it('BUDGET-REPO-003: BudgetSettlementsRepository.listForTrip / findWithUsers match BG76/BG77 (SETTLEMENT_SELECT) run raw', async () => {
const { user: alice } = createUser(testDb, { username: 'alice' });
const { user: bob } = createUser(testDb, { username: 'bob' });
const trip = createTrip(testDb, alice.id);
addTripMember(testDb, trip.id, bob.id);
const s1 = await budget.createSettlement(trip.id, { from_user_id: bob.id, to_user_id: alice.id, amount: 50, currency: 'USD' }, alice.id) as { id: number };
const s2 = await budget.createSettlement(trip.id, { from_user_id: alice.id, to_user_id: bob.id, amount: 10 }, bob.id) as { id: number };
const SETTLEMENT_SELECT = `
SELECT s.id, s.trip_id, s.from_user_id, s.to_user_id, s.amount, s.currency, s.exchange_rate,
s.created_at, s.settled_at, s.note, s.created_by_user_id,
COALESCE(fu.display_name, fu.username) AS from_username, fu.avatar AS from_avatar,
COALESCE(tu.display_name, tu.username) AS to_username, tu.avatar AS to_avatar
FROM budget_settlements s
JOIN users fu ON fu.id = s.from_user_id
JOIN users tu ON tu.id = s.to_user_id
`;
const convertedList = await budgetSettlementsRepoDirect.listForTrip(trip.id);
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
const legacyList = testDb.prepare(`${SETTLEMENT_SELECT} WHERE s.trip_id = ? ORDER BY s.created_at DESC, s.id DESC`).all(trip.id);
expect(convertedList).toEqual(legacyList);
expect(convertedList.map(s => s.id).sort()).toEqual([s1.id, s2.id].sort());
const convertedOne = await budgetSettlementsRepoDirect.findWithUsers(s1.id, trip.id);
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
const legacyOne = testDb.prepare(`${SETTLEMENT_SELECT} WHERE s.trip_id = ? AND s.id = ?`).get(trip.id, s1.id);
expect(convertedOne).toEqual(legacyOne);
});
it('M1: BudgetItemsRepository.listReceiptsForItems / listIdsForPlaces short-circuit on an empty id array', async () => {
expect(await budgetItemsRepoDirect.listReceiptsForItems([])).toEqual([]);
expect(await budgetItemsRepoDirect.listIdsForPlaces(1, [])).toEqual([]);
});
it('M1: BudgetItemMembersRepository/BudgetItemPayersRepository.listForItems short-circuit on an empty id array; BudgetItemMembers.insertIgnore hits its onConflict doNothing branch on a repeat insert', async () => {
expect(await budgetItemMembersRepoDirect.listForItems([])).toEqual([]);
expect(await budgetItemPayersRepoDirect.listForItems([])).toEqual([]);
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = await budget.createBudgetItem(trip.id, { name: 'Lunch', total_price: 10 }) as { id: number };
await budgetItemMembersRepoDirect.insertIgnore({ budget_item_id: item.id, user_id: user.id });
// A second insert for the same (budget_item_id, user_id) hits the unique
// index — onConflict(doNothing) swallows it instead of throwing.
await expect(budgetItemMembersRepoDirect.insertIgnore({ budget_item_id: item.id, user_id: user.id })).resolves.toBeUndefined();
expect(await countRows(await orm(), BudgetItemMembers, { budgetItem: item.id, user: user.id })).toBe(1);
});
});
// #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)', () => {
async 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 = await budget.createBudgetItem(trip.id, {
name: 'Aparthotel Silver', category: 'accommodation', currency: 'USD', exchange_rate: 1.17,
payers: [{ user_id: me.id, amount: 801.76 }], members,
});
await 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-078: adds each row up at the rate it was booked at, in whole cents', async () => {
const { trip } = await seedDollarBill();
// 801.76 USD at 1.17 is 685.26 EUR, the figure the settlement nets as well.
expect(await budget.tripTotals(trip.id, 'EUR')).toEqual({
total: 785.26,
byCategory: { accommodation: 685.26, food: 100 },
unconverted: [],
});
// 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-079: 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.
await 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((await budget.tripTotals(trip.id, 'EUR', rates)).total).toBe(100);
});
it('BUDGET-SVC-DB-080: the per-person summary splits trip cents, not raw amounts', async () => {
const { trip, me, bob, hotel } = await seedDollarBill();
await 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-081: 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);
await 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-082: 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-083: 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-084: 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-085: falls back to the display currency\'s quote when the trip\'s is unavailable', async () => {
const { trip, me, bob } = seedSameDayBill();
await 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();
}
});
});
// The VND/AUD report: the server reached no rates, so a VND bill was stored with the
// "not frozen" rate 1 and read as 8,920,000 AUD. RATES has no AUD quote, so every AUD
// trip below is that server; a rate can only come from what the caller lends.
describe('rows no rate can convert, and the rates that heal them', () => {
const VND_FX = { base: 'AUD', rates: { VND: 18241.3 } };
const rateOf = async (table: 'budget_items' | 'budget_settlements', id: number) => {
const row = table === 'budget_items'
? await findRow(await orm(), BudgetItems, { id })
: await findRow(await orm(), BudgetSettlements, { id });
if (!row) throw new Error(`no ${table} row ${id}`);
return row.exchange_rate;
};
const exchangeRates = () => (budget as unknown as { exchangeRates: ExchangeRatesService }).exchangeRates;
async function seedAudTrip() {
const { user: me } = createUser(testDb);
const { user: bob } = createUser(testDb);
const { user: carol } = createUser(testDb);
const { user: dave } = createUser(testDb);
const trip = createTrip(testDb, me.id);
for (const u of [bob, carol, dave]) addTripMember(testDb, trip.id, u.id);
await setTrip(trip.id, { currency: 'AUD' });
const members = [me, bob, carol, dave].map(u => ({ user_id: u.id }));
return { trip, me, bob, carol, members };
}
it('BUDGET-SVC-DB-056: freezes fallback_fx when getRates is null', async () => {
const { trip, me, members } = await seedAudTrip();
const bill = await budget.create(String(trip.id), {
name: 'Pho', currency: 'VND', payers: [{ user_id: me.id, amount: 8920000 }], members, fallback_fx: VND_FX,
});
expect(bill.exchange_rate).toBe(18241.3);
const s = await budget.settlement(trip.id, 'AUD', 'AUD');
expect(s.balances.find(b => b.user_id === me.id)!.balance).toBe(366.75);
});
it('BUDGET-SVC-DB-057: server quote wins', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const bill = await budget.create(String(trip.id), {
name: 'Taxi', currency: 'USD', total_price: 20, fallback_fx: { base: 'EUR', rates: { USD: 2 } },
});
expect(bill.exchange_rate).toBe(RATES.EUR.USD);
});
it('BUDGET-SVC-DB-058: fallback_fx on another base is ignored', async () => {
const { trip } = await seedAudTrip();
// Quoted against euros, the figure would freeze the bill against the wrong currency.
const bill = await budget.create(String(trip.id), {
name: 'Pho', currency: 'VND', total_price: 100000, fallback_fx: { base: 'EUR', rates: { VND: 27000 } },
});
expect(bill.exchange_rate).toBe(1);
});
it('BUDGET-SVC-DB-059: explicit exchange_rate wins, fallback_fx never reaches the row', async () => {
const { trip } = await seedAudTrip();
const data = { name: 'Pho', currency: 'VND', total_price: 100000, exchange_rate: 18000, fallback_fx: VND_FX };
const bill = await budget.create(String(trip.id), data);
expect(bill.exchange_rate).toBe(18000);
expect('fallback_fx' in data).toBe(false);
});
it('BUDGET-SVC-DB-060: currency change without any rate drops the old rate (items)', async () => {
const { trip } = await seedAudTrip();
const item = await budget.createBudgetItem(trip.id, { name: 'Taxi', currency: 'USD', exchange_rate: 0.65, total_price: 20 });
// The update SQL keeps the stored rate when none is sent: a USD rate on a VND bill.
expect(await budget.update(item.id, trip.id, { currency: 'VND' })).toMatchObject({ currency: 'VND', exchange_rate: 1 });
// With a lent rate the new currency freezes instead.
expect(await budget.update(item.id, trip.id, { currency: 'THB', fallback_fx: { base: 'AUD', rates: { THB: 23.5 } } }))
.toMatchObject({ currency: 'THB', exchange_rate: 23.5 });
});
it('BUDGET-SVC-DB-061: same for settlements', async () => {
const { trip, me, bob } = await seedAudTrip();
const transfer = await budget.createSettlement(trip.id, {
from_user_id: bob.id, to_user_id: me.id, amount: 20, currency: 'USD', fallback_fx: { base: 'AUD', rates: { USD: 0.65 } },
}, me.id);
expect(transfer).toMatchObject({ currency: 'USD', exchange_rate: 0.65 });
const moved = await budget.updateSettlement(transfer!.id, trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: 20, currency: 'VND' });
expect(moved).toMatchObject({ currency: 'VND', exchange_rate: 1 });
});
it('BUDGET-SVC-DB-062: unchanged currency never touches the rate', async () => {
const { trip } = await seedAudTrip();
const frozen = await budget.createBudgetItem(trip.id, { name: 'Pho', currency: 'VND', exchange_rate: 18241.3, total_price: 100000 });
const open = await budget.createBudgetItem(trip.id, { name: 'Bus', currency: 'VND', total_price: 50000 });
const fx = { base: 'AUD', rates: { VND: 20000 } };
expect(await budget.update(frozen.id, trip.id, { name: 'Pho bo', currency: 'vnd', fallback_fx: fx })).toMatchObject({ exchange_rate: 18241.3 });
// Not even an unfrozen row: an edit is no place to heal, freeze-rates is.
expect(await budget.update(open.id, trip.id, { name: 'Night bus', currency: 'VND', fallback_fx: fx })).toMatchObject({ exchange_rate: 1 });
});
it('BUDGET-SVC-DB-063: freezeMissingRates heals items and transfers from fallback_fx', async () => {
const { trip, me, bob, carol, members } = await seedAudTrip();
const bill = await budget.createBudgetItem(trip.id, { name: 'Pho', currency: 'VND', payers: [{ user_id: me.id, amount: 8920000 }], members });
const transfer = (await budget.insertSettlement(trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: 2229963, currency: 'VND' }, bob.id))!;
// Carol pays in baht. The rows are healed currency by currency, THB before VND, so her
// later transfer is written first and the answer still lists the rows by id.
const bahtTransfer = (await budget.insertSettlement(trip.id, { from_user_id: carol.id, to_user_id: me.id, amount: 2872.88, currency: 'THB' }, carol.id))!;
const before = await budget.settlement(trip.id, 'AUD', 'AUD');
expect(before.unconverted).toEqual({ item_ids: [bill.id], settlement_ids: [bahtTransfer.id, transfer.id], currencies: ['THB', 'VND'] });
expect(before.balances).toEqual([]);
const healed = await budget.freezeMissingRates(trip.id, { base: 'AUD', rates: { VND: 18241.3, THB: 23.5 } });
expect(healed!.items.map(i => [i.id, i.exchange_rate])).toEqual([[bill.id, 18241.3]]);
expect(healed!.settlements.map(s => [s.id, s.exchange_rate])).toEqual([[transfer.id, 18241.3], [bahtTransfer.id, 23.5]]);
expect(healed!.unresolved).toEqual([]);
const after = await budget.settlement(trip.id, 'AUD', 'AUD');
expect(after.unconverted).toEqual({ item_ids: [], settlement_ids: [], currencies: [] });
// 489.00 AUD four ways. Bob's 2,229,963 VND and Carol's 2,872.88 THB (122.25 AUD
// each) square their shares.
expect(after.balances.find(b => b.user_id === me.id)!.balance).toBe(122.25);
expect(after.balances.find(b => b.user_id === bob.id)!.balance).toBe(0);
expect(after.balances.find(b => b.user_id === carol.id)!.balance).toBe(0);
expect(after.balances.reduce((a, b) => a + Math.round(b.balance * 100), 0)).toBe(0);
});
it('BUDGET-SVC-DB-064: prefers the server quote, never touches frozen, trip-currency or NULL-currency rows', async () => {
const { user: me } = createUser(testDb);
const { user: bob } = createUser(testDb);
const trip = createTrip(testDb, me.id);
addTripMember(testDb, trip.id, bob.id);
const row = async (currency: string | null, exchange_rate?: number) =>
(await budget.createBudgetItem(trip.id, { name: 'Row', currency, exchange_rate, total_price: 10 })).id;
const usd = await row('USD');
const gbp = await row('GBP');
const xaf = await row('XAF');
const rub = await row('RUB', 90);
const eur = await row('EUR');
const implicit = await row(null);
const lower = await row('usd');
const usdTransfer = (await budget.insertSettlement(trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: 5, currency: 'USD' }, bob.id))!;
const plainTransfer = (await budget.insertSettlement(trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: 5 }, bob.id))!;
const healed = await budget.freezeMissingRates(trip.id, { base: 'EUR', rates: { USD: 2, GBP: 0.85 } });
expect(healed!.items.map(i => i.id)).toEqual([usd, gbp, lower]);
expect(healed!.settlements.map(s => s.id)).toEqual([usdTransfer.id]);
expect(healed!.unresolved).toEqual(['XAF']);
expect(await rateOf('budget_items', usd)).toBe(RATES.EUR.USD);
expect(await rateOf('budget_items', lower)).toBe(RATES.EUR.USD);
expect(await rateOf('budget_items', gbp)).toBe(0.85);
expect(await rateOf('budget_items', xaf)).toBe(1);
expect(await rateOf('budget_items', rub)).toBe(90);
expect(await rateOf('budget_items', eur)).toBe(1);
expect(await rateOf('budget_items', implicit)).toBe(1);
expect(await rateOf('budget_settlements', usdTransfer.id)).toBe(RATES.EUR.USD);
expect(await rateOf('budget_settlements', plainTransfer.id)).toBe(1);
});
it('BUDGET-SVC-DB-065: second call heals nothing even with another quote', async () => {
const { trip, me, members } = await seedAudTrip();
const bill = await budget.createBudgetItem(trip.id, { name: 'Pho', currency: 'VND', payers: [{ user_id: me.id, amount: 8920000 }], members });
await budget.freezeMissingRates(trip.id, VND_FX);
expect(await budget.freezeMissingRates(trip.id, { base: 'AUD', rates: { VND: 25000 } }))
.toEqual({ items: [], settlements: [], unresolved: [] });
expect(await rateOf('budget_items', bill.id)).toBe(18241.3);
});
it('BUDGET-SVC-DB-066: returns null and writes nothing when the trip currency changes during the fetch', async () => {
const { trip, me, members } = await seedAudTrip();
const bill = await budget.createBudgetItem(trip.id, { name: 'Pho', currency: 'VND', payers: [{ user_id: me.id, amount: 8920000 }], members });
const spy = vi.spyOn(exchangeRates(), 'getRates').mockImplementation(async () => {
// Someone switches the trip to euros while the rates are on their way.
// test-sql-allow: the trip changes underneath the service from inside its stubbed fetch, straight on the handle it holds.
testDb.prepare("UPDATE trips SET currency = 'EUR' WHERE id = ?").run(trip.id);
return null;
});
try {
expect(await budget.freezeMissingRates(trip.id, VND_FX)).toBeNull();
expect(await rateOf('budget_items', bill.id)).toBe(1);
} finally {
spy.mockRestore();
}
});
it('BUDGET-SVC-DB-067: returns without fetching when nothing is pending', async () => {
const { trip, me, bob, members } = await seedAudTrip();
await budget.createBudgetItem(trip.id, { name: 'Dinner', currency: 'AUD', payers: [{ user_id: me.id, amount: 80 }], members });
await budget.createBudgetItem(trip.id, { name: 'Implicit', total_price: 20, members });
await budget.createBudgetItem(trip.id, { name: 'Pho', currency: 'VND', exchange_rate: 18241.3, total_price: 100000, members });
await budget.insertSettlement(trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: 5 }, bob.id);
const spy = vi.spyOn(exchangeRates(), 'getRates');
try {
expect(await budget.freezeMissingRates(trip.id)).toEqual({ items: [], settlements: [], unresolved: [] });
expect(spy).not.toHaveBeenCalled();
} finally {
spy.mockRestore();
}
});
it('BUDGET-SVC-DB-068: tripTotals and per-person leave the VND row out and tripTotals reports its id', async () => {
const { trip, me, bob, members } = await seedAudTrip();
const bill = await budget.createBudgetItem(trip.id, {
name: 'Pho', category: 'food', currency: 'VND', payers: [{ user_id: me.id, amount: 8920000 }], members,
});
await budget.createBudgetItem(trip.id, {
name: 'Ferry', category: 'transport', currency: 'AUD', payers: [{ user_id: bob.id, amount: 40 }],
members: [{ user_id: me.id }, { user_id: bob.id }],
});
const rates = await budget.ratesForTripTotals(trip.id, 'AUD');
expect(rates).toBeNull();
expect(await budget.tripTotals(trip.id, 'AUD', rates)).toEqual({ total: 40, byCategory: { transport: 40 }, unconverted: [bill.id] });
const summary = await budget.perPersonSummary(trip.id);
expect(summary.map(s => [s.user_id, s.total_assigned, s.items_count])).toEqual([[me.id, 20, 1], [bob.id, 20, 1]]);
});
it('BUDGET-SVC-DB-069: settlement() passes baseRate only as display fallback (fetch order of 085 unchanged)', async () => {
const { user: me } = createUser(testDb);
const { user: bob } = createUser(testDb);
const trip = createTrip(testDb, me.id);
addTripMember(testDb, trip.id, bob.id);
await 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 mine = (s: { balances: { user_id: number; balance: number }[] }) => s.balances.find(b => b.user_id === me.id)!.balance;
// Neither quote: the caller's own figure labels the answer...
let spy = vi.spyOn(exchangeRates(), 'getRates').mockResolvedValue(null);
try {
const s = await budget.settlement(trip.id, 'USD', 'EUR', 1.2);
expect(spy.mock.calls.map(c => c[0])).toEqual(['EUR', 'USD']);
expect(s.currency).toBe('USD');
expect(mine(s)).toBe(60);
// ...and without it the answer stays in euros and says so.
const plain = await budget.settlement(trip.id, 'USD', 'EUR');
expect(plain.currency).toBe('EUR');
expect(mine(plain)).toBe(50);
} finally {
spy.mockRestore();
}
// With the display currency's quote (085's setup) it changes nothing.
spy = vi.spyOn(exchangeRates(), 'getRates').mockImplementation(async (base: string) => (base === 'EUR' ? null : RATES[base] ?? null));
try {
const s = await budget.settlement(trip.id, 'USD', 'EUR', 2);
expect(spy.mock.calls.map(c => c[0])).toEqual(['EUR', 'USD']);
expect(s.currency).toBe('USD');
expect(mine(s)).toBe(57.14);
} finally {
spy.mockRestore();
}
});
});