diff --git a/web/src/App.tsx b/web/src/App.tsx
index 3e83554..00873ec 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -9,6 +9,7 @@ import { Accounts } from '@/routes/Accounts'
import { Cases } from '@/routes/Cases'
import { Decisions } from '@/routes/Decisions'
import { Overview } from '@/routes/Overview'
+import { Payments } from '@/routes/Payments'
import { TransactionDetail } from '@/routes/TransactionDetail'
import { Transactions } from '@/routes/Transactions'
@@ -27,6 +28,7 @@ export function App() {
} />
} />
} />
+ } />
diff --git a/web/src/api/types.ts b/web/src/api/types.ts
index 3c60400..b8196c8 100644
--- a/web/src/api/types.ts
+++ b/web/src/api/types.ts
@@ -95,3 +95,19 @@ export interface DecisionLogResponse {
matched: boolean
outcomeLabel: string | null
}
+
+export type PaymentStatus =
+ | 'INITIATED'
+ | 'SCREENING'
+ | 'SUBMITTED'
+ | 'SETTLED'
+ | 'FAILED'
+ | 'CANCELLED'
+
+export interface PaymentResponse {
+ id: string
+ reference: string
+ amount: number
+ currency: string
+ status: PaymentStatus
+}
diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx
index fd4f0ba..97e4f0a 100644
--- a/web/src/components/Sidebar.tsx
+++ b/web/src/components/Sidebar.tsx
@@ -14,7 +14,7 @@ const NAV: NavItem[] = [
{ name: 'Overview', icon: 'home', path: '/' },
{ name: 'Accounts', icon: 'wallet', path: '/accounts' },
{ name: 'Transactions', icon: 'arrows', path: '/transactions' },
- { name: 'Payments', icon: 'send' },
+ { name: 'Payments', icon: 'send', path: '/payments' },
{ name: 'Decisions', icon: 'scale', path: '/decisions' },
{ name: 'Compliance', icon: 'shield', path: '/compliance/cases' },
{ name: 'Audit', icon: 'book' },
diff --git a/web/src/features/payments/PaymentsTable.tsx b/web/src/features/payments/PaymentsTable.tsx
new file mode 100644
index 0000000..4802b1c
--- /dev/null
+++ b/web/src/features/payments/PaymentsTable.tsx
@@ -0,0 +1,41 @@
+// SPDX-License-Identifier: BUSL-1.1
+// SPDX-FileCopyrightText: 2026 FinCore Engine Authors
+
+import type { PaymentResponse } from '@/api/types'
+import { formatMoney } from '@/lib/format'
+import { PaymentStatusPill } from './Pills'
+
+export function PaymentsTable({ payments }: { payments: PaymentResponse[] }) {
+ return (
+
+
+
+ | Payment ID |
+ Reference |
+ Amount |
+ Status |
+
+
+
+ {payments.map((payment) => (
+
+ |
+ {payment.id}
+ |
+ {payment.reference} |
+
+ {formatMoney(payment.amount, payment.currency)}
+ |
+
+
+ |
+
+ ))}
+
+
+ )
+}
diff --git a/web/src/features/payments/Pills.tsx b/web/src/features/payments/Pills.tsx
new file mode 100644
index 0000000..00cbd86
--- /dev/null
+++ b/web/src/features/payments/Pills.tsx
@@ -0,0 +1,22 @@
+// SPDX-License-Identifier: BUSL-1.1
+// SPDX-FileCopyrightText: 2026 FinCore Engine Authors
+
+import type { PaymentStatus } from '@/api/types'
+
+const STATUS_CLASS: Record = {
+ INITIATED: 'pill-grey',
+ SCREENING: 'pill-amber',
+ SUBMITTED: 'pill-violet',
+ SETTLED: 'pill-teal',
+ FAILED: 'pill-amber',
+ CANCELLED: 'pill-grey',
+}
+
+export function PaymentStatusPill({ status }: { status: PaymentStatus }) {
+ return (
+
+
+ {status}
+
+ )
+}
diff --git a/web/src/features/payments/usePayments.ts b/web/src/features/payments/usePayments.ts
new file mode 100644
index 0000000..7ed8057
--- /dev/null
+++ b/web/src/features/payments/usePayments.ts
@@ -0,0 +1,14 @@
+// SPDX-License-Identifier: BUSL-1.1
+// SPDX-FileCopyrightText: 2026 FinCore Engine Authors
+
+import { useQuery } from '@tanstack/react-query'
+import { apiFetch } from '@/api/client'
+import type { PageResponse, PaymentResponse } from '@/api/types'
+
+export function usePayments(page: number, size: number) {
+ return useQuery({
+ queryKey: ['payments', page, size],
+ queryFn: () =>
+ apiFetch>(`/v1/payments?page=${page}&size=${size}`),
+ })
+}
diff --git a/web/src/routes/Payments.tsx b/web/src/routes/Payments.tsx
new file mode 100644
index 0000000..316039d
--- /dev/null
+++ b/web/src/routes/Payments.tsx
@@ -0,0 +1,68 @@
+// SPDX-License-Identifier: BUSL-1.1
+// SPDX-FileCopyrightText: 2026 FinCore Engine Authors
+
+import { useState } from 'react'
+import { Shell } from '@/components/Shell'
+import { StateMessage } from '@/components/StateMessage'
+import { Pagination } from '@/features/accounts/Pagination'
+import { PaymentsTable } from '@/features/payments/PaymentsTable'
+import { usePayments } from '@/features/payments/usePayments'
+
+const PAGE_SIZE = 20
+
+export function Payments() {
+ const [page, setPage] = useState(0)
+ const { data, isPending, isError, refetch } = usePayments(page, PAGE_SIZE)
+
+ return (
+
+
+
+
+ {isPending ? (
+
+ ) : isError ? (
+
+
+
+ ) : data.totalElements === 0 ? (
+
+ ) : (
+
+ )}
+
+ {!isPending && !isError && (
+
setPage((p) => Math.max(0, p - 1))}
+ onNext={() => setPage((p) => p + 1)}
+ noun="payments"
+ />
+ )}
+
+
+
+ )
+}
diff --git a/web/src/test/Payments.test.tsx b/web/src/test/Payments.test.tsx
new file mode 100644
index 0000000..b082b70
--- /dev/null
+++ b/web/src/test/Payments.test.tsx
@@ -0,0 +1,104 @@
+// SPDX-License-Identifier: BUSL-1.1
+// SPDX-FileCopyrightText: 2026 FinCore Engine Authors
+
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import type { ReactElement } from 'react'
+import { MemoryRouter } from 'react-router-dom'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { PageResponse, PaymentResponse } from '@/api/types'
+import { ThemeProvider } from '@/components/ThemeProvider'
+import { Payments } from '@/routes/Payments'
+
+function page(
+ items: PaymentResponse[],
+ over: Partial> = {},
+): PageResponse {
+ return { items, page: 0, size: 20, totalElements: items.length, totalPages: 1, ...over }
+}
+
+const SAMPLE: PaymentResponse[] = [
+ { id: 'pay_0001', reference: 'order-1', amount: 100.0, currency: 'USD', status: 'INITIATED' },
+ { id: 'pay_0002', reference: 'order-2', amount: 250.5, currency: 'EUR', status: 'SETTLED' },
+]
+
+function ok(body: unknown) {
+ return { ok: true, status: 200, json: async () => body } as Response
+}
+
+function renderPayments() {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+ const ui: ReactElement = (
+
+
+
+
+
+
+
+ )
+ return render(ui)
+}
+
+afterEach(() => {
+ vi.restoreAllMocks()
+})
+
+describe('Payments', () => {
+ it('shows a loading state while the request is in flight', () => {
+ vi.spyOn(globalThis, 'fetch').mockReturnValue(new Promise(() => {}))
+ renderPayments()
+ expect(screen.getByText('Loading payments...')).toBeInTheDocument()
+ expect(screen.queryByRole('table')).not.toBeInTheDocument()
+ })
+
+ it('renders a row per payment with only the real contract fields', async () => {
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(ok(page(SAMPLE, { totalElements: 2 })))
+ renderPayments()
+ expect(await screen.findByText('order-1')).toBeInTheDocument()
+ expect(screen.getAllByRole('row')).toHaveLength(SAMPLE.length + 1)
+ expect(screen.getByText('pay_0001')).toBeInTheDocument()
+ expect(screen.getByText('SETTLED')).toBeInTheDocument()
+ expect(screen.queryByText('Provider')).not.toBeInTheDocument()
+ })
+
+ it('shows an empty state and disables Next when there are no payments', async () => {
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
+ ok(page([], { totalElements: 0, totalPages: 0 })),
+ )
+ renderPayments()
+ expect(await screen.findByText('No payments yet.')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled()
+ })
+
+ it('shows an error state with a working Retry', async () => {
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network down'))
+ renderPayments()
+ expect(await screen.findByText('Could not load payments.')).toBeInTheDocument()
+ const before = fetchMock.mock.calls.length
+ fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
+ await waitFor(() => expect(fetchMock.mock.calls.length).toBeGreaterThan(before))
+ })
+
+ it('paginates: Prev disabled on first page, Next refetches with the next page', async () => {
+ const fetchMock = vi
+ .spyOn(globalThis, 'fetch')
+ .mockResolvedValue(ok(page(SAMPLE, { totalElements: 45, totalPages: 3 })))
+ renderPayments()
+ await screen.findByText('order-1')
+ expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Next page' }))
+ await waitFor(() => expect(String(fetchMock.mock.calls.at(-1)?.[0])).toContain('page=1'))
+ })
+
+ it('marks the Payments sidebar item as the current page', async () => {
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(ok(page(SAMPLE, { totalElements: 2 })))
+ renderPayments()
+ await screen.findByText('order-1')
+ expect(screen.getByRole('link', { name: 'Payments' })).toHaveAttribute(
+ 'aria-current',
+ 'page',
+ )
+ })
+})