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
2 changes: 2 additions & 0 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -27,6 +28,7 @@ export function App() {
<Route path="/transactions/:id" element={<TransactionDetail />} />
<Route path="/compliance/cases" element={<Cases />} />
<Route path="/decisions" element={<Decisions />} />
<Route path="/payments" element={<Payments />} />
</Routes>
</BrowserRouter>
</ThemeProvider>
Expand Down
16 changes: 16 additions & 0 deletions web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
2 changes: 1 addition & 1 deletion web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
41 changes: 41 additions & 0 deletions web/src/features/payments/PaymentsTable.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<table className="tbl">
<thead>
<tr>
<th>Payment ID</th>
<th>Reference</th>
<th>Amount</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{payments.map((payment) => (
<tr key={payment.id}>
<td
className="mono"
title={payment.id}
style={{ color: 'var(--text-accent)' }}
>
{payment.id}
</td>
<td>{payment.reference}</td>
<td className="mono" style={{ color: 'var(--text-2)' }}>
{formatMoney(payment.amount, payment.currency)}
</td>
<td>
<PaymentStatusPill status={payment.status} />
</td>
</tr>
))}
</tbody>
</table>
)
}
22 changes: 22 additions & 0 deletions web/src/features/payments/Pills.tsx
Original file line number Diff line number Diff line change
@@ -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<PaymentStatus, string> = {
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 (
<span className={`pill ${STATUS_CLASS[status] ?? 'pill-grey'}`}>
<span className="dot" />
{status}
</span>
)
}
14 changes: 14 additions & 0 deletions web/src/features/payments/usePayments.ts
Original file line number Diff line number Diff line change
@@ -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<PageResponse<PaymentResponse>>(`/v1/payments?page=${page}&size=${size}`),
})
}
68 changes: 68 additions & 0 deletions web/src/routes/Payments.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Shell activeNav="Payments">
<div
style={{
padding: '20px 24px',
display: 'flex',
flexDirection: 'column',
gap: 16,
flex: 1,
minHeight: 0,
}}
>
<div
className="card"
style={{
flex: 1,
minHeight: 0,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<div style={{ flex: 1, overflow: 'auto' }} className="fc-scroll">
{isPending ? (
<StateMessage icon="activity" text="Loading payments..." />
) : isError ? (
<StateMessage icon="alert" text="Could not load payments.">
<button type="button" className="btn" onClick={() => refetch()}>
Retry
</button>
</StateMessage>
) : data.totalElements === 0 ? (
<StateMessage icon="inbox" text="No payments yet." />
) : (
<PaymentsTable payments={data.items} />
)}
</div>
{!isPending && !isError && (
<Pagination
page={data.page}
totalPages={data.totalPages}
totalElements={data.totalElements}
onPrev={() => setPage((p) => Math.max(0, p - 1))}
onNext={() => setPage((p) => p + 1)}
noun="payments"
/>
)}
</div>
</div>
</Shell>
)
}
104 changes: 104 additions & 0 deletions web/src/test/Payments.test.tsx
Original file line number Diff line number Diff line change
@@ -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<PaymentResponse>> = {},
): PageResponse<PaymentResponse> {
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 = (
<QueryClientProvider client={client}>
<MemoryRouter initialEntries={['/payments']}>
<ThemeProvider>
<Payments />
</ThemeProvider>
</MemoryRouter>
</QueryClientProvider>
)
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<Response>(() => {}))
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',
)
})
})
Loading