From 9c75e14ca3223d4a5fa06bdd6b0523ae048b8c2d Mon Sep 17 00:00:00 2001 From: aigon-x Date: Tue, 13 Oct 2026 09:49:47 +0200 Subject: [PATCH] feat: add searchByMemo for local invoice arrays (#614) Adds searchByMemo(invoices, query, opts) to src/search.ts for substring matching on Invoice.memo across an in-memory array, exported from src/index.ts. - case-insensitive by default; caseSensitive: true for exact-case matching - empty query returns all invoices unchanged - invoices with undefined/null memo are skipped without error - unit tests cover all acceptance criteria Fixes #614 --- src/index.ts | 4 ++ src/search.ts | 54 ++++++++++++++++++++++++++- test/searchByMemo.test.ts | 78 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 test/searchByMemo.test.ts diff --git a/src/index.ts b/src/index.ts index bf64e06..beb8fcd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1714,3 +1714,7 @@ export type { RetirementManagerOptions, RetirementEventMap, } from "./invoiceRetirement.js"; + +// Invoice memo search on local arrays (#614) +export { searchByMemo } from "./search.js"; +export type { SearchByMemoOptions } from "./search.js"; diff --git a/src/search.ts b/src/search.ts index 8d84608..7ed446a 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,56 @@ export async function searchInvoices( } catch (error) { throw new SearchFailedError(error instanceof Error ? error.message : String(error)); } +} + +/** Options for {@link searchByMemo}. */ +export interface SearchByMemoOptions { + /** + * When `true`, matching is case-sensitive. Defaults to `false` + * (case-insensitive). + */ + caseSensitive?: boolean; +} + +/** + * Filter an in-memory array of invoices by their memo content. + * + * Payment memo text is a common way users tag invoices (e.g. `"split:INV-001"`). + * This helper performs a substring match against {@link Invoice.memo} on a local + * array, complementing {@link searchInvoices}, which queries Horizon for + * on-chain data. The input array is never mutated. + * + * @param invoices - Invoices to filter. + * @param query - Substring to look for in `invoice.memo`. An empty string returns + * every invoice unchanged. + * @param opts - Matching options. `caseSensitive` defaults to `false`. + * @returns A new array of the invoices whose `memo` contains `query`. Invoices + * whose `memo` is `undefined` or `null` are skipped without error. + * + * @example + * ```ts + * const hits = searchByMemo(invoices, "split:INV-001"); + * const exact = searchByMemo(invoices, "Split", { caseSensitive: true }); + * ``` + */ +export function searchByMemo( + invoices: Invoice[], + query: string, + opts?: SearchByMemoOptions +): Invoice[] { + if (query === "") { + return invoices; + } + + const caseSensitive = opts?.caseSensitive ?? false; + const needle = caseSensitive ? query : query.toLowerCase(); + + return invoices.filter((invoice) => { + const memo = invoice.memo; + if (memo === undefined || memo === null) { + return false; + } + const haystack = caseSensitive ? memo : memo.toLowerCase(); + return haystack.includes(needle); + }); } \ No newline at end of file diff --git a/test/searchByMemo.test.ts b/test/searchByMemo.test.ts new file mode 100644 index 0000000..e0ede93 --- /dev/null +++ b/test/searchByMemo.test.ts @@ -0,0 +1,78 @@ +/** + * Tests for issue #614 — `searchByMemo` in `src/search.ts`. + * + * Covers: substring match, case-insensitive default, case-sensitive override, + * empty query returns all invoices, and invoices with a null/undefined memo + * being skipped without error. + */ +import { describe, it, expect } from "vitest"; +import { searchByMemo } from "../src/search.js"; +import type { Invoice } from "../src/types.js"; + +function makeInvoice(overrides: Partial = {}): Invoice { + return { + id: "1", + creator: "GCREATOR", + recipients: [{ address: "GRECIPIENT", amount: 100n }], + token: "TOKEN_USDC", + deadline: 2_000_000_000, + funded: 0n, + status: "Pending", + payments: [], + ...overrides, + }; +} + +const invoices: Invoice[] = [ + makeInvoice({ id: "1", memo: "split:INV-001" }), + makeInvoice({ id: "2", memo: "SPLIT:inv-002" }), + makeInvoice({ id: "3", memo: "rent for October" }), + makeInvoice({ id: "4", memo: undefined }), + makeInvoice({ id: "5" }), // memo omitted entirely +]; + +describe("searchByMemo", () => { + it("matches on a substring of the memo", () => { + const result = searchByMemo(invoices, "rent for"); + expect(result.map((i) => i.id)).toEqual(["3"]); + }); + + it("is case-insensitive by default", () => { + const result = searchByMemo(invoices, "split:inv-"); + expect(result.map((i) => i.id)).toEqual(["1", "2"]); + }); + + it("respects caseSensitive: true", () => { + const result = searchByMemo(invoices, "SPLIT:", { caseSensitive: true }); + expect(result.map((i) => i.id)).toEqual(["2"]); + + const lower = searchByMemo(invoices, "split:", { caseSensitive: true }); + expect(lower.map((i) => i.id)).toEqual(["1"]); + }); + + it("returns all invoices unchanged for an empty query", () => { + const result = searchByMemo(invoices, ""); + expect(result).toEqual(invoices); + expect(result).toHaveLength(invoices.length); + }); + + it("skips invoices with a null or undefined memo without error", () => { + const withNull = [ + ...invoices, + makeInvoice({ id: "6", memo: null as unknown as string }), + ]; + expect(() => searchByMemo(withNull, "split")).not.toThrow(); + const result = searchByMemo(withNull, "split"); + expect(result.map((i) => i.id)).toEqual(["1", "2"]); + }); + + it("returns an empty array when nothing matches", () => { + expect(searchByMemo(invoices, "zzz-no-match")).toEqual([]); + }); + + it("does not mutate the input array", () => { + const snapshot = [...invoices]; + searchByMemo(invoices, "split"); + expect(invoices).toEqual(snapshot); + }); +});