Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
76 changes: 76 additions & 0 deletions src/invoice/calculator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -66,3 +67,78 @@ export function computeAmounts(
): Record<string, bigint> {
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}%`;
}
56 changes: 56 additions & 0 deletions test/invoice/formatSplitPercentage.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});