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 { 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(); 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 { 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 }; } } }