From 0ec18a6f0acf3cd279fed02e487b5b8f2a5964bd Mon Sep 17 00:00:00 2001 From: Samet Date: Tue, 29 Sep 2026 15:44:28 +0300 Subject: [PATCH 1/2] feat(payment-receipt-reconciler): reconcile payment ops with account effects --- features/payment-receipt-reconciler/README.md | 41 ++++ .../PaymentReceiptReconcilerPanel.test.tsx | 83 ++++++++ .../__tests__/a11y.test.tsx | 27 +++ .../__tests__/effect-links.test.ts | 103 ++++++++++ .../__tests__/format.test.ts | 75 ++++++++ .../paymentReceiptReconciler.test.ts | 78 ++++++++ .../__tests__/receipt-amounts.test.ts | 101 ++++++++++ .../__tests__/receipt-fetch.test.ts | 55 ++++++ .../__tests__/schema.test.ts | 53 +++++ .../usePaymentReceiptReconciler.test.tsx | 85 ++++++++ .../components/EffectEvidence.tsx | 79 ++++++++ .../components/PaymentOperations.tsx | 63 ++++++ .../PaymentReceiptReconcilerEmptyState.tsx | 9 + .../PaymentReceiptReconcilerForm.tsx | 48 +++++ .../PaymentReceiptReconcilerPanel.tsx | 45 +++++ .../PaymentReceiptReconcilerResult.tsx | 120 ++++++++++++ .../components/ReceiptTotals.tsx | 37 ++++ features/payment-receipt-reconciler/copy.ts | 94 +++++++++ .../e2e/mixed-effects.spec.ts | 18 ++ .../e2e/payment-receipt-reconciler.spec.ts | 15 ++ .../fixtures/mixed-effects.fixture.ts | 103 ++++++++++ .../fixtures/payment-receipt.fixture.ts | 48 +++++ .../paymentReceiptReconciler.fixture.ts | 182 ++++++++++++++++++ .../hooks/usePaymentReceiptReconciler.ts | 73 +++++++ .../lib/effect-links.ts | 147 ++++++++++++++ .../payment-receipt-reconciler/lib/format.ts | 106 ++++++++++ .../lib/paymentReceiptReconciler.errors.ts | 11 ++ .../lib/paymentReceiptReconciler.ts | 51 +++++ .../lib/receipt-amounts.ts | 71 +++++++ .../lib/receipt-fetch.ts | 120 ++++++++++++ .../payment-receipt-reconciler/manifest.ts | 25 +++ .../msw/handlers.ts | 85 ++++++++ features/payment-receipt-reconciler/panel.tsx | 1 + features/payment-receipt-reconciler/schema.ts | 29 +++ features/payment-receipt-reconciler/types.ts | 99 ++++++++++ 35 files changed, 2380 insertions(+) create mode 100644 features/payment-receipt-reconciler/README.md create mode 100644 features/payment-receipt-reconciler/__tests__/PaymentReceiptReconcilerPanel.test.tsx create mode 100644 features/payment-receipt-reconciler/__tests__/a11y.test.tsx create mode 100644 features/payment-receipt-reconciler/__tests__/effect-links.test.ts create mode 100644 features/payment-receipt-reconciler/__tests__/format.test.ts create mode 100644 features/payment-receipt-reconciler/__tests__/paymentReceiptReconciler.test.ts create mode 100644 features/payment-receipt-reconciler/__tests__/receipt-amounts.test.ts create mode 100644 features/payment-receipt-reconciler/__tests__/receipt-fetch.test.ts create mode 100644 features/payment-receipt-reconciler/__tests__/schema.test.ts create mode 100644 features/payment-receipt-reconciler/__tests__/usePaymentReceiptReconciler.test.tsx create mode 100644 features/payment-receipt-reconciler/components/EffectEvidence.tsx create mode 100644 features/payment-receipt-reconciler/components/PaymentOperations.tsx create mode 100644 features/payment-receipt-reconciler/components/PaymentReceiptReconcilerEmptyState.tsx create mode 100644 features/payment-receipt-reconciler/components/PaymentReceiptReconcilerForm.tsx create mode 100644 features/payment-receipt-reconciler/components/PaymentReceiptReconcilerPanel.tsx create mode 100644 features/payment-receipt-reconciler/components/PaymentReceiptReconcilerResult.tsx create mode 100644 features/payment-receipt-reconciler/components/ReceiptTotals.tsx create mode 100644 features/payment-receipt-reconciler/copy.ts create mode 100644 features/payment-receipt-reconciler/e2e/mixed-effects.spec.ts create mode 100644 features/payment-receipt-reconciler/e2e/payment-receipt-reconciler.spec.ts create mode 100644 features/payment-receipt-reconciler/fixtures/mixed-effects.fixture.ts create mode 100644 features/payment-receipt-reconciler/fixtures/payment-receipt.fixture.ts create mode 100644 features/payment-receipt-reconciler/fixtures/paymentReceiptReconciler.fixture.ts create mode 100644 features/payment-receipt-reconciler/hooks/usePaymentReceiptReconciler.ts create mode 100644 features/payment-receipt-reconciler/lib/effect-links.ts create mode 100644 features/payment-receipt-reconciler/lib/format.ts create mode 100644 features/payment-receipt-reconciler/lib/paymentReceiptReconciler.errors.ts create mode 100644 features/payment-receipt-reconciler/lib/paymentReceiptReconciler.ts create mode 100644 features/payment-receipt-reconciler/lib/receipt-amounts.ts create mode 100644 features/payment-receipt-reconciler/lib/receipt-fetch.ts create mode 100644 features/payment-receipt-reconciler/manifest.ts create mode 100644 features/payment-receipt-reconciler/msw/handlers.ts create mode 100644 features/payment-receipt-reconciler/panel.tsx create mode 100644 features/payment-receipt-reconciler/schema.ts create mode 100644 features/payment-receipt-reconciler/types.ts 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} + + +
    + {links.map((link) => ( +
  • +

    + {copy.operationIdLabel}:{" "} + +

    + + + +
  • + ))} +
