Spaces:
Running
Running
Commit ·
d180c31
1
Parent(s): 3852523
feat(domain): compute net balances and simplified debts
Browse files- src/lib/domain/balances.ts +48 -0
- tests/unit/domain/balances.test.ts +57 -0
src/lib/domain/balances.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Expense, Settlement } from './types';
|
| 2 |
+
|
| 3 |
+
export interface Debt {
|
| 4 |
+
from: string;
|
| 5 |
+
to: string;
|
| 6 |
+
cents: number;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
/** Positive = is owed money; negative = owes money. Only users touched by any item appear. */
|
| 10 |
+
export function computeNets(expenses: Iterable<Expense>, settlements: Iterable<Settlement>): Map<string, number> {
|
| 11 |
+
const nets = new Map<string, number>();
|
| 12 |
+
const add = (id: string, delta: number) => nets.set(id, (nets.get(id) ?? 0) + delta);
|
| 13 |
+
for (const e of expenses) {
|
| 14 |
+
add(e.paidBy, e.amountCents);
|
| 15 |
+
for (const [uid, cents] of Object.entries(e.shares)) add(uid, -cents);
|
| 16 |
+
}
|
| 17 |
+
for (const s of settlements) {
|
| 18 |
+
add(s.from, s.amountCents);
|
| 19 |
+
add(s.to, -s.amountCents);
|
| 20 |
+
}
|
| 21 |
+
return nets;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
const byMagnitudeThenId = (a: [string, number], b: [string, number]) =>
|
| 25 |
+
Math.abs(b[1]) - Math.abs(a[1]) || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0);
|
| 26 |
+
|
| 27 |
+
/** Greedy pairing of largest debtor with largest creditor. Deterministic; at most n-1 transfers. */
|
| 28 |
+
export function simplifyDebts(nets: Map<string, number>): Debt[] {
|
| 29 |
+
const creditors = [...nets].filter(([, v]) => v > 0).sort(byMagnitudeThenId);
|
| 30 |
+
const debtors = [...nets]
|
| 31 |
+
.filter(([, v]) => v < 0)
|
| 32 |
+
.map(([id, v]) => [id, -v] as [string, number])
|
| 33 |
+
.sort(byMagnitudeThenId);
|
| 34 |
+
const debts: Debt[] = [];
|
| 35 |
+
let i = 0;
|
| 36 |
+
let j = 0;
|
| 37 |
+
while (i < debtors.length && j < creditors.length) {
|
| 38 |
+
const [debtor, owes] = debtors[i];
|
| 39 |
+
const [creditor, owed] = creditors[j];
|
| 40 |
+
const cents = Math.min(owes, owed);
|
| 41 |
+
debts.push({ from: debtor, to: creditor, cents });
|
| 42 |
+
debtors[i] = [debtor, owes - cents];
|
| 43 |
+
creditors[j] = [creditor, owed - cents];
|
| 44 |
+
if (debtors[i][1] === 0) i++;
|
| 45 |
+
if (creditors[j][1] === 0) j++;
|
| 46 |
+
}
|
| 47 |
+
return debts;
|
| 48 |
+
}
|
tests/unit/domain/balances.test.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from 'vitest';
|
| 2 |
+
import { computeNets, simplifyDebts } from '../../../src/lib/domain/balances';
|
| 3 |
+
import type { Expense, Settlement } from '../../../src/lib/domain/types';
|
| 4 |
+
|
| 5 |
+
const audit = { createdBy: 'a', createdAt: '2026-01-01T00:00:00.000Z', updatedBy: 'a', updatedAt: '2026-01-01T00:00:00.000Z' };
|
| 6 |
+
const exp = (paidBy: string, amountCents: number, shares: Record<string, number>): Expense => ({
|
| 7 |
+
id: `e_${Math.random()}`, version: 1, description: 'x', amountCents, currency: 'USD', date: '2026-01-01',
|
| 8 |
+
paidBy, split: { mode: 'exact', cents: shares }, shares, ...audit
|
| 9 |
+
});
|
| 10 |
+
const pay = (from: string, to: string, amountCents: number): Settlement => ({
|
| 11 |
+
id: `s_${Math.random()}`, version: 1, from, to, amountCents, date: '2026-01-01', ...audit
|
| 12 |
+
});
|
| 13 |
+
|
| 14 |
+
describe('computeNets', () => {
|
| 15 |
+
it('credits payer and debits participants', () => {
|
| 16 |
+
const nets = computeNets([exp('a', 3000, { a: 1000, b: 1000, c: 1000 })], []);
|
| 17 |
+
expect(nets).toEqual(new Map([['a', 2000], ['b', -1000], ['c', -1000]]));
|
| 18 |
+
});
|
| 19 |
+
|
| 20 |
+
it('applies settlements', () => {
|
| 21 |
+
const nets = computeNets([exp('a', 3000, { a: 1000, b: 1000, c: 1000 })], [pay('b', 'a', 1000)]);
|
| 22 |
+
expect(nets.get('a')).toBe(1000);
|
| 23 |
+
expect(nets.get('b')).toBe(0);
|
| 24 |
+
});
|
| 25 |
+
|
| 26 |
+
it('nets always sum to zero (property)', () => {
|
| 27 |
+
const nets = computeNets(
|
| 28 |
+
[exp('a', 1001, { a: 334, b: 333, c: 334 }), exp('b', 77, { c: 77 }), exp('c', 5, { a: 2, b: 3 })],
|
| 29 |
+
[pay('c', 'a', 50), pay('a', 'b', 7)]
|
| 30 |
+
);
|
| 31 |
+
expect([...nets.values()].reduce((x, y) => x + y, 0)).toBe(0);
|
| 32 |
+
});
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
describe('simplifyDebts', () => {
|
| 36 |
+
it('returns nothing when everyone is settled', () => {
|
| 37 |
+
expect(simplifyDebts(new Map([['a', 0], ['b', 0]]))).toEqual([]);
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
it('produces transfers that exactly settle all nets, using at most n-1 transfers', () => {
|
| 41 |
+
const nets = new Map([['a', 5000], ['b', -2000], ['c', -3000], ['d', 1000], ['e', -1000]]);
|
| 42 |
+
const debts = simplifyDebts(nets);
|
| 43 |
+
expect(debts.length).toBeLessThanOrEqual(4);
|
| 44 |
+
const after = new Map(nets);
|
| 45 |
+
for (const d of debts) {
|
| 46 |
+
expect(d.cents).toBeGreaterThan(0);
|
| 47 |
+
after.set(d.from, (after.get(d.from) ?? 0) + d.cents);
|
| 48 |
+
after.set(d.to, (after.get(d.to) ?? 0) - d.cents);
|
| 49 |
+
}
|
| 50 |
+
expect([...after.values()].every((v) => v === 0)).toBe(true);
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
it('is deterministic', () => {
|
| 54 |
+
const nets = new Map([['b', -100], ['a', 100], ['c', -50], ['d', 50]]);
|
| 55 |
+
expect(simplifyDebts(nets)).toEqual(simplifyDebts(new Map([...nets].reverse())));
|
| 56 |
+
});
|
| 57 |
+
});
|