File size: 3,252 Bytes
3852523
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5918e23
3852523
 
 
 
5918e23
 
3852523
5918e23
 
3852523
 
 
 
 
 
 
 
 
 
 
 
 
5918e23
3852523
 
 
 
 
 
 
 
 
5918e23
3852523
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
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 };
		}
	}
}