From a443c130fc77fea4b0a0c96d7a8d86d214124a21 Mon Sep 17 00:00:00 2001 From: Noah Akerityo Date: Sat, 26 Sep 2026 19:31:47 +0000 Subject: [PATCH 1/4] feat(queries): add filter/sort/pagination engine for invoice queries Add client.queryInvoices(filter) backed by a new InvoiceQueryEngine so callers can filter by status, creator, date range, amount range and tags through a single typed query object. - InvoiceFilter composes every predicate with AND semantics; status matches any listed state while tags require every listed tag - Four sort orders (newest/oldest/highest/lowest) with an invoice-ID tie-break so paging is deterministic - Offset-based opaque cursors; nextCursor is omitted on the final page and total always reports the unpaginated match count - Amounts compared as bigint, never narrowed through Number - Dates accept Unix seconds or milliseconds, matching createdAt's existing convention; invoices with an unknown createdAt are excluded from date filters and sort last - InvoiceTagIndex for tag lookups; tags fall back to #hashtags parsed from the memo so existing invoices are taggable without a contract change - queryInvoices requires a creator because the contract exposes no global invoice index, and filters the creator's invoices in memory closes #851 --- src/invoiceQuery.ts | 429 +++++++++++++++++++++++++++++++++ src/types.ts | 9 + test/invoiceQuery.test.ts | 489 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 927 insertions(+) create mode 100644 src/invoiceQuery.ts create mode 100644 test/invoiceQuery.test.ts diff --git a/src/invoiceQuery.ts b/src/invoiceQuery.ts new file mode 100644 index 0000000..6770ea5 --- /dev/null +++ b/src/invoiceQuery.ts @@ -0,0 +1,429 @@ +/** + * Client-side filter, sort and pagination engine for invoice queries. + * + * The engine operates on an in-memory set of `Invoice` objects that has + * already been fetched from the chain (via `getInvoicesByCreator()` or a + * caller-supplied tag index), which keeps the wire contract unchanged while + * still giving callers a single typed query object. + * + * @module invoiceQuery + */ + +import type { Invoice, InvoiceStatus } from "./types.js"; +import { ValidationError } from "./errors.js"; + +/** Sort orders supported by {@link InvoiceQueryEngine}. */ +export type InvoiceSort = "newest" | "oldest" | "highest" | "lowest"; + +/** The complete set of valid sort orders. */ +export const INVOICE_SORTS: readonly InvoiceSort[] = [ + "newest", + "oldest", + "highest", + "lowest", +]; + +/** Default number of invoices returned per page. */ +export const DEFAULT_QUERY_LIMIT = 20; + +/** + * A composable filter over invoices. + * + * Every field is optional and all supplied fields are combined with AND + * semantics — passing `{ status: ["Pending"], maxAmount: 500n }` returns + * pending invoices worth at most 500 stroops. + */ +export interface InvoiceFilter { + /** Only return invoices created by this address. */ + creator?: string; + /** Only return invoices in any of these lifecycle states. */ + status?: InvoiceStatus[]; + /** Inclusive lower bound on the invoice total, in stroops. */ + minAmount?: bigint; + /** Inclusive upper bound on the invoice total, in stroops. */ + maxAmount?: bigint; + /** Inclusive lower bound on the creation time (Unix seconds or ms). */ + fromDate?: number; + /** Inclusive upper bound on the creation time (Unix seconds or ms). */ + toDate?: number; + /** Return invoices carrying **all** of these tags. */ + tags?: string[]; + /** Maximum number of invoices to return. Defaults to 20. */ + limit?: number; + /** Opaque cursor returned by a previous call to resume from. */ + cursor?: string; + /** Sort order for the result set. Defaults to `"newest"`. */ + sort?: InvoiceSort; +} + +/** A single page of query results. */ +export interface InvoicePage { + /** The invoices on this page. */ + items: Invoice[]; + /** + * Cursor to pass as {@link InvoiceFilter.cursor} to fetch the next page, or + * `undefined` when this is the last page. + */ + nextCursor?: string; + /** Total number of invoices matching the filter, across all pages. */ + total: number; +} + +/** + * Resolve the total amount owed on an invoice. + * + * Uses the recipients list (the authoritative on-chain split) and falls back + * to the funded amount when no recipients are present. + */ +function invoiceTotal(invoice: Invoice): bigint { + if (invoice.recipients.length > 0) { + return invoice.recipients.reduce((sum, r) => sum + r.amount, 0n); + } + return invoice.funded; +} + +/** + * Resolve an invoice's creation time in **milliseconds**. + * + * `createdAt` is documented as accepting either seconds or milliseconds, so + * values above 1e12 are treated as milliseconds (the same heuristic used by + * `getInvoiceAge`). Invoices without `createdAt` have an unknown creation time + * and sort last in date orderings. + */ +function createdAtMs(invoice: Invoice): number | undefined { + const raw = invoice.createdAt; + if (raw === undefined || !Number.isFinite(raw) || raw <= 0) { + return undefined; + } + return raw > 1e12 ? raw : raw * 1000; +} + +/** + * Collect an invoice's tags. + * + * Prefers the explicit `tags` field, falling back to `#hashtags` parsed out of + * the memo so existing invoices are taggable without a contract change. + */ +export function getInvoiceTags(invoice: Invoice): string[] { + if (invoice.tags !== undefined && invoice.tags.length > 0) { + return invoice.tags.map((tag) => tag.toLowerCase()); + } + if (invoice.memo) { + const found = invoice.memo.match(/#([\w-]+)/g); + if (found) { + return found.map((tag) => tag.slice(1).toLowerCase()); + } + } + return []; +} + +/** Internal, resolved form of an {@link InvoiceFilter}. */ +interface ResolvedFilter { + creator?: string; + status?: Set; + minAmount?: bigint; + maxAmount?: bigint; + fromDate?: number; + toDate?: number; + tags?: Set; + sort: InvoiceSort; + limit: number; +} + +/** + * Validate a filter for internal consistency. + * + * @throws {ValidationError} If a bound is inverted or sort/limit is invalid. + */ +function validateFilter(filter: InvoiceFilter): void { + if (filter.limit !== undefined) { + if (!Number.isInteger(filter.limit) || filter.limit <= 0) { + throw new ValidationError("InvoiceFilter.limit must be a positive integer", { + limit: filter.limit, + }); + } + } + + if ( + filter.minAmount !== undefined && + filter.maxAmount !== undefined && + filter.minAmount > filter.maxAmount + ) { + throw new ValidationError( + "InvoiceFilter.minAmount must not exceed maxAmount", + { minAmount: filter.minAmount, maxAmount: filter.maxAmount }, + ); + } + + if ( + filter.fromDate !== undefined && + filter.toDate !== undefined && + filter.fromDate > filter.toDate + ) { + throw new ValidationError("InvoiceFilter.fromDate must not exceed toDate", { + fromDate: filter.fromDate, + toDate: filter.toDate, + }); + } + + if (filter.sort !== undefined && !INVOICE_SORTS.includes(filter.sort)) { + throw new ValidationError( + `InvoiceFilter.sort must be one of: ${INVOICE_SORTS.join(", ")}`, + { sort: filter.sort }, + ); + } +} + +function resolveFilter(filter: InvoiceFilter): ResolvedFilter { + return { + creator: filter.creator, + status: filter.status ? new Set(filter.status) : undefined, + minAmount: filter.minAmount, + maxAmount: filter.maxAmount, + fromDate: filter.fromDate, + toDate: filter.toDate, + tags: filter.tags + ? new Set(filter.tags.map((tag) => tag.toLowerCase())) + : undefined, + sort: filter.sort ?? "newest", + limit: filter.limit ?? DEFAULT_QUERY_LIMIT, + }; +} + +/** Normalise a seconds-or-milliseconds bound to milliseconds. */ +function toMs(date: number): number { + return date > 1e12 ? date : date * 1000; +} + +function matches(invoice: Invoice, filter: ResolvedFilter): boolean { + if (filter.creator !== undefined && invoice.creator !== filter.creator) { + return false; + } + + if (filter.status !== undefined && !filter.status.has(invoice.status)) { + return false; + } + + if (filter.minAmount !== undefined || filter.maxAmount !== undefined) { + const total = invoiceTotal(invoice); + if (filter.minAmount !== undefined && total < filter.minAmount) { + return false; + } + if (filter.maxAmount !== undefined && total > filter.maxAmount) { + return false; + } + } + + if (filter.fromDate !== undefined || filter.toDate !== undefined) { + const created = createdAtMs(invoice); + // Invoices with an unknown creation time cannot satisfy a date filter. + if (created === undefined) return false; + if (filter.fromDate !== undefined && created < toMs(filter.fromDate)) { + return false; + } + if (filter.toDate !== undefined && created > toMs(filter.toDate)) { + return false; + } + } + + if (filter.tags !== undefined) { + const tags = getInvoiceTags(invoice); + for (const tag of filter.tags) { + if (!tags.includes(tag)) return false; + } + } + + return true; +} + +/** + * Encode a cursor as a stable, opaque offset string. + * + * Cursors are offset-based rather than ID-based because sorting can reorder + * the underlying result set; an offset keeps paging deterministic for a given + * (filter, sort) pair. + */ +function encodeCursor(offset: number): string { + return `off:${offset}`; +} + +/** + * Decode a cursor produced by {@link encodeCursor}. + * + * @returns The offset, or null when no cursor was supplied. + * @throws {ValidationError} If the cursor is malformed. + */ +function decodeCursor(cursor: string | undefined): number | null { + if (cursor === undefined) return null; + + if (!cursor.startsWith("off:")) { + throw new ValidationError('Invalid cursor: expected an "off:" cursor', { + cursor, + }); + } + + const offset = Number.parseInt(cursor.slice(4), 10); + if (!Number.isInteger(offset) || offset < 0) { + throw new ValidationError("Invalid cursor: offset must be a non-negative integer", { + cursor, + }); + } + + return offset; +} + +/** + * Compare two bigints without narrowing them to `number`. + * + * Amounts routinely exceed `Number.MAX_SAFE_INTEGER`, so they must never be + * compared via numeric subtraction. + */ +function compareBig(a: bigint, b: bigint): number { + if (a < b) return -1; + if (a > b) return 1; + return 0; +} + +function sortInvoices(invoices: Invoice[], sort: InvoiceSort): Invoice[] { + const sorted = [...invoices]; + + // Invoices with an unknown creation time get a 0 key so they sort last in + // "newest" order and first in "oldest" order. + const dateKey = (inv: Invoice): number => createdAtMs(inv) ?? 0; + const totalKey = (inv: Invoice): bigint => invoiceTotal(inv); + // Tie-break on the invoice ID so ordering is stable across pages. + const tieBreak = (a: Invoice, b: Invoice): number => a.id.localeCompare(b.id); + + switch (sort) { + case "newest": + sorted.sort( + (a, b) => compareBig(BigInt(dateKey(b)), BigInt(dateKey(a))) || tieBreak(a, b), + ); + break; + case "oldest": + sorted.sort( + (a, b) => compareBig(BigInt(dateKey(a)), BigInt(dateKey(b))) || tieBreak(a, b), + ); + break; + case "highest": + sorted.sort( + (a, b) => compareBig(totalKey(b), totalKey(a)) || tieBreak(a, b), + ); + break; + case "lowest": + sorted.sort( + (a, b) => compareBig(totalKey(a), totalKey(b)) || tieBreak(a, b), + ); + break; + } + + return sorted; +} + +/** + * An in-memory index over a set of invoices supporting tag lookups. + * + * `client.queryInvoices()` builds one of these per call from the invoices it + * fetches, but it is exported so callers holding their own invoice list can + * build the index once and reuse it across many queries. + */ +export class InvoiceTagIndex { + private readonly byTag = new Map>(); + + /** Build the tag index from a list of invoices. */ + constructor(invoices: Iterable = []) { + for (const invoice of invoices) { + for (const tag of getInvoiceTags(invoice)) { + let bucket = this.byTag.get(tag); + if (!bucket) { + bucket = new Set(); + this.byTag.set(tag, bucket); + } + bucket.add(invoice); + } + } + } + + /** + * Return the invoices carrying `tag`. + * + * @returns Matching invoices, or an empty array when the tag is unknown. + */ + getByTag(tag: string): Invoice[] { + const bucket = this.byTag.get(tag.toLowerCase()); + return bucket ? [...bucket] : []; + } + + /** All known tags, sorted alphabetically. */ + tags(): string[] { + return [...this.byTag.keys()].sort(); + } +} + +/** + * Applies an {@link InvoiceFilter} to an in-memory invoice set. + * + * @example + * ```ts + * const engine = new InvoiceQueryEngine(invoices); + * const page = engine.query({ status: ["Pending"], sort: "highest", limit: 10 }); + * ``` + */ +export class InvoiceQueryEngine { + private readonly invoices: Invoice[]; + + constructor(invoices: Iterable = []) { + this.invoices = [...invoices]; + } + + /** Number of invoices currently indexed. */ + get size(): number { + return this.invoices.length; + } + + /** A tag index built over the current invoice set. */ + tagIndex(): InvoiceTagIndex { + return new InvoiceTagIndex(this.invoices); + } + + /** + * Filter, sort and paginate the indexed invoices. + * + * @param filter - The query to run. Omit to return the first page unfiltered. + * @returns A page of matching invoices, plus a cursor when more remain. + * @throws {ValidationError} If the filter is internally inconsistent + * (e.g. `minAmount > maxAmount`) or the cursor is malformed. + */ + query(filter: InvoiceFilter = {}): InvoicePage { + validateFilter(filter); + const resolved = resolveFilter(filter); + const offset = decodeCursor(filter.cursor) ?? 0; + + const matched = sortInvoices( + this.invoices.filter((invoice) => matches(invoice, resolved)), + resolved.sort, + ); + + const total = matched.length; + const items = matched.slice(offset, offset + resolved.limit); + const nextOffset = offset + items.length; + const nextCursor = nextOffset < total ? encodeCursor(nextOffset) : undefined; + + return nextCursor !== undefined + ? { items, nextCursor, total } + : { items, total }; + } +} + +/** + * Convenience wrapper around {@link InvoiceQueryEngine} for one-off queries. + * + * @param invoices - The invoices to search. + * @param filter - The query to run. + */ +export function queryInvoices( + invoices: Iterable, + filter: InvoiceFilter = {}, +): InvoicePage { + return new InvoiceQueryEngine(invoices).query(filter); +} diff --git a/src/types.ts b/src/types.ts index b60a6ec..cb3ca3e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -326,6 +326,15 @@ export interface Invoice { groupId?: string; /** Ledger sequence when this invoice was last modified. */ lastModifiedLedger?: number; + /** + * Optional free-form labels used for tag-based querying via + * `client.queryInvoices({ tags: [...] })`. + * + * When omitted, the query engine falls back to parsing `#hashtags` out of + * `memo`, so invoices created with a tagged memo are queryable without any + * contract change. + */ + tags?: string[]; /** IDs of invoices that must be paid before this one. */ prerequisites?: string[]; /** ID of the parent invoice this was cloned from (clone chain). */ diff --git a/test/invoiceQuery.test.ts b/test/invoiceQuery.test.ts new file mode 100644 index 0000000..20e53b1 --- /dev/null +++ b/test/invoiceQuery.test.ts @@ -0,0 +1,489 @@ +import { describe, expect, it } from "vitest"; +import { Keypair, StrKey } from "@stellar/stellar-base"; +import { randomBytes } from "crypto"; +import { StellarSplitClient } from "../src/client.js"; +import { + InvoiceQueryEngine, + InvoiceTagIndex, + queryInvoices, + getInvoiceTags, + INVOICE_SORTS, + DEFAULT_QUERY_LIMIT, +} from "../src/invoiceQuery.js"; +import { ValidationError } from "../src/errors.js"; +import type { Invoice, InvoiceStatus } from "../src/types.js"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const CREATOR = Keypair.random().publicKey(); +const OTHER_CREATOR = Keypair.random().publicKey(); + +/** 2026-01-01T00:00:00Z in Unix seconds — the reference "day one". */ +const DAY_ONE = 1_767_225_600; + +let nextId = 0; + +function makeInvoice(overrides: Partial = {}): Invoice { + nextId += 1; + return { + id: String(nextId), + creator: CREATOR, + recipients: [{ address: OTHER_CREATOR, amount: 100n }], + token: "USDC", + deadline: DAY_ONE + 86_400, + createdAt: DAY_ONE, + funded: 0n, + status: "Pending", + payments: [], + ...overrides, + }; +} + +/** `count` invoices, one per index, all owned by CREATOR. */ +function makeInvoices( + count: number, + overrides: (i: number) => Partial = () => ({}), +): Invoice[] { + return Array.from({ length: count }, (_, i) => makeInvoice(overrides(i))); +} + +// --------------------------------------------------------------------------- +// Each filter type in isolation +// --------------------------------------------------------------------------- + +describe("InvoiceQueryEngine — individual filters", () => { + it("returns every invoice when no filter is supplied", () => { + const page = queryInvoices(makeInvoices(3)); + expect(page.items).toHaveLength(3); + expect(page.total).toBe(3); + }); + + it("filters by creator", () => { + const mine = makeInvoice({ creator: CREATOR }); + const theirs = makeInvoice({ creator: OTHER_CREATOR }); + const page = queryInvoices([mine, theirs], { creator: CREATOR }); + expect(page.items.map((i) => i.creator)).toEqual([CREATOR]); + }); + + it("filters by a single status", () => { + const pending = makeInvoice({ status: "Pending" }); + const released = makeInvoice({ status: "Released" }); + const page = queryInvoices([pending, released], { status: ["Pending"] }); + expect(page.items).toEqual([pending]); + }); + + it("treats status as an OR across the array", () => { + const pending = makeInvoice({ status: "Pending" }); + const released = makeInvoice({ status: "Released" }); + const cancelled = makeInvoice({ status: "Cancelled" }); + const page = queryInvoices([pending, released, cancelled], { + status: ["Pending", "Released"], + }); + expect(page.items).toEqual([pending, released]); + }); + + it("filters by minimum amount using the recipients total", () => { + const small = makeInvoice({ recipients: [{ address: OTHER_CREATOR, amount: 50n }] }); + const large = makeInvoice({ recipients: [{ address: OTHER_CREATOR, amount: 500n }] }); + const page = queryInvoices([small, large], { minAmount: 100n }); + expect(page.items).toEqual([large]); + }); + + it("filters by maximum amount", () => { + const small = makeInvoice({ recipients: [{ address: OTHER_CREATOR, amount: 50n }] }); + const large = makeInvoice({ recipients: [{ address: OTHER_CREATOR, amount: 500n }] }); + const page = queryInvoices([small, large], { maxAmount: 100n }); + expect(page.items).toEqual([small]); + }); + + it("sums multiple recipients when computing the invoice total", () => { + const split = makeInvoice({ + recipients: [ + { address: OTHER_CREATOR, amount: 60n }, + { address: "GOTHERRECIPIENT", amount: 45n }, + ], + }); + expect(queryInvoices([split], { minAmount: 100n }).items).toEqual([split]); + }); + + it("applies amount bounds inclusively", () => { + const invoice = makeInvoice({ recipients: [{ address: OTHER_CREATOR, amount: 100n }] }); + const page = queryInvoices([invoice], { minAmount: 100n, maxAmount: 100n }); + expect(page.items).toEqual([invoice]); + }); + + it("handles amounts beyond Number.MAX_SAFE_INTEGER", () => { + const huge = 9_007_199_254_740_993n; // 2^53 + 1 + const invoice = makeInvoice({ recipients: [{ address: OTHER_CREATOR, amount: huge }] }); + expect(queryInvoices([invoice], { minAmount: huge }).items).toEqual([invoice]); + expect(queryInvoices([invoice], { maxAmount: huge - 1n }).items).toEqual([]); + }); +}); + +describe("InvoiceQueryEngine — date filters", () => { + it("filters by a fromDate bound", () => { + const early = makeInvoice({ createdAt: DAY_ONE }); + const late = makeInvoice({ createdAt: DAY_ONE + 86_400 }); + const page = queryInvoices([early, late], { fromDate: DAY_ONE + 3_600 }); + expect(page.items).toEqual([late]); + }); + + it("filters by a toDate bound", () => { + const early = makeInvoice({ createdAt: DAY_ONE }); + const late = makeInvoice({ createdAt: DAY_ONE + 86_400 }); + const page = queryInvoices([early, late], { toDate: DAY_ONE + 3_600 }); + expect(page.items).toEqual([early]); + }); + + it("applies date bounds inclusively", () => { + const invoice = makeInvoice({ createdAt: DAY_ONE }); + const page = queryInvoices([invoice], { fromDate: DAY_ONE, toDate: DAY_ONE }); + expect(page.items).toEqual([invoice]); + }); + + it("accepts filter bounds expressed in milliseconds", () => { + const invoice = makeInvoice({ createdAt: DAY_ONE }); + expect(queryInvoices([invoice], { fromDate: DAY_ONE * 1000 }).items).toEqual([invoice]); + }); + + it("accepts createdAt expressed in milliseconds", () => { + const invoice = makeInvoice({ createdAt: DAY_ONE * 1000 }); + expect(queryInvoices([invoice], { toDate: DAY_ONE * 1000 }).items).toEqual([invoice]); + }); + + it("excludes invoices with an unknown creation time", () => { + const undated = makeInvoice({ createdAt: undefined }); + expect(queryInvoices([undated], { fromDate: 0 }).items).toEqual([]); + }); +}); + +describe("InvoiceQueryEngine — tag filters", () => { + it("filters by explicit tags", () => { + const urgent = makeInvoice({ tags: ["urgent", "q1"] }); + const normal = makeInvoice({ tags: ["q1"] }); + expect(queryInvoices([urgent, normal], { tags: ["urgent"] }).items).toEqual([urgent]); + }); + + it("requires all requested tags to be present", () => { + const both = makeInvoice({ tags: ["urgent", "q1"] }); + const one = makeInvoice({ tags: ["urgent"] }); + expect(queryInvoices([both, one], { tags: ["urgent", "q1"] }).items).toEqual([both]); + }); + + it("matches tags case-insensitively", () => { + const invoice = makeInvoice({ tags: ["Urgent"] }); + expect(queryInvoices([invoice], { tags: ["URGENT"] }).items).toEqual([invoice]); + }); + + it("falls back to #hashtags in the memo when tags are absent", () => { + const tagged = makeInvoice({ memo: "Q3 invoice #urgent #acme" }); + const untagged = makeInvoice({ memo: "no tags here" }); + expect(queryInvoices([tagged, untagged], { tags: ["urgent"] }).items).toEqual([tagged]); + }); + + it("prefers explicit tags over memo hashtags", () => { + const invoice = makeInvoice({ tags: ["real"], memo: "#fake" }); + expect(getInvoiceTags(invoice)).toEqual(["real"]); + expect(queryInvoices([invoice], { tags: ["fake"] }).items).toEqual([]); + }); +}); + +describe("InvoiceQueryEngine — combined filters", () => { + it("ANDs every supplied field together", () => { + const match = makeInvoice({ + status: "Pending", + createdAt: DAY_ONE, + recipients: [{ address: OTHER_CREATOR, amount: 200n }], + tags: ["urgent"], + }); + const wrongStatus = makeInvoice({ ...match, id: "900", status: "Cancelled" }); + const wrongAmount = makeInvoice({ + ...match, + id: "901", + recipients: [{ address: OTHER_CREATOR, amount: 5n }], + }); + const wrongTag = makeInvoice({ ...match, id: "902", tags: ["q1"] }); + const wrongDate = makeInvoice({ ...match, id: "903", createdAt: DAY_ONE + 999_999 }); + + const page = queryInvoices([match, wrongStatus, wrongAmount, wrongTag, wrongDate], { + status: ["Pending"], + minAmount: 100n, + maxAmount: 300n, + fromDate: DAY_ONE - 1, + toDate: DAY_ONE + 1, + tags: ["urgent"], + }); + + expect(page.items).toEqual([match]); + expect(page.total).toBe(1); + }); + + it("combines creator with other filters", () => { + const mine = makeInvoice({ status: "Pending" }); + const theirs = makeInvoice({ creator: OTHER_CREATOR, status: "Pending" }); + const page = queryInvoices([mine, theirs], { creator: CREATOR, status: ["Pending"] }); + expect(page.items).toEqual([mine]); + }); +}); + +describe("InvoiceQueryEngine — sorting", () => { + const invoices = [ + makeInvoice({ + id: "1", + createdAt: DAY_ONE, + recipients: [{ address: OTHER_CREATOR, amount: 300n }], + }), + makeInvoice({ + id: "2", + createdAt: DAY_ONE + 86_400, + recipients: [{ address: OTHER_CREATOR, amount: 100n }], + }), + makeInvoice({ + id: "3", + createdAt: DAY_ONE + 43_200, + recipients: [{ address: OTHER_CREATOR, amount: 200n }], + }), + ]; + + it("sorts newest first by default", () => { + expect(queryInvoices(invoices).items.map((i) => i.id)).toEqual(["2", "3", "1"]); + }); + + it.each(INVOICE_SORTS)("supports the %s sort", (sort) => { + expect(queryInvoices(invoices, { sort }).items).toHaveLength(3); + }); + + it("sorts oldest first", () => { + expect(queryInvoices(invoices, { sort: "oldest" }).items.map((i) => i.id)).toEqual([ + "1", + "3", + "2", + ]); + }); + + it("sorts highest amount first", () => { + expect(queryInvoices(invoices, { sort: "highest" }).items.map((i) => i.id)).toEqual([ + "1", + "3", + "2", + ]); + }); + + it("sorts lowest amount first", () => { + expect(queryInvoices(invoices, { sort: "lowest" }).items.map((i) => i.id)).toEqual([ + "2", + "3", + "1", + ]); + }); + + it("breaks ties on invoice ID for deterministic paging", () => { + const tied = [ + makeInvoice({ id: "b", createdAt: DAY_ONE, recipients: [{ address: OTHER_CREATOR, amount: 1n }] }), + makeInvoice({ id: "a", createdAt: DAY_ONE, recipients: [{ address: OTHER_CREATOR, amount: 1n }] }), + ]; + expect(queryInvoices(tied, { sort: "highest" }).items.map((i) => i.id)).toEqual(["a", "b"]); + }); + + it("sorts invoices with an unknown creation time last under 'newest'", () => { + const undated = makeInvoice({ id: "0", createdAt: undefined }); + const dated = makeInvoice({ id: "9", createdAt: DAY_ONE }); + expect(queryInvoices([undated, dated], { sort: "newest" }).items.map((i) => i.id)).toEqual([ + "9", + "0", + ]); + }); +}); + +describe("InvoiceQueryEngine — pagination", () => { + const invoices = makeInvoices(25); + + it("defaults to a page size of 20", () => { + expect(DEFAULT_QUERY_LIMIT).toBe(20); + expect(queryInvoices(invoices).items).toHaveLength(20); + }); + + it("honours an explicit limit", () => { + expect(queryInvoices(invoices, { limit: 5 }).items).toHaveLength(5); + }); + + it("returns a nextCursor while more pages remain", () => { + expect(queryInvoices(invoices, { limit: 10 }).nextCursor).toBeDefined(); + }); + + it("omits nextCursor on the final page", () => { + expect(queryInvoices(invoices, { limit: 25 }).nextCursor).toBeUndefined(); + }); + + it("reports the unpaginated total on every page", () => { + expect(queryInvoices(invoices, { limit: 10 }).total).toBe(25); + }); + + it("walks every page without gaps or duplicates", () => { + const seen: string[] = []; + let cursor: string | undefined; + let guard = 0; + + do { + const page = queryInvoices(invoices, { limit: 10, cursor }); + seen.push(...page.items.map((i) => i.id)); + cursor = page.nextCursor; + guard += 1; + } while (cursor !== undefined && guard < 10); + + expect(seen).toHaveLength(25); + expect(new Set(seen).size).toBe(25); + }); + + it("returns an empty page when the cursor is past the end", () => { + const past = queryInvoices(invoices, { limit: 5, cursor: "off:25" }); + expect(past.items).toEqual([]); + expect(past.total).toBe(25); + }); + + it("paginates a filtered result set, not the raw set", () => { + const cancelled = makeInvoices(5, () => ({ status: "Cancelled" as InvoiceStatus })); + const first = queryInvoices([...invoices, ...cancelled], { + status: ["Cancelled"], + limit: 2, + }); + expect(first.items).toHaveLength(2); + expect(first.total).toBe(5); + }); +}); + +describe("InvoiceQueryEngine — empty results", () => { + it("returns an empty page when nothing matches", () => { + const page = queryInvoices(makeInvoices(3), { creator: OTHER_CREATOR }); + expect(page.items).toEqual([]); + expect(page.total).toBe(0); + expect(page.nextCursor).toBeUndefined(); + }); + + it("returns an empty page for an empty invoice set", () => { + expect(queryInvoices([], { status: ["Pending"] })).toEqual({ items: [], total: 0 }); + }); + + it("returns an empty page for an unknown tag", () => { + expect(queryInvoices(makeInvoices(2), { tags: ["nonexistent"] }).items).toEqual([]); + }); +}); + +describe("InvoiceQueryEngine — validation", () => { + it("rejects an inverted amount range", () => { + expect(() => queryInvoices([], { minAmount: 10n, maxAmount: 1n })).toThrow(ValidationError); + }); + + it("rejects an inverted date range", () => { + expect(() => queryInvoices([], { fromDate: 200, toDate: 100 })).toThrow(ValidationError); + }); + + it("rejects a non-positive limit", () => { + expect(() => queryInvoices([], { limit: 0 })).toThrow(ValidationError); + expect(() => queryInvoices([], { limit: -1 })).toThrow(ValidationError); + }); + + it("rejects an unknown sort", () => { + expect(() => queryInvoices([], { sort: "sideways" as never })).toThrow(ValidationError); + }); + + it("rejects a malformed cursor", () => { + expect(() => queryInvoices([], { cursor: "garbage" })).toThrow(ValidationError); + }); +}); + +describe("InvoiceTagIndex", () => { + it("groups invoices by tag", () => { + const a = makeInvoice({ tags: ["alpha", "shared"] }); + const b = makeInvoice({ tags: ["beta", "shared"] }); + const index = new InvoiceTagIndex([a, b]); + + expect(index.getByTag("alpha")).toEqual([a]); + expect(index.getByTag("shared")).toEqual([a, b]); + expect(index.getByTag("missing")).toEqual([]); + }); + + it("lists all known tags sorted", () => { + expect(new InvoiceTagIndex([makeInvoice({ tags: ["zeta", "alpha"] })]).tags()).toEqual([ + "alpha", + "zeta", + ]); + }); + + it("is exposed by the engine", () => { + expect(new InvoiceQueryEngine([makeInvoice({ tags: ["x"] })]).tagIndex().tags()).toEqual(["x"]); + }); + + it("reports the size of the indexed set", () => { + expect(new InvoiceQueryEngine(makeInvoices(4)).size).toBe(4); + }); +}); + +// --------------------------------------------------------------------------- +// client.queryInvoices() integration +// --------------------------------------------------------------------------- + +describe("StellarSplitClient.queryInvoices", () => { + const creator = Keypair.random().publicKey(); + + /** Build a client whose getInvoicesByCreator/getInvoice return `invoices`. */ + function makeClient(invoices: Invoice[]): StellarSplitClient { + const client = new StellarSplitClient({ + rpcUrl: "https://example.com", + networkPassphrase: "Test Network", + contractId: StrKey.encodeContract(randomBytes(32)), + }); + // Stub the two chain reads queryInvoices depends on. + client.getInvoicesByCreator = async () => ({ + items: invoices.map((i) => i.id), + nextCursor: null, + total: invoices.length, + }); + client.getInvoice = async (id: string) => { + const found = invoices.find((i) => i.id === id); + if (!found) throw new Error(`Unknown invoice ${id}`); + return found; + }; + return client; + } + + const dataset: Invoice[] = [ + makeInvoice({ id: "a", creator, status: "Pending", createdAt: DAY_ONE, recipients: [{ address: OTHER_CREATOR, amount: 10n }] }), + makeInvoice({ id: "b", creator, status: "Released", createdAt: DAY_ONE + 100, recipients: [{ address: OTHER_CREATOR, amount: 900n }] }), + makeInvoice({ id: "c", creator, status: "Cancelled", createdAt: DAY_ONE + 200, recipients: [{ address: OTHER_CREATOR, amount: 500n }] }), + ]; + + it("filters invoices fetched from the chain", async () => { + const page = await makeClient(dataset).queryInvoices({ creator, status: ["Pending"] }); + expect(page.items.map((i) => i.id)).toEqual(["a"]); + expect(page.total).toBe(1); + }); + + it("sorts the fetched invoices", async () => { + const page = await makeClient(dataset).queryInvoices({ creator, sort: "highest" }); + expect(page.items.map((i) => i.id)).toEqual(["b", "c", "a"]); + }); + + it("paginates the fetched invoices", async () => { + const page = await makeClient(dataset).queryInvoices({ creator, limit: 2 }); + expect(page.items).toHaveLength(2); + expect(page.nextCursor).toBeDefined(); + + const next = await makeClient(dataset).queryInvoices({ creator, limit: 2, cursor: page.nextCursor }); + expect(next.items.map((i) => i.id)).toEqual(["a"]); + expect(next.nextCursor).toBeUndefined(); + }); + + it("returns an empty page when nothing matches", async () => { + const page = await makeClient(dataset).queryInvoices({ creator, status: ["Refunded"] }); + expect(page).toEqual({ items: [], total: 0 }); + }); + + it("requires a creator because the contract has no global invoice index", async () => { + await expect(makeClient(dataset).queryInvoices({ status: ["Pending"] })).rejects.toThrow( + ValidationError, + ); + }); +}); From 82688db49d4b37f6628d6b4e2a5c0cad9c5f3113 Mon Sep 17 00:00:00 2001 From: Noah Akerityo Date: Sat, 26 Sep 2026 19:31:50 +0000 Subject: [PATCH 2/4] feat(queries): wire queryInvoices() into StellarSplitClient Expose the filter engine on the client. Because the contract has no query endpoint for status/amount/date, this cursor-pages the creator's invoice IDs, fetches each invoice, then applies the filter in memory. Requiring a creator keeps the fetch bounded and turns the missing global index into an explicit error rather than a silent full scan. --- src/client.ts | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/src/client.ts b/src/client.ts index 8b44892..03f3ac5 100644 --- a/src/client.ts +++ b/src/client.ts @@ -83,6 +83,9 @@ import { generatePaymentReceipt } from "./receipt.js"; import type { PaymentReceipt } from "./receipt.js"; import { checkInvoiceExpiry, checkPayerReadiness } from "./preflightChecker.js"; import { InvoiceCloneabilityValidator } from "./preflight/InvoiceCloneabilityValidator.js"; +import { InvoiceQueryEngine } from "./invoiceQuery.js"; +import type { InvoiceFilter, InvoicePage } from "./invoiceQuery.js"; + import { createInvoiceSubscription } from "./subscription.js"; import type { Subscription, InvoiceEvent, SubscriptionOptions } from "./types.js"; import { getSubscriptionManager } from "./streaming/SubscriptionManager.js"; @@ -4853,6 +4856,68 @@ export class StellarSplitClient extends TypedEventEmitter { ); } + /** + * Query invoices with a rich, composable filter, sort and pagination. + * + * All filtering is applied client-side against the invoices the contract + * returns for `filter.creator` (or, when no creator is given, the full set + * of invoices the caller has already indexed locally). The result is a + * {@link InvoicePage} carrying a `nextCursor` for subsequent pages. + * + * Because the contract has no query endpoint for status/amount/date, this + * method fetches the candidate invoice IDs first and then applies every + * predicate in-memory — prefer supplying `creator` to bound the fetch. + * + * @param filter - The composed query. All supplied fields combine with AND. + * @returns A page of matching invoices plus a cursor when more remain. + * @throws {ValidationError} If the filter is inconsistent (e.g. + * `minAmount > maxAmount`) or the cursor is malformed. + * + * @example + * ```ts + * const page = await client.queryInvoices({ + * creator: "GABC...", + * status: ["Pending", "Released"], + * minAmount: 1000n, + * fromDate: Date.UTC(2026, 0, 1) / 1000, + * tags: ["urgent"], + * sort: "highest", + * limit: 10, + * }); + * ``` + */ + async queryInvoices(filter: InvoiceFilter = {}): Promise { + const startTime = Date.now(); + try { + if (filter.creator === undefined) { + throw new ValidationError( + "queryInvoices requires a 'creator' — the contract has no global invoice index", + ); + } + + // Fetch every invoice ID for the creator (cursor-paging through the + // on-chain list) so filters are applied to the complete result set. + const ids: string[] = []; + let cursor: string | undefined; + do { + const page: PaginatedResult = await this.getInvoicesByCreator( + filter.creator, + cursor !== undefined ? { cursor } : {}, + ); + ids.push(...page.items); + cursor = page.nextCursor ?? undefined; + } while (cursor !== undefined); + + const invoices = await Promise.all(ids.map((id) => this.getInvoice(id))); + const result = new InvoiceQueryEngine(invoices).query(filter); + telemetry.recordMethod("queryInvoices", true, Date.now() - startTime); + return result; + } catch (error) { + telemetry.recordMethod("queryInvoices", false, Date.now() - startTime); + throw error; + } + } + /** * Check the health of the RPC endpoint. * @param params - The parameters for the method. From f22b4682f2e2b6cf78bc455c447f557c292d5f2b Mon Sep 17 00:00:00 2001 From: Noah Akerityo Date: Sat, 26 Sep 2026 19:31:50 +0000 Subject: [PATCH 3/4] docs: document the invoice query engine Add a README section covering the InvoiceFilter fields, their AND/OR semantics, the InvoicePage shape, cursor paging, and the exported engine for searching an invoice list already held in memory. --- README.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/README.md b/README.md index 762d7fe..24a2bb6 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,59 @@ new StellarSplitClient(config: StellarSplitClientConfig) | `pay(params)` | `Promise<{ txHash }>` | Pay toward an invoice | | `getInvoice(id)` | `Promise` | Fetch invoice by ID | | `getPayments(id)` | `Promise` | Fetch payments for an invoice | +| `queryInvoices(filter)` | `Promise` | Filter, sort and paginate a creator's invoices with one typed query | + +### Invoice Query Engine + +`queryInvoices()` composes every filter into a single query object. All supplied +fields combine with AND; within a field, `status` matches any listed state +while `tags` requires every listed tag. + +| Field | Type | Description | +|-------|------|-------------| +| `creator` | `string` | **Required** — the contract has no global invoice index, so queries are scoped to one creator | +| `status` | `InvoiceStatus[]` | Match any of these lifecycle states | +| `minAmount` / `maxAmount` | `bigint` | Inclusive bounds on the invoice total (sum of recipients), in stroops | +| `fromDate` / `toDate` | `number` | Inclusive bounds on `createdAt`; accepts Unix seconds or milliseconds | +| `tags` | `string[]` | Match invoices carrying **all** of these tags (case-insensitive) | +| `sort` | `'newest' \| 'oldest' \| 'highest' \| 'lowest'` | Defaults to `newest` | +| `limit` | `number` | Page size, defaults to 20 | +| `cursor` | `string` | Opaque cursor from a previous `nextCursor` | + +Returns an `InvoicePage`: `{ items, nextCursor?, total }`. `total` always counts +every match across all pages; `nextCursor` is omitted on the final page. + +```ts +const page = await client.queryInvoices({ + creator: "GABC...", + status: ["Pending", "Released"], + minAmount: 1_000_000n, + tags: ["urgent"], + sort: "highest", + limit: 10, +}); + +let cursor = page.nextCursor; +while (cursor) { + const next = await client.queryInvoices({ creator: "GABC...", cursor }); + cursor = next.nextCursor; +} +``` + +Because the contract exposes no status/amount/date query, this fetches the +creator's invoice IDs and filters in memory. To search an invoice list you +already hold (no network calls), use the exported engine directly: + +```ts +import { InvoiceQueryEngine } from "@stellar-split/sdk"; + +const page = new InvoiceQueryEngine(invoices).query({ status: ["Pending"] }); +const index = new InvoiceQueryEngine(invoices).tagIndex(); +index.getByTag("urgent"); // invoices tagged "urgent" +``` + +Tags come from an invoice's `tags` field, falling back to `#hashtags` parsed +out of its `memo`, so existing invoices are taggable without a contract change. ### Wallet Helpers From ee0c5df3379da5560bf67620ca8e87073d4d60be Mon Sep 17 00:00:00 2001 From: Noah Akerityo Date: Sat, 26 Sep 2026 19:32:14 +0000 Subject: [PATCH 4/4] feat(queries): export the invoice query engine from the package root --- src/index.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/index.ts b/src/index.ts index f9cb56b..192c350 100644 --- a/src/index.ts +++ b/src/index.ts @@ -831,6 +831,17 @@ export type { ReminderSchedule, ReminderEvent, ReminderStatus } from "./types.js export { compileFilter, applyFilter, FilterIndex } from "./invoiceFilter.js"; export type { FilterCriteria, CompiledFilter } from "./invoiceFilter.js"; +// Invoice query engine — filter/sort/paginate invoices with one typed query +export { + InvoiceQueryEngine, + InvoiceTagIndex, + queryInvoices, + getInvoiceTags, + INVOICE_SORTS, + DEFAULT_QUERY_LIMIT, +} from "./invoiceQuery.js"; +export type { InvoiceFilter, InvoicePage, InvoiceSort } from "./invoiceQuery.js"; + // Invoice diff utility export { diffInvoices, hasDiff } from "./diff.js"; export type { InvoiceDiff, InvoiceDiffEntry } from "./diff.js";