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