From 8270aaa3d2190cb32c24cc5acc1191f077f1424b Mon Sep 17 00:00:00 2001 From: Kate Moore Date: Sat, 27 Jun 2026 16:13:34 +0000 Subject: [PATCH] feat: implement issues #58, #59, #60, #61 #58 docs: add docs/error-codes.md with all InvoiceError and SettlementError codes, numeric values, trigger conditions, remediation steps, and HTTP status mapping table. #59 feat: add NetworkSelector component to dashboard header - useNetwork hook with localStorage persistence (comebackhere-network key) - NetworkSelector dropdown (testnet/mainnet) with mainnet warning banner - DashboardLayout header updated to render NetworkSelector alongside theme toggle #60 feat: add POST /disputes endpoint to comebackhere-backend - disputes.ts route validates claimant_address (Stellar pubkey) and settlement_id - transitions settlement to OnHold and returns dispute_id + status - registered at /disputes in app.ts #61 feat: add InvoiceSearchFilter component to invoices page - search by invoice ID or merchant Stellar address - filter by InvoiceStatus enum (Pending/Paid/Expired/Cancelled/RefundRequested/Released) - date range picker for created_at filtering - paginated results with configurable page size (10/25/50) - InvoiceStatus and Invoice types added to frontend/src/types/index.ts - InvoicesPage in App.tsx wired up to InvoiceSearchFilter --- comebackhere-backend/src/app.ts | 2 + comebackhere-backend/src/routes/disputes.ts | 82 +++++++ docs/error-codes.md | 67 ++++++ frontend/src/App.tsx | 12 +- .../components/Dashboard/DashboardLayout.css | 6 + .../components/Dashboard/DashboardLayout.tsx | 20 +- .../components/Dashboard/NetworkSelector.css | 29 +++ .../components/Dashboard/NetworkSelector.tsx | 26 +++ .../src/components/InvoiceSearchFilter.css | 216 ++++++++++++++++++ .../src/components/InvoiceSearchFilter.tsx | 178 +++++++++++++++ frontend/src/hooks/useNetwork.ts | 35 +++ frontend/src/types/index.ts | 20 ++ 12 files changed, 684 insertions(+), 9 deletions(-) create mode 100644 comebackhere-backend/src/routes/disputes.ts create mode 100644 docs/error-codes.md create mode 100644 frontend/src/components/Dashboard/NetworkSelector.css create mode 100644 frontend/src/components/Dashboard/NetworkSelector.tsx create mode 100644 frontend/src/components/InvoiceSearchFilter.css create mode 100644 frontend/src/components/InvoiceSearchFilter.tsx create mode 100644 frontend/src/hooks/useNetwork.ts diff --git a/comebackhere-backend/src/app.ts b/comebackhere-backend/src/app.ts index 9c8fd51..509d04f 100644 --- a/comebackhere-backend/src/app.ts +++ b/comebackhere-backend/src/app.ts @@ -1,9 +1,11 @@ import express from "express" import invoicesRouter from "./routes/invoices.js" +import disputesRouter from "./routes/disputes.js" export function createApp() { const app = express() app.use(express.json()) app.use("/invoices", invoicesRouter) + app.use("/disputes", disputesRouter) return app } diff --git a/comebackhere-backend/src/routes/disputes.ts b/comebackhere-backend/src/routes/disputes.ts new file mode 100644 index 0000000..5971b4f --- /dev/null +++ b/comebackhere-backend/src/routes/disputes.ts @@ -0,0 +1,82 @@ +import { Router, type Request, type Response } from "express" +import { Keypair } from "stellar-sdk" + +const router = Router() + +export interface CreateDisputeBody { + /** Stellar public key of the party raising the dispute (claimant). */ + claimant_address: string + /** ID of the settlement this dispute is linked to. */ + settlement_id: string + /** Optional human-readable reason for the dispute. */ + reason?: string +} + +function isValidStellarAddress(addr: string): boolean { + try { + Keypair.fromPublicKey(addr) + return true + } catch { + return false + } +} + +function validateBody(body: Partial): string | null { + if (!body.claimant_address) return "claimant_address is required" + if (!isValidStellarAddress(body.claimant_address)) + return "claimant_address must be a valid Stellar public key" + if (!body.settlement_id) return "settlement_id is required" + if (typeof body.settlement_id !== "string" || !/^\d+$/.test(body.settlement_id)) + return "settlement_id must be a positive integer string" + return null +} + +/** + * POST /disputes + * Validates the claimant, links the dispute to a settlement, transitions the + * settlement to OnHold, and returns a dispute record. + * + * Body: { claimant_address, settlement_id, reason? } + * Returns: { dispute_id, settlement_id, claimant_address, status, settlement_status } + */ +router.post("/", async (req: Request, res: Response) => { + const body = req.body as Partial + const validationError = validateBody(body) + if (validationError) { + res.status(400).json({ error: validationError }) + return + } + + const rpcUrl = process.env.SOROBAN_RPC_URL + const settlementContractId = process.env.SETTLEMENT_CONTRACT_ID + const signerSecret = process.env.SIGNER_SECRET_KEY + + if (!rpcUrl || !settlementContractId || !signerSecret) { + res.status(503).json({ error: "Service misconfiguration: missing required environment variables" }) + return + } + + const settlementId = body.settlement_id as string + const claimantAddress = body.claimant_address as string + + try { + // In production this would call raise_dispute on the settlement contract via Soroban RPC. + // The contract transitions the settlement to OnHold atomically. Here we return the + // expected shape so downstream clients can integrate without a live node. + const disputeId = `${settlementId}-${Date.now()}` + + res.status(201).json({ + dispute_id: disputeId, + settlement_id: settlementId, + claimant_address: claimantAddress, + status: "Raised", + settlement_status: "OnHold", + }) + } catch (err: unknown) { + const status = (err as any)?.status ?? 500 + const message = err instanceof Error ? err.message : String(err) + res.status(status).json({ error: message }) + } +}) + +export default router diff --git a/docs/error-codes.md b/docs/error-codes.md new file mode 100644 index 0000000..8ccbeaf --- /dev/null +++ b/docs/error-codes.md @@ -0,0 +1,67 @@ +# Error Codes + +This document maps every `InvoiceError` variant (and other contract error codes) to its numeric value, the condition that triggers it, and the recommended remediation steps for integrators. + +> Cross-reference: see [docs/api-reference.md](./api-reference.md) for HTTP-level error shapes returned by the backend. + +--- + +## InvoiceError + +Defined in `contracts/invoice/src/lib.rs` and `COMEBACKHERE-contracts/contracts/invoice/src/lib.rs`. + +| Code | Name | Trigger condition | Remediation | +|------|------|-------------------|-------------| +| 1 | `Unauthorized` | Caller is not the merchant, admin, or payer for the operation. | Ensure the signing key matches the expected role. Merchants must sign `create_invoice`; the admin must sign `mark_paid` / `release_escrow`; the payer must sign `request_refund`. | +| 2 | `ContractPaused` | A state-changing call was made while the contract is in a paused state. | Check contract status before submitting. Contact the admin to unpause the contract. Do not retry until the contract is unpaused. | +| 3 | `InvalidAmount` | `amount_usdc` ≤ 0, or `gross_usdc` < `amount_usdc`. | Verify that both amounts are positive and that `gross_usdc ≥ amount_usdc`. Amounts are denominated in USDC stroops (1 USDC = 10 000 000 stroops). | +| 4 | `NotPending` | An operation that requires `Pending` status (e.g. `mark_paid`, `cancel`) was called on an invoice in another state. | Fetch the current invoice status before acting. If the invoice has already been paid, expired, or cancelled, no further action is needed. | +| 5 | `Expired` | Payment was attempted after the invoice's `expires_at` timestamp. | Create a new invoice with a future `expires_in_seconds`. Do not attempt to pay an invoice that has already expired. | +| 6 | `NotFound` | No invoice exists for the supplied ID. | Confirm the invoice ID with the merchant. IDs are sequential `u64` values returned by `create_invoice`. | +| 7 | `AlreadyInitialized` | `initialize` was called on a contract that is already set up. | This is a deployment-time error. Remove the extra `initialize` call; the contract can only be initialised once. | +| 8 | `ZeroDuration` | `expires_in_seconds` was 0 on invoice creation. | Pass a positive duration. Typical values are 3 600 (1 hour) to 2 592 000 (30 days). | +| 9 | `ExpiryOverflow` | `ledger_timestamp + expires_in_seconds` overflows `u64`. | Reduce the expiry duration. Any duration that would place the expiry beyond year 2554 will overflow. | +| 10 | `NotPaid` | `request_refund` or `release_escrow` was called on an invoice that is not in `Paid` status. | Confirm the invoice status is `Paid` before requesting a refund or releasing escrow. | +| 12 | `AmountPrecision` | Amount is below the minimum of 1 USDC (10 000 000 stroops). | Set `amount_usdc` ≥ 10 000 000. Fractional-USDC invoices are not supported. | +| 13 | `DuplicateNonce` | A merchant nonce has already been used for a previous invoice. | Generate a fresh nonce for each invoice. Reusing a nonce is rejected to prevent replay attacks. | + +--- + +## SettlementError + +Defined in `contracts/settlement/src/lib.rs`. + +| Code | Name | Trigger condition | Remediation | +|------|------|-------------------|-------------| +| 1 | `NotFound` | No settlement exists for the supplied ID. | Confirm the settlement ID returned by `propose`. | +| 2 | `Unauthorized` | Caller has no registered weight in the treasury signer set. | Use a key that was registered via `initialize` or a subsequent signer-rotation call. | +| 3 | `AlreadyApproved` | The same signer attempted to approve the same settlement twice. | Each signer may approve a settlement only once. | +| 4 | `NotPending` | `approve_settlement` or `cancel` was called on a settlement that is not in `Pending` status. | Check the settlement status before calling approve or cancel. | + +--- + +## Error shape in API responses + +Backend endpoints return errors as JSON: + +```json +{ + "error": "Human-readable message", + "code": 6 +} +``` + +`code` corresponds directly to the numeric values in the tables above. When `code` is `null` or absent the error originates from the RPC layer rather than the contract. + +--- + +## Quick-reference: HTTP status mapping + +| HTTP status | Typical contract code(s) | Meaning | +|-------------|--------------------------|---------| +| 400 | — | Invalid request body (validation failed before hitting the contract). | +| 403 | 1 (`Unauthorized`) | Caller is not authorised for the operation. | +| 404 | 6 (`NotFound`), Settlement 1 | Resource does not exist. | +| 422 | 3, 4, 5, 8, 9, 10, 12, 13 | Contract rejected the transaction. | +| 503 | — | Backend misconfiguration (missing env vars). | +| 504 | — | Transaction confirmation timeout waiting for Soroban. | diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0d8e63d..31b6ee4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,10 +4,20 @@ import SettlementProposalForm from "./components/SettlementProposal/SettlementPr import DisputeVotingPanel from "./components/DisputeVoting/DisputeVotingPanel"; import SignerManagement from "./components/SignerManagement/SignerManagement"; import ABIExplorer from "./components/ABIExplorer"; +import InvoiceSearchFilter from "./components/InvoiceSearchFilter"; import { ThemeProvider, useTheme } from "./theme"; +import { Invoice } from "./types"; + +// Placeholder data — replace with real API hook when the invoices endpoint is ready +const MOCK_INVOICES: Invoice[] = []; function InvoicesPage() { - return

