assafvayner HF Staff commited on
Commit
d180c31
·
1 Parent(s): 3852523

feat(domain): compute net balances and simplified debts

Browse files
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
+ });