From ca289e37a4a7b99f4dfdd00638b8bb76f7a31ac6 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 28 May 2026 13:19:26 +0100 Subject: [PATCH] Improve ops settlement visibility --- apps/ops-console/app/globals.css | 50 +++++++ apps/ops-console/components/OpsConsole.tsx | 126 ++++++++++++++++-- .../ops-console/e2e/reconciliation-ui.spec.ts | 48 +++++-- .../hosted/control_plane.py | 5 +- tests/test_hosted_facilitator_routes.py | 54 ++++++++ 5 files changed, 259 insertions(+), 24 deletions(-) diff --git a/apps/ops-console/app/globals.css b/apps/ops-console/app/globals.css index 9fadd04..24bc238 100644 --- a/apps/ops-console/app/globals.css +++ b/apps/ops-console/app/globals.css @@ -13,6 +13,7 @@ --subtle: #70707b; --good: #23c483; --good-dim: rgba(35, 196, 131, 0.11); + --success-dim: var(--good-dim); --warn: #eab308; --warn-dim: rgba(234, 179, 8, 0.12); --danger: #f04444; @@ -1009,6 +1010,42 @@ tr[tabindex]:focus-visible, padding: 0 10px; } +.detailActions, +.evidenceActions, +.transactionCell { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.transactionCell { + display: flex; +} + +.inlineIconLink { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 24px; + border: 1px solid var(--line); + border-radius: var(--r-sm); + padding: 0 7px; + color: var(--text); + font-size: 12px; + font-weight: 650; +} + +.inlineIconLink:hover { + border-color: var(--line-strong); + background: var(--surface-hover); +} + +.inlineIconLink:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; +} + .smallIconButton { width: 30px; height: 30px; @@ -1243,6 +1280,19 @@ tr.selectedRow { border-color: rgba(234, 179, 8, 0.24); } +.queueWarning { + display: flex; + align-items: center; + gap: 8px; + border: 1px solid rgba(234, 179, 8, 0.28); + border-radius: var(--r-md); + padding: 10px 12px; + background: var(--warn-dim); + color: #fde047; + font-size: 13px; + font-weight: 650; +} + .queueTable { min-width: 1080px; } diff --git a/apps/ops-console/components/OpsConsole.tsx b/apps/ops-console/components/OpsConsole.tsx index a9a4445..2dc5e31 100644 --- a/apps/ops-console/components/OpsConsole.tsx +++ b/apps/ops-console/components/OpsConsole.tsx @@ -244,6 +244,17 @@ export function OpsConsole() { return undefined; }, [activeView, authState, refreshReconciliationQueue]); + useEffect(() => { + if (!issuedApiKey) { + return undefined; + } + const timeout = window.setTimeout(() => { + setIssuedApiKey(null); + setCopyStatus("Secret cleared automatically"); + }, 120_000); + return () => window.clearTimeout(timeout); + }, [issuedApiKey]); + const providerOptions = useMemo(() => overview?.providers.names ?? [], [overview]); const networkOptions = useMemo( () => Array.from(new Set([...(overview?.providers.networks ?? []), ...(overview?.pauseState.networks ?? [])])).sort(), @@ -1260,7 +1271,7 @@ function SellerRecentSettlements({ {record.transaction ? shorten(record.transaction) : record.errorReason || "pending"} @@ -1770,6 +1782,12 @@ function ReconciliationQueuePanel({