+
+ ); +} + +function EffectGroup({ + title, + effects +}: { + title: string; + effects: EffectLink["debits"]; +}) { + return ( +
+

{title}

+
    + {effects.map((effect) => ( +
  • +
    + + {formatEffectType(effect.type)} + + +
    +
    +
    +
    {copy.accountLabel}
    +
    + +
    +
    +
    +
    {copy.amountLabel}
    +
    + {formatAmountWithAsset(effect.amount, effect.asset)} +
    +
    +
    +
  • + ))} +
+
+ ); +} 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) => ( +
  1. +
    + + {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} +
    +
  2. + ))} +
+
+ ); +} 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 ( +
+ + {({ inputId, describedBy, invalid, required }) => ( + setValue(event.target.value)} + placeholder="3389e9f0f1a65f19736cacf544c2e825313e8447f569233bb8db39aa607c8889" + autoComplete="off" + spellCheck={false} + className="font-mono text-xs" + /> + )} + + +
+ ); +} 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..82a3853 --- /dev/null +++ b/features/payment-receipt-reconciler/msw/handlers.ts @@ -0,0 +1,85 @@ +import { http, HttpResponse } 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: unknown, + operations: unknown, + effects: unknown +) { + 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; From 8519b938c25abe0c3cdd583ae2d575c7aad3902e Mon Sep 17 00:00:00 2001 From: Ege Hidayet Koca Date: Wed, 30 Sep 2026 18:07:00 +0300 Subject: [PATCH 2/2] fix: type MSW fixture handlers with JsonBodyType instead of unknown HttpResponse.json() cannot infer a type parameter from a bare 'unknown' argument, since 'unknown' is not assignable to JsonBodyType's Record | primitive union. Typing the fixture parameters as JsonBodyType directly (msw's own exported type) fixes the type error without loosening what the fixtures can actually be. --- features/payment-receipt-reconciler/msw/handlers.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/features/payment-receipt-reconciler/msw/handlers.ts b/features/payment-receipt-reconciler/msw/handlers.ts index 82a3853..93ea585 100644 --- a/features/payment-receipt-reconciler/msw/handlers.ts +++ b/features/payment-receipt-reconciler/msw/handlers.ts @@ -1,4 +1,4 @@ -import { http, HttpResponse } from "msw"; +import { http, HttpResponse, type JsonBodyType } from "msw"; import { failedHash, failedTransaction, @@ -33,9 +33,9 @@ const TESTNET = "https://horizon-testnet.stellar.org"; function txHandlers( hash: string, - transaction: unknown, - operations: unknown, - effects: unknown + transaction: JsonBodyType, + operations: JsonBodyType, + effects: JsonBodyType ) { return [ http.get(`${TESTNET}/transactions/${hash}`, () => HttpResponse.json(transaction)),