diff --git a/features/payment-receipt-reconciler/README.md b/features/payment-receipt-reconciler/README.md
new file mode 100644
index 0000000..25e0473
--- /dev/null
+++ b/features/payment-receipt-reconciler/README.md
@@ -0,0 +1,41 @@
+# Payment Receipt Reconciler
+
+Turn one completed classic payment or path-payment transaction into a public
+receipt that connects its operations with observed account debit and credit
+effects — without promising a full accounting export.
+
+## Behaviour
+
+1. Validate a 64-character transaction hash locally (`invalid_hash` /
+ `empty_input`).
+2. Fetch Horizon `GET /transactions/{hash}`, `/operations` and `/effects` on
+ the selected network.
+3. Reject absent (`transaction_not_found`) or failed (`transaction_failed`)
+ transactions with specific outcomes.
+4. Keep only `payment`, `path_payment_strict_send` and
+ `path_payment_strict_receive` operations; everything else is listed under
+ **Outside this receipt**.
+5. Link each supported operation to its `account_debited` / `account_credited`
+ effects via the operation TOID embedded in the effect id. Missing either
+ side yields `incomplete_effects`.
+6. Sum exact amounts by asset code + issuer (`BigInt` stroops). The charged fee
+ is shown separately and never folded into debit totals.
+7. Produce a copyable public receipt: hash, selected network, ledger number.
+ Operation and effect ids stay visible for audit.
+
+## Data boundaries
+
+- Networks: testnet and mainnet Horizon only.
+- Amounts stay strings / `BigInt` — never floats, never secret keys.
+- Trade, trustline and other non-transfer effects are marked outside the
+ receipt rather than silently dropped.
+- No payment initiation or tax classification.
+
+## Design decisions
+
+- Effect linking uses the Horizon effect id (`{operationTOID}-{index}`) rather
+ than guessing from accounts or amounts, so multi-op transactions stay exact.
+- Failed transactions are an error outcome for this tool (unlike Transaction
+ Lookup) because a failed payment has no durable debit/credit receipt.
+- Transport failures and decode problems collapse to `request_failed`; domain
+ outcomes keep their own codes so copy stays actionable.
diff --git a/features/payment-receipt-reconciler/__tests__/PaymentReceiptReconcilerPanel.test.tsx b/features/payment-receipt-reconciler/__tests__/PaymentReceiptReconcilerPanel.test.tsx
new file mode 100644
index 0000000..2d3a92d
--- /dev/null
+++ b/features/payment-receipt-reconciler/__tests__/PaymentReceiptReconcilerPanel.test.tsx
@@ -0,0 +1,83 @@
+import { describe, expect, it } from "vitest";
+import { renderFeature, screen } from "@/core/testing/render";
+import { withMswHandlers } from "@/core/testing/msw";
+import { PaymentReceiptReconcilerPanel } from "@/features/payment-receipt-reconciler/components/PaymentReceiptReconcilerPanel";
+import { copy, errorCopy } from "@/features/payment-receipt-reconciler/copy";
+import { handlers } from "@/features/payment-receipt-reconciler/msw/handlers";
+import {
+ failedHash,
+ missingHash,
+ successfulHash,
+ unsupportedHash
+} from "@/features/payment-receipt-reconciler/fixtures/paymentReceiptReconciler.fixture";
+import { mixedEffectsHash } from "@/features/payment-receipt-reconciler/fixtures/mixed-effects.fixture";
+
+withMswHandlers(...handlers);
+
+describe("PaymentReceiptReconcilerPanel", () => {
+ it("shows the empty state first", () => {
+ renderFeature();
+ expect(screen.getByText(copy.emptyTitle)).toBeInTheDocument();
+ });
+
+ it("renders operations, effects, totals and a public receipt", async () => {
+ const { user } = renderFeature();
+
+ await user.type(screen.getByLabelText(copy.formLabel), successfulHash);
+ await user.click(screen.getByRole("button", { name: copy.submit }));
+
+ expect(await screen.findByText(copy.publicReceiptTitle)).toBeInTheDocument();
+ expect(screen.getByText(copy.operationsTitle)).toBeInTheDocument();
+ expect(screen.getByText(copy.effectsTitle)).toBeInTheDocument();
+ expect(screen.getByText(copy.totalsTitle)).toBeInTheDocument();
+ expect(screen.getByText("100 stroops (0.00001 XLM)")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: copy.copyReceipt })).toBeInTheDocument();
+ });
+
+ it("surfaces outside items for mixed transactions", async () => {
+ const { user } = renderFeature();
+
+ await user.type(screen.getByLabelText(copy.formLabel), mixedEffectsHash);
+ await user.click(screen.getByRole("button", { name: copy.submit }));
+
+ expect(await screen.findByText(copy.outsideTitle)).toBeInTheDocument();
+ expect(screen.getByText("change trust")).toBeInTheDocument();
+ expect(screen.getByText("trade")).toBeInTheDocument();
+ });
+
+ it("explains a failed transaction", async () => {
+ const { user } = renderFeature();
+
+ await user.type(screen.getByLabelText(copy.formLabel), failedHash);
+ await user.click(screen.getByRole("button", { name: copy.submit }));
+
+ expect(await screen.findByText(errorCopy.transaction_failed.title)).toBeInTheDocument();
+ });
+
+ it("explains an unsupported operation set", async () => {
+ const { user } = renderFeature();
+
+ await user.type(screen.getByLabelText(copy.formLabel), unsupportedHash);
+ await user.click(screen.getByRole("button", { name: copy.submit }));
+
+ expect(await screen.findByText(errorCopy.unsupported_operation.title)).toBeInTheDocument();
+ });
+
+ it("explains that a hash is not an account address", async () => {
+ const { user } = renderFeature();
+
+ await user.type(screen.getByLabelText(copy.formLabel), "GABC");
+ await user.click(screen.getByRole("button", { name: copy.submit }));
+
+ expect(await screen.findByText(errorCopy.invalid_hash.title)).toBeInTheDocument();
+ });
+
+ it("points at the network switch when a hash is not found", async () => {
+ const { user } = renderFeature();
+
+ await user.type(screen.getByLabelText(copy.formLabel), missingHash);
+ await user.click(screen.getByRole("button", { name: copy.submit }));
+
+ expect(await screen.findByText(errorCopy.transaction_not_found.title)).toBeInTheDocument();
+ });
+});
diff --git a/features/payment-receipt-reconciler/__tests__/a11y.test.tsx b/features/payment-receipt-reconciler/__tests__/a11y.test.tsx
new file mode 100644
index 0000000..7d724f2
--- /dev/null
+++ b/features/payment-receipt-reconciler/__tests__/a11y.test.tsx
@@ -0,0 +1,27 @@
+import { describe, it } from "vitest";
+import { renderFeature, screen } from "@/core/testing/render";
+import { expectNoAxeViolations } from "@/core/testing/axe";
+import { withMswHandlers } from "@/core/testing/msw";
+import { PaymentReceiptReconcilerPanel } from "@/features/payment-receipt-reconciler/components/PaymentReceiptReconcilerPanel";
+import { copy } from "@/features/payment-receipt-reconciler/copy";
+import { handlers } from "@/features/payment-receipt-reconciler/msw/handlers";
+import { successfulHash } from "@/features/payment-receipt-reconciler/fixtures/paymentReceiptReconciler.fixture";
+
+withMswHandlers(...handlers);
+
+describe("PaymentReceiptReconcilerPanel accessibility", () => {
+ it("has no WCAG A/AA violations in its initial state", async () => {
+ const { container } = renderFeature();
+ await expectNoAxeViolations(container);
+ });
+
+ it("has no WCAG A/AA violations with a reconciled receipt", async () => {
+ const { container, user } = renderFeature();
+
+ await user.type(screen.getByLabelText(copy.formLabel), successfulHash);
+ await user.click(screen.getByRole("button", { name: copy.submit }));
+ await screen.findByText(copy.publicReceiptTitle);
+
+ await expectNoAxeViolations(container);
+ });
+});
diff --git a/features/payment-receipt-reconciler/__tests__/effect-links.test.ts b/features/payment-receipt-reconciler/__tests__/effect-links.test.ts
new file mode 100644
index 0000000..b635bb3
--- /dev/null
+++ b/features/payment-receipt-reconciler/__tests__/effect-links.test.ts
@@ -0,0 +1,103 @@
+import { describe, expect, it } from "vitest";
+import {
+ linkOperationsToEffects,
+ normalizeBalanceEffect,
+ normalizePaymentOperation,
+ operationIdFromEffectId
+} from "@/features/payment-receipt-reconciler/lib/effect-links";
+import {
+ changeTrustOperation,
+ creditEffect,
+ debitEffect,
+ paymentOpId,
+ paymentOperation,
+ trustlineEffect
+} from "@/features/payment-receipt-reconciler/fixtures/paymentReceiptReconciler.fixture";
+import {
+ pathCreditEffect,
+ pathDebitEffect,
+ pathPaymentOperation,
+ tradeEffect
+} from "@/features/payment-receipt-reconciler/fixtures/mixed-effects.fixture";
+
+describe("operationIdFromEffectId", () => {
+ it("extracts the operation TOID", () => {
+ expect(operationIdFromEffectId(`${paymentOpId}-1`)).toBe(paymentOpId);
+ expect(operationIdFromEffectId("not-an-id")).toBeNull();
+ });
+});
+
+describe("normalizePaymentOperation", () => {
+ it("normalises a classic payment", () => {
+ const op = normalizePaymentOperation(paymentOperation);
+ expect(op?.type).toBe("payment");
+ expect(op?.amount).toBe("12.5000000");
+ expect(op?.asset).toEqual({ type: "native" });
+ });
+
+ it("normalises a path payment with source amount", () => {
+ const op = normalizePaymentOperation(pathPaymentOperation);
+ expect(op?.type).toBe("path_payment_strict_send");
+ expect(op?.sourceAmount).toBe("10.0000000");
+ expect(op?.sourceAsset).toEqual({ type: "native" });
+ });
+
+ it("rejects unsupported types", () => {
+ expect(normalizePaymentOperation(changeTrustOperation)).toBeNull();
+ });
+});
+
+describe("normalizeBalanceEffect", () => {
+ it("keeps debit and credit effects", () => {
+ expect(normalizeBalanceEffect(debitEffect)?.type).toBe("account_debited");
+ expect(normalizeBalanceEffect(creditEffect)?.operationId).toBe(paymentOpId);
+ });
+
+ it("rejects trade effects", () => {
+ expect(normalizeBalanceEffect(tradeEffect)).toBeNull();
+ });
+});
+
+describe("linkOperationsToEffects", () => {
+ it("links payment ops to debit and credit effects", () => {
+ const result = linkOperationsToEffects([paymentOperation], [debitEffect, creditEffect]);
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.value.operations).toHaveLength(1);
+ expect(result.value.links[0].debits).toHaveLength(1);
+ expect(result.value.links[0].credits).toHaveLength(1);
+ expect(result.value.outside).toEqual([]);
+ });
+
+ it("marks unrelated ops and effects as outside", () => {
+ const result = linkOperationsToEffects(
+ [pathPaymentOperation, changeTrustOperation],
+ [pathDebitEffect, pathCreditEffect, tradeEffect, trustlineEffect]
+ );
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.value.outside).toEqual(
+ expect.arrayContaining([
+ { kind: "operation", id: changeTrustOperation.id, type: "change_trust" },
+ { kind: "effect", id: tradeEffect.id, type: "trade" },
+ { kind: "effect", id: trustlineEffect.id, type: "trustline_created" }
+ ])
+ );
+ });
+
+ it("returns unsupported_operation when no payment ops exist", () => {
+ expect(linkOperationsToEffects([changeTrustOperation], [trustlineEffect])).toEqual({
+ ok: false,
+ code: "unsupported_operation"
+ });
+ });
+
+ it("returns incomplete_effects when a credit is missing", () => {
+ expect(linkOperationsToEffects([paymentOperation], [debitEffect])).toEqual({
+ ok: false,
+ code: "incomplete_effects"
+ });
+ });
+});
diff --git a/features/payment-receipt-reconciler/__tests__/format.test.ts b/features/payment-receipt-reconciler/__tests__/format.test.ts
new file mode 100644
index 0000000..012fc21
--- /dev/null
+++ b/features/payment-receipt-reconciler/__tests__/format.test.ts
@@ -0,0 +1,75 @@
+import { describe, expect, it } from "vitest";
+import {
+ assetKey,
+ formatAmount,
+ formatAmountWithAsset,
+ formatAsset,
+ formatFee,
+ formatNetwork,
+ formatPublicReceipt,
+ stroopsToXlm,
+ toStroops
+} from "@/features/payment-receipt-reconciler/lib/format";
+import { issuerAccount } from "@/features/payment-receipt-reconciler/fixtures/paymentReceiptReconciler.fixture";
+
+describe("formatAmount", () => {
+ it("formats with thousands separators and strips trailing zeros", () => {
+ expect(formatAmount("12.5000000")).toBe("12.5");
+ expect(formatAmount("1000.0000001")).toBe("1,000.0000001");
+ });
+
+ it("passes malformed Horizon values through", () => {
+ expect(formatAmount("not-an-amount")).toBe("not-an-amount");
+ });
+});
+
+describe("toStroops", () => {
+ it("converts without floating point", () => {
+ expect(toStroops("1.0000000")).toBe(10_000_000n);
+ expect(toStroops("0.0000001")).toBe(1n);
+ });
+});
+
+describe("formatAsset", () => {
+ it("renders native and credit assets", () => {
+ expect(formatAsset({ type: "native" })).toBe("XLM");
+ expect(formatAsset({ type: "credit", code: "USDC", issuer: issuerAccount })).toBe(
+ `USDC:${issuerAccount}`
+ );
+ });
+});
+
+describe("assetKey", () => {
+ it("keys native and credit assets distinctly", () => {
+ expect(assetKey({ type: "native" })).toBe("native");
+ expect(assetKey({ type: "credit", code: "USDC", issuer: issuerAccount })).toBe(
+ `USDC:${issuerAccount}`
+ );
+ });
+});
+
+describe("formatFee", () => {
+ it("shows stroops and XLM together", () => {
+ expect(stroopsToXlm("100")).toBe("0.00001");
+ expect(formatFee("100")).toBe("100 stroops (0.00001 XLM)");
+ });
+});
+
+describe("formatPublicReceipt", () => {
+ it("builds a copyable three-line receipt", () => {
+ expect(
+ formatPublicReceipt({
+ hash: "a".repeat(64),
+ network: "testnet",
+ ledger: 42
+ })
+ ).toContain("Testnet");
+ expect(formatNetwork("mainnet")).toBe("Mainnet");
+ });
+});
+
+describe("formatAmountWithAsset", () => {
+ it("joins amount and compact asset label", () => {
+ expect(formatAmountWithAsset("1.5", { type: "native" })).toBe("1.5 XLM");
+ });
+});
diff --git a/features/payment-receipt-reconciler/__tests__/paymentReceiptReconciler.test.ts b/features/payment-receipt-reconciler/__tests__/paymentReceiptReconciler.test.ts
new file mode 100644
index 0000000..43c1867
--- /dev/null
+++ b/features/payment-receipt-reconciler/__tests__/paymentReceiptReconciler.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it } from "vitest";
+import { withMswHandlers } from "@/core/testing/msw";
+import { runPaymentReceiptReconciler } from "@/features/payment-receipt-reconciler/lib/paymentReceiptReconciler";
+import {
+ effectsUnavailableHandler,
+ handlers
+} from "@/features/payment-receipt-reconciler/msw/handlers";
+import {
+ failedHash,
+ incompleteHash,
+ missingHash,
+ paymentOpId,
+ successfulHash,
+ unsupportedHash
+} from "@/features/payment-receipt-reconciler/fixtures/paymentReceiptReconciler.fixture";
+import { mixedEffectsHash } from "@/features/payment-receipt-reconciler/fixtures/mixed-effects.fixture";
+import { errorCopy } from "@/features/payment-receipt-reconciler/copy";
+
+const server = withMswHandlers(...handlers);
+
+describe("runPaymentReceiptReconciler", () => {
+ it("reconciles a successful payment with linked effects and totals", async () => {
+ const result = await runPaymentReceiptReconciler({ hash: successfulHash }, "testnet");
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.value.operations[0].id).toBe(paymentOpId);
+ expect(result.value.links[0].debits).toHaveLength(1);
+ expect(result.value.links[0].credits).toHaveLength(1);
+ expect(result.value.totals.feeCharged).toBe("100");
+ expect(result.value.publicReceipt).toEqual({
+ hash: successfulHash,
+ network: "testnet",
+ ledger: 2_048_000
+ });
+ });
+
+ it("keeps unrelated ops and effects visible on mixed transactions", async () => {
+ const result = await runPaymentReceiptReconciler({ hash: mixedEffectsHash }, "testnet");
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.value.operations[0].type).toBe("path_payment_strict_send");
+ expect(result.value.outside.some((item) => item.type === "change_trust")).toBe(true);
+ expect(result.value.outside.some((item) => item.type === "trade")).toBe(true);
+ });
+
+ it("rejects a failed transaction", async () => {
+ const result = await runPaymentReceiptReconciler({ hash: failedHash }, "testnet");
+ expect(result).toEqual({ ok: false, code: "transaction_failed" });
+ expect(errorCopy.transaction_failed.title).toBeTruthy();
+ });
+
+ it("rejects a missing transaction", async () => {
+ const result = await runPaymentReceiptReconciler({ hash: missingHash }, "testnet");
+ expect(result).toEqual({ ok: false, code: "transaction_not_found" });
+ expect(errorCopy.transaction_not_found.description).toMatch(/network/i);
+ });
+
+ it("rejects transactions without payment operations", async () => {
+ const result = await runPaymentReceiptReconciler({ hash: unsupportedHash }, "testnet");
+ expect(result).toEqual({ ok: false, code: "unsupported_operation" });
+ expect(errorCopy.unsupported_operation.description).toMatch(/path-payment/i);
+ });
+
+ it("rejects payments with incomplete effects", async () => {
+ const result = await runPaymentReceiptReconciler({ hash: incompleteHash }, "testnet");
+ expect(result).toEqual({ ok: false, code: "incomplete_effects" });
+ expect(errorCopy.incomplete_effects.title).toBeTruthy();
+ });
+
+ it("maps transport failures to request_failed", async () => {
+ server.use(effectsUnavailableHandler);
+ const result = await runPaymentReceiptReconciler({ hash: successfulHash }, "testnet");
+ expect(result).toEqual({ ok: false, code: "request_failed" });
+ expect(errorCopy.request_failed.description).toMatch(/connection/i);
+ });
+});
diff --git a/features/payment-receipt-reconciler/__tests__/receipt-amounts.test.ts b/features/payment-receipt-reconciler/__tests__/receipt-amounts.test.ts
new file mode 100644
index 0000000..8e4a762
--- /dev/null
+++ b/features/payment-receipt-reconciler/__tests__/receipt-amounts.test.ts
@@ -0,0 +1,101 @@
+import { describe, expect, it } from "vitest";
+import {
+ stroopsToAmount,
+ sumReceiptAmounts
+} from "@/features/payment-receipt-reconciler/lib/receipt-amounts";
+import type { EffectLink } from "@/features/payment-receipt-reconciler/types";
+import {
+ destinationAccount,
+ issuerAccount,
+ paymentOpId,
+ sourceAccount
+} from "@/features/payment-receipt-reconciler/fixtures/paymentReceiptReconciler.fixture";
+
+const nativeLink: EffectLink = {
+ operationId: paymentOpId,
+ debits: [
+ {
+ id: `${paymentOpId}-1`,
+ type: "account_debited",
+ account: sourceAccount,
+ amount: "12.5000000",
+ asset: { type: "native" },
+ operationId: paymentOpId
+ }
+ ],
+ credits: [
+ {
+ id: `${paymentOpId}-2`,
+ type: "account_credited",
+ account: destinationAccount,
+ amount: "12.5000000",
+ asset: { type: "native" },
+ operationId: paymentOpId
+ }
+ ]
+};
+
+describe("stroopsToAmount", () => {
+ it("round-trips integer stroops", () => {
+ expect(stroopsToAmount(125_000_000n)).toBe("12.5");
+ expect(stroopsToAmount(1n)).toBe("0.0000001");
+ });
+});
+
+describe("sumReceiptAmounts", () => {
+ it("sums exact debits and credits and keeps the fee separate", () => {
+ const totals = sumReceiptAmounts([nativeLink], "100");
+
+ expect(totals.debits).toEqual([{ asset: { type: "native" }, amount: "12.5" }]);
+ expect(totals.credits).toEqual([{ asset: { type: "native" }, amount: "12.5" }]);
+ expect(totals.feeCharged).toBe("100");
+ });
+
+ it("groups credit assets by code and issuer", () => {
+ const creditAsset = { type: "credit" as const, code: "USDC", issuer: issuerAccount };
+ const link: EffectLink = {
+ operationId: paymentOpId,
+ debits: [
+ {
+ id: `${paymentOpId}-1`,
+ type: "account_debited",
+ account: sourceAccount,
+ amount: "10.0000000",
+ asset: { type: "native" },
+ operationId: paymentOpId
+ },
+ {
+ id: `${paymentOpId}-3`,
+ type: "account_debited",
+ account: sourceAccount,
+ amount: "2.5000000",
+ asset: { type: "native" },
+ operationId: paymentOpId
+ }
+ ],
+ credits: [
+ {
+ id: `${paymentOpId}-2`,
+ type: "account_credited",
+ account: destinationAccount,
+ amount: "5.0000000",
+ asset: creditAsset,
+ operationId: paymentOpId
+ },
+ {
+ id: `${paymentOpId}-4`,
+ type: "account_credited",
+ account: destinationAccount,
+ amount: "1.2500000",
+ asset: creditAsset,
+ operationId: paymentOpId
+ }
+ ]
+ };
+
+ const totals = sumReceiptAmounts([link], "300");
+ expect(totals.debits).toEqual([{ asset: { type: "native" }, amount: "12.5" }]);
+ expect(totals.credits).toEqual([{ asset: creditAsset, amount: "6.25" }]);
+ expect(totals.feeCharged).toBe("300");
+ });
+});
diff --git a/features/payment-receipt-reconciler/__tests__/receipt-fetch.test.ts b/features/payment-receipt-reconciler/__tests__/receipt-fetch.test.ts
new file mode 100644
index 0000000..02605fc
--- /dev/null
+++ b/features/payment-receipt-reconciler/__tests__/receipt-fetch.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest";
+import { withMswHandlers } from "@/core/testing/msw";
+import {
+ fetchReceiptBundle,
+ parseReceiptAsset
+} from "@/features/payment-receipt-reconciler/lib/receipt-fetch";
+import {
+ effectsUnavailableHandler,
+ handlers
+} from "@/features/payment-receipt-reconciler/msw/handlers";
+import {
+ missingHash,
+ paymentOpId,
+ sourceAccount,
+ successfulHash
+} from "@/features/payment-receipt-reconciler/fixtures/paymentReceiptReconciler.fixture";
+import { issuerAccount } from "@/features/payment-receipt-reconciler/fixtures/paymentReceiptReconciler.fixture";
+
+const server = withMswHandlers(...handlers);
+
+describe("parseReceiptAsset", () => {
+ it("maps native and credit assets", () => {
+ expect(parseReceiptAsset("native")).toEqual({ type: "native" });
+ expect(parseReceiptAsset("credit_alphanum4", "USDC", issuerAccount)).toEqual({
+ type: "credit",
+ code: "USDC",
+ issuer: issuerAccount
+ });
+ expect(parseReceiptAsset("credit_alphanum4", "USDC")).toBeNull();
+ });
+});
+
+describe("fetchReceiptBundle", () => {
+ it("returns transaction, operations and effects together", async () => {
+ const result = await fetchReceiptBundle(successfulHash, "testnet");
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.value.transaction.hash).toBe(successfulHash);
+ expect(result.value.operations[0].id).toBe(paymentOpId);
+ expect(result.value.effects).toHaveLength(2);
+ expect(result.value.transaction.source_account).toBe(sourceAccount);
+ });
+
+ it("maps a missing transaction to transaction_not_found", async () => {
+ const result = await fetchReceiptBundle(missingHash, "testnet");
+ expect(result).toEqual({ ok: false, code: "transaction_not_found" });
+ });
+
+ it("maps a failed effects request to request_failed", async () => {
+ server.use(effectsUnavailableHandler);
+ const result = await fetchReceiptBundle(successfulHash, "testnet");
+ expect(result).toEqual({ ok: false, code: "request_failed" });
+ });
+});
diff --git a/features/payment-receipt-reconciler/__tests__/schema.test.ts b/features/payment-receipt-reconciler/__tests__/schema.test.ts
new file mode 100644
index 0000000..e354031
--- /dev/null
+++ b/features/payment-receipt-reconciler/__tests__/schema.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from "vitest";
+import {
+ isLikelyTransactionHash,
+ parsePaymentReceiptReconcilerInput
+} from "@/features/payment-receipt-reconciler/schema";
+import { successfulHash } from "@/features/payment-receipt-reconciler/fixtures/paymentReceiptReconciler.fixture";
+
+describe("parsePaymentReceiptReconcilerInput", () => {
+ it("rejects empty input", () => {
+ expect(parsePaymentReceiptReconcilerInput("")).toEqual({
+ ok: false,
+ code: "empty_input"
+ });
+ expect(parsePaymentReceiptReconcilerInput(" ")).toEqual({
+ ok: false,
+ code: "empty_input"
+ });
+ });
+
+ it("rejects account addresses and short hex", () => {
+ expect(parsePaymentReceiptReconcilerInput("GABC")).toEqual({
+ ok: false,
+ code: "invalid_hash"
+ });
+ expect(parsePaymentReceiptReconcilerInput("abc")).toEqual({
+ ok: false,
+ code: "invalid_hash"
+ });
+ });
+
+ it("accepts a 64-character hash and lowercases it", () => {
+ const upper = "A".repeat(64);
+ expect(parsePaymentReceiptReconcilerInput(upper)).toEqual({
+ ok: true,
+ value: { hash: successfulHash }
+ });
+ });
+
+ it("strips whitespace inside a pasted hash", () => {
+ const spaced = `${"a".repeat(32)} ${"a".repeat(32)}`;
+ expect(parsePaymentReceiptReconcilerInput(spaced)).toEqual({
+ ok: true,
+ value: { hash: successfulHash }
+ });
+ });
+});
+
+describe("isLikelyTransactionHash", () => {
+ it("matches only 64 hex characters", () => {
+ expect(isLikelyTransactionHash(successfulHash)).toBe(true);
+ expect(isLikelyTransactionHash("not-a-hash")).toBe(false);
+ });
+});
diff --git a/features/payment-receipt-reconciler/__tests__/usePaymentReceiptReconciler.test.tsx b/features/payment-receipt-reconciler/__tests__/usePaymentReceiptReconciler.test.tsx
new file mode 100644
index 0000000..88fbd28
--- /dev/null
+++ b/features/payment-receipt-reconciler/__tests__/usePaymentReceiptReconciler.test.tsx
@@ -0,0 +1,85 @@
+import { describe, expect, it } from "vitest";
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { NetworkProvider, useNetwork } from "@/core/network/NetworkProvider";
+import { withMswHandlers } from "@/core/testing/msw";
+import { usePaymentReceiptReconciler } from "@/features/payment-receipt-reconciler/hooks/usePaymentReceiptReconciler";
+import { handlers } from "@/features/payment-receipt-reconciler/msw/handlers";
+import {
+ missingHash,
+ successfulHash
+} from "@/features/payment-receipt-reconciler/fixtures/paymentReceiptReconciler.fixture";
+
+withMswHandlers(...handlers);
+
+function wrapper({ children }: { children: React.ReactNode }) {
+ return {children};
+}
+
+describe("usePaymentReceiptReconciler", () => {
+ it("starts idle", () => {
+ const { result } = renderHook(() => usePaymentReceiptReconciler(), { wrapper });
+ expect(result.current.state).toEqual({ status: "idle" });
+ });
+
+ it("loads a reconciled receipt", async () => {
+ const { result } = renderHook(() => usePaymentReceiptReconciler(), { wrapper });
+
+ await act(async () => {
+ await result.current.submit(successfulHash);
+ });
+
+ await waitFor(() => expect(result.current.state.status).toBe("success"));
+ });
+
+ it("rejects a malformed hash without a request", async () => {
+ const { result } = renderHook(() => usePaymentReceiptReconciler(), { wrapper });
+
+ await act(async () => {
+ await result.current.submit("not-a-hash");
+ });
+
+ expect(result.current.state).toEqual({ status: "error", code: "invalid_hash" });
+ });
+
+ it("reports a hash that does not exist", async () => {
+ const { result } = renderHook(() => usePaymentReceiptReconciler(), { wrapper });
+
+ await act(async () => {
+ await result.current.submit(missingHash);
+ });
+
+ await waitFor(() =>
+ expect(result.current.state).toEqual({
+ status: "error",
+ code: "transaction_not_found"
+ })
+ );
+ });
+
+ it("clears state on reset", async () => {
+ const { result } = renderHook(() => usePaymentReceiptReconciler(), { wrapper });
+
+ await act(async () => {
+ await result.current.submit(successfulHash);
+ });
+ await waitFor(() => expect(result.current.state.status).toBe("success"));
+
+ act(() => result.current.reset());
+ expect(result.current.state).toEqual({ status: "idle" });
+ });
+
+ it("derives away results when the network changes", async () => {
+ const { result } = renderHook(
+ () => ({ tool: usePaymentReceiptReconciler(), network: useNetwork() }),
+ { wrapper }
+ );
+
+ await act(async () => {
+ await result.current.tool.submit(successfulHash);
+ });
+ await waitFor(() => expect(result.current.tool.state.status).toBe("success"));
+
+ act(() => result.current.network.setNetwork("mainnet"));
+ expect(result.current.tool.state).toEqual({ status: "idle" });
+ });
+});
diff --git a/features/payment-receipt-reconciler/components/EffectEvidence.tsx b/features/payment-receipt-reconciler/components/EffectEvidence.tsx
new file mode 100644
index 0000000..3e0b69d
--- /dev/null
+++ b/features/payment-receipt-reconciler/components/EffectEvidence.tsx
@@ -0,0 +1,79 @@
+import { Card, CardHeader, CardTitle } from "@/core/ui/Card";
+import { CopyableValue } from "@/core/ui/CopyableValue";
+import { copy } from "@/features/payment-receipt-reconciler/copy";
+import {
+ formatAmountWithAsset,
+ formatEffectType
+} from "@/features/payment-receipt-reconciler/lib/format";
+import type { EffectLink } from "@/features/payment-receipt-reconciler/types";
+
+export function EffectEvidence({ links }: { links: EffectLink[] }) {
+ return (
+
+
+ {copy.effectsTitle}
+
+
+
+
+ );
+}
+
+function EffectGroup({
+ title,
+ effects
+}: {
+ title: string;
+ effects: EffectLink["debits"];
+}) {
+ return (
+
+ );
+}
diff --git a/features/payment-receipt-reconciler/components/PaymentOperations.tsx b/features/payment-receipt-reconciler/components/PaymentOperations.tsx
new file mode 100644
index 0000000..a5c8cd8
--- /dev/null
+++ b/features/payment-receipt-reconciler/components/PaymentOperations.tsx
@@ -0,0 +1,63 @@
+import { Card, CardHeader, CardTitle } from "@/core/ui/Card";
+import { CopyableValue } from "@/core/ui/CopyableValue";
+import { copy } from "@/features/payment-receipt-reconciler/copy";
+import {
+ formatAmountWithAsset,
+ formatOperationType
+} from "@/features/payment-receipt-reconciler/lib/format";
+import type { PaymentOperation } from "@/features/payment-receipt-reconciler/types";
+
+export function PaymentOperations({ operations }: { operations: PaymentOperation[] }) {
+ return (
+
+
+ {copy.operationsTitle}
+
+
+
+ {operations.map((operation) => (
+ -
+
+
+ {formatOperationType(operation.type)}
+
+
+
+
+
+
+
- {copy.fromLabel}
+ -
+
+
+
+
+
- {copy.toLabel}
+ -
+
+
+
+
+
- {copy.amountLabel}
+ -
+ {formatAmountWithAsset(operation.amount, operation.asset)}
+
+
+ {operation.sourceAmount && operation.sourceAsset ? (
+
+
- {copy.sourceAmountLabel}
+ -
+ {formatAmountWithAsset(operation.sourceAmount, operation.sourceAsset)}
+
+
+ ) : null}
+
+
+ ))}
+
+
+ );
+}
diff --git a/features/payment-receipt-reconciler/components/PaymentReceiptReconcilerEmptyState.tsx b/features/payment-receipt-reconciler/components/PaymentReceiptReconcilerEmptyState.tsx
new file mode 100644
index 0000000..4dd5085
--- /dev/null
+++ b/features/payment-receipt-reconciler/components/PaymentReceiptReconcilerEmptyState.tsx
@@ -0,0 +1,9 @@
+import { Receipt } from "lucide-react";
+import { EmptyState } from "@/core/ui/EmptyState";
+import { copy } from "@/features/payment-receipt-reconciler/copy";
+
+export function PaymentReceiptReconcilerEmptyState() {
+ return (
+
+ );
+}
diff --git a/features/payment-receipt-reconciler/components/PaymentReceiptReconcilerForm.tsx b/features/payment-receipt-reconciler/components/PaymentReceiptReconcilerForm.tsx
new file mode 100644
index 0000000..99effd7
--- /dev/null
+++ b/features/payment-receipt-reconciler/components/PaymentReceiptReconcilerForm.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import { useState, type FormEvent } from "react";
+import { Button } from "@/core/ui/Button";
+import { Field } from "@/core/ui/Field";
+import { Input } from "@/core/ui/Input";
+import { useNetwork } from "@/core/network/NetworkProvider";
+import { copy } from "@/features/payment-receipt-reconciler/copy";
+
+export function PaymentReceiptReconcilerForm({
+ onSubmit,
+ pending
+}: {
+ onSubmit: (value: string) => void;
+ pending: boolean;
+}) {
+ const [value, setValue] = useState("");
+ const { label } = useNetwork();
+
+ function handleSubmit(event: FormEvent) {
+ event.preventDefault();
+ onSubmit(value);
+ }
+
+ return (
+
+ );
+}
diff --git a/features/payment-receipt-reconciler/components/PaymentReceiptReconcilerPanel.tsx b/features/payment-receipt-reconciler/components/PaymentReceiptReconcilerPanel.tsx
new file mode 100644
index 0000000..52791b9
--- /dev/null
+++ b/features/payment-receipt-reconciler/components/PaymentReceiptReconcilerPanel.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import { Card } from "@/core/ui/Card";
+import { SkeletonRows } from "@/core/ui/Skeleton";
+import { StatusMessage } from "@/core/ui/StatusMessage";
+import { usePaymentReceiptReconciler } from "@/features/payment-receipt-reconciler/hooks/usePaymentReceiptReconciler";
+import { copy, errorCopy } from "@/features/payment-receipt-reconciler/copy";
+import { PaymentReceiptReconcilerForm } from "@/features/payment-receipt-reconciler/components/PaymentReceiptReconcilerForm";
+import { PaymentReceiptReconcilerResult } from "@/features/payment-receipt-reconciler/components/PaymentReceiptReconcilerResult";
+import { PaymentReceiptReconcilerEmptyState } from "@/features/payment-receipt-reconciler/components/PaymentReceiptReconcilerEmptyState";
+
+export function PaymentReceiptReconcilerPanel() {
+ const { state, submit } = usePaymentReceiptReconciler();
+
+ return (
+
+
+
+
+
+ {state.status === "loading" ? (
+
+
+ {copy.loading}
+
+
+
+ ) : null}
+
+ {state.status === "error" ? (
+
+ ) : null}
+
+ {state.status === "success" ? (
+
+ ) : null}
+
+ {state.status === "idle" ?
: null}
+
+ );
+}
diff --git a/features/payment-receipt-reconciler/components/PaymentReceiptReconcilerResult.tsx b/features/payment-receipt-reconciler/components/PaymentReceiptReconcilerResult.tsx
new file mode 100644
index 0000000..01a641c
--- /dev/null
+++ b/features/payment-receipt-reconciler/components/PaymentReceiptReconcilerResult.tsx
@@ -0,0 +1,120 @@
+"use client";
+
+import { useState } from "react";
+import { Card, CardHeader, CardTitle } from "@/core/ui/Card";
+import { Button } from "@/core/ui/Button";
+import { CopyableValue } from "@/core/ui/CopyableValue";
+import { DataList } from "@/core/ui/DataList";
+import { copyText } from "@/core/lib/clipboard";
+import { copy } from "@/features/payment-receipt-reconciler/copy";
+import { EffectEvidence } from "@/features/payment-receipt-reconciler/components/EffectEvidence";
+import { PaymentOperations } from "@/features/payment-receipt-reconciler/components/PaymentOperations";
+import { ReceiptTotals } from "@/features/payment-receipt-reconciler/components/ReceiptTotals";
+import {
+ formatNetwork,
+ formatPublicReceipt,
+ formatTimestamp
+} from "@/features/payment-receipt-reconciler/lib/format";
+import type { PaymentReceipt } from "@/features/payment-receipt-reconciler/types";
+
+export function PaymentReceiptReconcilerResult({ result }: { result: PaymentReceipt }) {
+ const [copied, setCopied] = useState(false);
+ const publicText = formatPublicReceipt(result.publicReceipt);
+
+ async function handleCopyReceipt() {
+ try {
+ await copyText(publicText);
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 1600);
+ } catch {
+ setCopied(false);
+ }
+ }
+
+ return (
+
+
+
+ {copy.publicReceiptTitle}
+
+
+ )
+ },
+ {
+ label: copy.networkLabel,
+ value: formatNetwork(result.publicReceipt.network)
+ },
+ {
+ label: copy.ledgerLabel,
+ value: String(result.publicReceipt.ledger),
+ mono: true
+ }
+ ]}
+ />
+
+
+
+ {copied ? copy.copiedReceipt : ""}
+
+
+
+
+
+
+ {copy.resultTitle}
+
+
+ )
+ },
+ { label: "Created", value: formatTimestamp(result.createdAt) }
+ ]}
+ />
+
+
+
+
+
+
+
+
+ {copy.outsideTitle}
+
+ {result.outside.length === 0 ? (
+ {copy.noOutside}
+ ) : (
+ <>
+ {copy.outsideDescription}
+
+ {result.outside.map((item) => (
+ -
+
+ {item.kind}
+
+
+ {item.type.replace(/_/g, " ")}
+
+
+
+ ))}
+
+ >
+ )}
+
+
+ );
+}
diff --git a/features/payment-receipt-reconciler/components/ReceiptTotals.tsx b/features/payment-receipt-reconciler/components/ReceiptTotals.tsx
new file mode 100644
index 0000000..fdbd67b
--- /dev/null
+++ b/features/payment-receipt-reconciler/components/ReceiptTotals.tsx
@@ -0,0 +1,37 @@
+import { Card, CardHeader, CardTitle } from "@/core/ui/Card";
+import { DataList } from "@/core/ui/DataList";
+import { copy } from "@/features/payment-receipt-reconciler/copy";
+import {
+ formatAmountWithAsset,
+ formatFee
+} from "@/features/payment-receipt-reconciler/lib/format";
+import type { ReceiptTotals } from "@/features/payment-receipt-reconciler/types";
+
+export function ReceiptTotals({ totals }: { totals: ReceiptTotals }) {
+ const debitValue =
+ totals.debits.length === 0
+ ? "—"
+ : totals.debits.map((entry) => formatAmountWithAsset(entry.amount, entry.asset)).join(", ");
+
+ const creditValue =
+ totals.credits.length === 0
+ ? "—"
+ : totals.credits
+ .map((entry) => formatAmountWithAsset(entry.amount, entry.asset))
+ .join(", ");
+
+ return (
+
+
+ {copy.totalsTitle}
+
+
+
+ );
+}
diff --git a/features/payment-receipt-reconciler/copy.ts b/features/payment-receipt-reconciler/copy.ts
new file mode 100644
index 0000000..6edb2fe
--- /dev/null
+++ b/features/payment-receipt-reconciler/copy.ts
@@ -0,0 +1,94 @@
+import type { PaymentReceiptReconcilerErrorCode } from "@/features/payment-receipt-reconciler/types";
+
+export const copy = {
+ formLabel: "Transaction hash",
+ formHint: "64 hexadecimal characters from a completed payment or path-payment.",
+ submit: "Reconcile receipt",
+ loading: "Reconciling...",
+ emptyTitle: "No receipt reconciled yet",
+ emptyDescription:
+ "Paste a successful payment or path-payment transaction hash to link its operations with account debit and credit effects.",
+ resultTitle: "Payment receipt",
+ publicReceiptTitle: "Public receipt",
+ operationsTitle: "Payment operations",
+ effectsTitle: "Effect evidence",
+ totalsTitle: "Exact totals",
+ outsideTitle: "Outside this receipt",
+ outsideDescription:
+ "These operations or effects belong to the transaction but are not part of the payment transfer itself.",
+ noOutside: "Every operation and effect in this transaction is part of the receipt.",
+ feeLabel: "Fee charged",
+ networkLabel: "Network",
+ ledgerLabel: "Ledger",
+ hashLabel: "Transaction hash",
+ debitLabel: "Debits",
+ creditLabel: "Credits",
+ linkedDebits: "Linked debits",
+ linkedCredits: "Linked credits",
+ operationIdLabel: "Operation ID",
+ effectIdLabel: "Effect ID",
+ fromLabel: "From",
+ toLabel: "To",
+ amountLabel: "Amount",
+ sourceAmountLabel: "Source amount",
+ accountLabel: "Account",
+ nativeAsset: "XLM",
+ unknownAsset: "Unknown asset",
+ copyReceipt: "Copy public receipt",
+ copiedReceipt: "Copied"
+} as const;
+
+export const errorCopy: Record<
+ PaymentReceiptReconcilerErrorCode,
+ { title: string; description: string }
+> = {
+ empty_input: {
+ title: "Enter a transaction hash",
+ description: "Paste the 64-character hash of the payment transaction you want to reconcile."
+ },
+ invalid_hash: {
+ title: "That is not a transaction hash",
+ description:
+ "Transaction hashes are exactly 64 hexadecimal characters (0-9 and a-f). Account addresses start with G and belong in the Balance Viewer instead."
+ },
+ transaction_not_found: {
+ title: "No transaction with this hash on the selected network",
+ description:
+ "Check the network switch in the header — a testnet hash does not exist on mainnet, and the reverse is also true."
+ },
+ transaction_failed: {
+ title: "This transaction failed on the ledger",
+ description:
+ "Only successful payments produce debit and credit effects. Look up a completed payment hash, or inspect the failure in Transaction Lookup."
+ },
+ unsupported_operation: {
+ title: "No payment or path-payment operations in this transaction",
+ description:
+ "This tool reconciles classic payment and path-payment operations only. Other operation types stay outside the receipt."
+ },
+ incomplete_effects: {
+ title: "Payment effects are incomplete for this transaction",
+ description:
+ "A supported payment operation is missing a visible debit or credit effect. Horizon may still be indexing — try again in a moment."
+ },
+ request_failed: {
+ title: "Could not reach Horizon",
+ description: "The request did not complete. Check your connection and try again."
+ }
+};
+
+export const operationTypeLabels: Record = {
+ payment: "Payment",
+ path_payment_strict_send: "Path payment (strict send)",
+ path_payment_strict_receive: "Path payment (strict receive)"
+};
+
+export const effectTypeLabels: Record = {
+ account_debited: "Account debited",
+ account_credited: "Account credited"
+};
+
+export const networkLabels: Record = {
+ testnet: "Testnet",
+ mainnet: "Mainnet"
+};
diff --git a/features/payment-receipt-reconciler/e2e/mixed-effects.spec.ts b/features/payment-receipt-reconciler/e2e/mixed-effects.spec.ts
new file mode 100644
index 0000000..2123346
--- /dev/null
+++ b/features/payment-receipt-reconciler/e2e/mixed-effects.spec.ts
@@ -0,0 +1,18 @@
+export const spec = {
+ route: "/tools/payment-receipt-reconciler",
+ steps: [
+ { action: "visit", target: "/tools/payment-receipt-reconciler" },
+ { action: "expect", target: "heading", value: "Payment Receipt Reconciler" },
+ {
+ action: "fill",
+ target: "Transaction hash",
+ value: ""
+ },
+ { action: "click", target: "Reconcile receipt" },
+ { action: "expect", target: "text", value: "Path payment (strict send)" },
+ { action: "expect", target: "text", value: "Outside this receipt" },
+ { action: "expect", target: "text", value: "change trust" },
+ { action: "expect", target: "text", value: "trade" },
+ { action: "expect", target: "text", value: "Exact totals" }
+ ]
+} as const;
diff --git a/features/payment-receipt-reconciler/e2e/payment-receipt-reconciler.spec.ts b/features/payment-receipt-reconciler/e2e/payment-receipt-reconciler.spec.ts
new file mode 100644
index 0000000..4f9d5a0
--- /dev/null
+++ b/features/payment-receipt-reconciler/e2e/payment-receipt-reconciler.spec.ts
@@ -0,0 +1,15 @@
+export const spec = {
+ route: "/tools/payment-receipt-reconciler",
+ steps: [
+ { action: "visit", target: "/tools/payment-receipt-reconciler" },
+ { action: "expect", target: "heading", value: "Payment Receipt Reconciler" },
+ { action: "fill", target: "Transaction hash", value: "<64 hex characters>" },
+ { action: "click", target: "Reconcile receipt" },
+ { action: "expect", target: "text", value: "Public receipt" },
+ { action: "expect", target: "text", value: "Payment operations" },
+ { action: "expect", target: "text", value: "Effect evidence" },
+ { action: "expect", target: "text", value: "Exact totals" },
+ { action: "switchNetwork", value: "mainnet" },
+ { action: "expect", target: "text", value: "No receipt reconciled yet" }
+ ]
+} as const;
diff --git a/features/payment-receipt-reconciler/fixtures/mixed-effects.fixture.ts b/features/payment-receipt-reconciler/fixtures/mixed-effects.fixture.ts
new file mode 100644
index 0000000..e00abd0
--- /dev/null
+++ b/features/payment-receipt-reconciler/fixtures/mixed-effects.fixture.ts
@@ -0,0 +1,103 @@
+import {
+ changeTrustOperation,
+ creditEffect,
+ debitEffect,
+ destinationAccount,
+ issuerAccount,
+ pathPaymentOpId,
+ paymentOpId,
+ sourceAccount,
+ successfulTransaction,
+ trustlineEffect
+} from "@/features/payment-receipt-reconciler/fixtures/paymentReceiptReconciler.fixture";
+
+/**
+ * Mixed transaction: a path-payment plus an unrelated change_trust, with a
+ * trade effect sitting beside the debit/credit pair.
+ */
+export const mixedEffectsHash = "2".repeat(64);
+
+export const mixedEffectsTransaction = {
+ ...successfulTransaction,
+ hash: mixedEffectsHash,
+ id: mixedEffectsHash,
+ fee_charged: "300",
+ ledger: 3_200_002,
+ operation_count: 2
+};
+
+export const pathPaymentOperation = {
+ id: pathPaymentOpId,
+ type: "path_payment_strict_send",
+ source_account: sourceAccount,
+ from: sourceAccount,
+ to: destinationAccount,
+ amount: "5.0000000",
+ asset_type: "credit_alphanum4",
+ asset_code: "USDC",
+ asset_issuer: issuerAccount,
+ source_amount: "10.0000000",
+ source_asset_type: "native",
+ paging_token: pathPaymentOpId
+};
+
+export const pathDebitEffect = {
+ id: `${pathPaymentOpId}-1`,
+ type: "account_debited",
+ account: sourceAccount,
+ amount: "10.0000000",
+ asset_type: "native",
+ created_at: "2026-05-02T11:00:00Z",
+ paging_token: `${pathPaymentOpId}-1`
+};
+
+export const pathCreditEffect = {
+ id: `${pathPaymentOpId}-2`,
+ type: "account_credited",
+ account: destinationAccount,
+ amount: "5.0000000",
+ asset_type: "credit_alphanum4",
+ asset_code: "USDC",
+ asset_issuer: issuerAccount,
+ created_at: "2026-05-02T11:00:00Z",
+ paging_token: `${pathPaymentOpId}-2`
+};
+
+export const tradeEffect = {
+ id: `${pathPaymentOpId}-3`,
+ type: "trade",
+ account: sourceAccount,
+ sold_amount: "10.0000000",
+ sold_asset_type: "native",
+ bought_amount: "5.0000000",
+ bought_asset_type: "credit_alphanum4",
+ bought_asset_code: "USDC",
+ bought_asset_issuer: issuerAccount,
+ created_at: "2026-05-02T11:00:00Z",
+ paging_token: `${pathPaymentOpId}-3`
+};
+
+function page(records: T[]) {
+ return {
+ _links: { self: { href: "" }, next: { href: "" }, prev: { href: "" } },
+ _embedded: { records }
+ };
+}
+
+export const mixedEffectsOperationsPage = page([pathPaymentOperation, changeTrustOperation]);
+export const mixedEffectsEffectsPage = page([
+ pathDebitEffect,
+ pathCreditEffect,
+ tradeEffect,
+ trustlineEffect
+]);
+
+export {
+ sourceAccount,
+ destinationAccount,
+ issuerAccount,
+ paymentOpId,
+ pathPaymentOpId,
+ debitEffect,
+ creditEffect
+};
diff --git a/features/payment-receipt-reconciler/fixtures/payment-receipt.fixture.ts b/features/payment-receipt-reconciler/fixtures/payment-receipt.fixture.ts
new file mode 100644
index 0000000..202b3a8
--- /dev/null
+++ b/features/payment-receipt-reconciler/fixtures/payment-receipt.fixture.ts
@@ -0,0 +1,48 @@
+import {
+ changeTrustOperation,
+ creditEffect,
+ debitEffect,
+ destinationAccount,
+ issuerAccount,
+ paymentOpId,
+ paymentOperation,
+ sourceAccount,
+ successfulTransaction,
+ trustlineEffect
+} from "@/features/payment-receipt-reconciler/fixtures/paymentReceiptReconciler.fixture";
+
+/** Deterministic edge-case: single native payment with matching effects. */
+export const paymentReceiptHash = "1".repeat(64);
+
+export const paymentReceiptTransaction = {
+ ...successfulTransaction,
+ hash: paymentReceiptHash,
+ id: paymentReceiptHash,
+ fee_charged: "200",
+ ledger: 3_100_001,
+ operation_count: 1
+};
+
+export const paymentReceiptOperationsPage = {
+ _links: { self: { href: "" }, next: { href: "" }, prev: { href: "" } },
+ _embedded: {
+ records: [
+ {
+ ...paymentOperation,
+ amount: "1.0000000"
+ }
+ ]
+ }
+};
+
+export const paymentReceiptEffectsPage = {
+ _links: { self: { href: "" }, next: { href: "" }, prev: { href: "" } },
+ _embedded: {
+ records: [
+ { ...debitEffect, amount: "1.0000000" },
+ { ...creditEffect, amount: "1.0000000" }
+ ]
+ }
+};
+
+export { sourceAccount, destinationAccount, issuerAccount, paymentOpId };
diff --git a/features/payment-receipt-reconciler/fixtures/paymentReceiptReconciler.fixture.ts b/features/payment-receipt-reconciler/fixtures/paymentReceiptReconciler.fixture.ts
new file mode 100644
index 0000000..f2806c5
--- /dev/null
+++ b/features/payment-receipt-reconciler/fixtures/paymentReceiptReconciler.fixture.ts
@@ -0,0 +1,182 @@
+import { Keypair } from "@stellar/stellar-sdk";
+import type { PaymentReceipt } from "@/features/payment-receipt-reconciler/types";
+
+const seed = (byte: number) => Keypair.fromRawEd25519Seed(Buffer.alloc(32, byte));
+
+export const sourceAccount = seed(1).publicKey();
+export const destinationAccount = seed(2).publicKey();
+export const issuerAccount = seed(3).publicKey();
+
+export const successfulHash = "a".repeat(64);
+export const failedHash = "b".repeat(64);
+export const missingHash = "c".repeat(64);
+export const unsupportedHash = "d".repeat(64);
+export const incompleteHash = "e".repeat(64);
+export const mixedHash = "f".repeat(64);
+
+/** Operation TOIDs used by effects — decimal strings Horizon returns. */
+export const paymentOpId = "4398046511105";
+export const changeTrustOpId = "4398046511106";
+export const pathPaymentOpId = "4398046511107";
+
+export const successfulTransaction = {
+ hash: successfulHash,
+ ledger: 2_048_000,
+ successful: true,
+ source_account: sourceAccount,
+ fee_charged: "100",
+ created_at: "2026-05-02T10:14:05Z",
+ operation_count: 1,
+ _links: { self: { href: "" } },
+ paging_token: "1",
+ id: successfulHash
+};
+
+export const failedTransaction = {
+ ...successfulTransaction,
+ hash: failedHash,
+ id: failedHash,
+ successful: false,
+ operation_count: 1
+};
+
+export const unsupportedTransaction = {
+ ...successfulTransaction,
+ hash: unsupportedHash,
+ id: unsupportedHash,
+ operation_count: 1
+};
+
+export const incompleteTransaction = {
+ ...successfulTransaction,
+ hash: incompleteHash,
+ id: incompleteHash,
+ operation_count: 1
+};
+
+export const paymentOperation = {
+ id: paymentOpId,
+ type: "payment",
+ source_account: sourceAccount,
+ from: sourceAccount,
+ to: destinationAccount,
+ amount: "12.5000000",
+ asset_type: "native",
+ paging_token: paymentOpId
+};
+
+export const changeTrustOperation = {
+ id: changeTrustOpId,
+ type: "change_trust",
+ source_account: sourceAccount,
+ asset_type: "credit_alphanum4",
+ asset_code: "USDC",
+ asset_issuer: issuerAccount,
+ limit: "1000.0000000",
+ paging_token: changeTrustOpId
+};
+
+export const debitEffect = {
+ id: `${paymentOpId}-1`,
+ type: "account_debited",
+ account: sourceAccount,
+ amount: "12.5000000",
+ asset_type: "native",
+ created_at: "2026-05-02T10:14:05Z",
+ paging_token: `${paymentOpId}-1`
+};
+
+export const creditEffect = {
+ id: `${paymentOpId}-2`,
+ type: "account_credited",
+ account: destinationAccount,
+ amount: "12.5000000",
+ asset_type: "native",
+ created_at: "2026-05-02T10:14:05Z",
+ paging_token: `${paymentOpId}-2`
+};
+
+export const trustlineEffect = {
+ id: `${changeTrustOpId}-1`,
+ type: "trustline_created",
+ account: sourceAccount,
+ asset_type: "credit_alphanum4",
+ asset_code: "USDC",
+ asset_issuer: issuerAccount,
+ limit: "1000.0000000",
+ created_at: "2026-05-02T10:14:05Z",
+ paging_token: `${changeTrustOpId}-1`
+};
+
+function page(records: T[]) {
+ return {
+ _links: { self: { href: "" }, next: { href: "" }, prev: { href: "" } },
+ _embedded: { records }
+ };
+}
+
+export const paymentOperationsPage = page([paymentOperation]);
+export const paymentEffectsPage = page([debitEffect, creditEffect]);
+export const emptyPage = page([]);
+
+export const unsupportedOperationsPage = page([changeTrustOperation]);
+export const unsupportedEffectsPage = page([trustlineEffect]);
+
+export const incompleteOperationsPage = page([paymentOperation]);
+export const incompleteEffectsPage = page([debitEffect]);
+
+export const successfulReceipt: PaymentReceipt = {
+ hash: successfulHash,
+ network: "testnet",
+ ledger: 2_048_000,
+ createdAt: "2026-05-02T10:14:05Z",
+ sourceAccount,
+ feeCharged: "100",
+ operations: [
+ {
+ id: paymentOpId,
+ type: "payment",
+ sourceAccount,
+ from: sourceAccount,
+ to: destinationAccount,
+ amount: "12.5000000",
+ asset: { type: "native" }
+ }
+ ],
+ links: [
+ {
+ operationId: paymentOpId,
+ debits: [
+ {
+ id: debitEffect.id,
+ type: "account_debited",
+ account: sourceAccount,
+ amount: "12.5000000",
+ asset: { type: "native" },
+ operationId: paymentOpId
+ }
+ ],
+ credits: [
+ {
+ id: creditEffect.id,
+ type: "account_credited",
+ account: destinationAccount,
+ amount: "12.5000000",
+ asset: { type: "native" },
+ operationId: paymentOpId
+ }
+ ]
+ }
+ ],
+ totals: {
+ debits: [{ asset: { type: "native" }, amount: "12.5" }],
+ credits: [{ asset: { type: "native" }, amount: "12.5" }],
+ feeCharged: "100"
+ },
+ outside: [],
+ publicReceipt: {
+ hash: successfulHash,
+ network: "testnet",
+ ledger: 2_048_000
+ }
+};
diff --git a/features/payment-receipt-reconciler/hooks/usePaymentReceiptReconciler.ts b/features/payment-receipt-reconciler/hooks/usePaymentReceiptReconciler.ts
new file mode 100644
index 0000000..759d9e4
--- /dev/null
+++ b/features/payment-receipt-reconciler/hooks/usePaymentReceiptReconciler.ts
@@ -0,0 +1,73 @@
+"use client";
+
+import { useCallback, useRef, useState } from "react";
+import { useNetwork } from "@/core/network/NetworkProvider";
+import { isErr } from "@/core/result/result";
+import type { StellarNetwork } from "@/core/network/types";
+import { parsePaymentReceiptReconcilerInput } from "@/features/payment-receipt-reconciler/schema";
+import { runPaymentReceiptReconciler } from "@/features/payment-receipt-reconciler/lib/paymentReceiptReconciler";
+import type {
+ PaymentReceipt,
+ PaymentReceiptReconcilerErrorCode
+} from "@/features/payment-receipt-reconciler/types";
+
+export type PaymentReceiptReconcilerState =
+ | { status: "idle" }
+ | { status: "loading" }
+ | { status: "success"; result: PaymentReceipt }
+ | { status: "error"; code: PaymentReceiptReconcilerErrorCode };
+
+const IDLE: PaymentReceiptReconcilerState = { status: "idle" };
+
+interface Held {
+ state: PaymentReceiptReconcilerState;
+ network: StellarNetwork;
+}
+
+export function usePaymentReceiptReconciler() {
+ const { network } = useNetwork();
+ const [held, setHeld] = useState({ state: IDLE, network });
+ const requestId = useRef(0);
+ const controller = useRef(null);
+
+ // A hash that exists on testnet generally does not exist on mainnet, so a
+ // result from another network is derived away instead of left on screen.
+ const state = held.network === network ? held.state : IDLE;
+
+ const submit = useCallback(
+ async (raw: string) => {
+ controller.current?.abort();
+ const parsed = parsePaymentReceiptReconcilerInput(raw);
+
+ if (isErr(parsed)) {
+ setHeld({ state: { status: "error", code: parsed.code }, network });
+ return;
+ }
+
+ requestId.current += 1;
+ const id = requestId.current;
+ const next = new AbortController();
+ controller.current = next;
+ setHeld({ state: { status: "loading" }, network });
+
+ const result = await runPaymentReceiptReconciler(parsed.value, network, next.signal);
+ if (id !== requestId.current || next.signal.aborted) return;
+
+ setHeld({
+ state: result.ok
+ ? { status: "success", result: result.value }
+ : { status: "error", code: result.code },
+ network
+ });
+ },
+ [network]
+ );
+
+ const reset = useCallback(() => {
+ controller.current?.abort();
+ requestId.current += 1;
+ setHeld({ state: IDLE, network });
+ }, [network]);
+
+ return { state, submit, reset };
+}
diff --git a/features/payment-receipt-reconciler/lib/effect-links.ts b/features/payment-receipt-reconciler/lib/effect-links.ts
new file mode 100644
index 0000000..000f7d5
--- /dev/null
+++ b/features/payment-receipt-reconciler/lib/effect-links.ts
@@ -0,0 +1,147 @@
+import { err, ok, type Result } from "@/core/result/result";
+import {
+ parseReceiptAsset,
+ type RawHorizonEffect,
+ type RawHorizonOperation
+} from "@/features/payment-receipt-reconciler/lib/receipt-fetch";
+import type {
+ EffectEvidence,
+ EffectLink,
+ OutsideItem,
+ PaymentOperation,
+ PaymentReceiptReconcilerErrorCode,
+ SupportedPaymentType
+} from "@/features/payment-receipt-reconciler/types";
+
+const SUPPORTED_OPS = new Set([
+ "payment",
+ "path_payment_strict_send",
+ "path_payment_strict_receive"
+]);
+
+const BALANCE_EFFECTS = new Set(["account_debited", "account_credited"]);
+
+/** `-` */
+const EFFECT_ID = /^(\d{1,19})-(\d{1,10})$/;
+
+export function isSupportedPaymentType(type: string): type is SupportedPaymentType {
+ return SUPPORTED_OPS.has(type);
+}
+
+export function operationIdFromEffectId(effectId: string): string | null {
+ const match = EFFECT_ID.exec(effectId);
+ return match ? match[1] : null;
+}
+
+export function normalizePaymentOperation(
+ raw: RawHorizonOperation
+): PaymentOperation | null {
+ if (!isSupportedPaymentType(raw.type)) return null;
+
+ const asset = parseReceiptAsset(raw.asset_type, raw.asset_code, raw.asset_issuer);
+ if (!asset || raw.amount === undefined) return null;
+
+ const operation: PaymentOperation = {
+ id: raw.id,
+ type: raw.type,
+ sourceAccount: raw.source_account,
+ from: raw.from ?? raw.source_account,
+ to: raw.to ?? "",
+ amount: String(raw.amount),
+ asset
+ };
+
+ if (raw.type !== "payment") {
+ const sourceAsset = parseReceiptAsset(
+ raw.source_asset_type,
+ raw.source_asset_code,
+ raw.source_asset_issuer
+ );
+ if (sourceAsset && raw.source_amount !== undefined) {
+ operation.sourceAmount = String(raw.source_amount);
+ operation.sourceAsset = sourceAsset;
+ }
+ }
+
+ return operation;
+}
+
+export function normalizeBalanceEffect(raw: RawHorizonEffect): EffectEvidence | null {
+ if (!BALANCE_EFFECTS.has(raw.type)) return null;
+
+ const operationId = operationIdFromEffectId(raw.id);
+ const asset = parseReceiptAsset(raw.asset_type, raw.asset_code, raw.asset_issuer);
+ if (!operationId || !asset || raw.amount === undefined || !raw.account) return null;
+
+ return {
+ id: raw.id,
+ type: raw.type as EffectEvidence["type"],
+ account: raw.account,
+ amount: String(raw.amount),
+ asset,
+ operationId
+ };
+}
+
+export interface LinkedReceiptParts {
+ operations: PaymentOperation[];
+ links: EffectLink[];
+ outside: OutsideItem[];
+}
+
+/**
+ * Connects each supported payment operation to its debit and credit effects.
+ *
+ * Unrelated operation and effect types are collected as `outside` items so
+ * they remain auditable rather than being silently dropped.
+ */
+export function linkOperationsToEffects(
+ operations: readonly RawHorizonOperation[],
+ effects: readonly RawHorizonEffect[]
+): Result {
+ const paymentOps: PaymentOperation[] = [];
+ const outside: OutsideItem[] = [];
+
+ for (const raw of operations) {
+ const payment = normalizePaymentOperation(raw);
+ if (payment) {
+ paymentOps.push(payment);
+ } else {
+ outside.push({ kind: "operation", id: raw.id, type: raw.type });
+ }
+ }
+
+ if (paymentOps.length === 0) return err("unsupported_operation");
+
+ const paymentIds = new Set(paymentOps.map((op) => op.id));
+ const effectsByOp = new Map();
+
+ for (const raw of effects) {
+ const balance = normalizeBalanceEffect(raw);
+ if (balance && paymentIds.has(balance.operationId)) {
+ const bucket = effectsByOp.get(balance.operationId) ?? [];
+ bucket.push(balance);
+ effectsByOp.set(balance.operationId, bucket);
+ continue;
+ }
+
+ // Trade effects on path payments, trustline changes, etc.
+ outside.push({ kind: "effect", id: raw.id, type: raw.type });
+ }
+
+ const links: EffectLink[] = [];
+
+ for (const operation of paymentOps) {
+ const linked = effectsByOp.get(operation.id) ?? [];
+ const debits = linked.filter((effect) => effect.type === "account_debited");
+ const credits = linked.filter((effect) => effect.type === "account_credited");
+
+ if (debits.length === 0 || credits.length === 0) {
+ return err("incomplete_effects");
+ }
+
+ links.push({ operationId: operation.id, debits, credits });
+ }
+
+ return ok({ operations: paymentOps, links, outside });
+}
diff --git a/features/payment-receipt-reconciler/lib/format.ts b/features/payment-receipt-reconciler/lib/format.ts
new file mode 100644
index 0000000..d79f04d
--- /dev/null
+++ b/features/payment-receipt-reconciler/lib/format.ts
@@ -0,0 +1,106 @@
+import { truncateMiddle } from "@/core/lib/strings";
+import {
+ copy,
+ effectTypeLabels,
+ networkLabels,
+ operationTypeLabels
+} from "@/features/payment-receipt-reconciler/copy";
+import type {
+ PublicReceipt,
+ ReceiptAsset
+} from "@/features/payment-receipt-reconciler/types";
+
+const AMOUNT = /^-?\d+(\.\d{1,7})?$/;
+const STROOPS_PER_UNIT = 10_000_000n;
+
+/**
+ * Converts a Stellar amount to stroops without ever touching a float.
+ */
+export function toStroops(amount: string): bigint {
+ const negative = amount.startsWith("-");
+ const [whole, fraction = ""] = (negative ? amount.slice(1) : amount).split(".");
+ const value =
+ BigInt(whole || "0") * STROOPS_PER_UNIT + BigInt(`${fraction}0000000`.slice(0, 7));
+ return negative ? -value : value;
+}
+
+/**
+ * Renders an amount with thousands separators and no trailing zeros.
+ * Non-Stellar strings pass through untouched.
+ */
+export function formatAmount(value: string): string {
+ if (!AMOUNT.test(value)) return value;
+
+ const stroops = toStroops(value);
+ const magnitude = stroops < 0n ? -stroops : stroops;
+ const whole = (magnitude / STROOPS_PER_UNIT)
+ .toString()
+ .replace(/\B(?=(\d{3})+(?!\d))/g, ",");
+ const fraction = (magnitude % STROOPS_PER_UNIT)
+ .toString()
+ .padStart(7, "0")
+ .replace(/0+$/, "");
+ const sign = stroops < 0n ? "-" : "";
+
+ return fraction ? `${sign}${whole}.${fraction}` : `${sign}${whole}`;
+}
+
+/** Canonical asset key for grouping — native or CODE:ISSUER. */
+export function assetKey(asset: ReceiptAsset): string {
+ return asset.type === "native" ? "native" : `${asset.code}:${asset.issuer}`;
+}
+
+/** Display label with full issuer visibility for credit assets. */
+export function formatAsset(asset: ReceiptAsset): string {
+ if (asset.type === "native") return copy.nativeAsset;
+ return `${asset.code}:${asset.issuer}`;
+}
+
+/** Compact label for dense lists — issuer is middle-truncated. */
+export function formatAssetCompact(asset: ReceiptAsset): string {
+ if (asset.type === "native") return copy.nativeAsset;
+ return `${asset.code} · ${truncateMiddle(asset.issuer, 4)}`;
+}
+
+export function formatAmountWithAsset(amount: string, asset: ReceiptAsset): string {
+ return `${formatAmount(amount)} ${formatAssetCompact(asset)}`;
+}
+
+/** Fees are reported in stroops. */
+export function stroopsToXlm(stroops: string): string {
+ const value = BigInt(stroops);
+ const whole = value / STROOPS_PER_UNIT;
+ const fraction = (value % STROOPS_PER_UNIT).toString().padStart(7, "0").replace(/0+$/, "");
+ return fraction ? `${whole}.${fraction}` : String(whole);
+}
+
+export function formatFee(stroops: string): string {
+ return `${stroops} stroops (${stroopsToXlm(stroops)} XLM)`;
+}
+
+export function formatOperationType(type: string): string {
+ return operationTypeLabels[type] ?? type.replace(/_/g, " ");
+}
+
+export function formatEffectType(type: string): string {
+ return effectTypeLabels[type] ?? type.replace(/_/g, " ");
+}
+
+export function formatNetwork(network: string): string {
+ return networkLabels[network] ?? network;
+}
+
+export function formatTimestamp(iso: string): string {
+ const date = new Date(iso);
+ if (Number.isNaN(date.getTime())) return iso;
+ return `${date.toISOString().slice(0, 19).replace("T", " ")} UTC`;
+}
+
+/** Plain-text public receipt for clipboard copy. */
+export function formatPublicReceipt(receipt: PublicReceipt): string {
+ return [
+ `${copy.hashLabel}: ${receipt.hash}`,
+ `${copy.networkLabel}: ${formatNetwork(receipt.network)}`,
+ `${copy.ledgerLabel}: ${receipt.ledger}`
+ ].join("\n");
+}
diff --git a/features/payment-receipt-reconciler/lib/paymentReceiptReconciler.errors.ts b/features/payment-receipt-reconciler/lib/paymentReceiptReconciler.errors.ts
new file mode 100644
index 0000000..98804a9
--- /dev/null
+++ b/features/payment-receipt-reconciler/lib/paymentReceiptReconciler.errors.ts
@@ -0,0 +1,11 @@
+import { classifyHorizonError } from "@/core/horizon/errors";
+import type { PaymentReceiptReconcilerErrorCode } from "@/features/payment-receipt-reconciler/types";
+
+/** Maps transport failures onto this tool's own error codes. */
+export function toPaymentReceiptReconcilerErrorCode(
+ error: unknown
+): PaymentReceiptReconcilerErrorCode {
+ const { code } = classifyHorizonError(error);
+ if (code === "not_found") return "transaction_not_found";
+ return "request_failed";
+}
diff --git a/features/payment-receipt-reconciler/lib/paymentReceiptReconciler.ts b/features/payment-receipt-reconciler/lib/paymentReceiptReconciler.ts
new file mode 100644
index 0000000..8cb48ad
--- /dev/null
+++ b/features/payment-receipt-reconciler/lib/paymentReceiptReconciler.ts
@@ -0,0 +1,51 @@
+import { err, ok, type Result } from "@/core/result/result";
+import type { StellarNetwork } from "@/core/network/types";
+import { linkOperationsToEffects } from "@/features/payment-receipt-reconciler/lib/effect-links";
+import { sumReceiptAmounts } from "@/features/payment-receipt-reconciler/lib/receipt-amounts";
+import { fetchReceiptBundle } from "@/features/payment-receipt-reconciler/lib/receipt-fetch";
+import type {
+ PaymentReceipt,
+ PaymentReceiptReconcilerErrorCode,
+ PaymentReceiptReconcilerInput
+} from "@/features/payment-receipt-reconciler/types";
+
+/**
+ * Orchestrates Horizon fetches, effect linking and amount totals into one
+ * Result-based payment receipt.
+ */
+export async function runPaymentReceiptReconciler(
+ input: PaymentReceiptReconcilerInput,
+ network: StellarNetwork,
+ signal?: AbortSignal
+): Promise> {
+ const bundle = await fetchReceiptBundle(input.hash, network, signal);
+ if (!bundle.ok) return bundle;
+
+ const { transaction, operations, effects } = bundle.value;
+
+ if (!transaction.successful) return err("transaction_failed");
+
+ const linked = linkOperationsToEffects(operations, effects);
+ if (!linked.ok) return linked;
+
+ const feeCharged = String(transaction.fee_charged);
+ const totals = sumReceiptAmounts(linked.value.links, feeCharged);
+
+ return ok({
+ hash: transaction.hash,
+ network,
+ ledger: transaction.ledger,
+ createdAt: transaction.created_at,
+ sourceAccount: transaction.source_account,
+ feeCharged,
+ operations: linked.value.operations,
+ links: linked.value.links,
+ totals,
+ outside: linked.value.outside,
+ publicReceipt: {
+ hash: transaction.hash,
+ network,
+ ledger: transaction.ledger
+ }
+ });
+}
diff --git a/features/payment-receipt-reconciler/lib/receipt-amounts.ts b/features/payment-receipt-reconciler/lib/receipt-amounts.ts
new file mode 100644
index 0000000..d5c37d8
--- /dev/null
+++ b/features/payment-receipt-reconciler/lib/receipt-amounts.ts
@@ -0,0 +1,71 @@
+import { assetKey, toStroops } from "@/features/payment-receipt-reconciler/lib/format";
+import type {
+ AssetAmount,
+ EffectLink,
+ ReceiptAsset,
+ ReceiptTotals
+} from "@/features/payment-receipt-reconciler/types";
+
+interface Accumulator {
+ asset: ReceiptAsset;
+ stroops: bigint;
+}
+
+/**
+ * Sums exact debit and credit amounts by asset code + issuer.
+ *
+ * Addition is done in stroops via BigInt so two Horizon amount strings never
+ * pass through floating point. The charged fee is kept separate — it is not a
+ * payment transfer and must not be folded into debit totals.
+ */
+export function sumReceiptAmounts(
+ links: readonly EffectLink[],
+ feeCharged: string
+): ReceiptTotals {
+ const debitMap = new Map();
+ const creditMap = new Map();
+
+ for (const link of links) {
+ for (const debit of link.debits) {
+ accumulate(debitMap, debit.asset, debit.amount);
+ }
+ for (const credit of link.credits) {
+ accumulate(creditMap, credit.asset, credit.amount);
+ }
+ }
+
+ return {
+ debits: toAssetAmounts(debitMap),
+ credits: toAssetAmounts(creditMap),
+ feeCharged: String(feeCharged)
+ };
+}
+
+function accumulate(map: Map, asset: ReceiptAsset, amount: string): void {
+ const key = assetKey(asset);
+ const existing = map.get(key);
+ const stroops = toStroops(amount);
+
+ if (existing) {
+ existing.stroops += stroops;
+ } else {
+ map.set(key, { asset, stroops });
+ }
+}
+
+function toAssetAmounts(map: Map): AssetAmount[] {
+ return [...map.values()].map(({ asset, stroops }) => ({
+ asset,
+ amount: stroopsToAmount(stroops)
+ }));
+}
+
+/** Inverse of `toStroops` — restores a 7-decimal amount string. */
+export function stroopsToAmount(stroops: bigint): string {
+ const negative = stroops < 0n;
+ const magnitude = negative ? -stroops : stroops;
+ const whole = magnitude / 10_000_000n;
+ const fraction = (magnitude % 10_000_000n).toString().padStart(7, "0").replace(/0+$/, "");
+ const body = fraction ? `${whole}.${fraction}` : String(whole);
+ return negative ? `-${body}` : body;
+}
diff --git a/features/payment-receipt-reconciler/lib/receipt-fetch.ts b/features/payment-receipt-reconciler/lib/receipt-fetch.ts
new file mode 100644
index 0000000..8b44f82
--- /dev/null
+++ b/features/payment-receipt-reconciler/lib/receipt-fetch.ts
@@ -0,0 +1,120 @@
+import { err, ok, type Result } from "@/core/result/result";
+import { horizonUrl } from "@/core/horizon/client";
+import type { StellarNetwork } from "@/core/network/types";
+import { toPaymentReceiptReconcilerErrorCode } from "@/features/payment-receipt-reconciler/lib/paymentReceiptReconciler.errors";
+import type {
+ PaymentReceiptReconcilerErrorCode,
+ ReceiptAsset
+} from "@/features/payment-receipt-reconciler/types";
+
+export interface RawHorizonTransaction {
+ hash: string;
+ ledger: number;
+ successful: boolean;
+ source_account: string;
+ fee_charged: string | number;
+ created_at: string;
+ operation_count: number;
+}
+
+export interface RawHorizonOperation {
+ id: string;
+ type: string;
+ source_account: string;
+ from?: string;
+ to?: string;
+ amount?: string;
+ asset_type?: string;
+ asset_code?: string;
+ asset_issuer?: string;
+ source_amount?: string;
+ source_asset_type?: string;
+ source_asset_code?: string;
+ source_asset_issuer?: string;
+}
+
+export interface RawHorizonEffect {
+ id: string;
+ type: string;
+ account?: string;
+ amount?: string;
+ asset_type?: string;
+ asset_code?: string;
+ asset_issuer?: string;
+}
+
+interface Collection {
+ _embedded?: { records?: T[] };
+}
+
+export interface ReceiptBundle {
+ transaction: RawHorizonTransaction;
+ operations: RawHorizonOperation[];
+ effects: RawHorizonEffect[];
+}
+
+export function parseReceiptAsset(
+ assetType?: string,
+ assetCode?: string,
+ assetIssuer?: string
+): ReceiptAsset | null {
+ if (assetType === "native") return { type: "native" };
+ if (assetType && assetCode && assetIssuer) {
+ return { type: "credit", code: assetCode, issuer: assetIssuer };
+ }
+ return null;
+}
+
+async function requestJson(url: string, signal?: AbortSignal): Promise {
+ const response = await fetch(url, { signal, headers: { Accept: "application/json" } });
+
+ if (!response.ok) {
+ throw Object.assign(new Error("Horizon request failed."), { status: response.status });
+ }
+
+ return (await response.json()) as T;
+}
+
+/**
+ * Fetches a transaction together with its operations and effects pages.
+ *
+ * All three endpoints are required for reconciliation — a partial response
+ * maps to `request_failed` rather than a half-built receipt.
+ */
+export async function fetchReceiptBundle(
+ hash: string,
+ network: StellarNetwork,
+ signal?: AbortSignal
+): Promise> {
+ try {
+ const transaction = await requestJson(
+ horizonUrl(network, `/transactions/${encodeURIComponent(hash)}`),
+ signal
+ );
+
+ const [operationsPage, effectsPage] = await Promise.all([
+ requestJson>(
+ horizonUrl(network, `/transactions/${encodeURIComponent(hash)}/operations`, {
+ limit: 200,
+ order: "asc"
+ }),
+ signal
+ ),
+ requestJson>(
+ horizonUrl(network, `/transactions/${encodeURIComponent(hash)}/effects`, {
+ limit: 200,
+ order: "asc"
+ }),
+ signal
+ )
+ ]);
+
+ return ok({
+ transaction,
+ operations: operationsPage._embedded?.records ?? [],
+ effects: effectsPage._embedded?.records ?? []
+ });
+ } catch (error) {
+ return err(toPaymentReceiptReconcilerErrorCode(error));
+ }
+}
diff --git a/features/payment-receipt-reconciler/manifest.ts b/features/payment-receipt-reconciler/manifest.ts
new file mode 100644
index 0000000..c359c4f
--- /dev/null
+++ b/features/payment-receipt-reconciler/manifest.ts
@@ -0,0 +1,25 @@
+import { Receipt } from "lucide-react";
+import type { FeatureManifest } from "@/core/registry/types";
+
+export const manifest: FeatureManifest = {
+ slug: "payment-receipt-reconciler",
+ title: "Payment Receipt Reconciler",
+ description:
+ "Turn a completed payment or path-payment into a receipt that links operations to debit and credit effects.",
+ character: "A careful clerk stamps each debit and credit onto one public receipt.",
+ category: "payments",
+ status: "working",
+ icon: Receipt,
+ networks: ["testnet", "mainnet"],
+ keywords: [
+ "payment",
+ "path payment",
+ "receipt",
+ "effects",
+ "debit",
+ "credit",
+ "reconcile",
+ "horizon",
+ "fee"
+ ]
+};
diff --git a/features/payment-receipt-reconciler/msw/handlers.ts b/features/payment-receipt-reconciler/msw/handlers.ts
new file mode 100644
index 0000000..93ea585
--- /dev/null
+++ b/features/payment-receipt-reconciler/msw/handlers.ts
@@ -0,0 +1,85 @@
+import { http, HttpResponse, type JsonBodyType } from "msw";
+import {
+ failedHash,
+ failedTransaction,
+ incompleteEffectsPage,
+ incompleteHash,
+ incompleteOperationsPage,
+ incompleteTransaction,
+ missingHash,
+ paymentEffectsPage,
+ paymentOperationsPage,
+ successfulHash,
+ successfulTransaction,
+ unsupportedEffectsPage,
+ unsupportedHash,
+ unsupportedOperationsPage,
+ unsupportedTransaction
+} from "@/features/payment-receipt-reconciler/fixtures/paymentReceiptReconciler.fixture";
+import {
+ paymentReceiptEffectsPage,
+ paymentReceiptHash,
+ paymentReceiptOperationsPage,
+ paymentReceiptTransaction
+} from "@/features/payment-receipt-reconciler/fixtures/payment-receipt.fixture";
+import {
+ mixedEffectsEffectsPage,
+ mixedEffectsHash,
+ mixedEffectsOperationsPage,
+ mixedEffectsTransaction
+} from "@/features/payment-receipt-reconciler/fixtures/mixed-effects.fixture";
+
+const TESTNET = "https://horizon-testnet.stellar.org";
+
+function txHandlers(
+ hash: string,
+ transaction: JsonBodyType,
+ operations: JsonBodyType,
+ effects: JsonBodyType
+) {
+ return [
+ http.get(`${TESTNET}/transactions/${hash}`, () => HttpResponse.json(transaction)),
+ http.get(`${TESTNET}/transactions/${hash}/operations`, () =>
+ HttpResponse.json(operations)
+ ),
+ http.get(`${TESTNET}/transactions/${hash}/effects`, () => HttpResponse.json(effects))
+ ];
+}
+
+export const handlers = [
+ ...txHandlers(successfulHash, successfulTransaction, paymentOperationsPage, paymentEffectsPage),
+ ...txHandlers(failedHash, failedTransaction, paymentOperationsPage, paymentEffectsPage),
+ ...txHandlers(
+ unsupportedHash,
+ unsupportedTransaction,
+ unsupportedOperationsPage,
+ unsupportedEffectsPage
+ ),
+ ...txHandlers(
+ incompleteHash,
+ incompleteTransaction,
+ incompleteOperationsPage,
+ incompleteEffectsPage
+ ),
+ ...txHandlers(
+ paymentReceiptHash,
+ paymentReceiptTransaction,
+ paymentReceiptOperationsPage,
+ paymentReceiptEffectsPage
+ ),
+ ...txHandlers(
+ mixedEffectsHash,
+ mixedEffectsTransaction,
+ mixedEffectsOperationsPage,
+ mixedEffectsEffectsPage
+ ),
+ http.get(`${TESTNET}/transactions/${missingHash}`, () =>
+ HttpResponse.json({ title: "Resource Missing", status: 404 }, { status: 404 })
+ )
+];
+
+/** Transaction resolves but the effects endpoint fails. */
+export const effectsUnavailableHandler = http.get(
+ `${TESTNET}/transactions/${successfulHash}/effects`,
+ () => HttpResponse.json({ title: "Internal Server Error", status: 500 }, { status: 500 })
+);
diff --git a/features/payment-receipt-reconciler/panel.tsx b/features/payment-receipt-reconciler/panel.tsx
new file mode 100644
index 0000000..97e07ff
--- /dev/null
+++ b/features/payment-receipt-reconciler/panel.tsx
@@ -0,0 +1 @@
+export { PaymentReceiptReconcilerPanel as default } from "@/features/payment-receipt-reconciler/components/PaymentReceiptReconcilerPanel";
diff --git a/features/payment-receipt-reconciler/schema.ts b/features/payment-receipt-reconciler/schema.ts
new file mode 100644
index 0000000..b3ff124
--- /dev/null
+++ b/features/payment-receipt-reconciler/schema.ts
@@ -0,0 +1,29 @@
+import { err, ok, type Result } from "@/core/result/result";
+import type {
+ PaymentReceiptReconcilerErrorCode,
+ PaymentReceiptReconcilerInput
+} from "@/features/payment-receipt-reconciler/types";
+
+/** A Stellar transaction hash is 32 bytes rendered as 64 hex characters. */
+const HASH = /^[a-fA-F0-9]{64}$/;
+
+export function isLikelyTransactionHash(value: string): boolean {
+ return HASH.test(value);
+}
+
+/**
+ * Parses raw form input into a validated request, without throwing or
+ * contacting Horizon.
+ */
+export function parsePaymentReceiptReconcilerInput(
+ raw: string
+): Result {
+ const hash = raw.replace(/\s+/g, "");
+
+ if (!hash) return err("empty_input");
+ if (!HASH.test(hash)) return err("invalid_hash");
+
+ // Horizon treats the hash case-insensitively but returns lowercase; normalise
+ // so the value shown back always matches the ledger's own rendering.
+ return ok({ hash: hash.toLowerCase() });
+}
diff --git a/features/payment-receipt-reconciler/types.ts b/features/payment-receipt-reconciler/types.ts
new file mode 100644
index 0000000..994ee4e
--- /dev/null
+++ b/features/payment-receipt-reconciler/types.ts
@@ -0,0 +1,99 @@
+import type { StellarNetwork } from "@/core/network/types";
+
+export type PaymentReceiptReconcilerErrorCode =
+ | "empty_input"
+ | "invalid_hash"
+ | "transaction_not_found"
+ | "transaction_failed"
+ | "unsupported_operation"
+ | "incomplete_effects"
+ | "request_failed";
+
+export interface PaymentReceiptReconcilerInput {
+ hash: string;
+}
+
+/** Asset identity keyed by code + issuer (native has neither). */
+export type ReceiptAsset =
+ | { type: "native" }
+ | { type: "credit"; code: string; issuer: string };
+
+export interface AssetAmount {
+ asset: ReceiptAsset;
+ /** Exact Horizon amount string — never a float. */
+ amount: string;
+}
+
+export type SupportedPaymentType =
+ | "payment"
+ | "path_payment_strict_send"
+ | "path_payment_strict_receive";
+
+export interface PaymentOperation {
+ id: string;
+ type: SupportedPaymentType;
+ sourceAccount: string;
+ from: string;
+ to: string;
+ /** Destination amount for payment / path-payment. */
+ amount: string;
+ asset: ReceiptAsset;
+ /** Present on path payments — source-side amount. */
+ sourceAmount?: string;
+ sourceAsset?: ReceiptAsset;
+}
+
+export type BalanceEffectType = "account_debited" | "account_credited";
+
+export interface EffectEvidence {
+ id: string;
+ type: BalanceEffectType;
+ account: string;
+ amount: string;
+ asset: ReceiptAsset;
+ /** Operation TOID this effect belongs to. */
+ operationId: string;
+}
+
+export interface EffectLink {
+ operationId: string;
+ debits: EffectEvidence[];
+ credits: EffectEvidence[];
+}
+
+/** Ops or effects that belong to the transaction but sit outside the receipt. */
+export interface OutsideItem {
+ kind: "operation" | "effect";
+ id: string;
+ type: string;
+}
+
+export interface ReceiptTotals {
+ debits: AssetAmount[];
+ credits: AssetAmount[];
+ /** Transaction fee charged, in stroops. */
+ feeCharged: string;
+}
+
+/** Copyable public receipt header. */
+export interface PublicReceipt {
+ hash: string;
+ network: StellarNetwork;
+ ledger: number;
+}
+
+export interface PaymentReceipt {
+ hash: string;
+ network: StellarNetwork;
+ ledger: number;
+ createdAt: string;
+ sourceAccount: string;
+ feeCharged: string;
+ operations: PaymentOperation[];
+ links: EffectLink[];
+ totals: ReceiptTotals;
+ outside: OutsideItem[];
+ publicReceipt: PublicReceipt;
+}
+
+export type PaymentReceiptReconcilerResult = PaymentReceipt;