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
35 changes: 35 additions & 0 deletions src/invoiceVersionTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,41 @@ export interface InvoiceVersionTrackerOptions {
store?: VersionStore;
}

export type VersionVector = Record<string, number>;

export class ConflictError extends Error {
constructor(message: string) {
super(message);
this.name = "ConflictError";
Object.setPrototypeOf(this, new.target.prototype);
}
}

export function detectVersionConflict(
current: VersionVector,
incoming: VersionVector,
): void {
let incomingDominates = false;
let currentDominates = false;

for (const actor of new Set([...Object.keys(current), ...Object.keys(incoming)])) {
const currentValue = current[actor] ?? 0;
const incomingValue = incoming[actor] ?? 0;

if (incomingValue > currentValue) {
incomingDominates = true;
}

if (currentValue > incomingValue) {
currentDominates = true;
}
}

if (incomingDominates && currentDominates) {
throw new ConflictError("Diverged version vectors detected");
}
}

/**
* Tracks the full version history of invoices, storing an ordered sequence of
* immutable snapshots that can be diffed between any two versions.
Expand Down
24 changes: 24 additions & 0 deletions src/lineItemNormalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,30 @@ import { UnsupportedLineItemAssetError } from "./errors.js";

/** Fixed-point scale used for conversion rates (1e18 = 1.0). */
const RATE_SCALE = 1_000_000_000_000_000_000n;
const STROOPS_PER_UNIT = 10_000_000n;
const STROOPS_PER_CENT = STROOPS_PER_UNIT / 100n;

/**
* Normalize a decimal asset amount to a 2-decimal string using integer stroop
* arithmetic and half-up cent rounding.
*/
export function normalize(amount: number | string): string {
const stroops = decimalToStroops(amount);
const roundedCents = (stroops + STROOPS_PER_CENT / 2n) / STROOPS_PER_CENT;
const whole = roundedCents / 100n;
const cents = roundedCents % 100n;

return `${whole}.${cents.toString().padStart(2, "0")}`;
}

function decimalToStroops(amount: number | string): bigint {
const value = typeof amount === "number" ? amount.toFixed(7) : amount.trim();
const negative = value.startsWith("-");
const normalized = negative ? value.slice(1) : value;
const [whole = "0", fraction = ""] = normalized.split(".");
const stroops = BigInt(whole) * STROOPS_PER_UNIT + BigInt(fraction.padEnd(7, "0").slice(0, 7) || "0");
return negative ? -stroops : stroops;
}

