diff --git a/src/index.ts b/src/index.ts index 35bb785..da9ebbe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -337,6 +337,13 @@ export type { HorizonProberConfig, } from "./horizonProber.js"; +// Invoice calculator +export { + calculateSplitAmounts, + computeAmounts, + formatSplitPercentage, +} from "./invoice/calculator.js"; + // AMM Calculator export { estimateSwapOutput, calculatePoolShare } from "./ammCalculator.js"; diff --git a/src/invoice/calculator.ts b/src/invoice/calculator.ts index 629c5ae..658ff31 100644 --- a/src/invoice/calculator.ts +++ b/src/invoice/calculator.ts @@ -10,6 +10,7 @@ */ import { auditSplitRounding, RoundingOverflowError } from "./rounding.js"; +import { SdkError, SdkErrorCode } from "../errors.js"; import type { SplitLine, AuditedSplitResult } from "../types.js"; // Re-export the error so callers can import it from the calculator. @@ -66,3 +67,78 @@ export function computeAmounts( ): Record { return calculateSplitAmounts(total, splits).amounts; } + +// --------------------------------------------------------------------------- +// Formatting helpers +// --------------------------------------------------------------------------- + +/** Basis points per whole unit (1.0 = 10000 bps). */ +const BASIS_POINTS_PER_UNIT = 10000n; + +/** + * Format a basis-point value as a human-readable percentage string. + * + * @param basisPoints - Integer value in basis points (0–10000). + * @param opts.decimals - Number of decimal places to display (default 2, range 0–4). + * @returns Percentage string such as `"33.33%"` or `"100.00%"`. + * + * @throws {SdkError} When `basisPoints` is outside the 0–10000 range. + * @throws {RangeError} When `opts.decimals` is outside the 0–4 range. + * + * @example + * ```ts + * formatSplitPercentage(3333n); // "33.33%" + * formatSplitPercentage(1n); // "0.01%" + * formatSplitPercentage(3333n, { decimals: 0 }); // "33%" + * ``` + */ +export function formatSplitPercentage( + basisPoints: bigint, + opts?: { decimals?: number }, +): string { + const decimals = opts?.decimals ?? 2; + + if (decimals < 0 || decimals > 4 || !Number.isInteger(decimals)) { + throw new RangeError(`decimals must be an integer between 0 and 4, got ${decimals}`); + } + + if (basisPoints < 0n || basisPoints > BASIS_POINTS_PER_UNIT) { + throw new SdkError( + `basisPoints must be between 0 and 10000, got ${basisPoints.toString()}`, + SdkErrorCode.INVALID_RECIPIENT, + { basisPoints: basisPoints.toString() }, + ); + } + + if (decimals === 0) { + // Integer percentage, with standard rounding. + const scaled = basisPoints + BASIS_POINTS_PER_UNIT / 2n; + const whole = scaled / BASIS_POINTS_PER_UNIT; + return `${whole.toString()}%`; + } + + // Compute whole and fractional parts without floating point. + const whole = basisPoints / BASIS_POINTS_PER_UNIT; + const remainder = basisPoints % BASIS_POINTS_PER_UNIT; + + // Scale remainder to the requested number of decimal places. + // e.g. decimals=2: remainder * 100 / 10000 = remainder / 100 + const divisor = 10n ** BigInt(4 - decimals); + let fractional = remainder / divisor; + const remainderMod = remainder % divisor; + + // Round to nearest at the last displayed decimal place. + const halfDivisor = divisor / 2n; + if (remainderMod >= halfDivisor) { + fractional += 1n; + } + + // Handle carry-over that turns fractional into a whole unit (e.g. 99.995 → 100.00). + const maxFractional = 10n ** BigInt(decimals) - 1n; + if (fractional > maxFractional) { + return `${(whole + 1n).toString()}.${"0".repeat(decimals)}%`; + } + + const fractionalStr = fractional.toString().padStart(decimals, "0"); + return `${whole.toString()}.${fractionalStr}%`; +} diff --git a/test/invoice/formatSplitPercentage.test.ts b/test/invoice/formatSplitPercentage.test.ts new file mode 100644 index 0000000..747fb67 --- /dev/null +++ b/test/invoice/formatSplitPercentage.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { formatSplitPercentage } from "../../src/invoice/calculator.js"; +import { SdkError, SdkErrorCode } from "../../src/errors.js"; + +describe("formatSplitPercentage", () => { + it("formats 3333 basis points as 33.33%", () => { + expect(formatSplitPercentage(3333n)).toBe("33.33%"); + }); + + it("formats 10000 basis points as 100.00%", () => { + expect(formatSplitPercentage(10000n)).toBe("100.00%"); + }); + + it("formats 1 basis point as 0.01%", () => { + expect(formatSplitPercentage(1n)).toBe("0.01%"); + }); + + it("formats 0 basis points as 0.00%", () => { + expect(formatSplitPercentage(0n)).toBe("0.00%"); + }); + + it("supports 0 decimals", () => { + expect(formatSplitPercentage(3333n, { decimals: 0 })).toBe("33%"); + }); + + it("rounds up at the last displayed decimal place", () => { + // 6667 bps = 66.67% exactly, 66.7% with one decimal place (rounds up from 66.66...) + expect(formatSplitPercentage(6666n, { decimals: 1 })).toBe("66.7%"); + }); + + it("supports up to 4 decimal places", () => { + expect(formatSplitPercentage(3333n, { decimals: 4 })).toBe("33.3300%"); + }); + + it("throws SdkError for negative basis points", () => { + expect(() => formatSplitPercentage(-1n)).toThrow(SdkError); + expect(() => formatSplitPercentage(-1n)).toThrow(SdkErrorCode.INVALID_RECIPIENT); + }); + + it("throws SdkError for basis points above 10000", () => { + expect(() => formatSplitPercentage(10001n)).toThrow(SdkError); + expect(() => formatSplitPercentage(10001n)).toThrow(SdkErrorCode.INVALID_RECIPIENT); + }); + + it("throws RangeError for decimals below 0", () => { + expect(() => formatSplitPercentage(3333n, { decimals: -1 })).toThrow(RangeError); + }); + + it("throws RangeError for decimals above 4", () => { + expect(() => formatSplitPercentage(3333n, { decimals: 5 })).toThrow(RangeError); + }); + + it("throws RangeError for non-integer decimals", () => { + expect(() => formatSplitPercentage(3333n, { decimals: 2.5 })).toThrow(RangeError); + }); +});