import Big from "big.js"; import { FloorDivide, SumBig, Zero } from "./exact-money"; /** * Proportional distribution of one header discount across lines, in exact * integer minor units, by largest remainder. * * This is the allocation docs/planning/discount-authority.md §5.2 step 3 and * §12 decision 5 require: the UI and the API both call it, the server compares * the client's `RecDisc` to its result to the minor unit, and a mismatch is a * `400`. That comparison is only safe because there is exactly one * implementation, so its ordering rules are part of the contract and must not * change silently: * * 1. Each line's exact share is `amount × weight / Σweight`. * 2. Every line receives the floor of its share. * 3. The minor units left over (always fewer than the number of lines with a * fractional share) go one each to the lines with the largest fractional * remainder. * 4. Equal remainders are ordered by `TieBreak` ascending, compared as plain * strings by UTF-16 code unit. Callers build it from the stable line id so * the order does not depend on array position when ids exist. * * Guarantees, for `0 ≤ amount ≤ Σweight`: the shares sum to `amount` exactly, * no share exceeds its line's weight, and a zero-weight line receives nothing. * Outside that range the function refuses rather than producing a best effort. */ export interface AllocationSlot { /** Opaque key the caller uses to find its line in the result. */ readonly Key: string; /** Non-negative integer minor units: the line's remaining capacity. */ readonly Weight: Big; /** Deterministic ordering for equal remainders; see the module comment. */ readonly TieBreak: string; } export type AllocationOutcome = | { readonly Ok: true; readonly Shares: ReadonlyMap } | { readonly Ok: false; readonly Code: "NO_CAPACITY" | "EXCEEDS_CAPACITY" | "INVALID_INPUT"; readonly Detail: string }; function IsNonNegativeInteger(value: Big): boolean { return value.gte(0) && value.eq(value.round(0, Big.roundDown)); } /** * Allocates `amount` (non-negative integer minor units) across `slots`. * Keys must be unique. */ export function AllocateLargestRemainder(amount: Big, slots: readonly AllocationSlot[]): AllocationOutcome { if (!IsNonNegativeInteger(amount)) { return { Ok: false, Code: "INVALID_INPUT", Detail: "amount must be a non-negative integer number of minor units" }; } const keys = new Set(); for (const slot of slots) { if (!IsNonNegativeInteger(slot.Weight)) { return { Ok: false, Code: "INVALID_INPUT", Detail: `weight for ${slot.Key} must be a non-negative integer number of minor units` }; } if (keys.has(slot.Key)) { return { Ok: false, Code: "INVALID_INPUT", Detail: `duplicate allocation key ${slot.Key}` }; } keys.add(slot.Key); } const shares = new Map(slots.map(slot => [slot.Key, Zero])); if (amount.eq(0)) { return { Ok: true, Shares: shares }; } const total = SumBig(slots.map(slot => slot.Weight)); if (total.eq(0)) { return { Ok: false, Code: "NO_CAPACITY", Detail: "a positive header discount has no eligible capacity to be allocated to" }; } if (amount.gt(total)) { return { Ok: false, Code: "EXCEEDS_CAPACITY", Detail: "the header discount is larger than the eligible capacity remaining" }; } const remainders: { Key: string; Remainder: Big; TieBreak: string }[] = []; let allocated = Zero; for (const slot of slots) { const { Quotient, Remainder } = FloorDivide(amount.times(slot.Weight), total); shares.set(slot.Key, Quotient); allocated = allocated.plus(Quotient); remainders.push({ Key: slot.Key, Remainder, TieBreak: slot.TieBreak }); } // All remainders share the denominator `total`, so comparing the integer // remainders compares the fractional parts exactly. remainders.sort((a, b) => { const byRemainder = b.Remainder.cmp(a.Remainder); if (byRemainder !== 0) { return byRemainder; } return a.TieBreak < b.TieBreak ? -1 : a.TieBreak > b.TieBreak ? 1 : 0; }); let leftover = amount.minus(allocated); for (const entry of remainders) { if (leftover.eq(0)) { break; } // Unreachable for a zero remainder: the leftover equals Σremainder/total, // and each remainder is below total, so enough lines have one. shares.set(entry.Key, (shares.get(entry.Key) as Big).plus(1)); leftover = leftover.minus(1); } return { Ok: true, Shares: shares }; }