/**
* Normalise a set of line items to a single settlement asset.
Expand Down
30 changes: 30 additions & 0 deletions src/pathQueryBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,36 @@ export interface PathQueryBuilderConfig {
maxEntries?: number;
}

export interface PathQueryStringOptions {
sourceAssetType?: "native" | "credit_alphanum4" | "credit_alphanum12";
[key: string]: string | number | boolean | undefined;
}

export function buildPathQuery(options: PathQueryStringOptions): string {
if (
options.sourceAssetType !== undefined &&
!["native", "credit_alphanum4", "credit_alphanum12"].includes(options.sourceAssetType)
) {
throw new InvalidPathQueryError(`Invalid source asset type: ${options.sourceAssetType}`);
}

const params = new URLSearchParams();
for (const [key, value] of Object.entries(options)) {
if (value === undefined) {
continue;
}

if (key === "sourceAssetType") {
params.append("source_asset_type", String(value));
continue;
}

params.append(key, String(value));
}

return params.toString();
}

/**
* Assembles validated `PathQuery` objects and executes them against Horizon,
* caching results by query parameters within a configurable TTL.
Expand Down
13 changes: 9 additions & 4 deletions src/paymentAggregator.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createHash } from "crypto";
import type { Invoice, Payment } from "./types.js";
import { groupBy } from "./utils.js";

export type PaymentLedger = Payment & { ledger: number };

Expand Down Expand Up @@ -223,16 +224,20 @@ export class PaymentAggregator {

private recompute(): void {
let totalFunded = this.baseFunded;
const payerBreakdown = new Map<string, bigint>();
let lastLedger = 0;

for (const payment of this.payments) {
totalFunded += payment.amount;
lastLedger = Math.max(lastLedger, payment.ledger);
}

const payerBreakdown = new Map<string, bigint>();
const groupedPayments = groupBy(this.payments, "payer");
for (const [payer, payments] of Object.entries(groupedPayments)) {
payerBreakdown.set(
payment.payer,
(payerBreakdown.get(payment.payer) ?? 0n) + payment.amount
payer,
payments.reduce((sum, payment) => sum + payment.amount, 0n),
);
lastLedger = Math.max(lastLedger, payment.ledger);
}

this.totalFunded = totalFunded;
Expand Down
12 changes: 12 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@ export function parseAmount(value: string): bigint {
return BigInt(whole) * STROOPS_PER_UNIT + BigInt(fracPadded);
}

export function groupBy<T extends Record<string, unknown>>(
array: T[],
key: keyof T,
): Record<string, T[]> {
return array.reduce<Record<string, T[]>>((groups, item) => {
const groupKey = String(item[key]);
groups[groupKey] ??= [];
groups[groupKey].push(item);
return groups;
}, {});
}

/**
* Validate a Stellar public key (G... address).
*
Expand Down
31 changes: 31 additions & 0 deletions test/invoiceVersionTracker.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
ConflictError,
InvoiceVersionTracker,
InMemoryVersionStore,
detectVersionConflict,
} from "../src/invoiceVersionTracker.js";
import type { InvoiceVersion } from "../src/invoiceVersionTracker.js";
import type { Invoice } from "../src/types.js";
Expand Down Expand Up @@ -272,3 +274,32 @@ describe("InvoiceVersionTracker", () => {
});
});
});

describe("detectVersionConflict", () => {
it("throws when version vectors diverge", () => {
expect(() =>
detectVersionConflict(
{ alice: 2, bob: 1 },
{ alice: 1, bob: 2 },
),
).toThrow(ConflictError);
});

it("does not throw when one vector strictly dominates", () => {
expect(() =>
detectVersionConflict(
{ alice: 1, bob: 1 },
{ alice: 2, bob: 1 },
),
).not.toThrow();
});

it("does not throw for identical vectors", () => {
expect(() =>
detectVersionConflict(
{ alice: 2, bob: 1 },
{ alice: 2, bob: 1 },
),
).not.toThrow();
});
});
13 changes: 12 additions & 1 deletion test/lineItemNormalizer.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { normalizeLineItems } from "../src/lineItemNormalizer.js";
import { normalize, normalizeLineItems } from "../src/lineItemNormalizer.js";
import { UnsupportedLineItemAssetError } from "../src/errors.js";
import type { InvoiceLineItem } from "../src/types.js";
import type { PriceOracle } from "../src/priceOracle.js";
Expand Down Expand Up @@ -97,3 +97,14 @@ describe("normalizeLineItems", () => {
expect(result.total).toBe(8_000_000n);
});
});

describe("normalize", () => {
it("rounds through stroops before formatting to 2 decimals", () => {
expect(normalize("0.0000001")).toBe("0.00");
expect(normalize("0.0000005")).toBe("0.00");
});

it("rounds half-up at the cent boundary", () => {
expect(normalize("0.005")).toBe("0.01");
});
});
21 changes: 21 additions & 0 deletions test/pathQueryBuilder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { buildPathQuery } from "../src/pathQueryBuilder.js";
import { InvalidPathQueryError } from "../src/errors.js";

describe("buildPathQuery", () => {
it("includes source_asset_type when provided", () => {
expect(buildPathQuery({ sourceAssetType: "native", limit: 10 })).toBe(
"source_asset_type=native&limit=10",
);
});

it("omits source_asset_type when not provided", () => {
expect(buildPathQuery({ limit: 10 })).toBe("limit=10");
});

it("throws for an invalid source asset type", () => {
expect(() =>
buildPathQuery({ sourceAssetType: "unsupported" as never }),
).toThrow(InvalidPathQueryError);
});
});
16 changes: 16 additions & 0 deletions test/paymentAggregator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import type { Invoice, Payment } from "../src/types.js";
import { PaymentAggregator } from "../src/paymentAggregator.js";
import type { PaymentSnapshot, PaymentSummary } from "../src/paymentAggregator.js";
import { groupBy } from "../src/utils.js";

function createInvoice(overrides: Partial<Invoice> = {}): Invoice {
return {
Expand Down Expand Up @@ -123,4 +124,19 @@ describe("PaymentAggregator", () => {
expect(aggregator.totalFunded).toBe(200n);
expect(aggregator.percentFunded).toBe(100);
});

it("uses shared groupBy behavior for payer aggregation", () => {
const grouped = groupBy(
[
createPayment({ payer: "payer-a", ledger: 1 }),
createPayment({ payer: "payer-a", ledger: 2 }),
createPayment({ payer: "payer-b", ledger: 3 }),
],
"payer",
);

expect(Object.keys(grouped)).toEqual(["payer-a", "payer-b"]);
expect(grouped["payer-a"]).toHaveLength(2);
expect(grouped["payer-b"]).toHaveLength(1);
});
});