From f45038b8144ac46cf03e96b5d2db1f6fa50f8cc6 Mon Sep 17 00:00:00 2001 From: "@tanya_r" Date: Fri, 19 Jun 2026 12:39:18 -0300 Subject: [PATCH] feat(web): add payments list screen on the live payments api Add a payments screen to the sandbox dashboard, listing payments newest first on GET /v1/payments with pagination and the usual loading, empty and error states. It mirrors the accounts screen: a TanStack Query hook over the shared api client, a typed PaymentResponse matching the backend contract, a status pill, a formatted amount, and a sidebar entry. The amount is typed as a number to match the payments serialization. All data comes from the API. Closes #289 --- web/src/App.tsx | 2 + web/src/api/types.ts | 16 +++ web/src/components/Sidebar.tsx | 2 +- web/src/features/payments/PaymentsTable.tsx | 41 ++++++++ web/src/features/payments/Pills.tsx | 22 +++++ web/src/features/payments/usePayments.ts | 14 +++ web/src/routes/Payments.tsx | 68 +++++++++++++ web/src/test/Payments.test.tsx | 104 ++++++++++++++++++++ 8 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 web/src/features/payments/PaymentsTable.tsx create mode 100644 web/src/features/payments/Pills.tsx create mode 100644 web/src/features/payments/usePayments.ts create mode 100644 web/src/routes/Payments.tsx create mode 100644 web/src/test/Payments.test.tsx 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 ( + + + + + + + + + + + {payments.map((payment) => ( + + + + + + + ))} + +
Payment IDReferenceAmountStatus
+ {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', + ) + }) +})