From cf221980bd52efc68996f337e743438b0c559582 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=81=E7=8F=AD=E4=B8=83=E5=8F=B7?= <9159450+luban-71@user.noreply.gitee.com> Date: Sat, 29 Aug 2026 19:29:59 +0800 Subject: [PATCH] feat(search): add searchByMemo for invoice memo substring search (#614) - Implement searchByMemo(invoices, query, opts?) in src/search.ts - Default case-insensitive substring matching; opts.caseSensitive to override - Empty query returns all invoices unchanged - Invoices with null/undefined memo are skipped without error - Export searchByMemo and SearchByMemoOptions from src/index.ts - Add 8 vitest tests covering substring, case-sensitivity, empty query, null/undefined memo, no-match, and input immutability Closes #614 --- src/index.ts | 11 ++++++ src/search.ts | 35 +++++++++++++++++- test/searchByMemo.test.ts | 78 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 test/searchByMemo.test.ts diff --git a/src/index.ts b/src/index.ts index 35bb785..3d61721 100644 --- a/src/index.ts +++ b/src/index.ts @@ -337,6 +337,17 @@ export type { HorizonProberConfig, } from "./horizonProber.js"; +// Invoice calculator +export { + calculateSplitAmounts, + computeAmounts, + formatSplitPercentage, +} from "./invoice/calculator.js"; + +// Invoice memo search +export { searchByMemo } from "./search.js"; +export type { SearchByMemoOptions } from "./search.js"; + // AMM Calculator export { estimateSwapOutput, calculatePoolShare } from "./ammCalculator.js"; diff --git a/src/search.ts b/src/search.ts index 8d84608..5da6a57 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. */ @@ -20,6 +20,39 @@ export interface SearchResult { status: InvoiceStatus; } +/** Options for {@link searchByMemo}. */ +export interface SearchByMemoOptions { + /** When true, match exact case; otherwise ignore case (default). */ + caseSensitive?: boolean; +} + +/** + * Search a local array of invoices by memo content. + * + * @param invoices - Array of invoices to search. + * @param query - Substring to look for in each invoice's memo. + * @param opts - Search options. + * @returns Invoices whose memo contains `query`; all invoices if `query` is empty. + */ +export function searchByMemo( + invoices: Invoice[], + query: string, + opts?: SearchByMemoOptions, +): Invoice[] { + if (query === "") { + return invoices; + } + + const needle = opts?.caseSensitive ? query : query.toLowerCase(); + return invoices.filter((invoice) => { + if (invoice.memo === undefined || invoice.memo === null) { + return false; + } + const haystack = opts?.caseSensitive ? invoice.memo : invoice.memo.toLowerCase(); + return haystack.includes(needle); + }); +} + /** * Search invoices by partial criteria using Horizon API. * diff --git a/test/searchByMemo.test.ts b/test/searchByMemo.test.ts new file mode 100644 index 0000000..3521d14 --- /dev/null +++ b/test/searchByMemo.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { searchByMemo } from "../src/search.js"; +import type { Invoice } from "../src/types.js"; + +function makeInvoice(id: string, memo?: string | null): Invoice { + return { + id, + creator: "GABC", + recipients: [], + token: "TOKEN", + deadline: 0, + funded: 0n, + status: "Pending", + payments: [], + memo: memo as unknown as undefined, + }; +} + +describe("searchByMemo", () => { + const invoices: Invoice[] = [ + makeInvoice("inv-1", "split:INV-001"), + makeInvoice("inv-2", "SPLIT:inv-002"), + makeInvoice("inv-3", "monthly payment"), + makeInvoice("inv-4", "split:"), + makeInvoice("inv-5", ""), + makeInvoice("inv-6", null as unknown as undefined), + makeInvoice("inv-7", undefined), + ]; + + it("returns invoices whose memo contains the query substring", () => { + const result = searchByMemo(invoices, "split"); + expect(result.map((i) => i.id)).toEqual(["inv-1", "inv-2", "inv-4"]); + }); + + it("is case-insensitive by default", () => { + const result = searchByMemo(invoices, "SPLIT"); + expect(result.map((i) => i.id)).toEqual(["inv-1", "inv-2", "inv-4"]); + }); + + it("can be made case-sensitive", () => { + const result = searchByMemo(invoices, "SPLIT", { caseSensitive: true }); + expect(result.map((i) => i.id)).toEqual(["inv-2"]); + }); + + it("returns all invoices when query is empty", () => { + const result = searchByMemo(invoices, ""); + expect(result.map((i) => i.id)).toEqual([ + "inv-1", + "inv-2", + "inv-3", + "inv-4", + "inv-5", + "inv-6", + "inv-7", + ]); + }); + + it("skips invoices with null or undefined memo", () => { + const result = searchByMemo(invoices, "payment"); + expect(result.map((i) => i.id)).toEqual(["inv-3"]); + }); + + it("returns empty array when no memo matches", () => { + const result = searchByMemo(invoices, "nonexistent"); + expect(result).toEqual([]); + }); + + it("matches partial substrings anywhere in the memo", () => { + const result = searchByMemo(invoices, "nv-00"); + expect(result.map((i) => i.id)).toEqual(["inv-1", "inv-2"]); + }); + + it("does not mutate the input array", () => { + const before = invoices.map((i) => i.id); + searchByMemo(invoices, "split"); + expect(invoices.map((i) => i.id)).toEqual(before); + }); +});