From e71c06b304981bee324c2c7ba2b487625dd28dc7 Mon Sep 17 00:00:00 2001 From: Param Harrison Date: Wed, 14 Oct 2026 13:40:29 +0300 Subject: [PATCH 1/2] factory: claim #193 by runner-20609-1791974429779 From e7c6105c71369ba26524e1d2cfc671c8923b97b3 Mon Sep 17 00:00:00 2001 From: Param Harrison Date: Wed, 14 Oct 2026 14:45:56 +0300 Subject: [PATCH 2/2] factory: build #193 --- src/balances.ts | 39 ++++++++++-- tests/balances.test.ts | 135 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 tests/balances.test.ts diff --git a/src/balances.ts b/src/balances.ts index 12460ff..901da5b 100644 --- a/src/balances.ts +++ b/src/balances.ts @@ -44,16 +44,45 @@ export function computeBalances(db: Database, groupId: string): MemberBalance[] }); } -/** Minimal set of transfers that settles every balance to zero (greedy largest-first matching). */ +interface Party { + id: string; + amount: number; // cents still to receive (creditor) or pay (debtor) +} + +/** + * Transfers that settle every balance to exactly zero, payable mostly in cash: the whole-dollar + * part of each balance is settled first, then the leftover cents with separate transfers under $1. + */ export function settleUp(balances: MemberBalance[]): Transfer[] { const creditors = balances .filter((b) => b.netCents > 0) - .map((b) => ({ id: b.memberId, amount: b.netCents })) - .sort((a, b) => b.amount - a.amount); + .map((b) => ({ id: b.memberId, amount: b.netCents })); const debtors = balances .filter((b) => b.netCents < 0) - .map((b) => ({ id: b.memberId, amount: -b.netCents })) - .sort((a, b) => b.amount - a.amount); + .map((b) => ({ id: b.memberId, amount: -b.netCents })); + + // Pass 1 moves only whole dollars, so every transfer is a multiple of 100. Afterwards one + // side has no whole dollars left, so each pass-2 transfer is under 100 cents. + const dollarPart = (p: Party): Party => ({ id: p.id, amount: Math.floor(p.amount / 100) * 100 }); + const dollarCreditors = creditors.map(dollarPart); + const dollarDebtors = debtors.map(dollarPart); + const dollarTransfers = matchGreedy(dollarCreditors, dollarDebtors); + + const settled = new Map(); + for (const t of dollarTransfers) { + settled.set(t.fromMemberId, (settled.get(t.fromMemberId) ?? 0) + t.amountCents); + settled.set(t.toMemberId, (settled.get(t.toMemberId) ?? 0) + t.amountCents); + } + const remainder = (p: Party): Party => ({ id: p.id, amount: p.amount - (settled.get(p.id) ?? 0) }); + const centsTransfers = matchGreedy(creditors.map(remainder), debtors.map(remainder)); + + return [...dollarTransfers, ...centsTransfers]; +} + +/** Greedy largest-first matching of debtors to creditors, until one side runs out. */ +function matchGreedy(creditorsIn: Party[], debtorsIn: Party[]): Transfer[] { + const creditors = creditorsIn.filter((p) => p.amount > 0).sort((a, b) => b.amount - a.amount); + const debtors = debtorsIn.filter((p) => p.amount > 0).sort((a, b) => b.amount - a.amount); const transfers: Transfer[] = []; let ci = 0; diff --git a/tests/balances.test.ts b/tests/balances.test.ts new file mode 100644 index 0000000..7f4ee2a --- /dev/null +++ b/tests/balances.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test } from "bun:test"; +import { settleUp, type MemberBalance } from "../src/balances.ts"; +import type { Transfer } from "../src/types.ts"; + +function balance(memberId: string, netCents: number): MemberBalance { + return { memberId, name: memberId, paidCents: 0, owedCents: 0, netCents }; +} + +/** Each member's net after applying the transfers; all zero means fully settled. */ +function netsAfter(balances: MemberBalance[], transfers: Transfer[]): Map { + const nets = new Map(balances.map((b) => [b.memberId, b.netCents])); + for (const t of transfers) { + nets.set(t.fromMemberId, nets.get(t.fromMemberId)! + t.amountCents); + nets.set(t.toMemberId, nets.get(t.toMemberId)! - t.amountCents); + } + return nets; +} + +function expectSettled(balances: MemberBalance[], transfers: Transfer[]) { + for (const [, net] of netsAfter(balances, transfers)) expect(net).toBe(0); +} + +function expectWholeDollarsOrCentsOnly(transfers: Transfer[]) { + for (const t of transfers) { + expect(Number.isInteger(t.amountCents)).toBe(true); + expect(t.amountCents).toBeGreaterThan(0); + expect(t.amountCents % 100 === 0 || t.amountCents < 100).toBe(true); + } +} + +function sortTransfers(transfers: Transfer[]): Transfer[] { + return [...transfers].sort( + (a, b) => + a.fromMemberId.localeCompare(b.fromMemberId) || + a.toMemberId.localeCompare(b.toMemberId) || + a.amountCents - b.amountCents, + ); +} + +/** Small deterministic PRNG so the random cases are reproducible. */ +function seededRandom(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + return state / 2 ** 32; + }; +} + +function randomZeroSumBalances(rand: () => number, count: number): MemberBalance[] { + const nets = Array.from({ length: count - 1 }, () => Math.floor(rand() * 20001) - 10000); + nets.push(-nets.reduce((sum, n) => sum + n, 0)); + return nets.map((n, i) => balance(`m${i}`, n)); +} + +/** The single-pass greedy matching settleUp used before whole-dollar settling. */ +function onePassSettleUp(balances: MemberBalance[]): Transfer[] { + const creditors = balances + .filter((b) => b.netCents > 0) + .map((b) => ({ id: b.memberId, amount: b.netCents })) + .sort((a, b) => b.amount - a.amount); + const debtors = balances + .filter((b) => b.netCents < 0) + .map((b) => ({ id: b.memberId, amount: -b.netCents })) + .sort((a, b) => b.amount - a.amount); + const transfers: Transfer[] = []; + let ci = 0; + let di = 0; + while (ci < creditors.length && di < debtors.length) { + const creditor = creditors[ci]!; + const debtor = debtors[di]!; + const amount = Math.min(creditor.amount, debtor.amount); + if (amount > 0) transfers.push({ fromMemberId: debtor.id, toMemberId: creditor.id, amountCents: amount }); + creditor.amount -= amount; + debtor.amount -= amount; + if (creditor.amount === 0) ci++; + if (debtor.amount === 0) di++; + } + return transfers; +} + +describe("settleUp", () => { + test("issue example: $100 dinner for three gives $33 transfers plus cents-only remainders", () => { + const balances = [balance("asha", 6667), balance("ben", -3333), balance("chen", -3334)]; + const transfers = settleUp(balances); + expect(sortTransfers(transfers)).toEqual( + sortTransfers([ + { fromMemberId: "ben", toMemberId: "asha", amountCents: 3300 }, + { fromMemberId: "chen", toMemberId: "asha", amountCents: 3300 }, + { fromMemberId: "ben", toMemberId: "asha", amountCents: 33 }, + { fromMemberId: "chen", toMemberId: "asha", amountCents: 34 }, + ]), + ); + }); + + test("every transfer is whole dollars or cents only", () => { + const rand = seededRandom(193); + for (let i = 0; i < 200; i++) { + const balances = randomZeroSumBalances(rand, 2 + Math.floor(rand() * 7)); + expectWholeDollarsOrCentsOnly(settleUp(balances)); + } + expectWholeDollarsOrCentsOnly( + settleUp([balance("asha", 2075), balance("dev", 1050), balance("ben", -3125)]), + ); + }); + + test("settles exactly when one debtor owes two creditors cents amounts (2075 / 1050 / -3125)", () => { + const balances = [balance("asha", 2075), balance("dev", 1050), balance("ben", -3125)]; + const transfers = settleUp(balances); + expectSettled(balances, transfers); + expectWholeDollarsOrCentsOnly(transfers); + }); + + test("settles exactly for seeded random zero-sum balances", () => { + const rand = seededRandom(42); + for (let i = 0; i < 500; i++) { + const balances = randomZeroSumBalances(rand, 2 + Math.floor(rand() * 9)); + expectSettled(balances, settleUp(balances)); + } + }); + + test("whole-dollar balances produce no sub-dollar transfers and match the one-pass result", () => { + const rand = seededRandom(7); + for (let i = 0; i < 200; i++) { + const balances = randomZeroSumBalances(rand, 2 + Math.floor(rand() * 7)).map((b) => + balance(b.memberId, b.netCents - (b.netCents % 100)), + ); + // Re-balance to zero sum on the last member, keeping it whole dollars. + const last = balances[balances.length - 1]!; + last.netCents -= balances.reduce((sum, b) => sum + b.netCents, 0); + const transfers = settleUp(balances); + for (const t of transfers) expect(t.amountCents % 100).toBe(0); + expect(transfers).toEqual(onePassSettleUp(balances)); + } + }); +});