Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
54 changes: 53 additions & 1 deletion src/search.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down Expand Up @@ -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);
});
}
78 changes: 78 additions & 0 deletions test/searchByMemo.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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);
});
});