mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
3a8191fc07
A display-currency payment whose halves pointed opposite ways no longer stayed settled (#1445), the plan sidebar and the PDF total still went through the trip currency, and a zero-decimal display currency was not rounded. Every booking type now maps to one Costs key on every path; a cost the old map filed under 'other' still moves with a type change.
1696 lines
74 KiB
TypeScript
1696 lines
74 KiB
TypeScript
/**
|
|
* Settlement-math and FX-freeze unit tests for BudgetService over a real
|
|
* in-memory SQLite DB (Plan 4 Task 8b-3, off the prepare-stub bridge this
|
|
* suite used to run on). Moved 1:1 from the legacy
|
|
* tests/unit/services/budgetService.test.ts (same cases, incl. the
|
|
* #1335/#1445/#1426 pins); the SUT is the folded BudgetService with a
|
|
* constructor-injected ExchangeRatesService stub (the only remaining mock —
|
|
* FX rates are an external call, not DB state) and repositories built by
|
|
* `budgetRepoArgs` over the suite's own better-sqlite3 handle (the same
|
|
* `createSnapshotTestDb()` + async `vi.mock` idiom as every other converted
|
|
* `tests/unit/nest/*.test.ts`), and the raw settlement update is exercised
|
|
* as applySettlementUpdate (the no-freeze write the REST updateSettlement
|
|
* wraps).
|
|
*
|
|
* ~50 of these cases hard-code literal ids (alice=1, bob=2, carol=3,
|
|
* dave=4; item/settlement ids 1-3) in their assertions, which a `createUser`/
|
|
* `createTrip` factory autoincrement can't reproduce test-to-test (ids keep
|
|
* growing across a file — `resetTestDb`'s own docstring). `setupDb` below
|
|
* inserts every row with those literal ids directly instead, matching what
|
|
* the old `mockDb.db.prepare` stub used to hand back — clearing its own
|
|
* tables first so a test that reconfigures the fixture mid-test (one does)
|
|
* stays safe to call twice.
|
|
*/
|
|
import { db as testDb } from '../../../src/db/database';
|
|
import { BudgetItemMembers } from '../../../src/db/entities/BudgetItemMembers.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 { Trips } from '../../../src/db/entities/Trips.entity';
|
|
import { Users } from '../../../src/db/entities/Users.entity';
|
|
import { BudgetService } from '../../../src/nest/budget/budget.service';
|
|
import type { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
|
|
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
|
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
|
import type { BudgetItem, BudgetItemMember, BudgetItemPayer } from '../../../src/types';
|
|
import { budgetRepoArgs } from '../../helpers/budget-repos';
|
|
import { deleteRows, findRow, insertRow } from '../../helpers/factories/rows';
|
|
import { resetTestDb } from '../../helpers/test-db';
|
|
import { createTestUnitOfWork } from '../../helpers/test-uow';
|
|
import { sharedTestOrm } from '../../helpers/test-uow';
|
|
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
|
|
|
vi.mock('../../../src/db/database', async () => {
|
|
const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock');
|
|
return buildDbMock(createSnapshotTestDb());
|
|
});
|
|
|
|
const mockRates = { getRates: vi.fn() };
|
|
|
|
const permissionsStub = { checkPermission: vi.fn(() => true) } as unknown as PermissionsService;
|
|
|
|
let budget: BudgetService;
|
|
|
|
beforeAll(async () => {
|
|
budget = new BudgetService(
|
|
permissionsStub,
|
|
mockRates as unknown as ExchangeRatesService,
|
|
new RealtimeService(),
|
|
await createTestUnitOfWork(testDb),
|
|
...(await budgetRepoArgs(testDb)),
|
|
);
|
|
});
|
|
|
|
afterAll(() => {
|
|
testDb.close();
|
|
});
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
// Who actually paid is recorded as explicit payers (budget_item_payers); members
|
|
// are only the equal-split participants.
|
|
|
|
function makeItem(id: number, total_price: number, trip_id = 1): BudgetItem {
|
|
return { id, trip_id, name: `Item ${id}`, total_price, category: 'other' } as BudgetItem;
|
|
}
|
|
|
|
function makeMember(
|
|
budget_item_id: number,
|
|
user_id: number,
|
|
username: string,
|
|
): BudgetItemMember & { budget_item_id: number } {
|
|
return { budget_item_id, user_id, paid: 0, username, avatar: null } as BudgetItemMember & { budget_item_id: number };
|
|
}
|
|
|
|
function makePayer(
|
|
budget_item_id: number,
|
|
user_id: number,
|
|
amount: number,
|
|
username: string,
|
|
): BudgetItemPayer & { budget_item_id: number } {
|
|
return { budget_item_id, user_id, amount, username, avatar: null } as BudgetItemPayer & { budget_item_id: number };
|
|
}
|
|
|
|
// A raw budget_settlements row as listSettlements reads it (joined usernames/avatars).
|
|
function makeSettlementRow(
|
|
id: number,
|
|
from_user_id: number,
|
|
to_user_id: number,
|
|
amount: number,
|
|
currency: string | null = null,
|
|
exchange_rate = 1,
|
|
) {
|
|
return {
|
|
id,
|
|
trip_id: 1,
|
|
from_user_id,
|
|
to_user_id,
|
|
amount,
|
|
currency,
|
|
exchange_rate,
|
|
created_at: '2026-01-01',
|
|
created_by_user_id: from_user_id,
|
|
from_username: `u${from_user_id}`,
|
|
from_avatar: null,
|
|
to_username: `u${to_user_id}`,
|
|
to_avatar: null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Add money the way the ledger does — in whole cents. Summing the returned
|
|
* two-decimal figures with `+` reintroduces exactly the binary dust the fix is
|
|
* about, so an "adds up to zero" assertion has to count cents to mean anything.
|
|
*/
|
|
const centSum = (values: number[]) => values.reduce((a, v) => a + Math.round(v * 100), 0);
|
|
|
|
/** Every trip in this suite is id 1, owned by a user no test ever names or asserts on. */
|
|
const GHOST_OWNER_ID = 999999;
|
|
|
|
async function seedBaseline() {
|
|
const orm = await sharedTestOrm(testDb);
|
|
await insertRow(orm, Users, {
|
|
id: GHOST_OWNER_ID,
|
|
username: 'ghost_owner',
|
|
email: 'ghost_owner@test.example.com',
|
|
password_hash: 'x',
|
|
role: 'user',
|
|
});
|
|
await insertRow(orm, Trips, { id: 1, user: GHOST_OWNER_ID, title: 'Trip', currency: 'EUR' });
|
|
}
|
|
|
|
async function seedUser(id: number, username: string) {
|
|
await insertRow(await sharedTestOrm(testDb), Users, {
|
|
id,
|
|
username,
|
|
email: `${username}.${id}@test.example.com`,
|
|
password_hash: 'x',
|
|
role: 'user',
|
|
});
|
|
}
|
|
|
|
async function setupDb(
|
|
items: BudgetItem[],
|
|
members: (BudgetItemMember & { budget_item_id: number })[],
|
|
payers: (BudgetItemPayer & { budget_item_id: number })[] = [],
|
|
settlements: ReturnType<typeof makeSettlementRow>[] = [],
|
|
) {
|
|
// Idempotent: one test reconfigures the fixture mid-run (recomputes settlement
|
|
// after booking its own offered flows), so a second call must not collide with
|
|
// the first's rows. Children before parents; users last since settlements and
|
|
// items/members/payers all reference them.
|
|
const orm = await sharedTestOrm(testDb);
|
|
await deleteRows(orm, BudgetSettlements);
|
|
await deleteRows(orm, BudgetItemPayers);
|
|
await deleteRows(orm, BudgetItemMembers);
|
|
await deleteRows(orm, BudgetItems);
|
|
await deleteRows(orm, Users, { id: { $ne: GHOST_OWNER_ID } });
|
|
|
|
// Every user id these rows reference, with the username the fixture gave it —
|
|
// members/payers carry one explicitly; a settlement-only party (no expense
|
|
// behind the transfer) falls back to the settlement row's own username.
|
|
const usernames = new Map<number, string>();
|
|
for (const m of members) usernames.set(m.user_id, m.username);
|
|
for (const p of payers) usernames.set(p.user_id, p.username);
|
|
for (const s of settlements) {
|
|
if (!usernames.has(s.from_user_id)) usernames.set(s.from_user_id, s.from_username);
|
|
if (!usernames.has(s.to_user_id)) usernames.set(s.to_user_id, s.to_username);
|
|
}
|
|
for (const [id, username] of usernames) await seedUser(id, username);
|
|
|
|
for (const item of items) {
|
|
await insertRow(orm, BudgetItems, {
|
|
id: item.id,
|
|
trip: item.trip_id,
|
|
name: item.name,
|
|
total_price: item.total_price,
|
|
currency: item.currency ?? null,
|
|
exchange_rate: item.exchange_rate ?? 1,
|
|
});
|
|
}
|
|
for (const m of members) {
|
|
await insertRow(orm, BudgetItemMembers, {
|
|
budgetItem: m.budget_item_id,
|
|
user: m.user_id,
|
|
paid: m.paid ?? 0,
|
|
amount: m.amount ?? null,
|
|
});
|
|
}
|
|
for (const p of payers) {
|
|
await insertRow(orm, BudgetItemPayers, { budgetItem: p.budget_item_id, user: p.user_id, amount: p.amount });
|
|
}
|
|
for (const s of settlements) {
|
|
await insertRow(orm, BudgetSettlements, {
|
|
id: s.id,
|
|
trip: 1,
|
|
fromUser: s.from_user_id,
|
|
toUser: s.to_user_id,
|
|
amount: s.amount,
|
|
currency: s.currency,
|
|
exchange_rate: s.exchange_rate,
|
|
});
|
|
}
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
resetTestDb(testDb);
|
|
await seedBaseline();
|
|
await setupDb([], [], []);
|
|
});
|
|
|
|
// ── calculateSettlement ──────────────────────────────────────────────────────
|
|
|
|
describe('calculateSettlement', () => {
|
|
it('returns empty balances and flows when trip has no items', async () => {
|
|
await setupDb([], [], []);
|
|
const result = await budget.calculateSettlement(1);
|
|
expect(result.balances).toEqual([]);
|
|
expect(result.flows).toEqual([]);
|
|
});
|
|
|
|
it('returns no flows when there are items but no members', async () => {
|
|
await setupDb([makeItem(1, 100)], [], [makePayer(1, 1, 100, 'alice')]);
|
|
const result = await budget.calculateSettlement(1);
|
|
expect(result.flows).toEqual([]);
|
|
});
|
|
|
|
it('returns no flows when no one has paid', async () => {
|
|
await setupDb([makeItem(1, 100)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], []);
|
|
const result = await budget.calculateSettlement(1);
|
|
expect(result.flows).toEqual([]);
|
|
// "No flows" on its own said nothing about the balances behind them: they
|
|
// were -50/-50 here until #2225, an offer of nothing next to money owed.
|
|
expect(centSum(result.balances.map((b) => b.balance))).toBe(0);
|
|
});
|
|
|
|
it('2 members, 1 payer: payer is owed half, non-payer owes half', async () => {
|
|
// Item: $100. Alice paid all, [Alice, Bob] split. Each owes $50. Alice net: +$50. Bob: -$50.
|
|
await setupDb(
|
|
[makeItem(1, 100)],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
|
[makePayer(1, 1, 100, 'alice')],
|
|
);
|
|
const result = await budget.calculateSettlement(1);
|
|
const alice = result.balances.find((b) => b.user_id === 1)!;
|
|
const bob = result.balances.find((b) => b.user_id === 2)!;
|
|
expect(alice.balance).toBe(50);
|
|
expect(bob.balance).toBe(-50);
|
|
expect(result.flows).toHaveLength(1);
|
|
expect(result.flows[0].from.user_id).toBe(2); // Bob owes
|
|
expect(result.flows[0].to.user_id).toBe(1); // Alice is owed
|
|
expect(result.flows[0].amount).toBe(50);
|
|
});
|
|
|
|
it('3 members, 1 payer: correct 3-way split', async () => {
|
|
// Item: $90. Alice paid. Each of 3 owes $30. Alice net: +$60. Bob: -$30. Carol: -$30.
|
|
await setupDb(
|
|
[makeItem(1, 90)],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
|
[makePayer(1, 1, 90, 'alice')],
|
|
);
|
|
const result = await budget.calculateSettlement(1);
|
|
const alice = result.balances.find((b) => b.user_id === 1)!;
|
|
const bob = result.balances.find((b) => b.user_id === 2)!;
|
|
const carol = result.balances.find((b) => b.user_id === 3)!;
|
|
expect(alice.balance).toBe(60);
|
|
expect(bob.balance).toBe(-30);
|
|
expect(carol.balance).toBe(-30);
|
|
expect(result.flows).toHaveLength(2);
|
|
});
|
|
|
|
it('all paid equally: all balances are zero, no flows', async () => {
|
|
// Item: $60. 3 members, each paid $20 and owes $20. Net: 0 for everyone.
|
|
await setupDb(
|
|
[makeItem(1, 60)],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
|
[makePayer(1, 1, 20, 'alice'), makePayer(1, 2, 20, 'bob'), makePayer(1, 3, 20, 'carol')],
|
|
);
|
|
const result = await budget.calculateSettlement(1);
|
|
// Exactly zero, not "within a cent": a tolerance here is what let #1382's
|
|
// stranded cents through unnoticed.
|
|
for (const b of result.balances) {
|
|
expect(b.balance).toBe(0);
|
|
}
|
|
expect(result.flows).toHaveLength(0);
|
|
});
|
|
|
|
it('flow direction: from is debtor (owes), to is creditor (is owed)', async () => {
|
|
// Alice paid $100 for 2 people. Bob owes Alice $50.
|
|
await setupDb(
|
|
[makeItem(1, 100)],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
|
[makePayer(1, 1, 100, 'alice')],
|
|
);
|
|
const result = await budget.calculateSettlement(1);
|
|
const flow = result.flows[0];
|
|
expect(flow.from.username).toBe('bob'); // debtor
|
|
expect(flow.to.username).toBe('alice'); // creditor
|
|
});
|
|
|
|
it('amounts are rounded to 2 decimal places', async () => {
|
|
// Item: $10. 3 members, 1 payer. Share = 3.333... Each rounded to 3.33.
|
|
await setupDb(
|
|
[makeItem(1, 10)],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
|
[makePayer(1, 1, 10, 'alice')],
|
|
);
|
|
const result = await budget.calculateSettlement(1);
|
|
for (const b of result.balances) {
|
|
const str = b.balance.toString();
|
|
const decimals = str.includes('.') ? str.split('.')[1].length : 0;
|
|
expect(decimals).toBeLessThanOrEqual(2);
|
|
}
|
|
for (const flow of result.flows) {
|
|
const str = flow.amount.toString();
|
|
const decimals = str.includes('.') ? str.split('.')[1].length : 0;
|
|
expect(decimals).toBeLessThanOrEqual(2);
|
|
}
|
|
});
|
|
|
|
it('2 items with different payers: aggregates balances correctly', async () => {
|
|
// Item 1: $100, Alice paid, [Alice, Bob] (Alice net: +50, Bob: -50)
|
|
// Item 2: $60, Bob paid, [Alice, Bob] (Bob net: +30, Alice: -30)
|
|
// Final: Alice: +50 - 30 = +20, Bob: -50 + 30 = -20
|
|
await setupDb(
|
|
[makeItem(1, 100), makeItem(2, 60)],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(2, 1, 'alice'), makeMember(2, 2, 'bob')],
|
|
[makePayer(1, 1, 100, 'alice'), makePayer(2, 2, 60, 'bob')],
|
|
);
|
|
const result = await budget.calculateSettlement(1);
|
|
const alice = result.balances.find((b) => b.user_id === 1)!;
|
|
const bob = result.balances.find((b) => b.user_id === 2)!;
|
|
expect(alice.balance).toBe(20);
|
|
expect(bob.balance).toBe(-20);
|
|
expect(result.flows).toHaveLength(1);
|
|
expect(result.flows[0].amount).toBe(20);
|
|
});
|
|
|
|
it('counts a settlement with no matching expense as an amount still to square up', async () => {
|
|
// bob paid alice 30 but every expense behind it was deleted: alice now owes bob.
|
|
await setupDb([], [], [], [makeSettlementRow(1, 2, 1, 30)]);
|
|
const result = await budget.calculateSettlement(1);
|
|
const alice = result.balances.find((b) => b.user_id === 1)!;
|
|
const bob = result.balances.find((b) => b.user_id === 2)!;
|
|
expect(bob.balance).toBe(30);
|
|
expect(alice.balance).toBe(-30);
|
|
expect(result.flows).toEqual([
|
|
expect.objectContaining({
|
|
amount: 30,
|
|
from: expect.objectContaining({ user_id: 1 }),
|
|
to: expect.objectContaining({ user_id: 2 }),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('#1335 converts a foreign expense with the frozen exchange_rate, not live rates', async () => {
|
|
// $110 booked at a frozen rate of 1.1 (USD per 1 EUR) = 100 EUR. Live rates have since
|
|
// drifted to 1.2, but the converted amount must stay on the frozen rate so an already
|
|
// settled position isn't re-opened with a residual.
|
|
await setupDb(
|
|
[{ ...makeItem(1, 110), currency: 'USD', exchange_rate: 1.1 } as BudgetItem],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
|
[makePayer(1, 1, 110, 'alice')],
|
|
);
|
|
const result = await budget.calculateSettlement(1, {
|
|
base: 'EUR',
|
|
tripCurrency: 'EUR',
|
|
rates: { EUR: 1, USD: 1.2 },
|
|
});
|
|
const bob = result.balances.find((b) => b.user_id === 2)!;
|
|
// 110 / 1.1 = 100 EUR; Bob owes half = 50 (frozen). With the live 1.2 it would be ~45.83.
|
|
expect(bob.balance).toBeCloseTo(-50, 2);
|
|
});
|
|
|
|
it('#1335 a legacy row (exchange_rate = 1) still converts with live rates', async () => {
|
|
await setupDb(
|
|
[{ ...makeItem(1, 120), currency: 'USD', exchange_rate: 1 } as BudgetItem],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
|
[makePayer(1, 1, 120, 'alice')],
|
|
);
|
|
const result = await budget.calculateSettlement(1, {
|
|
base: 'EUR',
|
|
tripCurrency: 'EUR',
|
|
rates: { EUR: 1, USD: 1.2 },
|
|
});
|
|
const bob = result.balances.find((b) => b.user_id === 2)!;
|
|
// 120 / 1.2 (live) = 100 EUR; Bob owes 50 — unchanged behaviour for pre-#1335 rows.
|
|
expect(bob.balance).toBeCloseTo(-50, 2);
|
|
});
|
|
|
|
it('#1445 a settle-up transfer with a frozen currency+rate keeps the position balanced when rates drift', async () => {
|
|
// Trip currency EUR; Bob viewed in USD (display) and owes Alice 50 EUR = 62.50 USD
|
|
// at the settle-time rate of 1.25 USD/EUR. He records that 62.50 USD transfer, which
|
|
// freezes currency=USD, exchange_rate=1.25. Live rates have since drifted (now the
|
|
// recompute passes EUR=0.5 per 1 USD), but the frozen transfer still nets to -50+50=0.
|
|
await setupDb(
|
|
[makeItem(1, 100)], // trip-currency expense (currency NULL)
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
|
[makePayer(1, 1, 100, 'alice')],
|
|
[makeSettlementRow(1, 2, 1, 62.5, 'USD', 1.25)],
|
|
);
|
|
const result = await budget.calculateSettlement(1, {
|
|
base: 'USD',
|
|
tripCurrency: 'EUR',
|
|
rates: { USD: 1, EUR: 0.5 },
|
|
});
|
|
const bob = result.balances.find((b) => b.user_id === 2)!;
|
|
expect(bob.balance).toBeCloseTo(0, 2); // settled — no residual re-opens
|
|
});
|
|
|
|
it('#1445 a legacy settle-up transfer (currency NULL) still converts with live rates', async () => {
|
|
// Same shape, but the transfer predates the fix (currency NULL). It is re-converted
|
|
// to trip currency with live rates, so a drift re-opens the position — unchanged
|
|
// legacy behaviour that normalises once the row is re-edited.
|
|
await setupDb(
|
|
[makeItem(1, 100)],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
|
[makePayer(1, 1, 100, 'alice')],
|
|
[makeSettlementRow(1, 2, 1, 62.5, null, 1)],
|
|
);
|
|
const result = await budget.calculateSettlement(1, {
|
|
base: 'USD',
|
|
tripCurrency: 'EUR',
|
|
rates: { USD: 1, EUR: 0.5 },
|
|
});
|
|
const bob = result.balances.find((b) => b.user_id === 2)!;
|
|
// settleToTrip(62.5) = 62.5 * 0.5 = 31.25 EUR; balance -50 + 31.25 = -18.75 EUR → reopens.
|
|
expect(Math.abs(bob.balance)).toBeGreaterThan(1);
|
|
});
|
|
|
|
// ── Multi-payer (#1426 regression): several people front one bill ──────────
|
|
// The UI could only send one payer between 3.2.0 and this fix, but the ledger
|
|
// has credited each payer individually since 3.1.0. These pin that.
|
|
|
|
it('2 payers, 3 members: each payer is credited what they actually paid', async () => {
|
|
// $90 bill split 3 ways ($30 each). Alice and Bob each fronted $45.
|
|
// Alice: +45 - 30 = +15. Bob: +45 - 30 = +15. Carol: -30.
|
|
await setupDb(
|
|
[makeItem(1, 90)],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
|
[makePayer(1, 1, 45, 'alice'), makePayer(1, 2, 45, 'bob')],
|
|
);
|
|
const result = await budget.calculateSettlement(1);
|
|
const balance = (uid: number) => result.balances.find((b) => b.user_id === uid)!.balance;
|
|
|
|
expect(balance(1)).toBeCloseTo(15, 2);
|
|
expect(balance(2)).toBeCloseTo(15, 2);
|
|
expect(balance(3)).toBeCloseTo(-30, 2);
|
|
});
|
|
|
|
it('2 payers with unequal amounts: credits follow the actual amounts paid', async () => {
|
|
// $100 bill split 2 ways ($50 each). Alice fronted $70, Bob fronted $30.
|
|
// Alice: +70 - 50 = +20. Bob: +30 - 50 = -20. Bob owes Alice $20.
|
|
await setupDb(
|
|
[makeItem(1, 100)],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
|
[makePayer(1, 1, 70, 'alice'), makePayer(1, 2, 30, 'bob')],
|
|
);
|
|
const result = await budget.calculateSettlement(1);
|
|
const balance = (uid: number) => result.balances.find((b) => b.user_id === uid)!.balance;
|
|
|
|
expect(balance(1)).toBeCloseTo(20, 2);
|
|
expect(balance(2)).toBeCloseTo(-20, 2);
|
|
expect(result.flows).toHaveLength(1);
|
|
expect(result.flows[0].from.user_id).toBe(2);
|
|
expect(result.flows[0].to.user_id).toBe(1);
|
|
expect(result.flows[0].amount).toBeCloseTo(20, 2);
|
|
});
|
|
|
|
it('multi-payer balances sum to zero (no money invented or destroyed)', async () => {
|
|
// The invariant that makes settle-up trustworthy: credits == debits.
|
|
await setupDb(
|
|
[makeItem(1, 90), makeItem(2, 55)],
|
|
[
|
|
makeMember(1, 1, 'alice'),
|
|
makeMember(1, 2, 'bob'),
|
|
makeMember(1, 3, 'carol'),
|
|
makeMember(2, 1, 'alice'),
|
|
makeMember(2, 3, 'carol'),
|
|
],
|
|
[
|
|
makePayer(1, 1, 45, 'alice'),
|
|
makePayer(1, 2, 45, 'bob'),
|
|
makePayer(2, 2, 25, 'bob'),
|
|
makePayer(2, 3, 30, 'carol'),
|
|
],
|
|
);
|
|
const result = await budget.calculateSettlement(1);
|
|
|
|
expect(centSum(result.balances.map((b) => b.balance))).toBe(0);
|
|
});
|
|
|
|
it('3 payers on one bill: an odd total still splits to the cent', async () => {
|
|
// $100.01 split 3 ways. splitEqualShares distributes the remainder cent,
|
|
// so debits must still exactly cancel the $100.01 of credits.
|
|
await setupDb(
|
|
[makeItem(1, 100.01)],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
|
[makePayer(1, 1, 33.34, 'alice'), makePayer(1, 2, 33.34, 'bob'), makePayer(1, 3, 33.33, 'carol')],
|
|
);
|
|
const result = await budget.calculateSettlement(1);
|
|
|
|
expect(centSum(result.balances.map((b) => b.balance))).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ── Refunds: negative amounts are first-class expenses (#2176) ───────────────
|
|
|
|
describe('calculateSettlement — negative amounts (#2176)', () => {
|
|
it('a refund credits the members and debits its recipient', async () => {
|
|
// A 30 € refund lands with Alice; all three had shared the original cost.
|
|
// Alice received 30 but only 10 of it was hers, so she owes 20.
|
|
await setupDb(
|
|
[makeItem(1, -30)],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
|
[makePayer(1, 1, -30, 'alice')],
|
|
);
|
|
const result = await budget.calculateSettlement(1);
|
|
const balance = (uid: number) => result.balances.find((b) => b.user_id === uid)!.balance;
|
|
|
|
expect(balance(1)).toBe(-20);
|
|
expect(balance(2)).toBe(10);
|
|
expect(balance(3)).toBe(10);
|
|
expect(centSum(result.balances.map((b) => b.balance))).toBe(0);
|
|
});
|
|
|
|
it('a refund reduces the debt its expense created', async () => {
|
|
// 90 € dinner fronted by Alice, then a 30 € partial reimbursement she keeps —
|
|
// both split three ways. Bob's debt drops from 30 to 20.
|
|
await setupDb(
|
|
[makeItem(1, 90), makeItem(2, -30)],
|
|
[
|
|
makeMember(1, 1, 'alice'),
|
|
makeMember(1, 2, 'bob'),
|
|
makeMember(1, 3, 'carol'),
|
|
makeMember(2, 1, 'alice'),
|
|
makeMember(2, 2, 'bob'),
|
|
makeMember(2, 3, 'carol'),
|
|
],
|
|
[makePayer(1, 1, 90, 'alice'), makePayer(2, 1, -30, 'alice')],
|
|
);
|
|
const result = await budget.calculateSettlement(1);
|
|
const balance = (uid: number) => result.balances.find((b) => b.user_id === uid)!.balance;
|
|
|
|
expect(balance(1)).toBe(40);
|
|
expect(balance(2)).toBe(-20);
|
|
expect(balance(3)).toBe(-20);
|
|
expect(result.flows).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
amount: 20,
|
|
from: expect.objectContaining({ user_id: 2 }),
|
|
to: expect.objectContaining({ user_id: 1 }),
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('an odd negative total still nets to exactly zero', async () => {
|
|
await setupDb(
|
|
[makeItem(1, -100.01)],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
|
[makePayer(1, 1, -100.01, 'alice')],
|
|
);
|
|
const result = await budget.calculateSettlement(1);
|
|
|
|
expect(centSum(result.balances.map((b) => b.balance))).toBe(0);
|
|
});
|
|
|
|
it('a negative custom split settles by the custom amounts', async () => {
|
|
// 100 € refund to Alice; by agreement Bob is owed 70 of it, Carol 30.
|
|
await setupDb(
|
|
[makeItem(1, -100)],
|
|
[
|
|
{ ...makeMember(1, 2, 'bob'), amount: -70 },
|
|
{ ...makeMember(1, 3, 'carol'), amount: -30 },
|
|
],
|
|
[makePayer(1, 1, -100, 'alice')],
|
|
);
|
|
const result = await budget.calculateSettlement(1);
|
|
const balance = (uid: number) => result.balances.find((b) => b.user_id === uid)!.balance;
|
|
|
|
expect(balance(1)).toBe(-100);
|
|
expect(balance(2)).toBe(70);
|
|
expect(balance(3)).toBe(30);
|
|
expect(centSum(result.balances.map((b) => b.balance))).toBe(0);
|
|
});
|
|
|
|
it('a payer-less refund owes nobody anything until its recipient is named (#2225)', async () => {
|
|
// Nobody is recorded as having received the refund, so there is no credit to
|
|
// hand back: the row is outstanding, not a debt the trip owes its members.
|
|
// It used to credit all three 30 € out of thin air.
|
|
await setupDb(
|
|
[makeItem(1, -90)],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
|
[],
|
|
);
|
|
const result = await budget.calculateSettlement(1);
|
|
|
|
expect(result.balances).toEqual([]);
|
|
expect(result.flows).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ── Final budget per participant ──────────────────────────────────────────
|
|
|
|
describe('calculateSettlement — finalBudgets', () => {
|
|
/**
|
|
* The whole point of the figure: gross outlay minus what came back minus what
|
|
* still has to. It is the same ledger the balances come from, read from the
|
|
* other end, so every case here also pins that the subtraction the UI prints
|
|
* lands on the number beside it.
|
|
*/
|
|
const checkIdentity = (rows: { expenses: number; reimbursed: number; pending: number; final: number }[]) => {
|
|
for (const r of rows) {
|
|
expect(Math.round(r.final * 100)).toBe(
|
|
Math.round(r.expenses * 100) - Math.round(r.reimbursed * 100) - Math.round(r.pending * 100),
|
|
);
|
|
}
|
|
};
|
|
|
|
it('charges each participant their share of what the payer fronted', async () => {
|
|
// Alice fronts 100 for the two of them. Nothing has been paid back yet, so the
|
|
// trip costs each of them 50: Alice is out 100 with 50 still coming, Bob is out
|
|
// nothing with 50 still to pay.
|
|
await setupDb(
|
|
[makeItem(1, 100)],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
|
[makePayer(1, 1, 100, 'alice')],
|
|
);
|
|
const result = await budget.calculateSettlement(1);
|
|
|
|
const alice = result.finalBudgets.find((f) => f.user_id === 1)!;
|
|
const bob = result.finalBudgets.find((f) => f.user_id === 2)!;
|
|
expect(alice).toMatchObject({ expenses: 100, reimbursed: 0, pending: 50, final: 50 });
|
|
expect(bob).toMatchObject({ expenses: 0, reimbursed: 0, pending: -50, final: 50 });
|
|
checkIdentity(result.finalBudgets);
|
|
});
|
|
|
|
it('a recorded transfer moves out of pending and into reimbursed, leaving the final alone', async () => {
|
|
// Bob pays his 50 back. What the trip costs either of them cannot change — only
|
|
// which of the two lines under it the 50 now sits on.
|
|
await setupDb(
|
|
[makeItem(1, 100)],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
|
[makePayer(1, 1, 100, 'alice')],
|
|
[makeSettlementRow(1, 2, 1, 50)],
|
|
);
|
|
const result = await budget.calculateSettlement(1);
|
|
|
|
const alice = result.finalBudgets.find((f) => f.user_id === 1)!;
|
|
const bob = result.finalBudgets.find((f) => f.user_id === 2)!;
|
|
expect(alice).toMatchObject({ expenses: 100, reimbursed: 50, pending: 0, final: 50 });
|
|
expect(bob).toMatchObject({ expenses: 0, reimbursed: -50, pending: 0, final: 50 });
|
|
checkIdentity(result.finalBudgets);
|
|
});
|
|
|
|
it('sums the finals to what the trip actually spent', async () => {
|
|
// 90 + 60 across three people, fronted by two of them. However the debts are
|
|
// arranged, the trip cost the group exactly what it spent.
|
|
await setupDb(
|
|
[makeItem(1, 90), makeItem(2, 60)],
|
|
[
|
|
makeMember(1, 1, 'alice'),
|
|
makeMember(1, 2, 'bob'),
|
|
makeMember(1, 3, 'carol'),
|
|
makeMember(2, 2, 'bob'),
|
|
makeMember(2, 3, 'carol'),
|
|
],
|
|
[makePayer(1, 1, 90, 'alice'), makePayer(2, 2, 60, 'bob')],
|
|
);
|
|
const result = await budget.calculateSettlement(1);
|
|
|
|
expect(centSum(result.finalBudgets.map((f) => f.final))).toBe(15000);
|
|
expect(result.finalBudgets.find((f) => f.user_id === 1)!.final).toBe(30);
|
|
expect(result.finalBudgets.find((f) => f.user_id === 2)!.final).toBe(60);
|
|
expect(result.finalBudgets.find((f) => f.user_id === 3)!.final).toBe(60);
|
|
checkIdentity(result.finalBudgets);
|
|
});
|
|
|
|
it('follows a custom split rather than an equal one', async () => {
|
|
// Alice fronts 100 but only owes 20 of it — the split says so.
|
|
await setupDb(
|
|
[makeItem(1, 100)],
|
|
[
|
|
{ ...makeMember(1, 1, 'alice'), amount: 20 },
|
|
{ ...makeMember(1, 2, 'bob'), amount: 80 },
|
|
],
|
|
[makePayer(1, 1, 100, 'alice')],
|
|
);
|
|
const result = await budget.calculateSettlement(1);
|
|
|
|
expect(result.finalBudgets.find((f) => f.user_id === 1)!.final).toBe(20);
|
|
expect(result.finalBudgets.find((f) => f.user_id === 2)!.final).toBe(80);
|
|
checkIdentity(result.finalBudgets);
|
|
});
|
|
|
|
it('leaves an expense nobody paid out of the final budget (#2225)', async () => {
|
|
// The unpaid row stays out of the ledger, so it cannot charge anybody either.
|
|
await setupDb(
|
|
[makeItem(1, 90)],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
|
[],
|
|
);
|
|
const result = await budget.calculateSettlement(1);
|
|
|
|
expect(result.finalBudgets).toEqual([]);
|
|
});
|
|
|
|
it('gives a refund back to whoever was charged for it (#2176)', async () => {
|
|
// A 30 refund Alice received, split between the two of them: each is 15 better
|
|
// off, so the trip costs them -15 on this row alone.
|
|
await setupDb(
|
|
[makeItem(1, -30)],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
|
[makePayer(1, 1, -30, 'alice')],
|
|
);
|
|
const result = await budget.calculateSettlement(1);
|
|
|
|
expect(result.finalBudgets.find((f) => f.user_id === 1)!).toMatchObject({ expenses: -30, final: -15 });
|
|
expect(result.finalBudgets.find((f) => f.user_id === 2)!.final).toBe(-15);
|
|
checkIdentity(result.finalBudgets);
|
|
});
|
|
|
|
it('keeps the breakdown adding up in a display currency of its own', async () => {
|
|
// The three lines are converted as their own sets, like the balances are, and
|
|
// the final is subtracted afterwards — so what the breakdown prints adds up in
|
|
// whatever currency the viewer picked, at whatever the live rate happens to be.
|
|
await setupDb(
|
|
[makeItem(1, 100)],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
|
[makePayer(1, 1, 100, 'alice')],
|
|
[makeSettlementRow(1, 2, 1, 33.33)],
|
|
);
|
|
for (const eurPerUsd of [0.855, 0.9312, 0.94]) {
|
|
const result = await budget.calculateSettlement(1, {
|
|
base: 'USD',
|
|
tripCurrency: 'EUR',
|
|
rates: { USD: 1, EUR: eurPerUsd },
|
|
});
|
|
|
|
checkIdentity(result.finalBudgets);
|
|
// And the pending line is the balance itself, not a second opinion on it.
|
|
for (const f of result.finalBudgets) {
|
|
expect(f.pending).toBe(result.balances.find((b) => b.user_id === f.user_id)!.balance);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('costs nobody anything when a transfer has no expense behind it', async () => {
|
|
// Bob handed Alice 40 with no expense on the trip to justify it. Alice has the
|
|
// 40 but owes it straight back, so the trip has cost neither of them anything —
|
|
// the transfer shows up as reimbursed on one line and outstanding on the next.
|
|
await setupDb([], [], [], [makeSettlementRow(1, 2, 1, 40)]);
|
|
const result = await budget.calculateSettlement(1);
|
|
|
|
expect(result.finalBudgets.find((f) => f.user_id === 1)!).toMatchObject({
|
|
expenses: 0,
|
|
reimbursed: 40,
|
|
pending: -40,
|
|
final: 0,
|
|
});
|
|
expect(result.finalBudgets.find((f) => f.user_id === 2)!).toMatchObject({
|
|
expenses: 0,
|
|
reimbursed: -40,
|
|
pending: 40,
|
|
final: 0,
|
|
});
|
|
checkIdentity(result.finalBudgets);
|
|
});
|
|
|
|
it('lists the rows each figure is made of, signed the way the figure is', async () => {
|
|
// The first case read row by row, with 20 of Bob's 50 already sent: Alice
|
|
// fronted the one expense, received the 20, and the open flow is the 30 coming
|
|
// to her; on Bob's side the same transfer and flow are going out.
|
|
await setupDb(
|
|
[makeItem(1, 100)],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
|
[makePayer(1, 1, 100, 'alice')],
|
|
[makeSettlementRow(1, 2, 1, 20)],
|
|
);
|
|
const result = await budget.calculateSettlement(1);
|
|
|
|
expect(result.finalBudgets.find((f) => f.user_id === 1)!.sources).toEqual({
|
|
fronted: [{ item_id: 1, cents: 10000 }],
|
|
moved: [{ settlement_id: 1, from_user_id: 2, to_user_id: 1, cents: 2000 }],
|
|
outstanding: [{ from_user_id: 2, to_user_id: 1, cents: 3000 }],
|
|
});
|
|
expect(result.finalBudgets.find((f) => f.user_id === 2)!.sources).toEqual({
|
|
fronted: [],
|
|
moved: [{ settlement_id: 1, from_user_id: 2, to_user_id: 1, cents: -2000 }],
|
|
outstanding: [{ from_user_id: 2, to_user_id: 1, cents: -3000 }],
|
|
});
|
|
});
|
|
|
|
it('keeps an expense with no split members out of the rows, as it is out of the ledger', async () => {
|
|
// Item 1 has a payer but nobody to split it with: a planning-only entry that
|
|
// charges nobody, so it cannot be listed as something Alice fronted either.
|
|
await setupDb(
|
|
[makeItem(1, 100), makeItem(2, 40)],
|
|
[makeMember(2, 1, 'alice'), makeMember(2, 2, 'bob')],
|
|
[makePayer(1, 1, 100, 'alice'), makePayer(2, 1, 40, 'alice')],
|
|
);
|
|
const result = await budget.calculateSettlement(1);
|
|
|
|
const alice = result.finalBudgets.find((f) => f.user_id === 1)!;
|
|
expect(alice.expenses).toBe(40);
|
|
expect(alice.sources.fronted).toEqual([{ item_id: 2, cents: 4000 }]);
|
|
});
|
|
|
|
it('spreads a foreign-currency figure over its rows so they still add up in the display currency', async () => {
|
|
// Two USD expenses booked at their own frozen rates and one in the trip's euros,
|
|
// viewed in pounds: every row goes through the conversion its figure went
|
|
// through, and the cents lost to rounding land on rows instead of between them.
|
|
const sum = (rows: { cents: number }[]) => rows.reduce((a, r) => a + r.cents, 0);
|
|
await setupDb(
|
|
[
|
|
{ ...makeItem(1, 100), currency: 'USD', exchange_rate: 1.08 },
|
|
{ ...makeItem(2, 33.33), currency: 'USD', exchange_rate: 1.1 },
|
|
makeItem(3, 50),
|
|
],
|
|
[
|
|
makeMember(1, 1, 'alice'),
|
|
makeMember(1, 2, 'bob'),
|
|
makeMember(1, 3, 'carol'),
|
|
makeMember(2, 1, 'alice'),
|
|
makeMember(2, 2, 'bob'),
|
|
makeMember(2, 3, 'carol'),
|
|
makeMember(3, 1, 'alice'),
|
|
makeMember(3, 2, 'bob'),
|
|
makeMember(3, 3, 'carol'),
|
|
],
|
|
[makePayer(1, 1, 100, 'alice'), makePayer(2, 1, 33.33, 'alice'), makePayer(3, 2, 50, 'bob')],
|
|
[makeSettlementRow(1, 3, 1, 20, 'GBP', 0.8547), makeSettlementRow(2, 3, 2, 7.77, 'GBP', 0.8547)],
|
|
);
|
|
for (const eurPerGbp of [1.17, 1.1523, 1.2]) {
|
|
const result = await budget.calculateSettlement(1, {
|
|
base: 'GBP',
|
|
tripCurrency: 'EUR',
|
|
rates: { GBP: 1, EUR: eurPerGbp },
|
|
});
|
|
|
|
checkIdentity(result.finalBudgets);
|
|
for (const f of result.finalBudgets) {
|
|
expect(sum(f.sources.fronted)).toBe(Math.round(f.expenses * 100));
|
|
expect(sum(f.sources.moved)).toBe(Math.round(f.reimbursed * 100));
|
|
expect(sum(f.sources.outstanding)).toBe(Math.round(f.pending * 100));
|
|
}
|
|
// Alice's two rows each stay within a cent of their own conversion: the
|
|
// remainder is handed out, not rounded away one row at a time.
|
|
const alice = result.finalBudgets.find((f) => f.user_id === 1)!;
|
|
expect(alice.sources.fronted.map((r) => r.item_id)).toEqual([1, 2]);
|
|
expect(Math.abs(alice.sources.fronted[0].cents - Math.round((100 / 1.08) * 100) / eurPerGbp)).toBeLessThan(1);
|
|
expect(Math.abs(alice.sources.fronted[1].cents - Math.round((33.33 / 1.1) * 100) / eurPerGbp)).toBeLessThan(1);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── #1382: balances and flows have to tell the same story ────────────────────
|
|
// The ledger is netted in whole cents of the trip currency, so a balance is only
|
|
// ever a whole number of cents and every one of them can be settled. What used to
|
|
// be smoothed over with a 0.01 tolerance is now exact.
|
|
|
|
describe('calculateSettlement — cent-exact settle-up (#1382)', () => {
|
|
it('#1382 offers the last cent as a flow instead of stranding it', async () => {
|
|
// 30.00 fronted by Alice, split three ways. Bob transfers 9.99 instead of his
|
|
// 10.00 and Carol pays exactly, so one cent is still open. The reporter's
|
|
// symptom was that the balances showed that cent while "Settle up" offered
|
|
// nothing to clear it: below 0.01 the old filter dropped the person entirely.
|
|
await setupDb(
|
|
[makeItem(1, 30)],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
|
[makePayer(1, 1, 30, 'alice')],
|
|
[makeSettlementRow(1, 2, 1, 9.99), makeSettlementRow(2, 3, 1, 10)],
|
|
);
|
|
const result = await budget.calculateSettlement(1);
|
|
const balance = (uid: number) => result.balances.find((b) => b.user_id === uid)!.balance;
|
|
|
|
expect(balance(1)).toBe(0.01);
|
|
expect(balance(2)).toBe(-0.01);
|
|
expect(balance(3)).toBe(0);
|
|
expect(result.flows).toEqual([
|
|
expect.objectContaining({
|
|
amount: 0.01,
|
|
from: expect.objectContaining({ user_id: 2 }),
|
|
to: expect.objectContaining({ user_id: 1 }),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('#1382 booking every offered flow leaves all balances at exactly zero', async () => {
|
|
// 10.00 three ways is 3.34/3.33/3.33 — the case where the old greedy walked
|
|
// away from the rounding remainder. Recording the flows it offers must square
|
|
// the trip up completely, or the leftover cent is there forever.
|
|
const items = [makeItem(1, 10)];
|
|
const members = [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')];
|
|
const payers = [makePayer(1, 1, 10, 'alice')];
|
|
await setupDb(items, members, payers);
|
|
|
|
const first = await budget.calculateSettlement(1);
|
|
expect(first.flows.length).toBeGreaterThan(0);
|
|
|
|
const booked = first.flows.map((f, i) => makeSettlementRow(i + 1, f.from.user_id, f.to.user_id, f.amount));
|
|
await setupDb(items, members, payers, booked);
|
|
|
|
const after = await budget.calculateSettlement(1);
|
|
expect(after.balances.map((b) => b.balance)).toEqual([0, 0, 0]);
|
|
expect(after.flows).toEqual([]);
|
|
});
|
|
|
|
it('#1382 a foreign-currency expense nets to exactly zero, not to a sub-cent residual', async () => {
|
|
// 100 USD frozen at 1.1 is 90.909090… EUR: no split of it lands on whole cents.
|
|
// The equal split divides the credited cents rather than the foreign total, so
|
|
// the debits cancel the credits exactly instead of leaving dust behind.
|
|
await setupDb(
|
|
[{ ...makeItem(1, 100), currency: 'USD', exchange_rate: 1.1 } as BudgetItem],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
|
[makePayer(1, 1, 100, 'alice')],
|
|
);
|
|
const result = await budget.calculateSettlement(1, {
|
|
base: 'EUR',
|
|
tripCurrency: 'EUR',
|
|
rates: { EUR: 1, USD: 1.1 },
|
|
});
|
|
|
|
expect(centSum(result.balances.map((b) => b.balance))).toBe(0);
|
|
expect(centSum(result.flows.map((f) => f.amount))).toBe(
|
|
Math.round(result.balances.find((b) => b.user_id === 1)!.balance * 100),
|
|
);
|
|
});
|
|
|
|
it('#1382 a display currency of its own neither invents nor loses a cent', async () => {
|
|
// Nothing here is foreign: an all-EUR trip, but the viewer's preferred currency
|
|
// is USD. Rounding each balance into that currency on its own used to leave the
|
|
// set adding up to a cent that no flow could ever clear, and the drift moved
|
|
// with the live rate — money appearing without a single expense being touched.
|
|
await setupDb(
|
|
[makeItem(1, 100)],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
|
[makePayer(1, 1, 100, 'alice')],
|
|
);
|
|
for (const eurPerUsd of [0.855, 0.9312, 0.94]) {
|
|
const result = await budget.calculateSettlement(1, {
|
|
base: 'USD',
|
|
tripCurrency: 'EUR',
|
|
rates: { USD: 1, EUR: eurPerUsd },
|
|
});
|
|
|
|
expect(centSum(result.balances.map((b) => b.balance))).toBe(0);
|
|
for (const b of result.balances) {
|
|
// What settle-up would move for this person has to be their balance exactly.
|
|
const moved =
|
|
centSum(result.flows.filter((f) => f.to.user_id === b.user_id).map((f) => f.amount)) -
|
|
centSum(result.flows.filter((f) => f.from.user_id === b.user_id).map((f) => f.amount));
|
|
expect(moved).toBe(Math.round(b.balance * 100));
|
|
}
|
|
}
|
|
});
|
|
|
|
it('#2225 an unpaid bill owes nobody anything', async () => {
|
|
// Nobody is down as a payer, so nobody is out of pocket and there is nothing
|
|
// to pay back. It used to debit all three 30 € against no credit at all,
|
|
// leaving Σ(balances) at -90 with no flow able to clear it.
|
|
await setupDb(
|
|
[makeItem(1, 90)],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
|
[],
|
|
);
|
|
const result = await budget.calculateSettlement(1);
|
|
|
|
expect(result.balances).toEqual([]);
|
|
expect(result.flows).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ── Unpaid expenses stay out of the ledger (#2225) ────────────────────────
|
|
|
|
describe('calculateSettlement: unpaid expenses (#2225)', () => {
|
|
it('an unpaid expense does not turn its members into debtors of an unrelated payer', async () => {
|
|
// The shape from the issue: a 300 € bill Alice fronted for all four, plus a
|
|
// 60 € row nobody has paid that only Bob and Carol are on. The 60 € used to
|
|
// be debited with no credit behind it, and the simplifier (which knows
|
|
// nothing about which expense made which debt) handed the extra 60 € to
|
|
// Alice, who was never owed it.
|
|
await setupDb(
|
|
[makeItem(1, 300), makeItem(2, 60)],
|
|
[
|
|
makeMember(1, 1, 'alice'),
|
|
makeMember(1, 2, 'bob'),
|
|
makeMember(1, 3, 'carol'),
|
|
makeMember(1, 4, 'dave'),
|
|
makeMember(2, 2, 'bob'),
|
|
makeMember(2, 3, 'carol'),
|
|
],
|
|
[makePayer(1, 1, 300, 'alice')],
|
|
);
|
|
const result = await budget.calculateSettlement(1);
|
|
const balance = (uid: number) => result.balances.find((b) => b.user_id === uid)!.balance;
|
|
|
|
expect(centSum(result.balances.map((b) => b.balance))).toBe(0);
|
|
// Being on the unpaid row costs Bob and Carol nothing over Dave, who is not.
|
|
expect(balance(2)).toBe(balance(4));
|
|
expect(balance(3)).toBe(balance(4));
|
|
expect(balance(1)).toBe(225);
|
|
expect(centSum(result.flows.map((f) => f.amount))).toBe(Math.round(balance(1) * 100));
|
|
for (const f of result.flows) expect(f.to.user_id).toBe(1);
|
|
});
|
|
|
|
it('an unpaid expense with a custom split is skipped too', async () => {
|
|
// The custom branch had the same hole and no coverage: 100 € split 70/30 by
|
|
// agreement, with nobody recorded as having paid it.
|
|
await setupDb(
|
|
[makeItem(1, 90), makeItem(2, 100)],
|
|
[
|
|
makeMember(1, 1, 'alice'),
|
|
makeMember(1, 2, 'bob'),
|
|
makeMember(1, 3, 'carol'),
|
|
{ ...makeMember(2, 2, 'bob'), amount: 70 },
|
|
{ ...makeMember(2, 3, 'carol'), amount: 30 },
|
|
],
|
|
[makePayer(1, 1, 90, 'alice')],
|
|
);
|
|
const result = await budget.calculateSettlement(1);
|
|
const balance = (uid: number) => result.balances.find((b) => b.user_id === uid)!.balance;
|
|
|
|
expect(balance(1)).toBe(60);
|
|
expect(balance(2)).toBe(-30);
|
|
expect(balance(3)).toBe(-30);
|
|
expect(centSum(result.balances.map((b) => b.balance))).toBe(0);
|
|
});
|
|
|
|
it('a zero-total item with no payer contributes nothing and no rows', async () => {
|
|
// Payer-less is payer-less regardless of the total: it contributes 0 either
|
|
// way, and both panels synthesise a missing member's 0.00 row from the trip
|
|
// roster (CostsPanel.tsx:853, MCostsTab.tsx:273), so dropping the row costs
|
|
// the UI nothing.
|
|
await setupDb([makeItem(1, 0)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], []);
|
|
const result = await budget.calculateSettlement(1);
|
|
|
|
expect(result.balances).toEqual([]);
|
|
expect(result.flows).toEqual([]);
|
|
});
|
|
|
|
it('a custom split left with no payer and a zeroed total creates no debt', async () => {
|
|
// PUT /budget/:id/payers with an empty array zeroes total_price via
|
|
// writeItemPayers and, unlike updateBudgetItem, never re-applies it, so the
|
|
// row survives as custom per-member amounts with no credit behind them. A
|
|
// guard that also required a non-zero total would let exactly this shape
|
|
// through and rebuild the #2225 phantom debt from a first-party endpoint.
|
|
await setupDb(
|
|
[makeItem(1, 0)],
|
|
[
|
|
{ ...makeMember(1, 2, 'bob'), amount: 40 },
|
|
{ ...makeMember(1, 3, 'cara'), amount: 60 },
|
|
],
|
|
[],
|
|
);
|
|
const result = await budget.calculateSettlement(1);
|
|
|
|
expect(result.balances).toEqual([]);
|
|
expect(result.flows).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ── Rows no rate can convert (the VND/AUD report) ───────────────────────────
|
|
// A foreign row with no frozen rate and no live one used to be read 1:1, as if it
|
|
// were already in the trip currency: 8,920,000 VND became 8,920,000 AUD of debt.
|
|
|
|
describe('calculateSettlement: unconverted rows', () => {
|
|
const four = () => [
|
|
makeMember(1, 1, 'alice'),
|
|
makeMember(1, 2, 'bob'),
|
|
makeMember(1, 3, 'carol'),
|
|
makeMember(1, 4, 'dave'),
|
|
];
|
|
const vndBill = (exchange_rate: number) =>
|
|
({ ...makeItem(1, 8920000), currency: 'VND', exchange_rate }) as BudgetItem;
|
|
const none = { item_ids: [], settlement_ids: [], currencies: [] };
|
|
|
|
it('VND/AUD regression: an unfrozen 8,920,000 VND bill on an AUD trip with no rates is left out whole, Σ balances 0, no VND-scale balance', async () => {
|
|
await setupDb(
|
|
[vndBill(1), makeItem(2, 40)],
|
|
[...four(), makeMember(2, 2, 'bob'), makeMember(2, 3, 'carol')],
|
|
[makePayer(1, 1, 8920000, 'alice'), makePayer(2, 2, 40, 'bob')],
|
|
);
|
|
const result = await budget.calculateSettlement(1, { base: 'AUD', tripCurrency: 'AUD', rates: null });
|
|
expect(result.unconverted).toEqual({ item_ids: [1], settlement_ids: [], currencies: ['VND'] });
|
|
expect(result.currency).toBe('AUD');
|
|
// Only the 40 AUD dinner settles; the bill moves nobody, its payer included.
|
|
expect(result.balances.map((b) => [b.user_id, b.balance])).toEqual([
|
|
[2, 20],
|
|
[3, -20],
|
|
]);
|
|
expect(centSum(result.balances.map((b) => b.balance))).toBe(0);
|
|
expect(result.balances.every((b) => Math.abs(b.balance) < 1000)).toBe(true);
|
|
expect(result.finalBudgets.flatMap((f) => f.sources.fronted.map((r) => r.item_id))).toEqual([2]);
|
|
});
|
|
|
|
it('VND/AUD regression: frozen at 18241.3 it books 489.00 AUD, split 4 ways +366.75 / -122.25 x3', async () => {
|
|
await setupDb([vndBill(18241.3)], four(), [makePayer(1, 1, 8920000, 'alice')]);
|
|
const result = await budget.calculateSettlement(1, { base: 'AUD', tripCurrency: 'AUD', rates: null });
|
|
expect(result.unconverted).toEqual(none);
|
|
expect(result.balances.map((b) => b.balance)).toEqual([366.75, -122.25, -122.25, -122.25]);
|
|
expect(result.finalBudgets.find((f) => f.user_id === 1)!.expenses).toBe(489);
|
|
});
|
|
|
|
it('a foreign row the rates do not quote is left out and listed while others convert live', async () => {
|
|
await setupDb(
|
|
[vndBill(1), { ...makeItem(2, 65), currency: 'USD', exchange_rate: 1 } as BudgetItem],
|
|
[...four(), makeMember(2, 1, 'alice'), makeMember(2, 2, 'bob')],
|
|
[makePayer(1, 1, 8920000, 'alice'), makePayer(2, 1, 65, 'alice')],
|
|
);
|
|
const result = await budget.calculateSettlement(1, {
|
|
base: 'AUD',
|
|
tripCurrency: 'AUD',
|
|
rates: { AUD: 1, USD: 0.65 },
|
|
});
|
|
expect(result.unconverted).toEqual({ item_ids: [1], settlement_ids: [], currencies: ['VND'] });
|
|
// 65 USD at today's 0.65 per dollar is 100 AUD, half of it Bob's.
|
|
expect(result.balances.map((b) => [b.user_id, b.balance])).toEqual([
|
|
[1, 50],
|
|
[2, -50],
|
|
]);
|
|
});
|
|
|
|
it('a frozen row converts without any rates (unchanged)', async () => {
|
|
await setupDb(
|
|
[{ ...makeItem(1, 110), currency: 'USD', exchange_rate: 1.1 } as BudgetItem],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
|
[makePayer(1, 1, 110, 'alice')],
|
|
);
|
|
const result = await budget.calculateSettlement(1, { base: 'EUR', tripCurrency: 'EUR', rates: null });
|
|
expect(result.unconverted).toEqual(none);
|
|
expect(result.balances.find((b) => b.user_id === 2)!.balance).toBe(-50);
|
|
});
|
|
|
|
it('trip-currency and NULL-currency rows with rate 1 count as they are', async () => {
|
|
await setupDb(
|
|
[
|
|
{ ...makeItem(1, 100), currency: 'aud', exchange_rate: 1 } as BudgetItem,
|
|
{ ...makeItem(2, 60), currency: null, exchange_rate: 1 } as BudgetItem,
|
|
],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(2, 1, 'alice'), makeMember(2, 2, 'bob')],
|
|
[makePayer(1, 1, 100, 'alice'), makePayer(2, 1, 60, 'alice')],
|
|
);
|
|
const result = await budget.calculateSettlement(1, { base: 'AUD', tripCurrency: 'AUD', rates: null });
|
|
expect(result.unconverted).toEqual(none);
|
|
expect(result.balances.find((b) => b.user_id === 2)!.balance).toBe(-80);
|
|
});
|
|
|
|
it('planning-only and unpaid unconvertible rows are listed but never touch balances', async () => {
|
|
await setupDb(
|
|
[
|
|
{ ...makeItem(1, 500000), currency: 'VND', exchange_rate: 1 } as BudgetItem, // planning-only
|
|
{ ...makeItem(2, 700000), currency: 'VND', exchange_rate: 1 } as BudgetItem, // nobody paid yet
|
|
makeItem(3, 30),
|
|
],
|
|
[makeMember(2, 1, 'alice'), makeMember(2, 2, 'bob'), makeMember(3, 1, 'alice'), makeMember(3, 2, 'bob')],
|
|
[makePayer(3, 1, 30, 'alice')],
|
|
);
|
|
const result = await budget.calculateSettlement(1, { base: 'AUD', tripCurrency: 'AUD', rates: null });
|
|
expect(result.unconverted).toEqual({ item_ids: [1, 2], settlement_ids: [], currencies: ['VND'] });
|
|
expect(result.balances.map((b) => [b.user_id, b.balance])).toEqual([
|
|
[1, 15],
|
|
[2, -15],
|
|
]);
|
|
});
|
|
|
|
it('an unfrozen foreign transfer without a quote is left out and listed in settlement_ids', async () => {
|
|
await setupDb(
|
|
[makeItem(1, 100)],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
|
[makePayer(1, 1, 100, 'alice')],
|
|
[makeSettlementRow(9, 2, 1, 1000000, 'VND', 1)],
|
|
);
|
|
const result = await budget.calculateSettlement(1, { base: 'AUD', tripCurrency: 'AUD', rates: null });
|
|
expect(result.unconverted).toEqual({ item_ids: [], settlement_ids: [9], currencies: ['VND'] });
|
|
// A million dong did not square a 50 dollar debt 20,000 times over.
|
|
expect(result.balances.find((b) => b.user_id === 2)!.balance).toBe(-50);
|
|
expect(result.finalBudgets.every((f) => f.sources.moved.length === 0)).toBe(true);
|
|
// Still on the ledger list, so it can be edited or undone.
|
|
expect(result.settlements.map((s) => s.id)).toEqual([9]);
|
|
});
|
|
|
|
it('a NULL-currency transfer still reads in the display currency', async () => {
|
|
await setupDb(
|
|
[makeItem(1, 100)],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
|
[makePayer(1, 1, 100, 'alice')],
|
|
[makeSettlementRow(9, 2, 1, 62.5, null, 1)],
|
|
);
|
|
const result = await budget.calculateSettlement(1, {
|
|
base: 'USD',
|
|
tripCurrency: 'EUR',
|
|
rates: { EUR: 1, USD: 1.25 },
|
|
});
|
|
expect(result.currency).toBe('USD');
|
|
expect(result.unconverted).toEqual(none);
|
|
expect(result.balances.map((b) => b.balance)).toEqual([0, 0]);
|
|
});
|
|
|
|
it("base EUR on an AUD trip without quote or baseRate answers in AUD with currency 'AUD'", async () => {
|
|
await setupDb(
|
|
[makeItem(1, 100)],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
|
[makePayer(1, 1, 100, 'alice')],
|
|
);
|
|
for (const rates of [null, { AUD: 1, USD: 0.65 }]) {
|
|
const result = await budget.calculateSettlement(1, { base: 'EUR', tripCurrency: 'AUD', rates });
|
|
// Trip cents, labelled as what they are rather than printed as euros.
|
|
expect(result.currency).toBe('AUD');
|
|
expect(result.balances.map((b) => b.balance)).toEqual([50, -50]);
|
|
}
|
|
});
|
|
|
|
it('baseRate stands in for the missing display quote and a transfer frozen at it cancels its flow to the cent (#2525)', async () => {
|
|
const bill = () => [makeItem(1, 123.45)];
|
|
const pair = () => [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')];
|
|
const paid = () => [makePayer(1, 1, 123.45, 'alice')];
|
|
await setupDb(bill(), pair(), paid());
|
|
const before = await budget.calculateSettlement(1, {
|
|
base: 'USD',
|
|
tripCurrency: 'EUR',
|
|
rates: null,
|
|
baseRate: 1.1429,
|
|
});
|
|
expect(before.currency).toBe('USD');
|
|
// Bob's 61.73 EUR at the browser's 1.1429 dollars per euro.
|
|
expect(before.flows.map((f) => f.amount)).toEqual([70.55]);
|
|
|
|
// Bob pays what settle-up offers, frozen at the same browser quote (fallback_fx).
|
|
await setupDb(bill(), pair(), paid(), [makeSettlementRow(9, 2, 1, 70.55, 'USD', 1.1429)]);
|
|
const after = await budget.calculateSettlement(1, {
|
|
base: 'USD',
|
|
tripCurrency: 'EUR',
|
|
rates: null,
|
|
baseRate: 1.1429,
|
|
});
|
|
expect(after.balances.map((b) => b.balance)).toEqual([0, 0]);
|
|
expect(after.flows).toEqual([]);
|
|
});
|
|
|
|
it('a server quote wins over baseRate', async () => {
|
|
await setupDb(
|
|
[makeItem(1, 123.45)],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
|
[makePayer(1, 1, 123.45, 'alice')],
|
|
);
|
|
const result = await budget.calculateSettlement(1, {
|
|
base: 'USD',
|
|
tripCurrency: 'EUR',
|
|
rates: { EUR: 1, USD: 1.1429 },
|
|
baseRate: 2,
|
|
});
|
|
expect(result.currency).toBe('USD');
|
|
expect(result.flows.map((f) => f.amount)).toEqual([70.55]);
|
|
});
|
|
|
|
it('baseRate never converts a row', async () => {
|
|
await setupDb(
|
|
[{ ...makeItem(1, 100), currency: 'USD', exchange_rate: 1 } as BudgetItem],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
|
[makePayer(1, 1, 100, 'alice')],
|
|
);
|
|
// 0.61 EUR per AUD would convert this bill arithmetically; it only relabels the result.
|
|
const result = await budget.calculateSettlement(1, {
|
|
base: 'EUR',
|
|
tripCurrency: 'AUD',
|
|
rates: null,
|
|
baseRate: 0.61,
|
|
});
|
|
expect(result.currency).toBe('EUR');
|
|
expect(result.unconverted).toEqual({ item_ids: [1], settlement_ids: [], currencies: ['USD'] });
|
|
expect(result.balances).toEqual([]);
|
|
});
|
|
|
|
it('a bill in the display currency needs no rate at all: it counts as typed (#2965)', async () => {
|
|
await setupDb(
|
|
[{ ...makeItem(1, 100), currency: 'EUR', exchange_rate: 1 } as BudgetItem],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
|
[makePayer(1, 1, 100, 'alice')],
|
|
);
|
|
const result = await budget.calculateSettlement(1, {
|
|
base: 'EUR',
|
|
tripCurrency: 'AUD',
|
|
rates: null,
|
|
baseRate: 0.61,
|
|
});
|
|
expect(result.unconverted).toEqual(none);
|
|
expect(result.balances.map((b) => b.balance)).toEqual([50, -50]);
|
|
});
|
|
});
|
|
|
|
// ── Currencies shown without decimals (#2604) ────────────────────────────────
|
|
|
|
describe('calculateSettlement: currencies shown in whole units (#2604)', () => {
|
|
const KRW = { base: 'KRW', tripCurrency: 'KRW', rates: null };
|
|
type Result = Awaited<ReturnType<BudgetService['calculateSettlement']>>;
|
|
|
|
/**
|
|
* What the panels print for a won is a whole won, so every figure has to be
|
|
* one already and the printed set has to add up the way the ledger does: the
|
|
* balances to zero, each person's flows to their balance, each final-budget
|
|
* line to the rows listed under it.
|
|
*/
|
|
const expectWholeAndSquare = (result: Result) => {
|
|
const figures = [
|
|
...result.balances.map((b) => b.balance),
|
|
...result.flows.map((f) => f.amount),
|
|
...result.finalBudgets.flatMap((f) => [f.expenses, f.reimbursed, f.pending, f.final]),
|
|
...result.finalBudgets.flatMap((f) => [
|
|
...f.sources.fronted.map((r) => r.cents / 100),
|
|
...f.sources.moved.map((r) => r.cents / 100),
|
|
...f.sources.outstanding.map((r) => r.cents / 100),
|
|
]),
|
|
];
|
|
for (const v of figures) expect(Number.isInteger(v)).toBe(true);
|
|
expect(result.balances.reduce((a, b) => a + b.balance, 0)).toBe(0);
|
|
for (const b of result.balances) {
|
|
const moved =
|
|
result.flows.filter((f) => f.to.user_id === b.user_id).reduce((a, f) => a + f.amount, 0) -
|
|
result.flows.filter((f) => f.from.user_id === b.user_id).reduce((a, f) => a + f.amount, 0);
|
|
expect(moved).toBe(b.balance);
|
|
}
|
|
for (const f of result.finalBudgets) {
|
|
expect(f.sources.fronted.reduce((a, r) => a + r.cents, 0)).toBe(Math.round(f.expenses * 100));
|
|
expect(f.sources.moved.reduce((a, r) => a + r.cents, 0)).toBe(Math.round(f.reimbursed * 100));
|
|
expect(f.sources.outstanding.reduce((a, r) => a + r.cents, 0)).toBe(Math.round(f.pending * 100));
|
|
expect(f.final).toBe(f.expenses - f.reimbursed - f.pending);
|
|
}
|
|
};
|
|
|
|
/** Books every flow the settlement offers, as the Settle button does, and reads the trip again. */
|
|
const settleEverything = async (
|
|
items: BudgetItem[],
|
|
members: ReturnType<typeof makeMember>[],
|
|
payers: ReturnType<typeof makePayer>[],
|
|
opts: Parameters<BudgetService['calculateSettlement']>[1],
|
|
currency: string,
|
|
) => {
|
|
const first = await budget.calculateSettlement(1, opts);
|
|
const booked = first.flows.map((f, i) =>
|
|
makeSettlementRow(i + 1, f.from.user_id, f.to.user_id, f.amount, currency),
|
|
);
|
|
await setupDb(items, members, payers, booked);
|
|
return budget.calculateSettlement(1, opts);
|
|
};
|
|
|
|
it('₩10,000 three ways: balances in whole won that add up, and settle-up moves exactly them', async () => {
|
|
// The report: the ledger holds 6,666.67 / -3,333.33 / -3,333.34 and the panel
|
|
// rounded each one on its own to +6,667 / -3,333 / -3,333, one won out, with
|
|
// two flows of 3,333 under "You're owed 6,667".
|
|
const items = [{ ...makeItem(1, 10000), currency: 'KRW' } as BudgetItem];
|
|
const members = [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')];
|
|
const payers = [makePayer(1, 1, 10000, 'alice')];
|
|
await setupDb(items, members, payers);
|
|
|
|
const result = await budget.calculateSettlement(1, KRW);
|
|
expectWholeAndSquare(result);
|
|
expect(result.balances.find((b) => b.user_id === 1)?.balance).toBe(6667);
|
|
expect(result.flows.map((f) => f.amount).sort()).toEqual([3333, 3334]);
|
|
|
|
const after = await settleEverything(items, members, payers, KRW, 'KRW');
|
|
expect(after.balances.map((b) => b.balance)).toEqual([0, 0, 0]);
|
|
expect(after.flows).toEqual([]);
|
|
expectWholeAndSquare(after);
|
|
});
|
|
|
|
it.each([
|
|
[60001, 4],
|
|
[100001, 3],
|
|
])('₩%i split %i ways squares up in whole won', async (total, people) => {
|
|
const names = ['alice', 'bob', 'carol', 'dave'].slice(0, people);
|
|
const items = [{ ...makeItem(1, total), currency: 'KRW' } as BudgetItem];
|
|
const members = names.map((n, i) => makeMember(1, i + 1, n));
|
|
const payers = [makePayer(1, 1, total, 'alice')];
|
|
await setupDb(items, members, payers);
|
|
|
|
expectWholeAndSquare(await budget.calculateSettlement(1, KRW));
|
|
|
|
const after = await settleEverything(items, members, payers, KRW, 'KRW');
|
|
expect(after.balances.every((b) => b.balance === 0)).toBe(true);
|
|
expect(after.flows).toEqual([]);
|
|
});
|
|
|
|
it('a yen bill on a won trip, split four ways, squares up in whole won', async () => {
|
|
// ¥4,000 at a frozen 0.1086 yen per won is 36,832.41 won: neither the total nor
|
|
// the shares land on a whole won, which summed to -1 on screen.
|
|
const items = [{ ...makeItem(1, 4000), currency: 'JPY', exchange_rate: 0.1086 } as BudgetItem];
|
|
const members = ['alice', 'bob', 'carol', 'dave'].map((n, i) => makeMember(1, i + 1, n));
|
|
const payers = [makePayer(1, 1, 4000, 'alice')];
|
|
await setupDb(items, members, payers);
|
|
|
|
expectWholeAndSquare(await budget.calculateSettlement(1, KRW));
|
|
const after = await settleEverything(items, members, payers, KRW, 'KRW');
|
|
expect(after.balances.every((b) => b.balance === 0)).toBe(true);
|
|
expect(after.flows).toEqual([]);
|
|
});
|
|
|
|
it('a transfer stored with a fraction of a won still reads in whole won', async () => {
|
|
// What the Settle button used to send: the unrounded 3,333.34. The ledger keeps
|
|
// it to the hundredth; what is shown stays whole and adds up.
|
|
await setupDb(
|
|
[{ ...makeItem(1, 10000), currency: 'KRW' } as BudgetItem],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
|
[makePayer(1, 1, 10000, 'alice')],
|
|
[makeSettlementRow(1, 3, 1, 3333.34, 'KRW')],
|
|
);
|
|
const result = await budget.calculateSettlement(1, KRW);
|
|
expectWholeAndSquare(result);
|
|
expect(result.flows.map((f) => f.amount)).toEqual([3333]);
|
|
});
|
|
|
|
it('a euro trip read in won is shown in whole won and still adds up', async () => {
|
|
const items = [makeItem(1, 100.01)];
|
|
const members = [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')];
|
|
const payers = [makePayer(1, 1, 100.01, 'alice')];
|
|
await setupDb(items, members, payers, [makeSettlementRow(1, 2, 1, 12.34, 'EUR')]);
|
|
const results = await Promise.all(
|
|
[1473.21, 1500, 1612.77].map((krwPerEur) =>
|
|
budget.calculateSettlement(1, { base: 'KRW', tripCurrency: 'EUR', rates: { EUR: 1, KRW: krwPerEur } }),
|
|
),
|
|
);
|
|
for (const result of results) {
|
|
expect(result.currency).toBe('KRW');
|
|
expectWholeAndSquare(result);
|
|
}
|
|
});
|
|
|
|
it('a bill in a won display currency on a euro trip is split in whole won, and settle-up clears it', async () => {
|
|
// Read in won, a won bill counts as typed (#2965) and skips the conversion that
|
|
// rounds to whole won, so it has to be split in whole won itself: 3,333.33 each
|
|
// printed as -3,333 / -3,333 beside +6,667 again.
|
|
const fx = { base: 'KRW', tripCurrency: 'EUR', rates: { EUR: 1, KRW: 1500 } };
|
|
const items = [{ ...makeItem(1, 10000), currency: 'KRW', exchange_rate: 1500 } as BudgetItem];
|
|
const members = [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')];
|
|
const payers = [makePayer(1, 1, 10000, 'alice')];
|
|
await setupDb(items, members, payers);
|
|
|
|
const result = await budget.calculateSettlement(1, fx);
|
|
expect(result.currency).toBe('KRW');
|
|
expectWholeAndSquare(result);
|
|
expect(result.balances.map((b) => b.balance).sort((x, y) => x - y)).toEqual([-3334, -3333, 6667]);
|
|
|
|
const after = await settleEverything(items, members, payers, fx, 'KRW');
|
|
expectWholeAndSquare(after);
|
|
expect(after.balances.every((b) => b.balance === 0)).toBe(true);
|
|
expect(after.flows).toEqual([]);
|
|
});
|
|
|
|
it('a two-decimal display currency keeps its cents', async () => {
|
|
// The euro case the rest of this file pins, read once more beside the won ones:
|
|
// 10.00 three ways stays 3.34 / 3.33 / 3.33, not whole euros.
|
|
await setupDb(
|
|
[makeItem(1, 10)],
|
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
|
[makePayer(1, 1, 10, 'alice')],
|
|
);
|
|
const result = await budget.calculateSettlement(1);
|
|
expect(result.flows.map((f) => f.amount).sort()).toEqual([3.33, 3.34]);
|
|
});
|
|
});
|
|
|
|
// ── freezeForeignRate (write-path FX freeze, #1445) ───────────────────────────
|
|
// The trip seeded by beforeEach/seedBaseline is EUR — every case here relies on
|
|
// that baseline instead of reconfiguring a `FROM trips` stub.
|
|
|
|
describe('freezeForeignRate', () => {
|
|
it('freezes the live rate for a foreign currency into exchange_rate', async () => {
|
|
mockRates.getRates.mockResolvedValue({ EUR: 1, USD: 1.25 });
|
|
const data: { currency?: string | null; exchange_rate?: number } = { currency: 'usd' };
|
|
await budget.freezeForeignRate(1, data);
|
|
expect(mockRates.getRates).toHaveBeenCalledWith('EUR');
|
|
expect(data.exchange_rate).toBe(1.25);
|
|
});
|
|
|
|
it('leaves the rate unset when the currency equals the trip currency', async () => {
|
|
const data: { currency?: string | null; exchange_rate?: number } = { currency: 'EUR' };
|
|
await budget.freezeForeignRate(1, data);
|
|
expect(mockRates.getRates).not.toHaveBeenCalled();
|
|
expect(data.exchange_rate).toBeUndefined();
|
|
});
|
|
|
|
it('respects an explicit exchange_rate from the caller', async () => {
|
|
const data: { currency?: string | null; exchange_rate?: number } = { currency: 'USD', exchange_rate: 2 };
|
|
await budget.freezeForeignRate(1, data);
|
|
expect(mockRates.getRates).not.toHaveBeenCalled();
|
|
expect(data.exchange_rate).toBe(2);
|
|
});
|
|
|
|
it('degrades to live rates (no freeze) when the rate fetch fails', async () => {
|
|
mockRates.getRates.mockResolvedValue(null);
|
|
const data: { currency?: string | null; exchange_rate?: number } = { currency: 'USD' };
|
|
await budget.freezeForeignRate(1, data);
|
|
expect(data.exchange_rate).toBeUndefined();
|
|
});
|
|
|
|
it('does not re-freeze on update when the currency is unchanged', async () => {
|
|
await insertRow(await sharedTestOrm(testDb), BudgetItems, {
|
|
id: 9,
|
|
trip: 1,
|
|
name: 'Existing',
|
|
total_price: 1,
|
|
currency: 'USD',
|
|
});
|
|
const data: { currency?: string | null; exchange_rate?: number } = { currency: 'USD' };
|
|
await budget.freezeForeignRate(1, data, 9);
|
|
expect(mockRates.getRates).not.toHaveBeenCalled();
|
|
expect(data.exchange_rate).toBeUndefined();
|
|
});
|
|
|
|
it('does not re-freeze a settlement edit when its stored currency is unchanged (#1445)', async () => {
|
|
const data: { currency?: string | null; exchange_rate?: number } = { currency: 'USD' };
|
|
// the settlement already holds USD — pass it as existingCurrency → keep the frozen rate
|
|
await budget.freezeForeignRate(1, data, undefined, 'USD');
|
|
expect(mockRates.getRates).not.toHaveBeenCalled();
|
|
expect(data.exchange_rate).toBeUndefined();
|
|
});
|
|
|
|
it('re-freezes a settlement edit when its currency actually changes', async () => {
|
|
mockRates.getRates.mockResolvedValue({ EUR: 1, USD: 1.25 });
|
|
const data: { currency?: string | null; exchange_rate?: number } = { currency: 'USD' };
|
|
await budget.freezeForeignRate(1, data, undefined, 'GBP'); // was GBP → now USD → re-freeze
|
|
expect(data.exchange_rate).toBe(1.25);
|
|
});
|
|
});
|
|
|
|
// ── applySettlementUpdate (the raw no-freeze write behind updateSettlement) ──
|
|
|
|
describe('applySettlementUpdate', () => {
|
|
it('returns null when the settlement is not in the trip', async () => {
|
|
expect(await budget.applySettlementUpdate(7, 1, { from_user_id: 2, to_user_id: 1, amount: 10 })).toBeNull();
|
|
});
|
|
|
|
it('updates the row (rounded to cents) and returns the refreshed settlement', async () => {
|
|
await seedUser(1, 'alice');
|
|
await seedUser(2, 'bob');
|
|
await insertRow(await sharedTestOrm(testDb), BudgetSettlements, {
|
|
id: 7,
|
|
trip: 1,
|
|
fromUser: 1,
|
|
toUser: 2,
|
|
amount: 5,
|
|
currency: null,
|
|
exchange_rate: 1,
|
|
settled_at: null,
|
|
});
|
|
|
|
const res = await budget.applySettlementUpdate(7, 1, { from_user_id: 2, to_user_id: 1, amount: 10.126 });
|
|
// Quirk fix: currency/exchange_rate/settled_at/note are presence-gated CASE guards —
|
|
// omitted from this update, so the row keeps what it already had (NULL/1/NULL/NULL,
|
|
// the note since #2340) while from/to/amount (rounded) take the new values. Checked
|
|
// against the persisted row rather than a mocked `run` call, now that there is a real one.
|
|
const stored = (await findRow(await sharedTestOrm(testDb), BudgetSettlements, { id: 7 }))!;
|
|
const row = {
|
|
from_user_id: stored.from_user_id,
|
|
to_user_id: stored.to_user_id,
|
|
amount: stored.amount,
|
|
currency: stored.currency,
|
|
exchange_rate: stored.exchange_rate,
|
|
settled_at: stored.settled_at,
|
|
note: stored.note,
|
|
};
|
|
expect(row).toEqual({
|
|
from_user_id: 2,
|
|
to_user_id: 1,
|
|
amount: 10.13,
|
|
currency: null,
|
|
exchange_rate: 1,
|
|
settled_at: null,
|
|
note: null,
|
|
});
|
|
expect(res).toMatchObject({ id: 7, from_user_id: 2, to_user_id: 1, amount: 10.13 });
|
|
});
|
|
});
|
|
|
|
// ── A bill in the display currency counts as typed (#2965) ──────────────────
|
|
// A yen trip read in euros, the flight entered in euros: booked through the yen
|
|
// at its frozen rate and back at today's, 1,081.09 EUR came back as 1,078.86 EUR
|
|
// a day later, and repaying exactly what was typed left 2.23 EUR owed.
|
|
|
|
describe('calculateSettlement: bills in the display currency (#2965)', () => {
|
|
// The flight was frozen at 176.37 yen per euro; today a euro buys 176.73.
|
|
const flight = () => [{ ...makeItem(1, 1081.09), currency: 'EUR', exchange_rate: 1 / 176.37 } as BudgetItem];
|
|
const pair = () => [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')];
|
|
const paid = () => [makePayer(1, 1, 1081.09, 'alice')];
|
|
const today = { base: 'EUR', tripCurrency: 'JPY', rates: { JPY: 1, EUR: 1 / 176.73 } };
|
|
|
|
it('owes half of what was typed, whatever the rate did since', async () => {
|
|
await setupDb(flight(), pair(), paid());
|
|
const result = await budget.calculateSettlement(1, today);
|
|
expect(result.currency).toBe('EUR');
|
|
expect(centSum(result.balances.map((b) => b.balance))).toBe(0);
|
|
const [alice, bob] = result.balances.map((b) => b.balance);
|
|
expect([540.54, 540.55]).toContain(alice);
|
|
expect(bob).toBe(-alice);
|
|
const fronted = result.finalBudgets.find((f) => f.user_id === 1);
|
|
expect(fronted?.expenses).toBe(1081.09);
|
|
expect(fronted?.sources.fronted).toEqual([{ item_id: 1, cents: 108109 }]);
|
|
});
|
|
|
|
it('a repayment of exactly what is owed in euros squares the trip, frozen at any rate', async () => {
|
|
await setupDb(flight(), pair(), paid());
|
|
const owed = (await budget.calculateSettlement(1, today)).flows[0].amount;
|
|
// Bob pays the euros the flow names, frozen at today's yen rate.
|
|
await setupDb(flight(), pair(), paid(), [makeSettlementRow(5, 2, 1, owed, 'EUR', 1 / 176.73)]);
|
|
const results = await Promise.all(
|
|
[1 / 176.73, 1 / 170, 1 / 185].map((eurPerYen) =>
|
|
budget.calculateSettlement(1, { ...today, rates: { JPY: 1, EUR: eurPerYen } }),
|
|
),
|
|
);
|
|
for (const result of results) {
|
|
expect(result.balances.map((b) => b.balance)).toEqual([0, 0]);
|
|
expect(result.flows).toEqual([]);
|
|
for (const f of result.finalBudgets) expect(f.final).toBeCloseTo(f.user_id === 1 ? 1081.09 - owed : owed, 2);
|
|
}
|
|
});
|
|
|
|
it('a euro payment beyond the euro debt pays the yen debt at its frozen rate (#1445 stays)', async () => {
|
|
// A yen dinner on top: Bob owes 540.55 EUR for the flight and 5,000 yen for dinner.
|
|
await setupDb(
|
|
[...flight(), { ...makeItem(2, 10000) } as BudgetItem],
|
|
[...pair(), makeMember(2, 1, 'alice'), makeMember(2, 2, 'bob')],
|
|
[...paid(), makePayer(2, 1, 10000, 'alice')],
|
|
);
|
|
const before = await budget.calculateSettlement(1, today);
|
|
const owed = before.flows[0].amount;
|
|
await setupDb(
|
|
[...flight(), { ...makeItem(2, 10000) } as BudgetItem],
|
|
[...pair(), makeMember(2, 1, 'alice'), makeMember(2, 2, 'bob')],
|
|
[...paid(), makePayer(2, 1, 10000, 'alice')],
|
|
[makeSettlementRow(5, 2, 1, owed, 'EUR', 1 / 176.73)],
|
|
);
|
|
// Settled at today's rate it squares the trip; the yen part keeps its frozen rate,
|
|
// so it stays within a cent when the yen moves afterwards.
|
|
const now = await budget.calculateSettlement(1, today);
|
|
expect(now.balances.map((b) => b.balance)).toEqual([0, 0]);
|
|
const later = await budget.calculateSettlement(1, { ...today, rates: { JPY: 1, EUR: 1 / 160 } });
|
|
for (const b of later.balances) expect(Math.abs(b.balance)).toBeLessThanOrEqual(0.01);
|
|
expect(centSum(later.balances.map((b) => b.balance))).toBe(0);
|
|
});
|
|
|
|
it('settled when the halves point opposite ways, it stays settled when the yen moves', async () => {
|
|
// Bob paid a 100 EUR train for both, Alice a 100,000 yen hotel for both: Alice
|
|
// owes Bob 50 EUR in euros, Bob owes Alice 50,000 yen. Bob pays the net in euros.
|
|
// The euro credit used to stay in euros beside the yen it was netted with, and
|
|
// the balance reopened as soon as the rate moved.
|
|
const items = () => [
|
|
{ ...makeItem(1, 100), currency: 'EUR', exchange_rate: 1 / 176.37 } as BudgetItem,
|
|
{ ...makeItem(2, 100000) } as BudgetItem,
|
|
];
|
|
const members = () => [...pair(), makeMember(2, 1, 'alice'), makeMember(2, 2, 'bob')];
|
|
const payers = () => [makePayer(1, 2, 100, 'bob'), makePayer(2, 1, 100000, 'alice')];
|
|
await setupDb(items(), members(), payers());
|
|
const offer = (await budget.calculateSettlement(1, today)).flows;
|
|
expect(offer).toHaveLength(1);
|
|
expect(offer[0].from.user_id).toBe(2);
|
|
await setupDb(items(), members(), payers(), [makeSettlementRow(5, 2, 1, offer[0].amount, 'EUR', 1 / 176.73)]);
|
|
const results = await Promise.all(
|
|
[1 / 176.73, 1 / 160, 1 / 195].map((eurPerYen) =>
|
|
budget.calculateSettlement(1, { ...today, rates: { JPY: 1, EUR: eurPerYen } }),
|
|
),
|
|
);
|
|
for (const result of results) {
|
|
expect(result.balances.map((b) => b.balance)).toEqual([0, 0]);
|
|
expect(result.flows).toEqual([]);
|
|
for (const f of result.finalBudgets) {
|
|
expect(f.sources.moved.reduce((a, r) => a + r.cents, 0)).toBe(Math.round(f.reimbursed * 100));
|
|
expect(f.final).toBeCloseTo(f.expenses - f.reimbursed - f.pending, 2);
|
|
}
|
|
}
|
|
// What Bob sent is what Alice received, read on the day it was sent.
|
|
const alice = results[0].finalBudgets.find((f) => f.user_id === 1);
|
|
expect(alice?.reimbursed).toBe(offer[0].amount);
|
|
});
|
|
|
|
it('read in the trip currency nothing changes: the bill goes through its frozen rate', async () => {
|
|
await setupDb(flight(), pair(), paid());
|
|
const result = await budget.calculateSettlement(1, { base: 'JPY', tripCurrency: 'JPY', rates: null });
|
|
expect(result.currency).toBe('JPY');
|
|
// 1,081.09 EUR at 176.37 is 190,672 yen (whole yen), half each.
|
|
expect(centSum(result.balances.map((b) => b.balance))).toBe(0);
|
|
expect(Math.abs(result.balances[0].balance)).toBeCloseTo(95336, -1);
|
|
});
|
|
});
|