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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions features/payment-receipt-reconciler/README.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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(<PaymentReceiptReconcilerPanel />);
expect(screen.getByText(copy.emptyTitle)).toBeInTheDocument();
});

it("renders operations, effects, totals and a public receipt", async () => {
const { user } = renderFeature(<PaymentReceiptReconcilerPanel />);

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(<PaymentReceiptReconcilerPanel />);

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(<PaymentReceiptReconcilerPanel />);

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(<PaymentReceiptReconcilerPanel />);

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(<PaymentReceiptReconcilerPanel />);

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(<PaymentReceiptReconcilerPanel />);

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();
});
});
27 changes: 27 additions & 0 deletions features/payment-receipt-reconciler/__tests__/a11y.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<PaymentReceiptReconcilerPanel />);
await expectNoAxeViolations(container);
});

it("has no WCAG A/AA violations with a reconciled receipt", async () => {
const { container, user } = renderFeature(<PaymentReceiptReconcilerPanel />);

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);
});
});
103 changes: 103 additions & 0 deletions features/payment-receipt-reconciler/__tests__/effect-links.test.ts
Original file line number Diff line number Diff line change
@@ -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"
});
});
});
75 changes: 75 additions & 0 deletions features/payment-receipt-reconciler/__tests__/format.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading
Loading