splitwise / src /lib /domain /split.ts
assafvayner's picture
assafvayner HF Staff
fix(domain): guard split allocation and balance invariants
5918e23
Raw
History Blame Contribute Delete
3.25 kB
import type { Split } from './types';
export class SplitError extends Error {
constructor(
message: string,
public readonly field: string = 'split'
) {
super(message);
this.name = 'SplitError';
}
}
export interface Weighted {
id: string;
weight: number;
}
/**
* Largest-remainder allocation of `total` integer cents across weighted participants.
* Deterministic: leftover cents go to the largest fractional remainders, ties broken by ascending id.
*/
export function allocate(total: number, weights: Weighted[]): Record<string, number> {
if (!Number.isSafeInteger(total) || total <= 0) throw new SplitError('Total must be a positive integer', 'amount');
if (weights.length === 0) throw new SplitError('At least one participant is required', 'participants');
const ids = new Set<string>();
let sumW = 0;
for (const w of weights) {
if (ids.has(w.id)) throw new SplitError(`Duplicate participant ${w.id}`, 'participants');
if (!Number.isSafeInteger(w.weight) || w.weight <= 0) throw new SplitError(`Weight for ${w.id} must be positive`, 'shares');
ids.add(w.id);
sumW += w.weight;
}
const rows = weights.map((w) => {
const scaled = total * w.weight;
if (!Number.isSafeInteger(scaled)) throw new SplitError('Amount too large to split precisely', 'amount');
const floor = Math.floor(scaled / sumW);
return { id: w.id, floor, remainder: scaled - floor * sumW };
});
const leftover = total - rows.reduce((acc, r) => acc + r.floor, 0);
if (leftover < 0 || leftover >= rows.length) throw new SplitError('Internal allocation error', 'amount');
const order = [...rows].sort((x, y) => y.remainder - x.remainder || (x.id < y.id ? -1 : x.id > y.id ? 1 : 0));
for (let k = 0; k < leftover; k++) order[k].floor += 1;
return Object.fromEntries(rows.map((r) => [r.id, r.floor]));
}
/** Resolves a Split into per-participant integer cents that sum exactly to amountCents. */
export function resolveShares(amountCents: number, split: Split): Record<string, number> {
switch (split.mode) {
case 'equal':
return allocate(
amountCents,
split.participants.map((id) => ({ id, weight: 1 }))
);
case 'percent': {
const entries = Object.entries(split.bp);
if (entries.length === 0) throw new SplitError('At least one participant is required', 'participants');
const total = entries.reduce((acc, [, bp]) => acc + bp, 0);
if (total !== 10000) throw new SplitError(`Percentages must add up to 100% (got ${(total / 100).toFixed(2)}%)`, 'shares');
return allocate(
amountCents,
entries.map(([id, bp]) => ({ id, weight: bp }))
);
}
case 'exact': {
const entries = Object.entries(split.cents);
if (entries.length === 0) throw new SplitError('At least one participant is required', 'participants');
if (!Number.isSafeInteger(amountCents) || amountCents <= 0) throw new SplitError('Total must be a positive integer', 'amount');
const total = entries.reduce((acc, [, c]) => acc + c, 0);
for (const [id, c] of entries) {
if (!Number.isSafeInteger(c) || c <= 0) throw new SplitError(`Amount for ${id} must be positive`, 'shares');
}
if (total !== amountCents) throw new SplitError('Amounts must add up to the total', 'shares');
return { ...split.cents };
}
}
}