From 640faea80c02218a8fe341b9efe190cd44f4fbdd Mon Sep 17 00:00:00 2001 From: Zac Lou <97340247+ZacLou@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:56:54 +0800 Subject: [PATCH] feat(search): add searchByMemo for local invoice memo search Implements substring search across invoice memos with optional case-sensitive matching. Skips invoices with undefined/null memos and returns the full array when the query is empty. Closes Stellar-split/split-sdk#614 --- src/index.ts | 2 ++ src/search.ts | 40 +++++++++++++++++++++++-- test/searchByMemo.test.ts | 62 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 test/searchByMemo.test.ts diff --git a/src/index.ts b/src/index.ts index f9cb56b..438b3a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,8 @@ import type { ExportFormat } from "./export.js"; export { StellarSplitClient } from "./client.js"; export { FinalityChecker } from "./finalityChecker.js"; export { buildPaymentMemo, parsePaymentMemo } from "./memoBuilder.js"; +export { searchByMemo } from "./search.js"; +export type { SearchByMemoOptions } from "./search.js"; export type { StellarSplitClientConfig, NetworkConfig, diff --git a/src/search.ts b/src/search.ts index 8d84608..51dcd12 100644 --- a/src/search.ts +++ b/src/search.ts @@ -1,5 +1,5 @@ import { Horizon } from "@stellar/stellar-sdk"; -import type { InvoiceStatus } from "./types.js"; +import type { Invoice, InvoiceStatus } from "./types.js"; import { SearchFailedError } from "./errors.js"; /** Query parameters for searching invoices. */ @@ -45,4 +45,40 @@ export async function searchInvoices( } catch (error) { throw new SearchFailedError(error instanceof Error ? error.message : String(error)); } -} \ No newline at end of file +} + +/** Options for searching invoice memo content. */ +export interface SearchByMemoOptions { + /** When true, matching is case-sensitive. Defaults to false. */ + caseSensitive?: boolean; +} + +/** + * Search a local array of invoices by memo content. + * + * @param invoices - Array of invoices to search + * @param query - Substring to match against `invoice.memo` + * @param opts - Optional search options + * @returns Invoices whose memo contains the query; all invoices when query is empty + */ +export function searchByMemo( + invoices: Invoice[], + query: string, + opts: SearchByMemoOptions = {}, +): Invoice[] { + if (!query) { + return invoices; + } + + const { caseSensitive = false } = opts; + const normalizedQuery = caseSensitive ? query : query.toLowerCase(); + + return invoices.filter((invoice) => { + const memo = invoice.memo; + if (memo === undefined || memo === null) { + return false; + } + const normalizedMemo = caseSensitive ? memo : memo.toLowerCase(); + return normalizedMemo.includes(normalizedQuery); + }); +} diff --git a/test/searchByMemo.test.ts b/test/searchByMemo.test.ts new file mode 100644 index 0000000..77070bb --- /dev/null +++ b/test/searchByMemo.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; +import { searchByMemo } from "../src/search.js"; +import type { Invoice } from "../src/types.js"; + +function makeInvoice(id: string, memo?: string): Invoice { + return { + id, + creator: "GCREATOR", + recipients: [{ address: "GRECIP", amount: 100n }], + token: "USDC", + deadline: 1_000_000, + funded: 0n, + status: "Pending", + payments: [], + memo, + } as Invoice; +} + +describe("searchByMemo", () => { + it("returns all invoices when query is empty", () => { + const invoices = [ + makeInvoice("a", "split:INV-001"), + makeInvoice("b", "Other memo"), + makeInvoice("c"), + ]; + expect(searchByMemo(invoices, "")).toEqual(invoices); + }); + + it("matches substring case-insensitively by default", () => { + const invoices = [ + makeInvoice("a", "split:INV-001"), + makeInvoice("b", "SPLIT:inv-002"), + makeInvoice("c", "unrelated"), + ]; + const result = searchByMemo(invoices, "inv"); + expect(result.map((i) => i.id)).toEqual(["a", "b"]); + }); + + it("honors caseSensitive option", () => { + const invoices = [ + makeInvoice("a", "split:INV-001"), + makeInvoice("b", "split:inv-002"), + ]; + expect(searchByMemo(invoices, "INV", { caseSensitive: true }).map((i) => i.id)).toEqual([ + "a", + ]); + }); + + it("skips invoices with undefined or null memo", () => { + const invoices = [ + makeInvoice("a", "split:INV-001"), + makeInvoice("b"), + makeInvoice("c", null as unknown as string), + ]; + expect(searchByMemo(invoices, "INV").map((i) => i.id)).toEqual(["a"]); + }); + + it("returns empty array when no memo matches", () => { + const invoices = [makeInvoice("a", "hello"), makeInvoice("b", "world")]; + expect(searchByMemo(invoices, "xyz")).toEqual([]); + }); +});