diff --git a/src/money/cents.ts b/src/money/cents.ts index f6321d8..a5f5cde 100644 --- a/src/money/cents.ts +++ b/src/money/cents.ts @@ -29,22 +29,23 @@ export function parseCents(input: string): number { /** * Split an integer amount of cents evenly across member ids. * - * SEEDED DEFECT (issue #2): this floors every share and drops the - * remainder instead of handing the leftover cents to the first N members, - * so the shares can sum to less than totalCents. `bun test` does not catch - * this because the baseline tests only split amounts that divide evenly. - * The correct remainder rule is documented in - * .claude/skills/handling-money/references/rules.md. + * Remainder rule (.claude/skills/handling-money/references/rules.md): every + * member gets floor(totalCents / n), and the first `remainder` members, in + * the order passed in, get one extra cent, so the shares always sum to + * exactly totalCents. Negative or non-integer totals are out of scope for + * the rule and keep the plain floored share, unchanged from before. */ export function splitCents(totalCents: number, memberIds: string[]): Record { if (memberIds.length === 0) { throw new Error("cannot split among zero members"); } - const share = Math.floor(totalCents / memberIds.length); + const base = Math.floor(totalCents / memberIds.length); + const remainder = + Number.isInteger(totalCents) && totalCents >= 0 ? totalCents - base * memberIds.length : 0; const shares: Record = {}; - for (const id of memberIds) { - shares[id] = share; - } + memberIds.forEach((id, i) => { + shares[id] = i < remainder ? base + 1 : base; + }); return shares; } diff --git a/tests/money.test.ts b/tests/money.test.ts index ed1ee3f..b4351a9 100644 --- a/tests/money.test.ts +++ b/tests/money.test.ts @@ -36,4 +36,34 @@ describe("splitCents", () => { const shares = splitCents(2000, ["a", "b"]); expect(sumCents(Object.values(shares))).toBe(2000); }); + + test("gives the remainder cents to the first members", () => { + expect(splitCents(1000, ["a", "b", "c"])).toEqual({ a: 334, b: 333, c: 333 }); + }); + + test("shares always sum to the total", () => { + const cases: Array<[number, number]> = [ + [1000, 3], + [101, 5], + [1, 3], + [2, 3], + [999, 7], + [1003, 4], + ]; + for (const [total, n] of cases) { + const ids = Array.from({ length: n }, (_, i) => `m${i}`); + const shares = splitCents(total, ids); + expect(sumCents(Object.values(shares))).toBe(total); + const base = Math.floor(total / n); + const remainder = total - base * n; + ids.forEach((id, i) => { + expect(shares[id]).toBe(i < remainder ? base + 1 : base); + }); + } + }); + + test("leaves negative and non-integer totals floored as before", () => { + expect(splitCents(-1000, ["a", "b", "c"])).toEqual({ a: -334, b: -334, c: -334 }); + expect(splitCents(10.5, ["a", "b"])).toEqual({ a: 5, b: 5 }); + }); });