Skip to content
Merged
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
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,11 @@ export {
serializePaymentReceipt,
deserializePaymentReceipt,
finalizePaymentReceipt,
// Local receipt registry (hash-based lookup)
registerReceipt,
getReceiptByTxHash,
getAllReceipts,
clearReceipts,
} from "./receipt.js";
export type {
PaymentReceipt,
Expand Down
49 changes: 49 additions & 0 deletions src/receipt.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -307,3 +308,51 @@ 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<string, ChainPaymentReceipt>();

/**
* 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();
}
135 changes: 135 additions & 0 deletions test/receipt.registry.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {},
): 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();
});
});
});