No matching reconciliation records.

)} + {queue?.truncated ? ( +
+ + Queue is truncated at {formatNumber(queue.filters.limit)} records. Narrow the filters before taking action. +
+ ) : null} ); } @@ -1917,6 +1935,7 @@ function SettlementPanel({ loading={!overview} selectedRecordId={selectedSettlementId} onSelectRecord={onSelectSettlement} + onSelectSeller={onSelectSeller} />
@@ -1957,12 +1976,14 @@ function RecentSettlementsTable({ records, loading, selectedRecordId, - onSelectRecord + onSelectRecord, + onSelectSeller }: { records: SettlementRecordSummary[]; loading: boolean; selectedRecordId: number | null; onSelectRecord: (recordId: number) => Promise; + onSelectSeller: (sellerRef: string) => void; }) { if (loading) { return ; @@ -2017,7 +2038,16 @@ function RecentSettlementsTable({ - {record.sellerRef} + {record.paymentProfileId} @@ -2027,10 +2057,12 @@ function RecentSettlementsTable({ {record.amountUsdc ? `${record.amountUsdc} USDC` : record.amountAtomic || "unknown"} {shorten(record.payer)} {shorten(record.payTo)} - {record.transaction ? shorten(record.transaction) : record.errorReason || "pending"} + + + @@ -2049,6 +2082,20 @@ function RecentSettlementsTable({ ); } +function TransactionCell({ record }: { record: SettlementRecordSummary }) { + if (!record.transaction) { + return {record.errorReason || "pending"}; + } + return ( + + {shorten(record.transaction)} + + {record.provider === "circle_gateway" ? "Gateway reference" : "Evidence in detail"} + + + ); +} + function SettlementDetailPanel({ detail, loadState, @@ -2061,6 +2108,8 @@ function SettlementDetailPanel({ onSelectSeller: (sellerRef: string) => void; }) { const record = detail?.record; + const [copyStatus, setCopyStatus] = useState(null); + const [copyingValue, setCopyingValue] = useState(null); return (
@@ -2096,12 +2145,26 @@ function SettlementDetailPanel({

Transaction Evidence

- {record.explorerUrl ? ( - - - Open explorer - - ) : null} +
+ {record.transaction ? ( + + ) : null} + {record.explorerUrl ? ( + + + Open explorer + + ) : null} +
{record.transactionKind === "gateway_transfer" ? "Gateway Transfer" : "Transaction Hash"}{record.transaction || "pending"}
@@ -2109,6 +2172,45 @@ function SettlementDetailPanel({
Pay To{record.payTo || "unknown"}
Asset{assetLabel(record.asset)}
+
+ {record.traceId ? ( + + ) : null} + {record.payer ? ( + + ) : null} + {record.payTo ? ( + + ) : null} + {copyStatus ? {copyStatus} : null} +
@@ -2142,7 +2244,7 @@ function SettlementDetailPanel({ ) : ( - {attempt.transaction ? "reference" : "none"} + {attempt.transactionKind === "gateway_transfer" ? "Gateway reference" : attempt.transaction ? "Reference" : "none"} )} {attempt.errorReason || "none"} diff --git a/apps/ops-console/e2e/reconciliation-ui.spec.ts b/apps/ops-console/e2e/reconciliation-ui.spec.ts index cf4e549..8242d98 100644 --- a/apps/ops-console/e2e/reconciliation-ui.spec.ts +++ b/apps/ops-console/e2e/reconciliation-ui.spec.ts @@ -2,6 +2,8 @@ import { expect, test } from "@playwright/test"; const queueRecordId = 987654; const queueTimestamp = new Date(Date.now() - 900_000).toISOString(); +const evmTransaction = + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const queueItem = { recordId: queueRecordId, @@ -73,7 +75,7 @@ test("operator can filter reconciliation queue and open settlement detail", asyn }, counts: { unknown: 1 }, items: [queueItem], - truncated: false + truncated: true }) }); }); @@ -85,8 +87,9 @@ test("operator can filter reconciliation queue and open settlement detail", asyn body: JSON.stringify({ record: { ...queueItem, - transactionKind: "reference", - explorerUrl: "", + transaction: evmTransaction, + transactionKind: "evm_tx", + explorerUrl: `https://testnet.arcscan.app/tx/${evmTransaction}`, fingerprint: "redacted", rawRequirements: { scheme: "exact", @@ -103,13 +106,25 @@ test("operator can filter reconciliation queue and open settlement detail", asyn attemptId: 1, settlementRecordId: queueRecordId, traceId: "tr_queue_e2e", - status: "failed", + status: "settled", startedAt: queueTimestamp, finishedAt: queueTimestamp, - transaction: "", - transactionKind: "reference", + transaction: evmTransaction, + transactionKind: "evm_tx", + explorerUrl: `https://testnet.arcscan.app/tx/${evmTransaction}`, + errorReason: "" + }, + { + attemptId: 2, + settlementRecordId: queueRecordId, + traceId: "tr_queue_gateway", + status: "settled", + startedAt: queueTimestamp, + finishedAt: queueTimestamp, + transaction: "gateway-transfer-123", + transactionKind: "gateway_transfer", explorerUrl: "", - errorReason: "provider_timeout" + errorReason: "" } ] }) @@ -126,16 +141,22 @@ test("operator can filter reconciliation queue and open settlement detail", asyn await reconciliationPanel.getByLabel("Status").selectOption("unknown"); await reconciliationPanel.getByLabel("Minimum Age").selectOption("300"); await reconciliationPanel.getByLabel("Seller").fill("qa-queue-seller"); + await reconciliationPanel.getByLabel("Provider").fill(" exact_evm "); + await reconciliationPanel.getByLabel("Network").fill(" eip155:5042002 "); await Promise.all([ page.waitForRequest((request) => { const url = new URL(request.url()); return url.pathname === "/api/ops/reconciliation" && url.searchParams.get("status") === "unknown" && url.searchParams.get("sellerRef") === "qa-queue-seller" && - url.searchParams.get("minAgeSeconds") === "300"; + url.searchParams.get("minAgeSeconds") === "300" && + url.searchParams.get("provider") === "exact_evm" && + url.searchParams.get("network") === "eip155:5042002" && + url.searchParams.get("limit") === "50"; }), reconciliationPanel.getByRole("button", { name: "Refresh reconciliation queue" }).click() ]); + await expect(reconciliationPanel.getByText("Queue is truncated at 50 records")).toBeVisible(); await reconciliationPanel .locator(`button[aria-label="Open reconciliation record ${queueRecordId}"]`) .click(); @@ -144,9 +165,16 @@ test("operator can filter reconciliation queue and open settlement detail", asyn await expect(detailPanel.getByRole("heading", { name: "Transaction Evidence" })).toBeVisible(); await expect(detailPanel.getByRole("heading", { name: "Settlement Attempts" })).toBeVisible(); await expect(detailPanel.getByRole("heading", { name: "Reconciliation" })).toBeVisible(); - await expect(detailPanel.getByText("provider_timeout").first()).toBeVisible(); await expect(detailPanel.getByText("sha256:test")).toBeVisible(); - await expect(detailPanel.getByText("failed").first()).toBeVisible(); + await expect(detailPanel.getByRole("link", { name: "Open explorer" })).toHaveAttribute( + "href", + `https://testnet.arcscan.app/tx/${evmTransaction}` + ); + await expect(detailPanel.getByRole("link", { name: `Open attempt 1 transaction` })).toHaveAttribute( + "href", + `https://testnet.arcscan.app/tx/${evmTransaction}` + ); + await expect(detailPanel.getByText("Gateway reference")).toBeVisible(); await expect(reconciliationPanel.locator("tr.selectedRow").getByText("qa-queue-seller")).toBeVisible(); }); diff --git a/src/hosted_facilitator/hosted/control_plane.py b/src/hosted_facilitator/hosted/control_plane.py index dbf75f1..2b3b248 100644 --- a/src/hosted_facilitator/hosted/control_plane.py +++ b/src/hosted_facilitator/hosted/control_plane.py @@ -1167,6 +1167,7 @@ def _settlement_detail(store: Any, record_id: int) -> dict[str, Any] | None: _settlement_attempt_json( attempt, network=_safe_network_or_none(str(getattr(record, "network", "") or "")) or "", + provider=_safe_provider_name(str(getattr(record, "provider", "") or "")), ) for attempt in attempts ], @@ -1363,7 +1364,7 @@ def _settlement_record_detail_json(record: Any) -> dict[str, Any]: return summary -def _settlement_attempt_json(attempt: Any, *, network: str) -> dict[str, Any]: +def _settlement_attempt_json(attempt: Any, *, network: str, provider: str = "") -> dict[str, Any]: transaction = _safe_transaction(str(getattr(attempt, "transaction", "") or "")) return { "attemptId": max(0, int(getattr(attempt, "attempt_id", 0) or 0)), @@ -1373,7 +1374,7 @@ def _settlement_attempt_json(attempt: Any, *, network: str) -> dict[str, Any]: "startedAt": _safe_datetime_iso(getattr(attempt, "started_at", None)), "finishedAt": _safe_datetime_iso(getattr(attempt, "finished_at", None)), "transaction": transaction, - "transactionKind": _transaction_kind("", transaction), + "transactionKind": _transaction_kind(provider, transaction), "explorerUrl": _explorer_url(network, transaction), "errorReason": _safe_error_reason(str(getattr(attempt, "error_reason", "") or "")), } diff --git a/tests/test_hosted_facilitator_routes.py b/tests/test_hosted_facilitator_routes.py index 8ee41fa..2924577 100644 --- a/tests/test_hosted_facilitator_routes.py +++ b/tests/test_hosted_facilitator_routes.py @@ -36,6 +36,7 @@ ) from hosted_facilitator.hosted.storage import ( InMemorySettlementStore, + SettlementAttemptStatus, SettlementRecord, SettlementStatus, SqliteSettlementStore, @@ -2245,6 +2246,59 @@ def test_hosted_exact_factory_requires_signer_guard_in_hosted_mode(monkeypatch): create_hosted_exact_facilitator_app(config) +def test_control_plane_settlement_detail_preserves_gateway_attempt_kind(): + store = _HostedSafeMemoryStore() + record, _claim = store.claim_for_settlement( + seller_account_id="alpha", + payment_profile_id="default", + fingerprint="fp-gateway-detail", + provider="circle_gateway", + scheme="exact", + network="eip155:5042002", + trace_id="tr_gateway_detail", + raw_requirements={ + "scheme": "exact", + "network": "eip155:5042002", + "asset": "0x3600000000000000000000000000000000000000", + "amount": "1000", + "payTo": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "extra": {"name": "GatewayWalletBatched"}, + }, + ) + attempt_id = store.start_settle_attempt(record, trace_id="tr_gateway_detail") + store.mark_settled( + record, + transaction="gateway-transfer-123", + payer="0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + store.finish_settle_attempt( + attempt_id, + status=SettlementAttemptStatus.SETTLED, + transaction="gateway-transfer-123", + ) + engine = HostedFacilitatorEngine( + router=ProviderRouter([]), + store=store, + ) + app = create_hosted_facilitator_app( + engine=engine, + resolver=StaticSellerAccountResolver( + [SellerAccountConfig(seller_account_id="default", api_keys=("secret-key",))] + ), + operations_authorizer=_AllowAllOperationsAuthorizer(), + ) + + response = TestClient(app).get(f"/ops/api/settlements/{record.record_id}") + + assert response.status_code == 200 + body = response.json() + assert body["record"]["provider"] == "circle_gateway" + assert body["record"]["transactionKind"] == "gateway_transfer" + assert body["record"]["explorerUrl"] == "" + assert body["attempts"][0]["transactionKind"] == "gateway_transfer" + assert body["attempts"][0]["explorerUrl"] == "" + + def _envelope_for_network(network: str) -> dict: body = _envelope() body["paymentPayload"]["accepted"]["network"] = network