Files
TREK/server/tests/unit/nest/budget.service.db.test.ts
T
jubnl 3e37474a2c test(server): plan 4 — constructor ripple for the rosterUserIds/getPlaceWithTags inline
Hand-built BudgetService/BudgetMcp/TodoService call sites drop the removed
DatabaseService constructor arg (budgetRepoArgs() now appends a
TripMembersRepository so most spread call sites need no other change);
budget.service.test.ts's rosterHas() helper and svc() factory move from a
spied DatabaseService to a stubbed TripMembersRepository. The
AccommodationsService helper (tests/helpers/accommodations-service.ts) and
its two direct callers drop the same arg. PackingService call sites are
untouched (its new param is optional, so every existing hand-built instance
still resolves rosterUserIds through the DatabaseService fallback).
2026-09-24 06:18:32 +02:00

1404 lines
74 KiB
TypeScript
Raw 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();
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => null,
isOwner: () => false,
};
return mock;
});
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
// 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 },
} 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 { DatabaseService } from '../../../src/nest/database/database.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 } 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';
// 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, before any `dbs()`/`new DatabaseService(testDb)` call —
// `canAccessTrip`/`isOwner`/`rosterUserIds`/`getPlaceWithTags` resolve
// `TripsRepository`/`TripMembersRepository`/`PlacesRepository` through it
// now, not through `db/database.ts`'s deleted free functions.
let dbsEm: import('@mikro-orm/core').EntityManager | undefined;
const dbs = () => new DatabaseService(testDb, dbsEm);
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(dbs(), budget, await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestTripMembersRepo(testDb), await createTestBudgetItemsRepo(testDb), await createTestJourneyShareTokensRepo(testDb), await createTestJourneysRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestJourneyContributorsRepo(testDb), await createTestShareTokensRepo(testDb)),
new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)),
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();
});
function paidFlag(itemId: number, memberId: number): number | undefined {
const row = testDb
.prepare('SELECT paid FROM budget_item_members WHERE budget_item_id = ? AND user_id = ?')
.get(itemId, memberId) as { paid: number } | undefined;
return row?.paid;
}
describe('deleting a member re-splits their expenses (#1553)', () => {
function personsOf(itemId: number): number | null {
return (testDb.prepare('SELECT persons FROM budget_items WHERE id = ?').get(itemId) as { persons: number | null }).persons;
}
function memberCount(itemId: number): number {
return (testDb.prepare('SELECT COUNT(*) AS count FROM budget_item_members WHERE budget_item_id = ?')
.get(itemId) as { count: number }).count;
}
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(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(memberCount(item.id)).toBe(2);
expect(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(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(memberCount(item.id)).toBe(0);
expect(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(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(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(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(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(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(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(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);
testDb.prepare('UPDATE trips SET currency = ? WHERE id = ?').run(tripCurrency, trip.id);
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 = (id: number) =>
testDb.prepare('SELECT currency, exchange_rate FROM budget_items WHERE id = ?')
.get(id) as { currency: string | null; exchange_rate: number };
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' });
testDb.prepare("UPDATE trips SET currency = 'EUR' WHERE id = ?").run(trip.id);
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(itemRow(implicit.id)).toEqual({ currency: 'EUR', exchange_rate: RATES.RUB.EUR });
expect(itemRow(usd.id)).toEqual({ currency: 'USD', exchange_rate: RATES.RUB.USD });
// Already in the trip's new currency → no conversion left to freeze.
expect(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);
testDb.prepare("UPDATE trips SET currency = 'EUR' WHERE id = ?").run(trip.id);
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');
testDb.prepare("UPDATE trips SET currency = 'RUB' WHERE id = ?").run(trip.id);
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' });
testDb.prepare("UPDATE trips SET currency = 'EUR' WHERE id = ?").run(trip.id);
const priced = (price: number | null, currency: string | null) => {
const r = testDb.prepare('INSERT INTO places (trip_id, name, price, currency) VALUES (?, ?, ?, ?)')
.run(trip.id, 'Place', price, currency);
return Number(r.lastInsertRowid);
};
// 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 = priced(15, null);
const jpy = priced(1500, 'JPY');
const free = priced(null, null);
await budget.rebaseTripCurrency(trip.id, 'JPY');
const placeRow = (id: number) =>
testDb.prepare('SELECT price, currency FROM places WHERE id = ?')
.get(id) as { price: number | null; currency: string | null };
// 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(placeRow(implicit)).toEqual({ price: 15, currency: 'EUR' });
expect(placeRow(jpy)).toEqual({ price: 1500, currency: 'JPY' });
// Nothing to denominate without a price: leave it inheriting the trip's currency.
expect(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);
testDb.prepare("UPDATE trips SET currency = 'EUR' WHERE id = ?").run(trip.id);
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 = () =>
testDb.prepare('SELECT currency, exchange_rate FROM budget_settlements WHERE id = ?').get(settlement.id) as { currency: string | null; exchange_rate: number };
expect(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(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' });
testDb.prepare("UPDATE trips SET currency = 'EUR' WHERE id = ?").run(trip.id);
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(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 = testDb.prepare('SELECT persons FROM budget_items WHERE id = ?').get(item.id) as { persons: number | null };
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);
testDb.prepare("UPDATE trips SET currency = 'EUR' WHERE id = ?").run(trip.id);
const item = await budget.createBudgetItem(trip.id, { name: 'Implicit', total_price: 100, members: [{ user_id: user.id }] });
await budget.rebaseTripCurrency(trip.id, 'RUB');
const row = testDb.prepare('SELECT currency, exchange_rate FROM budget_items WHERE id = ?').get(item.id) as { currency: string | null; exchange_rate: number };
expect(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 = Number(testDb
.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Flight', 'flight')")
.run(trip.id).lastInsertRowid);
const item = await budget.linkBudgetItemToReservation(trip.id, reservationId, { name: 'Flight', total_price: 200 });
expect(item.reservation_id).toBe(reservationId);
const row = testDb.prepare('SELECT reservation_id FROM budget_items WHERE id = ?').get(item.id) as { reservation_id: number | null };
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 = Number(testDb
.prepare("INSERT INTO places (trip_id, name) VALUES (?, 'Louvre')")
.run(trip.id).lastInsertRowid);
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 = testDb.prepare('SELECT place_id FROM budget_items WHERE id = ?').get(item.id) as { place_id: number | null };
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-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 = testDb.prepare('SELECT total_price FROM budget_items WHERE id = ?').get(item.id) as { total_price: number };
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 res = testDb.prepare(
"INSERT INTO reservations (trip_id, title, type, metadata) VALUES (?, 'Flight', 'flight', ?)",
).run(trip.id, JSON.stringify({ airline: 'CZ', seat: '12A', price: '2040', priceCurrency: 'CNY' }));
const reservationId = Number(res.lastInsertRowid);
const item = await budget.createBudgetItem(trip.id, { name: 'Flight', total_price: 2040 });
testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(reservationId, item.id);
expect(await budget.deleteBudgetItem(item.id, trip.id)).toBe(true);
const after = testDb.prepare('SELECT metadata FROM reservations WHERE id = ?').get(reservationId) as { metadata: string };
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(testDb.prepare('SELECT id FROM budget_items WHERE id = ?').get(item.id)).toBeUndefined();
});
});
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);
const payerRows = testDb
.prepare('SELECT count(*) AS cnt FROM budget_item_payers WHERE budget_item_id = ?')
.get(unpaid.id) as { cnt: number };
expect(payerRows.cnt).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' });
testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('Alice Displayed', alice.id);
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 = testDb.prepare('SELECT settled_at FROM budget_settlements WHERE id = ?').get(dated!.id) as { settled_at: string | null };
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 = testDb.prepare('SELECT note, ticket_json FROM budget_items WHERE id = ?').get(item!.id) as {
note: string | null; ticket_json: string | null;
};
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 = testDb.prepare('SELECT note FROM budget_items WHERE id = ?').get(item!.id) as { note: string | null };
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 = testDb.prepare('SELECT note FROM budget_items WHERE id = ?').get(item!.id) as { note: string | null };
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 = testDb.prepare('SELECT note, ticket_json FROM budget_items WHERE id = ?').get(item!.id) as {
note: string | null; ticket_json: string | null;
};
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 = testDb.prepare('SELECT note, ticket_json FROM budget_items WHERE id = ?').get(item!.id) as {
note: string | null; ticket_json: string | null;
};
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();
const cat = testDb.prepare("SELECT 1 FROM budget_category_order WHERE trip_id = ? AND category = 'atomic-test'").get(trip.id);
expect(cat).toBeUndefined();
});
});
/**
* 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 = (itemId: number) =>
(testDb.prepare('SELECT total_price FROM budget_items WHERE id = ?').get(itemId) as { total_price: number }).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(totalOf(item.id)).toBe(163.21);
expect(String(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(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(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(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 res = testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name, mime_type, file_size) VALUES (?, ?, ?, ?, ?)').run(
trip.id, 'receipt-123.jpg', 'receipt.jpg', 'image/jpeg', 1024
);
const fileId = Number(res.lastInsertRowid);
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 = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'f1.jpg', 'f1.jpg').lastInsertRowid);
// File 2: will be kept
const f2 = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'f2.jpg', 'f2.jpg').lastInsertRowid);
// File 3: will be added
const f3 = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'f3.jpg', 'f3.jpg').lastInsertRowid);
// File 4: will be removed but is shared with another budget item -> should NOT be trashed
const f4 = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'f4.jpg', 'f4.jpg').lastInsertRowid);
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 = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(fid) as { deleted_at: string | null };
expect(row.deleted_at).toBeNull();
}
expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE budget_item_id = ?').get(item.id)).toEqual({ c: 2 });
// f4 keeps the link it has to the other expense.
expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ?').get(f4)).toEqual({ c: 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 = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'r.jpg', 'r.jpg').lastInsertRowid);
const place = testDb.prepare('INSERT INTO places (trip_id, name) VALUES (?, ?)').run(trip.id, 'Osteria').lastInsertRowid;
const reservation = testDb.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Table', 'restaurant')").run(trip.id).lastInsertRowid;
testDb.prepare('INSERT INTO file_links (file_id, place_id) VALUES (?, ?)').run(file, place);
testDb.prepare('INSERT INTO file_links (file_id, reservation_id) VALUES (?, ?)').run(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(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ? AND budget_item_id = ?').get(file, item.id)).toEqual({ c: 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 = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'dup.jpg', 'dup.jpg').lastInsertRowid);
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 = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'del1.jpg', 'del1.jpg').lastInsertRowid);
// File 2: linked to a place directly
const place = testDb.prepare('INSERT INTO places (trip_id, name) VALUES (?, ?)').run(trip.id, 'Hotel').lastInsertRowid;
const f2 = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name, place_id) VALUES (?, ?, ?, ?)').run(trip.id, 'del2.jpg', 'del2.jpg', place).lastInsertRowid);
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 = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(fid) as { deleted_at: string | null };
expect(row.deleted_at).toBeNull();
}
const linkRows = testDb.prepare('SELECT * FROM file_links WHERE budget_item_id = ?').all(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 = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'r.pdf', 'r.pdf').lastInsertRowid);
const item = await budget.createBudgetItem(trip.id, { name: 'Dinner', receipt_file_ids: [plain] });
await budget.deleteBudgetItem(item.id, trip.id);
expect((testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(plain) as { deleted_at: string | null }).deleted_at).toBeNull();
// The link is gone, because it was all the row carried.
expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ?').get(plain)).toEqual({ c: 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 = Number(testDb.prepare('INSERT INTO places (trip_id, name) VALUES (?, ?)').run(trip.id, 'Cafe').lastInsertRowid);
const file = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'menu.pdf', 'menu.pdf').lastInsertRowid);
testDb.prepare('INSERT INTO file_links (file_id, place_id) VALUES (?, ?)').run(file, place);
const item = await budget.createBudgetItem(trip.id, { name: 'Lunch', receipt_file_ids: [file] });
await budget.deleteBudgetItem(item.id, trip.id);
const row = testDb.prepare('SELECT place_id, budget_item_id FROM file_links WHERE file_id = ?').get(file) as { place_id: number | null; budget_item_id: number | null };
expect(row.place_id).toBe(place);
expect(row.budget_item_id).toBeNull();
expect((testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(file) as { deleted_at: string | null }).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 = Number(testDb.prepare('INSERT INTO places (trip_id, name) VALUES (?, ?)').run(trip.id, 'Cafe').lastInsertRowid);
const file = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'receipt.pdf', 'receipt.pdf').lastInsertRowid);
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).
testDb.prepare('INSERT INTO file_links (file_id, budget_item_id, place_id) VALUES (?, ?, ?)').run(file, item.id, place);
await budget.deleteBudgetItem(item.id, trip.id);
const row = testDb.prepare('SELECT place_id, budget_item_id FROM file_links WHERE file_id = ?').get(file) as { place_id: number | null; budget_item_id: number | null };
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 = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'r.pdf', 'r.pdf').lastInsertRowid);
const item = await budget.createBudgetItem(trip.id, { name: 'Lunch' }) as { id: number };
const linkId = Number(testDb.prepare('INSERT INTO file_links (file_id) VALUES (?)').run(file).lastInsertRowid);
await budget.updateBudgetItem(item.id, trip.id, { receipt_file_ids: [file] });
const row = testDb.prepare('SELECT id, budget_item_id FROM file_links WHERE file_id = ?').get(file) as { id: number; budget_item_id: number | null };
// 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 = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'old.pdf', 'old.pdf').lastInsertRowid);
const item = await budget.createBudgetItem(trip.id, { name: 'Taxi', receipt_file_ids: [file] });
testDb.prepare('UPDATE trip_files SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?').run(file);
// 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(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ? AND budget_item_id = ?').get(file, item.id)).toEqual({ c: 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 = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'keep.pdf', 'keep.pdf').lastInsertRowid);
const item = await budget.createBudgetItem(trip.id, { name: 'Hotel', receipt_file_ids: [file] });
const before = testDb.prepare('SELECT id FROM file_links WHERE file_id = ? AND budget_item_id = ?').get(file, item.id) as { id: number };
await budget.updateBudgetItem(item.id, trip.id, { name: 'Hotel 2', receipt_file_ids: [file] });
const after = testDb.prepare('SELECT id FROM file_links WHERE file_id = ? AND budget_item_id = ?').get(file, item.id) as { id: number };
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.
testDb.prepare('INSERT INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)').run(trip.id, 'Food', 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);
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 = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'r.pdf', 'r.pdf').lastInsertRowid);
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);
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);
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);
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.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);
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);
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();
const rows = testDb.prepare('SELECT COUNT(*) c FROM budget_item_members WHERE budget_item_id = ? AND user_id = ?').get(item.id, user.id);
expect(rows).toEqual({ c: 1 });
});
});