diff --git a/src/invoiceVersionTracker.ts b/src/invoiceVersionTracker.ts index d7e3f08..6505610 100644 --- a/src/invoiceVersionTracker.ts +++ b/src/invoiceVersionTracker.ts @@ -136,6 +136,41 @@ export interface InvoiceVersionTrackerOptions { store?: VersionStore; } +export type VersionVector = Record; + +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. diff --git a/src/lineItemNormalizer.ts b/src/lineItemNormalizer.ts index 2601e3f..09748f3 100644 --- a/src/lineItemNormalizer.ts +++ b/src/lineItemNormalizer.ts @@ -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. diff --git a/src/pathQueryBuilder.ts b/src/pathQueryBuilder.ts index f3c310d..65eac5b 100644 --- a/src/pathQueryBuilder.ts +++ b/src/pathQueryBuilder.ts @@ -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. diff --git a/src/paymentAggregator.ts b/src/paymentAggregator.ts index 6155d04..b01f6c4 100644 --- a/src/paymentAggregator.ts +++ b/src/paymentAggregator.ts @@ -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 }; @@ -223,16 +224,20 @@ export class PaymentAggregator { private recompute(): void { let totalFunded = this.baseFunded; - const payerBreakdown = new Map(); let lastLedger = 0; for (const payment of this.payments) { totalFunded += payment.amount; + lastLedger = Math.max(lastLedger, payment.ledger); + } + + const payerBreakdown = new Map(); + 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; diff --git a/src/utils.ts b/src/utils.ts index f5f9fe8..8153807 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -30,6 +30,18 @@ export function parseAmount(value: string): bigint { return BigInt(whole) * STROOPS_PER_UNIT + BigInt(fracPadded); } +export function groupBy>( + array: T[], + key: keyof T, +): Record { + return array.reduce>((groups, item) => { + const groupKey = String(item[key]); + groups[groupKey] ??= []; + groups[groupKey].push(item); + return groups; + }, {}); +} + /** * Validate a Stellar public key (G... address). * diff --git a/test/invoiceVersionTracker.test.ts b/test/invoiceVersionTracker.test.ts index 231e289..d17e324 100644 --- a/test/invoiceVersionTracker.test.ts +++ b/test/invoiceVersionTracker.test.ts @@ -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"; @@ -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(); + }); +}); diff --git a/test/lineItemNormalizer.test.ts b/test/lineItemNormalizer.test.ts index cc73841..02fcdcb 100644 --- a/test/lineItemNormalizer.test.ts +++ b/test/lineItemNormalizer.test.ts @@ -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"; @@ -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"); + }); +}); diff --git a/test/pathQueryBuilder.test.ts b/test/pathQueryBuilder.test.ts new file mode 100644 index 0000000..55e1019 --- /dev/null +++ b/test/pathQueryBuilder.test.ts @@ -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); + }); +}); diff --git a/test/paymentAggregator.test.ts b/test/paymentAggregator.test.ts index 995643f..ed55760 100644 --- a/test/paymentAggregator.test.ts +++ b/test/paymentAggregator.test.ts @@ -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 { return { @@ -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); + }); });