diff --git a/src/index.ts b/src/index.ts index ae9e66a..35bb785 100644 --- a/src/index.ts +++ b/src/index.ts @@ -647,6 +647,11 @@ export { serializePaymentReceipt, deserializePaymentReceipt, finalizePaymentReceipt, + // Local receipt registry (hash-based lookup) + registerReceipt, + getReceiptByTxHash, + getAllReceipts, + clearReceipts, } from "./receipt.js"; export type { PaymentReceipt, diff --git a/src/receipt.ts b/src/receipt.ts index 62a63c1..3f0faf1 100644 --- a/src/receipt.ts +++ b/src/receipt.ts @@ -1,6 +1,7 @@ import { createHash } from "crypto"; import { Horizon } from "@stellar/stellar-sdk"; import type { DecodedTransactionResult, Invoice, Payment } from "./types.js"; +import type { PaymentReceipt as ChainPaymentReceipt } from "./types/receipts.js"; import { PayerAddressRequiredError } from "./errors.js"; import { decodeTransactionResult } from "./txResultDecoder.js"; @@ -307,3 +308,52 @@ function _buildReceiptObject(data: { }, }; } + +// --------------------------------------------------------------------------- +// Local receipt registry — module-level singleton, hash-based lookup. +// Uses the chain PaymentReceipt type from src/types/receipts.ts which carries +// a txHash field. The compiled-summary PaymentReceipt defined above does not +// participate in this registry. +// --------------------------------------------------------------------------- + +const _receiptRegistry = new Map(); + +/** + * Store a receipt in the module-level registry, keyed by its `txHash`. + * Calling this again with the same `txHash` will overwrite the previous entry. + * + * @param receipt - The chain payment receipt to store. + */ +export function registerReceipt(receipt: ChainPaymentReceipt): void { + _receiptRegistry.set(receipt.txHash, receipt); +} + +/** + * Retrieve a previously registered receipt by transaction hash. + * + * @param txHash - The transaction hash to look up. + * @returns The stored receipt, or `null` if none exists for that hash. + */ +export function getReceiptByTxHash(txHash: string): ChainPaymentReceipt | null { + return _receiptRegistry.get(txHash) ?? null; +} + +/** + * Return all registered receipts sorted by `timestamp` ascending. + * + * @returns A new array of receipts in chronological order. + */ +export function getAllReceipts(): ChainPaymentReceipt[] { + return Array.from(_receiptRegistry.values()).sort( + (a, b) => a.timestamp - b.timestamp, + ); +} + +/** + * Remove all entries from the registry. + * Intended for test teardown — use with care in production code. + */ +export function clearReceipts(): void { + _receiptRegistry.clear(); +} + diff --git a/test/receipt.registry.test.ts b/test/receipt.registry.test.ts new file mode 100644 index 0000000..9260595 --- /dev/null +++ b/test/receipt.registry.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + registerReceipt, + getReceiptByTxHash, + getAllReceipts, + clearReceipts, +} from "../src/receipt.js"; +import type { PaymentReceipt as ChainPaymentReceipt } from "../src/types/receipts.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeReceipt( + overrides: Partial = {}, +): ChainPaymentReceipt { + return { + invoiceId: "invoice-1", + paymentId: "payment-1", + amount: "1000000", + recipientId: "GABC123", + txHash: "abc123hash", + ledger: 1000, + timestamp: 1_700_000_000_000, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Test suite +// --------------------------------------------------------------------------- + +describe("receipt registry", () => { + // Always start each test with an empty registry. + beforeEach(() => { + clearReceipts(); + }); + + describe("registerReceipt / getReceiptByTxHash", () => { + it("stores a receipt and retrieves it by txHash", () => { + const receipt = makeReceipt({ txHash: "tx-aaa" }); + registerReceipt(receipt); + + const retrieved = getReceiptByTxHash("tx-aaa"); + expect(retrieved).toEqual(receipt); + }); + + it("returns null for an unknown txHash", () => { + const result = getReceiptByTxHash("nonexistent-hash"); + expect(result).toBeNull(); + }); + + it("overwrites a receipt with the same txHash", () => { + const first = makeReceipt({ txHash: "tx-dup", amount: "100" }); + const second = makeReceipt({ txHash: "tx-dup", amount: "200" }); + + registerReceipt(first); + registerReceipt(second); + + const retrieved = getReceiptByTxHash("tx-dup"); + expect(retrieved?.amount).toBe("200"); + }); + + it("stores multiple distinct receipts independently", () => { + const r1 = makeReceipt({ txHash: "tx-1" }); + const r2 = makeReceipt({ txHash: "tx-2" }); + + registerReceipt(r1); + registerReceipt(r2); + + expect(getReceiptByTxHash("tx-1")).toEqual(r1); + expect(getReceiptByTxHash("tx-2")).toEqual(r2); + }); + }); + + describe("getAllReceipts", () => { + it("returns an empty array when no receipts are registered", () => { + expect(getAllReceipts()).toEqual([]); + }); + + it("returns all registered receipts", () => { + const r1 = makeReceipt({ txHash: "tx-1" }); + const r2 = makeReceipt({ txHash: "tx-2" }); + + registerReceipt(r1); + registerReceipt(r2); + + const all = getAllReceipts(); + expect(all).toHaveLength(2); + }); + + it("returns receipts ordered by timestamp ascending", () => { + const oldest = makeReceipt({ txHash: "tx-old", timestamp: 1_000 }); + const newest = makeReceipt({ txHash: "tx-new", timestamp: 3_000 }); + const middle = makeReceipt({ txHash: "tx-mid", timestamp: 2_000 }); + + // Register in non-chronological order to verify sorting. + registerReceipt(newest); + registerReceipt(oldest); + registerReceipt(middle); + + const all = getAllReceipts(); + expect(all.map((r) => r.txHash)).toEqual(["tx-old", "tx-mid", "tx-new"]); + }); + + it("does not mutate the internal registry order when sorted", () => { + const a = makeReceipt({ txHash: "tx-z", timestamp: 999 }); + const b = makeReceipt({ txHash: "tx-a", timestamp: 1 }); + + registerReceipt(a); + registerReceipt(b); + + // Call twice — result should be consistent. + const first = getAllReceipts().map((r) => r.txHash); + const second = getAllReceipts().map((r) => r.txHash); + expect(first).toEqual(second); + }); + }); + + describe("clearReceipts", () => { + it("empties the registry", () => { + registerReceipt(makeReceipt({ txHash: "tx-1" })); + registerReceipt(makeReceipt({ txHash: "tx-2" })); + + clearReceipts(); + + expect(getAllReceipts()).toHaveLength(0); + expect(getReceiptByTxHash("tx-1")).toBeNull(); + }); + + it("is idempotent — calling on an empty registry does not throw", () => { + expect(() => clearReceipts()).not.toThrow(); + }); + }); +});