From b58d16ef1223e8e5b730f257119fa12141287641 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=81=E7=8F=AD=E4=B8=83=E5=8F=B7?= <9159450+luban-71@user.noreply.gitee.com> Date: Sat, 29 Aug 2026 18:31:04 +0800 Subject: [PATCH] feat(invoice): add formatSplitPercentage for basis-point percentages (#620) - Add formatSplitPercentage(basisPoints, opts?) to invoice/calculator.ts - Convert 0-10000 bps to human-readable percent with configurable decimals (0-4) - Throw SdkError.INVALID_RECIPIENT for out-of-range bps - Throw RangeError for invalid decimals - Export from index.ts and add unit tests Closes Stellar-split/split-sdk#620 --- src/index.ts | 7 ++ src/invoice/calculator.ts | 76 ++++++++++++++++++++++ test/invoice/formatSplitPercentage.test.ts | 56 ++++++++++++++++ 3 files changed, 139 insertions(+) create mode 100644 test/invoice/formatSplitPercentage.test.ts 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); + }); +});