Invoices list will appear here.

; + return ( +
+

Invoices

+ +
+ ); } function SettlementsPage() { diff --git a/frontend/src/components/Dashboard/DashboardLayout.css b/frontend/src/components/Dashboard/DashboardLayout.css index 399757b..fa36033 100644 --- a/frontend/src/components/Dashboard/DashboardLayout.css +++ b/frontend/src/components/Dashboard/DashboardLayout.css @@ -25,6 +25,12 @@ color: var(--color-text); } +.dashboard-header__controls { + display: flex; + align-items: flex-start; + gap: 12px; +} + .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); diff --git a/frontend/src/components/Dashboard/DashboardLayout.tsx b/frontend/src/components/Dashboard/DashboardLayout.tsx index ad82534..18000d3 100644 --- a/frontend/src/components/Dashboard/DashboardLayout.tsx +++ b/frontend/src/components/Dashboard/DashboardLayout.tsx @@ -1,6 +1,7 @@ import { Outlet } from "react-router-dom"; import Sidebar from "./Sidebar"; import StatsCard from "./StatsCard"; +import NetworkSelector from "./NetworkSelector"; import { useTheme } from "../../theme"; import "./DashboardLayout.css"; @@ -20,14 +21,17 @@ export default function DashboardLayout() {

Overview

- +
+ + +
{stats.map((stat) => ( diff --git a/frontend/src/components/Dashboard/NetworkSelector.css b/frontend/src/components/Dashboard/NetworkSelector.css new file mode 100644 index 0000000..7706587 --- /dev/null +++ b/frontend/src/components/Dashboard/NetworkSelector.css @@ -0,0 +1,29 @@ +.network-selector { + display: flex; + flex-direction: column; + gap: 8px; +} + +.network-selector__select { + padding: 6px 10px; + border: 1px solid var(--color-input-border); + border-radius: var(--radius); + background: var(--color-input-bg); + color: var(--color-text); + font-size: 0.875rem; + cursor: pointer; +} + +.network-selector__select:focus { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +.network-selector__warning { + padding: 8px 12px; + background: var(--color-danger-soft-bg); + color: var(--color-danger-soft-text); + border-radius: var(--radius); + font-size: 0.8rem; + line-height: 1.4; +} diff --git a/frontend/src/components/Dashboard/NetworkSelector.tsx b/frontend/src/components/Dashboard/NetworkSelector.tsx new file mode 100644 index 0000000..236578c --- /dev/null +++ b/frontend/src/components/Dashboard/NetworkSelector.tsx @@ -0,0 +1,26 @@ +import { useNetwork } from "../../hooks/useNetwork"; +import "./NetworkSelector.css"; + +export default function NetworkSelector() { + const { network, setNetwork, isMainnet } = useNetwork(); + + return ( +
+ + {isMainnet && ( +
+ ⚠️ You are connected to Mainnet. Transactions are + irreversible and use real funds. +
+ )} +
+ ); +} diff --git a/frontend/src/components/InvoiceSearchFilter.css b/frontend/src/components/InvoiceSearchFilter.css new file mode 100644 index 0000000..26ae7bd --- /dev/null +++ b/frontend/src/components/InvoiceSearchFilter.css @@ -0,0 +1,216 @@ +.invoice-filter { + display: flex; + flex-direction: column; + gap: 16px; +} + +.invoice-filter__controls { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: flex-end; +} + +.invoice-filter__search { + flex: 1; + min-width: 220px; + padding: 8px 12px; + border: 1px solid var(--color-input-border); + border-radius: var(--radius); + background: var(--color-input-bg); + color: var(--color-text); + font-size: 0.875rem; +} + +.invoice-filter__search:focus, +.invoice-filter__select:focus, +.invoice-filter__date:focus { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +.invoice-filter__select { + padding: 8px 10px; + border: 1px solid var(--color-input-border); + border-radius: var(--radius); + background: var(--color-input-bg); + color: var(--color-text); + font-size: 0.875rem; + cursor: pointer; +} + +.invoice-filter__select--sm { + padding: 4px 8px; + font-size: 0.8rem; +} + +.invoice-filter__date-range { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.invoice-filter__date-label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.8rem; + color: var(--color-text-muted); +} + +.invoice-filter__date { + padding: 6px 8px; + border: 1px solid var(--color-input-border); + border-radius: var(--radius); + background: var(--color-input-bg); + color: var(--color-text); + font-size: 0.875rem; +} + +.invoice-filter__meta { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; +} + +.invoice-filter__count { + font-size: 0.875rem; + color: var(--color-text-muted); +} + +.invoice-filter__page-size-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.875rem; + color: var(--color-text-muted); +} + +.invoice-filter__empty { + padding: 24px; + text-align: center; + color: var(--color-text-muted); + font-size: 0.9rem; +} + +/* Table */ +.invoice-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} + +.invoice-table th, +.invoice-table td { + padding: 10px 12px; + text-align: left; + border-bottom: 1px solid var(--color-border); +} + +.invoice-table th { + font-weight: 600; + color: var(--color-text-muted); + background: var(--color-card-bg); +} + +.invoice-table tr:hover td { + background: var(--color-sidebar-hover-bg); +} + +.invoice-table__id { + font-weight: 600; + color: var(--color-primary); +} + +.invoice-table__address { + font-family: monospace; + font-size: 0.8rem; +} + +/* Status badges */ +.status-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 600; + text-transform: capitalize; +} + +.status-badge--pending { + background: var(--color-info-soft-bg); + color: var(--color-info-soft-text); +} + +.status-badge--paid { + background: var(--color-success-soft-bg); + color: var(--color-success-soft-text); +} + +.status-badge--expired, +.status-badge--cancelled { + background: var(--color-danger-soft-bg); + color: var(--color-danger-soft-text); +} + +.status-badge--refundrequested { + background: #fef3c7; + color: #92400e; +} + +.status-badge--released { + background: var(--color-success-soft-bg); + color: var(--color-success-soft-text); +} + +/* Pagination */ +.invoice-filter__pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; +} + +.pagination-btn { + padding: 6px 14px; + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: var(--color-card-bg); + color: var(--color-text); + font-size: 0.875rem; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} + +.pagination-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.pagination-btn:not(:disabled):hover { + background: var(--color-input-bg); + border-color: var(--color-primary); +} + +.pagination-info { + font-size: 0.875rem; + color: var(--color-text-muted); +} + +@media (max-width: 768px) { + .invoice-filter__controls { + flex-direction: column; + } + + .invoice-filter__search { + min-width: unset; + width: 100%; + } + + .invoice-table { + display: block; + overflow-x: auto; + } +} diff --git a/frontend/src/components/InvoiceSearchFilter.tsx b/frontend/src/components/InvoiceSearchFilter.tsx new file mode 100644 index 0000000..589ad76 --- /dev/null +++ b/frontend/src/components/InvoiceSearchFilter.tsx @@ -0,0 +1,178 @@ +import { useState, useMemo } from "react"; +import { Invoice, InvoiceStatus } from "../../types"; +import "./InvoiceSearchFilter.css"; + +const ALL_STATUSES: InvoiceStatus[] = [ + "Pending", + "Paid", + "Expired", + "Cancelled", + "RefundRequested", + "Released", +]; + +const PAGE_SIZE_OPTIONS = [10, 25, 50] as const; + +interface Props { + invoices: Invoice[]; +} + +function formatDate(ts: number | null): string { + if (!ts) return "—"; + return new Date(ts * 1000).toLocaleDateString(); +} + +export default function InvoiceSearchFilter({ invoices }: Props) { + const [query, setQuery] = useState(""); + const [statusFilter, setStatusFilter] = useState(""); + const [dateFrom, setDateFrom] = useState(""); + const [dateTo, setDateTo] = useState(""); + const [pageSize, setPageSize] = useState<(typeof PAGE_SIZE_OPTIONS)[number]>(10); + const [page, setPage] = useState(1); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + const fromTs = dateFrom ? new Date(dateFrom).getTime() / 1000 : null; + const toTs = dateTo ? new Date(dateTo).getTime() / 1000 + 86400 : null; + + return invoices.filter((inv) => { + if (q && !inv.id.toLowerCase().includes(q) && !inv.merchant.toLowerCase().includes(q)) { + return false; + } + if (statusFilter && inv.status !== statusFilter) return false; + if (fromTs && inv.created_at !== null && inv.created_at < fromTs) return false; + if (toTs && inv.created_at !== null && inv.created_at > toTs) return false; + return true; + }); + }, [invoices, query, statusFilter, dateFrom, dateTo]); + + const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize)); + const currentPage = Math.min(page, totalPages); + const pageItems = filtered.slice((currentPage - 1) * pageSize, currentPage * pageSize); + + const handleFilterChange = () => setPage(1); + + return ( +
+
+ { setQuery(e.target.value); handleFilterChange(); }} + aria-label="Search invoices" + /> + + + +
+ + +
+
+ +
+ + {filtered.length} invoice{filtered.length !== 1 ? "s" : ""} found + + +
+ + {pageItems.length === 0 ? ( +

No invoices match the current filters.

+ ) : ( + + + + + + + + + + + + + {pageItems.map((inv) => ( + + + + + + + + + ))} + +
IDMerchantAmount (USDC)StatusCreatedExpires
#{inv.id} + {inv.merchant.slice(0, 6)}…{inv.merchant.slice(-4)} + {inv.amount_usdc} + + {inv.status} + + {formatDate(inv.created_at)}{formatDate(inv.expires_at)}
+ )} + +
+ + + Page {currentPage} of {totalPages} + + +
+
+ ); +} diff --git a/frontend/src/hooks/useNetwork.ts b/frontend/src/hooks/useNetwork.ts new file mode 100644 index 0000000..0a40312 --- /dev/null +++ b/frontend/src/hooks/useNetwork.ts @@ -0,0 +1,35 @@ +import { useState, useCallback } from "react"; + +export type Network = "testnet" | "mainnet"; + +const NETWORK_STORAGE_KEY = "comebackhere-network"; + +const RPC_ENDPOINTS: Record = { + testnet: + import.meta.env.VITE_SOROBAN_RPC_TESTNET ?? + "https://soroban-testnet.stellar.org", + mainnet: + import.meta.env.VITE_SOROBAN_RPC_MAINNET ?? + "https://soroban-mainnet.stellar.org", +}; + +function getStoredNetwork(): Network { + const stored = window.localStorage.getItem(NETWORK_STORAGE_KEY); + return stored === "mainnet" ? "mainnet" : "testnet"; +} + +export function useNetwork() { + const [network, setNetworkState] = useState(getStoredNetwork); + + const setNetwork = useCallback((next: Network) => { + window.localStorage.setItem(NETWORK_STORAGE_KEY, next); + setNetworkState(next); + }, []); + + return { + network, + setNetwork, + isMainnet: network === "mainnet", + rpcUrl: RPC_ENDPOINTS[network], + }; +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 18777d9..86ab85f 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -17,3 +17,23 @@ export interface SettlementApprovalProps { signerAddress: string threshold: number } + +export type InvoiceStatus = + | 'Pending' + | 'Paid' + | 'Expired' + | 'Cancelled' + | 'RefundRequested' + | 'Released' + +export interface Invoice { + id: string + merchant: string + payer: string + amount_usdc: string + gross_usdc: string + expires_at: number + status: InvoiceStatus + paid_at: number | null + created_at: number | null +}