mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
06bee95ae3
A euro trip read in dollars books a bill entered in dollars at the rate of the day, from the euro's quote: 12,345.67 USD at 1.1398 is 10,831.44 EUR. The Costs screen and the settlement then turned those euros back into dollars with the dollar's own quote, 1 / 0.87732. Frankfurter rounds the two quotes separately, so they are not exact inverses, and the bill read $12,346.05 the moment it was saved, a $250.00 one $250.01. The error grows with the amount, which is why the large rows showed it first. Paying back what settle-up offered for such a bill left the trip open with the debt flipped the other way. Both sides now convert trip money to the display currency with the trip currency's own quote, the one every entry rate is frozen from. On the server, settlement() fetches that quote for the REST route and for the MCP get_settlement_summary tool, which now makes the same call, and a legacy transfer without a currency reads back through the same factor. On the client, useExchangeRates takes the trip currency as an anchor and converts through its cached quote, falling back to the display currency's while it is missing, so it works offline from cache and needs no change to the settlement response. The final budget builds each row from the unrounded trip amount, because a euro cent is worth more than a dollar cent and 10,831.44 EUR came back as 12,345.68; the balances and settle-up still net whole trip cents. A same-day bill in the display currency now reads exactly as typed on the row, the payer chip, the day header, the totals, Outstanding and the final budget, on both shells and on the shared trip page, and paying the amount settle-up offers leaves both balances at zero. When the rate really moved, the row keeps the value the totals count, with what was entered and the euros it was booked at underneath, "$801.76 -> 685,26 EUR". One helper builds that line for the desktop list, the phone list, payments, the final budget breakdown and the shared page; it prints nothing for an amount of 0 or a bill that reads as typed. The expense editors share one module for their preview. The split summary puts the share the row lends or borrows next to the entered one, "$400.88 -> $390.53 each", and an expense saved without a currency still opens in the trip currency. Both CSV exports add the amount in the trip currency when it differs from the display currency. Kept from before: the desktop totals recompute when rates arrive, a booking's linked cost is labelled in the trip currency, a legacy transfer is read in the display currency, the shared page reads the frozen rate in whole cents, a booking import keeps the currency it was quoted in at a rate frozen before the write (REST and plugin RPC), and the MCP trip summary, the budget-overview prompt and the per-person summary add every row up in trip cents at its frozen rate. The packing-list prompt reads the trip title directly instead of building the whole summary, which could wait on a rates fetch.
1316 lines
65 KiB
TypeScript
1316 lines
65 KiB
TypeScript
/**
|
||
* 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 },
|
||
// The dollar's own quote, as Frankfurter prints it: rounded, so not the exact
|
||
// inverse of the euro's 1.1429 (1 / 0.87497 = 1.142896...).
|
||
USD: { USD: 1, EUR: 0.87497 },
|
||
} as Record<string, Record<string, number>>,
|
||
}));
|
||
// Constructor-injected since the fold; the class is mocked at the module path
|
||
// so every separately-constructed instance sees the same deterministic rates
|
||
// as the SUT.
|
||
vi.mock('../../../src/nest/budget/exchange-rates.service', () => ({
|
||
ExchangeRatesService: class {
|
||
async getRates(base: string) {
|
||
return RATES[base.toUpperCase()] ?? null;
|
||
}
|
||
},
|
||
}));
|
||
|
||
import { 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 (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.
|
||
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;
|
||
|
||
// 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', () => {
|
||
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 = (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 = 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);
|
||
});
|
||
});
|
||
|
||
// ── 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', () => {
|
||
const { user: alice } = createUser(testDb, { username: 'alice' });
|
||
const trip = createTrip(testDb, alice.id);
|
||
|
||
const item = 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', () => {
|
||
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 = budget.createBudgetItem(trip.id, {
|
||
name: 'Refund', total_price: -60, payers: [{ user_id: alice.id, amount: -60 }],
|
||
}) as { id: number };
|
||
|
||
const updated = 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', () => {
|
||
// 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 = 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', () => {
|
||
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 = 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', () => {
|
||
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);
|
||
budget.createBudgetItem(trip.id, {
|
||
name: 'Dinner', payers: [{ user_id: alice.id, amount: 90 }],
|
||
member_ids: [alice.id, bob.id],
|
||
});
|
||
budget.createBudgetItem(trip.id, {
|
||
name: 'Refund', payers: [{ user_id: alice.id, amount: -30 }],
|
||
member_ids: [alice.id, bob.id],
|
||
});
|
||
|
||
const result = 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', () => {
|
||
// 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 = budget.createBudgetItem(trip.id, { name: 'Flight', total_price: 2040 });
|
||
testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(reservationId, item.id);
|
||
|
||
expect(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', () => {
|
||
const { user } = createUser(testDb, { username: 'owner' });
|
||
const trip = createTrip(testDb, user.id, { title: 'Trip' });
|
||
const item = budget.createBudgetItem(trip.id, { name: 'Coffee', total_price: 4 });
|
||
|
||
expect(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', () => {
|
||
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);
|
||
|
||
budget.createBudgetItem(trip.id, {
|
||
name: 'Dinner', payers: [{ user_id: alice.id, amount: 120 }],
|
||
member_ids: [alice.id, bob.id, carol.id],
|
||
});
|
||
const before = 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 = 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 = 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 = 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)', () => {
|
||
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');
|
||
});
|
||
|
||
it('BUDGET-SVC-DB-042: a settle-up payment carries its own settled_at, independent of created_at', () => {
|
||
const { user: alice } = createUser(testDb, { username: 'alice' });
|
||
const { user: bob } = createUser(testDb, { username: 'bob' });
|
||
const trip = createTrip(testDb, alice.id);
|
||
|
||
const noDate = 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 = 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(budget.getSettlement(dated!.id, trip.id)!.settled_at).toBe('2026-01-05');
|
||
|
||
const moved = 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 = 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', () => {
|
||
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 = budget.insertSettlement(trip.id, { ...parties, settled_at: '' }, alice.id);
|
||
expect(blank!.settled_at).toBeNull();
|
||
|
||
const dated = budget.insertSettlement(trip.id, { ...parties, settled_at: '2026-01-05' }, alice.id);
|
||
expect(budget.applySettlementUpdate(dated!.id, trip.id, { ...parties, settled_at: null })!.settled_at).toBeNull();
|
||
|
||
budget.applySettlementUpdate(dated!.id, trip.id, { ...parties, settled_at: '2026-01-05' });
|
||
expect(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', () => {
|
||
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);
|
||
});
|
||
|
||
it('attaches receipts created with receipt_file_ids and lists them', () => {
|
||
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 = 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 = 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', () => {
|
||
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);
|
||
|
||
budget.createBudgetItem(trip.id, { name: 'Other', receipt_file_ids: [f4] });
|
||
const item = budget.createBudgetItem(trip.id, { name: 'Dinner', receipt_file_ids: [f1, f2, f4] });
|
||
|
||
// Update item: remove f1 and f4, keep f2, add f3
|
||
const updated = 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', () => {
|
||
// 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 = budget.createBudgetItem(trip.id, { name: 'Dinner', total_price: 40, receipt_file_ids: [file] });
|
||
const again = 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', () => {
|
||
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 = budget.createBudgetItem(trip.id, { name: 'Taxi' });
|
||
|
||
const updated = 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', () => {
|
||
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 = budget.createBudgetItem(trip.id, { name: 'Lunch', receipt_file_ids: [f1, f2] });
|
||
const deleted = 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', () => {
|
||
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 = budget.createBudgetItem(trip.id, { name: 'Dinner', receipt_file_ids: [plain] });
|
||
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', () => {
|
||
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 = budget.createBudgetItem(trip.id, { name: 'Lunch', receipt_file_ids: [file] });
|
||
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('leaves a receipt already in the trash linked, so restoring it comes back attached', () => {
|
||
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 = 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.
|
||
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', () => {
|
||
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 = 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 };
|
||
|
||
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);
|
||
});
|
||
});
|
||
|
||
// #2525: a bill entered in dollars on a euro trip was booked at the rate of the day. The
|
||
// totals the MCP summary, the budget prompt and the per-person resource hand out added its
|
||
// dollars to the euros as they stood and called the sum euros.
|
||
describe('trip totals read every row in the trip currency (#2525)', () => {
|
||
function seedDollarBill() {
|
||
const { user: me } = createUser(testDb);
|
||
const { user: bob } = createUser(testDb);
|
||
const trip = createTrip(testDb, me.id);
|
||
addTripMember(testDb, trip.id, bob.id);
|
||
const members = [{ user_id: me.id }, { user_id: bob.id }];
|
||
const hotel = budget.createBudgetItem(trip.id, {
|
||
name: 'Aparthotel Silver', category: 'accommodation', currency: 'USD', exchange_rate: 1.17,
|
||
payers: [{ user_id: me.id, amount: 801.76 }], members,
|
||
});
|
||
budget.createBudgetItem(trip.id, {
|
||
name: 'Dinner', category: 'food', currency: 'EUR',
|
||
payers: [{ user_id: bob.id, amount: 100 }], members,
|
||
});
|
||
return { trip, me, bob, hotel };
|
||
}
|
||
|
||
it('BUDGET-SVC-DB-044: adds each row up at the rate it was booked at, in whole cents', async () => {
|
||
const { trip } = seedDollarBill();
|
||
// 801.76 USD at 1.17 is 685.26 EUR, the figure the settlement nets as well.
|
||
expect(budget.tripTotals(trip.id, 'EUR')).toEqual({
|
||
total: 785.26,
|
||
byCategory: { accommodation: 685.26, food: 100 },
|
||
});
|
||
// Both rows carry what they need, so nothing waits on today's rates.
|
||
expect(await budget.ratesForTripTotals(trip.id, 'EUR')).toBeNull();
|
||
});
|
||
|
||
it('BUDGET-SVC-DB-045: fetches today\'s rate only for a row that never froze one', async () => {
|
||
const { user: me } = createUser(testDb);
|
||
const trip = createTrip(testDb, me.id);
|
||
// Written before the freeze existed: the column default of 1 is not a booked rate.
|
||
budget.createBudgetItem(trip.id, { name: 'Old taxi', category: 'transport', total_price: 114.29, currency: 'USD' });
|
||
const rates = await budget.ratesForTripTotals(trip.id, 'EUR');
|
||
expect(rates).toEqual(RATES.EUR);
|
||
// 114.29 USD at 1.1429 per euro, the same way the settlement reads such a row.
|
||
expect(budget.tripTotals(trip.id, 'EUR', rates).total).toBe(100);
|
||
});
|
||
|
||
it('BUDGET-SVC-DB-046: the per-person summary splits trip cents, not raw amounts', async () => {
|
||
const { trip, me, bob, hotel } = seedDollarBill();
|
||
budget.toggleMemberPaid(hotel.id, trip.id, bob.id, true);
|
||
|
||
const summary = await budget.perPersonSummary(trip.id);
|
||
const of = (id: number) => summary.find(s => s.user_id === id)!;
|
||
// Half of 685.26 EUR plus half of 100 EUR each. The query this replaced put
|
||
// 450.88 on both, half the dollars counted as euros, and never said which.
|
||
expect(of(me.id)).toMatchObject({ total_assigned: 392.63, total_paid: 0, items_count: 2, currency: 'EUR' });
|
||
expect(of(bob.id)).toMatchObject({ total_assigned: 392.63, total_paid: 342.63, items_count: 2, currency: 'EUR' });
|
||
});
|
||
|
||
it('BUDGET-SVC-DB-047: an equal split hands out the odd cent instead of a float tail', async () => {
|
||
const { user: a } = createUser(testDb);
|
||
const { user: b } = createUser(testDb);
|
||
const { user: c } = createUser(testDb);
|
||
const trip = createTrip(testDb, a.id);
|
||
addTripMember(testDb, trip.id, b.id);
|
||
addTripMember(testDb, trip.id, c.id);
|
||
budget.createBudgetItem(trip.id, {
|
||
name: 'Boat', currency: 'EUR', payers: [{ user_id: a.id, amount: 100 }],
|
||
members: [{ user_id: a.id }, { user_id: b.id }, { user_id: c.id }],
|
||
});
|
||
|
||
const shares = (await budget.perPersonSummary(trip.id)).map(s => Math.round(s.total_assigned * 100));
|
||
expect(shares.slice().sort((x, y) => x - y)).toEqual([3333, 3333, 3334]);
|
||
expect(shares.reduce((x, y) => x + y, 0)).toBe(10000);
|
||
});
|
||
});
|
||
|
||
// #2525: a euro trip read in dollars froze a dollar bill's rate from the euro's quote and
|
||
// converted the ledger back with the dollar's. The two quotes are not exact inverses, so a
|
||
// bill entered today read a few cents off and paying what settle-up offered left a balance.
|
||
describe('the settlement converts with the quote the entry rate was frozen from (#2525)', () => {
|
||
function seedSameDayBill() {
|
||
const { user: me } = createUser(testDb);
|
||
const { user: bob } = createUser(testDb);
|
||
const trip = createTrip(testDb, me.id);
|
||
addTripMember(testDb, trip.id, bob.id);
|
||
return { trip, me, bob };
|
||
}
|
||
|
||
it('BUDGET-SVC-DB-048: a same-day bill in the display currency reads as typed and settles to zero', async () => {
|
||
const { trip, me, bob } = seedSameDayBill();
|
||
// Frozen now, from the euro's quote: 1.1429.
|
||
const bill = await budget.create(String(trip.id), {
|
||
name: 'Villa', category: 'accommodation', currency: 'USD', total_price: 12345.67,
|
||
payers: [{ user_id: me.id, amount: 12345.67 }], members: [{ user_id: me.id }, { user_id: bob.id }],
|
||
}) as { id: number; exchange_rate: number };
|
||
expect(bill.exchange_rate).toBe(1.1429);
|
||
|
||
const before = await budget.settlement(trip.id, 'USD', 'EUR');
|
||
const mine = before.finalBudgets.find(f => f.user_id === me.id)!;
|
||
expect(mine.expenses).toBe(12345.67);
|
||
expect(mine.sources.fronted).toEqual([{ item_id: bill.id, cents: 1234567 }]);
|
||
expect(before.flows.map(f => f.amount)).toEqual([6172.84]);
|
||
|
||
// Bob pays exactly what settle-up offers, in dollars, as the Costs screen records it.
|
||
await budget.createSettlement(trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: 6172.84, currency: 'USD' }, me.id);
|
||
const after = await budget.settlement(trip.id, 'USD', 'EUR');
|
||
expect(after.balances.map(b => b.balance)).toEqual([0, 0]);
|
||
expect(after.flows).toEqual([]);
|
||
expect((await budget.settlement(trip.id, 'EUR', 'EUR')).balances.map(b => b.balance)).toEqual([0, 0]);
|
||
});
|
||
|
||
it('BUDGET-SVC-DB-051: the final budget lists a bill at what was typed even where its trip cent rounds away', async () => {
|
||
const { trip, me, bob } = seedSameDayBill();
|
||
// 123.45 USD is 108.0147 EUR. As a whole trip cent, 108.01 EUR, it came back as 123.44.
|
||
const bill = await budget.create(String(trip.id), {
|
||
name: 'Taxi', currency: 'USD', total_price: 123.45,
|
||
payers: [{ user_id: me.id, amount: 123.45 }], members: [{ user_id: me.id }, { user_id: bob.id }],
|
||
}) as { id: number };
|
||
const s = await budget.settlement(trip.id, 'USD', 'EUR');
|
||
const mine = s.finalBudgets.find(f => f.user_id === me.id)!;
|
||
const bobs = s.finalBudgets.find(f => f.user_id === bob.id)!;
|
||
expect(mine.sources.fronted).toEqual([{ item_id: bill.id, cents: 12345 }]);
|
||
expect(mine.expenses).toBe(123.45);
|
||
// The two shares still add up to the bill, and the ledger in euros is untouched.
|
||
expect(Math.round((mine.final + bobs.final) * 100)).toBe(12345);
|
||
const inEur = await budget.settlement(trip.id, 'EUR', 'EUR');
|
||
expect(inEur.finalBudgets.find(f => f.user_id === me.id)!.sources.fronted).toEqual([{ item_id: bill.id, cents: 10801 }]);
|
||
});
|
||
|
||
it('BUDGET-SVC-DB-049: a transfer saved without a currency reads back in the display currency as entered', async () => {
|
||
const { trip, me, bob } = seedSameDayBill();
|
||
await budget.createSettlement(trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: 30 }, me.id);
|
||
const s = await budget.settlement(trip.id, 'USD', 'EUR');
|
||
const mine = s.finalBudgets.find(f => f.user_id === me.id)!;
|
||
expect(mine.reimbursed).toBe(30);
|
||
expect(s.balances.find(b => b.user_id === me.id)!.balance).toBe(-30);
|
||
});
|
||
|
||
it('BUDGET-SVC-DB-050: falls back to the display currency\'s quote when the trip\'s is unavailable', async () => {
|
||
const { trip, me, bob } = seedSameDayBill();
|
||
budget.createBudgetItem(trip.id, {
|
||
name: 'Dinner', currency: 'EUR', payers: [{ user_id: me.id, amount: 100 }],
|
||
members: [{ user_id: me.id }, { user_id: bob.id }],
|
||
});
|
||
const rates = (budget as unknown as { exchangeRates: ExchangeRatesService }).exchangeRates;
|
||
const spy = vi.spyOn(rates, 'getRates').mockImplementation(async (base: string) => (base === 'EUR' ? null : RATES[base] ?? null));
|
||
try {
|
||
const s = await budget.settlement(trip.id, 'USD', 'EUR');
|
||
expect(spy.mock.calls.map(c => c[0])).toEqual(['EUR', 'USD']);
|
||
// 50 EUR at 1 / 0.87497 USD per euro.
|
||
expect(s.balances.find(b => b.user_id === me.id)!.balance).toBe(57.14);
|
||
} finally {
|
||
spy.mockRestore();
|
||
}
|
||
});
|
||
});
|