Spaces:
Running
Running
| 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 }; | |
| } | |
| } | |
| } | |