mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
aa1f76ad11
A foreign expense without a frozen rate is converted with today's rate. When the server could not fetch rates itself, it counted the amount 1:1 in the trip currency, although the browser had the rates all along. A VND bill on an AUD trip was settled as millions of AUD. Such a row is now left out of the balances, flows, final budgets, trip totals and per-person summaries. The settlement reports it under `unconverted` and says which currency its figures are in; the trip summary lists the ids and the budget prompt says how many are missing. The browser lends the rates it already holds (`fallback_fx`) when it saves an expense or a settle-up transfer, and to heal existing rows through the new POST .../budget/freeze-rates route, which an editor's Costs view calls for the rows the settlement left out. The server still prefers its own rate, uses a lent one only while it is quoted against the trip currency, and never replaces a frozen or explicit rate. The settlement also takes the browser's display rate (`base_rate`) in place of a display quote the server cannot fetch; the browser leaves out a figure the query would refuse. The browser's own fetch gives up after 10 s. Changing a row's currency while no rate is available no longer keeps the old currency's rate; the row goes back to unfrozen. MCP gets the same through the new freeze_budget_rates tool, which only ever pins the server's own rate.
1559 lines
79 KiB
TypeScript
1559 lines
79 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 },
|
||
unconverted: [],
|
||
});
|
||
// Both rows carry what they need, so nothing waits on today's rates.
|
||
expect(await budget.ratesForTripTotals(trip.id, 'EUR')).toBeNull();
|
||
});
|
||
|
||
it('BUDGET-SVC-DB-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();
|
||
}
|
||
});
|
||
});
|
||
|
||
// The VND/AUD report: the server reached no rates, so a VND bill was stored with the
|
||
// "not frozen" rate 1 and read as 8,920,000 AUD. RATES has no AUD quote, so every AUD
|
||
// trip below is that server; a rate can only come from what the caller lends.
|
||
describe('rows no rate can convert, and the rates that heal them', () => {
|
||
const VND_FX = { base: 'AUD', rates: { VND: 18241.3 } };
|
||
const rateOf = (table: 'budget_items' | 'budget_settlements', id: number) =>
|
||
(testDb.prepare(`SELECT exchange_rate FROM ${table} WHERE id = ?`).get(id) as { exchange_rate: number }).exchange_rate;
|
||
const exchangeRates = () => (budget as unknown as { exchangeRates: ExchangeRatesService }).exchangeRates;
|
||
|
||
function seedAudTrip() {
|
||
const { user: me } = createUser(testDb);
|
||
const { user: bob } = createUser(testDb);
|
||
const { user: carol } = createUser(testDb);
|
||
const { user: dave } = createUser(testDb);
|
||
const trip = createTrip(testDb, me.id);
|
||
for (const u of [bob, carol, dave]) addTripMember(testDb, trip.id, u.id);
|
||
testDb.prepare("UPDATE trips SET currency = 'AUD' WHERE id = ?").run(trip.id);
|
||
const members = [me, bob, carol, dave].map(u => ({ user_id: u.id }));
|
||
return { trip, me, bob, carol, members };
|
||
}
|
||
|
||
it('BUDGET-SVC-DB-056: freezes fallback_fx when getRates is null', async () => {
|
||
const { trip, me, members } = seedAudTrip();
|
||
const bill = await budget.create(String(trip.id), {
|
||
name: 'Pho', currency: 'VND', payers: [{ user_id: me.id, amount: 8920000 }], members, fallback_fx: VND_FX,
|
||
});
|
||
expect(bill.exchange_rate).toBe(18241.3);
|
||
const s = await budget.settlement(trip.id, 'AUD', 'AUD');
|
||
expect(s.balances.find(b => b.user_id === me.id)!.balance).toBe(366.75);
|
||
});
|
||
|
||
it('BUDGET-SVC-DB-057: server quote wins', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id);
|
||
const bill = await budget.create(String(trip.id), {
|
||
name: 'Taxi', currency: 'USD', total_price: 20, fallback_fx: { base: 'EUR', rates: { USD: 2 } },
|
||
});
|
||
expect(bill.exchange_rate).toBe(RATES.EUR.USD);
|
||
});
|
||
|
||
it('BUDGET-SVC-DB-058: fallback_fx on another base is ignored', async () => {
|
||
const { trip } = seedAudTrip();
|
||
// Quoted against euros, the figure would freeze the bill against the wrong currency.
|
||
const bill = await budget.create(String(trip.id), {
|
||
name: 'Pho', currency: 'VND', total_price: 100000, fallback_fx: { base: 'EUR', rates: { VND: 27000 } },
|
||
});
|
||
expect(bill.exchange_rate).toBe(1);
|
||
});
|
||
|
||
it('BUDGET-SVC-DB-059: explicit exchange_rate wins, fallback_fx never reaches the row', async () => {
|
||
const { trip } = seedAudTrip();
|
||
const data = { name: 'Pho', currency: 'VND', total_price: 100000, exchange_rate: 18000, fallback_fx: VND_FX };
|
||
const bill = await budget.create(String(trip.id), data);
|
||
expect(bill.exchange_rate).toBe(18000);
|
||
expect('fallback_fx' in data).toBe(false);
|
||
});
|
||
|
||
it('BUDGET-SVC-DB-060: currency change without any rate drops the old rate (items)', async () => {
|
||
const { trip } = seedAudTrip();
|
||
const item = budget.createBudgetItem(trip.id, { name: 'Taxi', currency: 'USD', exchange_rate: 0.65, total_price: 20 });
|
||
// The update SQL keeps the stored rate when none is sent: a USD rate on a VND bill.
|
||
expect(await budget.update(item.id, trip.id, { currency: 'VND' })).toMatchObject({ currency: 'VND', exchange_rate: 1 });
|
||
// With a lent rate the new currency freezes instead.
|
||
expect(await budget.update(item.id, trip.id, { currency: 'THB', fallback_fx: { base: 'AUD', rates: { THB: 23.5 } } }))
|
||
.toMatchObject({ currency: 'THB', exchange_rate: 23.5 });
|
||
});
|
||
|
||
it('BUDGET-SVC-DB-061: same for settlements', async () => {
|
||
const { trip, me, bob } = seedAudTrip();
|
||
const transfer = await budget.createSettlement(trip.id, {
|
||
from_user_id: bob.id, to_user_id: me.id, amount: 20, currency: 'USD', fallback_fx: { base: 'AUD', rates: { USD: 0.65 } },
|
||
}, me.id);
|
||
expect(transfer).toMatchObject({ currency: 'USD', exchange_rate: 0.65 });
|
||
const moved = await budget.updateSettlement(transfer!.id, trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: 20, currency: 'VND' });
|
||
expect(moved).toMatchObject({ currency: 'VND', exchange_rate: 1 });
|
||
});
|
||
|
||
it('BUDGET-SVC-DB-062: unchanged currency never touches the rate', async () => {
|
||
const { trip } = seedAudTrip();
|
||
const frozen = budget.createBudgetItem(trip.id, { name: 'Pho', currency: 'VND', exchange_rate: 18241.3, total_price: 100000 });
|
||
const open = budget.createBudgetItem(trip.id, { name: 'Bus', currency: 'VND', total_price: 50000 });
|
||
const fx = { base: 'AUD', rates: { VND: 20000 } };
|
||
expect(await budget.update(frozen.id, trip.id, { name: 'Pho bo', currency: 'vnd', fallback_fx: fx })).toMatchObject({ exchange_rate: 18241.3 });
|
||
// Not even an unfrozen row: an edit is no place to heal, freeze-rates is.
|
||
expect(await budget.update(open.id, trip.id, { name: 'Night bus', currency: 'VND', fallback_fx: fx })).toMatchObject({ exchange_rate: 1 });
|
||
});
|
||
|
||
it('BUDGET-SVC-DB-063: freezeMissingRates heals items and transfers from fallback_fx', async () => {
|
||
const { trip, me, bob, carol, members } = seedAudTrip();
|
||
const bill = budget.createBudgetItem(trip.id, { name: 'Pho', currency: 'VND', payers: [{ user_id: me.id, amount: 8920000 }], members });
|
||
const transfer = budget.insertSettlement(trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: 2229963, currency: 'VND' }, bob.id)!;
|
||
// Carol pays in baht. The rows are healed currency by currency, THB before VND, so her
|
||
// later transfer is written first and the answer still lists the rows by id.
|
||
const bahtTransfer = budget.insertSettlement(trip.id, { from_user_id: carol.id, to_user_id: me.id, amount: 2872.88, currency: 'THB' }, carol.id)!;
|
||
|
||
const before = await budget.settlement(trip.id, 'AUD', 'AUD');
|
||
expect(before.unconverted).toEqual({ item_ids: [bill.id], settlement_ids: [bahtTransfer.id, transfer.id], currencies: ['THB', 'VND'] });
|
||
expect(before.balances).toEqual([]);
|
||
|
||
const healed = await budget.freezeMissingRates(trip.id, { base: 'AUD', rates: { VND: 18241.3, THB: 23.5 } });
|
||
expect(healed!.items.map(i => [i.id, i.exchange_rate])).toEqual([[bill.id, 18241.3]]);
|
||
expect(healed!.settlements.map(s => [s.id, s.exchange_rate])).toEqual([[transfer.id, 18241.3], [bahtTransfer.id, 23.5]]);
|
||
expect(healed!.unresolved).toEqual([]);
|
||
|
||
const after = await budget.settlement(trip.id, 'AUD', 'AUD');
|
||
expect(after.unconverted).toEqual({ item_ids: [], settlement_ids: [], currencies: [] });
|
||
// 489.00 AUD four ways. Bob's 2,229,963 VND and Carol's 2,872.88 THB (122.25 AUD
|
||
// each) square their shares.
|
||
expect(after.balances.find(b => b.user_id === me.id)!.balance).toBe(122.25);
|
||
expect(after.balances.find(b => b.user_id === bob.id)!.balance).toBe(0);
|
||
expect(after.balances.find(b => b.user_id === carol.id)!.balance).toBe(0);
|
||
expect(after.balances.reduce((a, b) => a + Math.round(b.balance * 100), 0)).toBe(0);
|
||
});
|
||
|
||
it('BUDGET-SVC-DB-064: prefers the server quote, never touches frozen, trip-currency or NULL-currency rows', async () => {
|
||
const { user: me } = createUser(testDb);
|
||
const { user: bob } = createUser(testDb);
|
||
const trip = createTrip(testDb, me.id);
|
||
addTripMember(testDb, trip.id, bob.id);
|
||
const row = (currency: string | null, exchange_rate?: number) =>
|
||
budget.createBudgetItem(trip.id, { name: 'Row', currency, exchange_rate, total_price: 10 }).id;
|
||
const usd = row('USD');
|
||
const gbp = row('GBP');
|
||
const xaf = row('XAF');
|
||
const rub = row('RUB', 90);
|
||
const eur = row('EUR');
|
||
const implicit = row(null);
|
||
const lower = row('usd');
|
||
const usdTransfer = budget.insertSettlement(trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: 5, currency: 'USD' }, bob.id)!;
|
||
const plainTransfer = budget.insertSettlement(trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: 5 }, bob.id)!;
|
||
|
||
const healed = await budget.freezeMissingRates(trip.id, { base: 'EUR', rates: { USD: 2, GBP: 0.85 } });
|
||
expect(healed!.items.map(i => i.id)).toEqual([usd, gbp, lower]);
|
||
expect(healed!.settlements.map(s => s.id)).toEqual([usdTransfer.id]);
|
||
expect(healed!.unresolved).toEqual(['XAF']);
|
||
|
||
expect(rateOf('budget_items', usd)).toBe(RATES.EUR.USD);
|
||
expect(rateOf('budget_items', lower)).toBe(RATES.EUR.USD);
|
||
expect(rateOf('budget_items', gbp)).toBe(0.85);
|
||
expect(rateOf('budget_items', xaf)).toBe(1);
|
||
expect(rateOf('budget_items', rub)).toBe(90);
|
||
expect(rateOf('budget_items', eur)).toBe(1);
|
||
expect(rateOf('budget_items', implicit)).toBe(1);
|
||
expect(rateOf('budget_settlements', usdTransfer.id)).toBe(RATES.EUR.USD);
|
||
expect(rateOf('budget_settlements', plainTransfer.id)).toBe(1);
|
||
});
|
||
|
||
it('BUDGET-SVC-DB-065: second call heals nothing even with another quote', async () => {
|
||
const { trip, me, members } = seedAudTrip();
|
||
const bill = budget.createBudgetItem(trip.id, { name: 'Pho', currency: 'VND', payers: [{ user_id: me.id, amount: 8920000 }], members });
|
||
await budget.freezeMissingRates(trip.id, VND_FX);
|
||
expect(await budget.freezeMissingRates(trip.id, { base: 'AUD', rates: { VND: 25000 } }))
|
||
.toEqual({ items: [], settlements: [], unresolved: [] });
|
||
expect(rateOf('budget_items', bill.id)).toBe(18241.3);
|
||
});
|
||
|
||
it('BUDGET-SVC-DB-066: returns null and writes nothing when the trip currency changes during the fetch', async () => {
|
||
const { trip, me, members } = seedAudTrip();
|
||
const bill = budget.createBudgetItem(trip.id, { name: 'Pho', currency: 'VND', payers: [{ user_id: me.id, amount: 8920000 }], members });
|
||
const spy = vi.spyOn(exchangeRates(), 'getRates').mockImplementation(async () => {
|
||
// Someone switches the trip to euros while the rates are on their way.
|
||
testDb.prepare("UPDATE trips SET currency = 'EUR' WHERE id = ?").run(trip.id);
|
||
return null;
|
||
});
|
||
try {
|
||
expect(await budget.freezeMissingRates(trip.id, VND_FX)).toBeNull();
|
||
expect(rateOf('budget_items', bill.id)).toBe(1);
|
||
} finally {
|
||
spy.mockRestore();
|
||
}
|
||
});
|
||
|
||
it('BUDGET-SVC-DB-067: returns without fetching when nothing is pending', async () => {
|
||
const { trip, me, bob, members } = seedAudTrip();
|
||
budget.createBudgetItem(trip.id, { name: 'Dinner', currency: 'AUD', payers: [{ user_id: me.id, amount: 80 }], members });
|
||
budget.createBudgetItem(trip.id, { name: 'Implicit', total_price: 20, members });
|
||
budget.createBudgetItem(trip.id, { name: 'Pho', currency: 'VND', exchange_rate: 18241.3, total_price: 100000, members });
|
||
budget.insertSettlement(trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: 5 }, bob.id);
|
||
const spy = vi.spyOn(exchangeRates(), 'getRates');
|
||
try {
|
||
expect(await budget.freezeMissingRates(trip.id)).toEqual({ items: [], settlements: [], unresolved: [] });
|
||
expect(spy).not.toHaveBeenCalled();
|
||
} finally {
|
||
spy.mockRestore();
|
||
}
|
||
});
|
||
|
||
it('BUDGET-SVC-DB-068: tripTotals and per-person leave the VND row out and tripTotals reports its id', async () => {
|
||
const { trip, me, bob, members } = seedAudTrip();
|
||
const bill = budget.createBudgetItem(trip.id, {
|
||
name: 'Pho', category: 'food', currency: 'VND', payers: [{ user_id: me.id, amount: 8920000 }], members,
|
||
});
|
||
budget.createBudgetItem(trip.id, {
|
||
name: 'Ferry', category: 'transport', currency: 'AUD', payers: [{ user_id: bob.id, amount: 40 }],
|
||
members: [{ user_id: me.id }, { user_id: bob.id }],
|
||
});
|
||
const rates = await budget.ratesForTripTotals(trip.id, 'AUD');
|
||
expect(rates).toBeNull();
|
||
expect(budget.tripTotals(trip.id, 'AUD', rates)).toEqual({ total: 40, byCategory: { transport: 40 }, unconverted: [bill.id] });
|
||
const summary = await budget.perPersonSummary(trip.id);
|
||
expect(summary.map(s => [s.user_id, s.total_assigned, s.items_count])).toEqual([[me.id, 20, 1], [bob.id, 20, 1]]);
|
||
});
|
||
|
||
it('BUDGET-SVC-DB-069: settlement() passes baseRate only as display fallback (fetch order of 050 unchanged)', async () => {
|
||
const { user: me } = createUser(testDb);
|
||
const { user: bob } = createUser(testDb);
|
||
const trip = createTrip(testDb, me.id);
|
||
addTripMember(testDb, trip.id, bob.id);
|
||
budget.createBudgetItem(trip.id, {
|
||
name: 'Dinner', currency: 'EUR', payers: [{ user_id: me.id, amount: 100 }],
|
||
members: [{ user_id: me.id }, { user_id: bob.id }],
|
||
});
|
||
const mine = (s: { balances: { user_id: number; balance: number }[] }) => s.balances.find(b => b.user_id === me.id)!.balance;
|
||
|
||
// Neither quote: the caller's own figure labels the answer...
|
||
let spy = vi.spyOn(exchangeRates(), 'getRates').mockResolvedValue(null);
|
||
try {
|
||
const s = await budget.settlement(trip.id, 'USD', 'EUR', 1.2);
|
||
expect(spy.mock.calls.map(c => c[0])).toEqual(['EUR', 'USD']);
|
||
expect(s.currency).toBe('USD');
|
||
expect(mine(s)).toBe(60);
|
||
// ...and without it the answer stays in euros and says so.
|
||
const plain = await budget.settlement(trip.id, 'USD', 'EUR');
|
||
expect(plain.currency).toBe('EUR');
|
||
expect(mine(plain)).toBe(50);
|
||
} finally {
|
||
spy.mockRestore();
|
||
}
|
||
|
||
// With the display currency's quote (050's setup) it changes nothing.
|
||
spy = vi.spyOn(exchangeRates(), 'getRates').mockImplementation(async (base: string) => (base === 'EUR' ? null : RATES[base] ?? null));
|
||
try {
|
||
const s = await budget.settlement(trip.id, 'USD', 'EUR', 2);
|
||
expect(spy.mock.calls.map(c => c[0])).toEqual(['EUR', 'USD']);
|
||
expect(s.currency).toBe('USD');
|
||
expect(mine(s)).toBe(57.14);
|
||
} finally {
|
||
spy.mockRestore();
|
||
}
|
||
});
|
||
});
|