Files
TREK/server/tests/unit/nest/budget.service.db.test.ts
T
Maurice 9522e4ce4a fix(budget): keep an uneven split from corrupting the expense total
163.21 across two people is 81.61 and 81.60. Both are exact to the cent — the
client splits properly — but the server discarded the total it was sent and
re-derived it by adding those two as doubles, which lands on
163.20999999999998. That is the number that went into the row.

It looked like a display quirk because everything routed through Intl came
out right; only the places that print a number as it stands showed it. The
stored value was wrong all along, which is why reopening the expense showed
it back, and why the price stamped onto a linked booking carried it too.

The domain already had this rule and followed it everywhere else — the
settlement maths converts to whole cents before it adds anything. The payer
sum was the one arithmetic still done in euros, in both the create and the
update path, so it now goes through the same rule.

The client side is what makes rows that are already stored wrong stop
looking wrong: the expense form and the mobile cost sheet seed their total
from a cent-clean value, and the three panels that print a booking's price
verbatim tidy it when it parses as a number. Free text an import wrote there
passes through untouched rather than becoming NaN.

Closes #1964
2026-08-20 17:44:21 +02:00

776 lines
35 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';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => null,
isOwner: () => false,
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
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 { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
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';
const budget = new BudgetService(
new DatabaseService(testDb),
new PermissionsService(new DatabaseService(testDb)),
new ExchangeRatesService(),
new RealtimeService(),
);
// 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.
const dbs = () => new DatabaseService(testDb);
const membersSvc = new TripMembersService(
dbs(),
budget,
new UserCleanupService(dbs(), budget),
new PermissionsService(dbs()),
new RealtimeService(),
notificationsStub(),
);
const createGuest = membersSvc.createGuest.bind(membersSvc);
const deleteGuest = membersSvc.deleteGuest.bind(membersSvc);
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
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', () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const guests = ['G1', 'G2', 'G3'].map(n => createGuest(trip.id, n, owner.id).member);
const item = budget.createBudgetItem(trip.id, {
name: 'Dinner', total_price: 400,
member_ids: [owner.id, ...guests.map(g => g.id)],
});
expect(personsOf(item.id)).toBe(4);
deleteGuest(trip.id, guests[0].id);
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', () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const guest = createGuest(trip.id, 'G1', owner.id).member;
// No member rows — `persons` is just a number someone typed.
const item = budget.createBudgetItem(trip.id, { name: 'Rental', total_price: 300, persons: 6 });
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', () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const guest = createGuest(trip.id, 'G1', owner.id).member;
const item = budget.createBudgetItem(trip.id, { name: 'Taxi', total_price: 50, member_ids: [guest.id] });
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', () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const guest = createGuest(trip.id, 'G1', owner.id).member;
const item = budget.createBudgetItem(trip.id, { name: 'Dinner', total_price: 200, member_ids: [owner.id, guest.id] });
deleteGuest(trip.id, guest.id);
// A client that loaded before the deletion still sends the guest back (#1553).
const updated = 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', () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const guest = createGuest(trip.id, 'G1', owner.id).member;
const item = budget.createBudgetItem(trip.id, { name: 'Drinks', total_price: 60, member_ids: [owner.id, guest.id] });
deleteGuest(trip.id, guest.id);
const result = 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', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip A' });
const item = budget.createBudgetItem(trip.id, { name: 'Hotel', total_price: 100 });
budget.updateMembers(item.id, trip.id, [user.id]);
const member = 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)', () => {
const { user } = createUser(testDb);
const tripA = createTrip(testDb, user.id, { title: 'Trip A' });
const tripB = createTrip(testDb, user.id, { title: 'Trip B' });
const itemB = budget.createBudgetItem(tripB.id, { name: 'Foreign expense', total_price: 50 });
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 = budget.toggleMemberPaid(itemB.id, tripA.id, user.id, true);
expect(member).toBeNull();
expect(paidFlag(itemB.id, user.id)).toBe(0); // unchanged
});
});
describe('calculateSettlement custom splits', () => {
it('BUDGET-SVC-DB-003: settles by the custom per-member amounts, not the equal split (#1458)', () => {
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.
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 = 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', () => {
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).
budget.createBudgetItem(trip.id, { name: 'Coffee', payers: [{ user_id: alice.id, amount: 10 }], members });
budget.createBudgetItem(trip.id, { name: 'Dinner', payers: [{ user_id: bob.id, amount: 100.01 }], members });
budget.createBudgetItem(trip.id, { name: 'Stamp', payers: [{ user_id: carol.id, amount: 0.01 }], members });
const before = budget.calculateSettlement(trip.id);
expect(before.flows.length).toBeGreaterThan(0);
for (const f of before.flows) {
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 = 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 } = 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 = budget.calculateSettlement(trip.id, { base: 'RUB', tripCurrency: 'RUB', rates: RATES.RUB });
const second = 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. */
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, 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.
budget.createBudgetItem(trip.id, { name: 'Проезд обратно', total_price: 9000, currency: 'RUB', members });
budget.createBudgetItem(trip.id, { name: 'Проезд туда', currency: 'RUB', payers: [{ user_id: me.id, amount: 9000 }], members });
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', () => {
const { trip, me, danil, serega } = seedIssue1543Trip('RUB');
const result = budget.calculateSettlement(trip.id, { base: 'RUB', tripCurrency: 'RUB', rates: RATES.RUB });
const balanceOf = (id: number) => result.balances.find(b => b.user_id === id)!.balance;
// Total spend is 9 000 + 9 000 + $100 (≈7 668 ₽), so each of the three owes a third
// of it 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 totalSpend = 18000 + 100 / RATES.RUB.USD;
const share = totalSpend / 3;
expect(balanceOf(me.id)).toBeCloseTo(9000 + 100 / RATES.RUB.USD - share, -1);
expect(balanceOf(danil.id)).toBeCloseTo(-share, -1);
expect(balanceOf(serega.id)).toBeCloseTo(-share, -1);
// The 9 000 ₽ expense nobody paid is the only imbalance in the trip.
expect(result.balances.reduce((a, b) => a + b.balance, 0)).toBeCloseTo(-9000, 1);
});
it('BUDGET-SVC-DB-005: reports the same balances when the display currency differs from the trip currency', () => {
const { trip, danil } = seedIssue1543Trip('RUB');
// Same trip, viewed in EUR: every balance is the RUB one converted once, at the end.
const inEur = 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 = (18000 + 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 = budget.createBudgetItem(trip.id, { name: 'Implicit', total_price: 100, members }) as { id: number };
const usd = budget.createBudgetItem(trip.id, { name: 'USD', total_price: 100, currency: 'USD', exchange_rate: 1.1429, members }) as { id: number };
const rub = 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 }];
budget.createBudgetItem(trip.id, { name: 'Hotel', payers: [{ user_id: alice.id, amount: 100 }], members });
budget.createBudgetItem(trip.id, { name: 'Dinner', currency: 'USD', exchange_rate: 1.1429, payers: [{ user_id: bob.id, amount: 60 }], members });
const before = 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 = 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-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 = 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', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = budget.createBudgetItem(trip.id, { name: 'Hotel', total_price: 100, member_ids: [user.id] });
const items = 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', () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb, { username: 'other' });
const trip = createTrip(testDb, owner.id);
const item = budget.createBudgetItem(trip.id, { name: 'Dinner', total_price: 80, member_ids: [owner.id, other.id] });
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 = 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', () => {
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 = 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)', () => {
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 = 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', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = 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(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', () => {
const { user: alice } = createUser(testDb, { username: 'alice' });
const { user: outsider } = createUser(testDb, { username: 'outsider' });
const trip = createTrip(testDb, alice.id);
const item = 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);
});
});
describe('post-fold quirk fixes', () => {
it('BUDGET-SVC-DB-019: settlements prefer display_name over username (quirk fix)', () => {
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 = 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(budget.listSettlements(trip.id)[0].from_username).toBe('Alice Displayed');
});
// ── Notes vs. itemized receipts (#1658) ────────────────────────────────────
it('BUDGET-SVC-DB-021: a note and a receipt are stored in their own columns', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = 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', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = 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.
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', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = budget.createBudgetItem(trip.id, { name: 'Taxi', total_price: 12, note: 'airport run' });
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', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = budget.createBudgetItem(trip.id, { name: 'Supermarket', total_price: 30, note: 'reimburse from the kitty' });
// An old tab, still encoding the receipt into `note`.
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', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = 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)', () => {
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.
expect(() => budget.createBudgetItem(trip.id, { name: null as unknown as string, category: 'atomic-test' })).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', () => {
const { user: alice } = createUser(testDb);
const { user: bob } = createUser(testDb);
const trip = createTrip(testDb, alice.id);
addTripMember(testDb, trip.id, bob.id);
const item = 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', () => {
const { user: alice } = createUser(testDb);
const { user: bob } = createUser(testDb);
const trip = createTrip(testDb, alice.id);
addTripMember(testDb, trip.id, bob.id);
const item = budget.createBudgetItem(trip.id, { name: 'Hotel', total_price: 10, members: [{ user_id: alice.id }] });
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', () => {
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 = 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', () => {
const { user: alice } = createUser(testDb);
const { user: bob } = createUser(testDb);
const trip = createTrip(testDb, alice.id);
addTripMember(testDb, trip.id, bob.id);
const item = 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);
});
})