diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 8b8bfce..09cc8f4 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -19,10 +19,18 @@ export async function apiFetch(path: string): Promise { return (await response.json()) as T } -export async function apiPost(path: string): Promise { - const headers: Record = { Accept: 'application/json' } +export async function apiPost( + path: string, + options: { body?: unknown; headers?: Record } = {}, +): Promise { + const headers: Record = { Accept: 'application/json', ...options.headers } if (DEV_BEARER) headers.Authorization = `Bearer ${DEV_BEARER}` - const response = await fetch(`${BASE_URL}${path}`, { method: 'POST', headers }) + const init: RequestInit = { method: 'POST', headers } + if (options.body !== undefined) { + headers['Content-Type'] = 'application/json' + init.body = JSON.stringify(options.body) + } + const response = await fetch(`${BASE_URL}${path}`, init) if (!response.ok) throw new ApiError(response.status) return (await response.json()) as T } diff --git a/web/src/features/payments/PaymentForm.tsx b/web/src/features/payments/PaymentForm.tsx new file mode 100644 index 0000000..ba92fcf --- /dev/null +++ b/web/src/features/payments/PaymentForm.tsx @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +import { useState } from 'react' +import type { NewPayment } from './useInitiatePayment' + +const CURRENCIES = ['USD', 'EUR', 'GBP'] +const MAX_REFERENCE = 140 + +interface PaymentFormProps { + pending: boolean + onSubmit: (payment: NewPayment) => void + onCancel: () => void +} + +export function PaymentForm({ pending, onSubmit, onCancel }: PaymentFormProps) { + const [amount, setAmount] = useState('') + const [currency, setCurrency] = useState(CURRENCIES[0]) + const [reference, setReference] = useState('') + + const amountValue = Number(amount) + const valid = + amount.trim() !== '' && + Number.isFinite(amountValue) && + amountValue > 0 && + reference.trim().length > 0 + + const submit = (event: React.FormEvent) => { + event.preventDefault() + if (!valid) return + onSubmit({ amount: amountValue, currency, reference: reference.trim() }) + } + + return ( +
+ setAmount(event.target.value)} + placeholder="Amount" + /> + + setReference(event.target.value)} + placeholder="Reference" + style={{ flex: 1 }} + /> + + +
+ ) +} diff --git a/web/src/features/payments/useInitiatePayment.ts b/web/src/features/payments/useInitiatePayment.ts new file mode 100644 index 0000000..b1409b3 --- /dev/null +++ b/web/src/features/payments/useInitiatePayment.ts @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { apiPost } from '@/api/client' +import type { PaymentResponse } from '@/api/types' + +export interface NewPayment { + amount: number + currency: string + reference: string +} + +export function useInitiatePayment() { + const queryClient = useQueryClient() + return useMutation({ + // The key is supplied by the caller (one per user intent) so a retry of the same submit reuses it and the + // backend dedupes it, rather than minting a fresh key and creating a duplicate payment. + mutationFn: ({ + payment, + idempotencyKey, + }: { + payment: NewPayment + idempotencyKey: string + }) => + apiPost('/v1/payments', { + body: payment, + headers: { 'Idempotency-Key': idempotencyKey }, + }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['payments'] }), + }) +} diff --git a/web/src/routes/Payments.tsx b/web/src/routes/Payments.tsx index 316039d..cd6fa5e 100644 --- a/web/src/routes/Payments.tsx +++ b/web/src/routes/Payments.tsx @@ -5,14 +5,18 @@ import { useState } from 'react' import { Shell } from '@/components/Shell' import { StateMessage } from '@/components/StateMessage' import { Pagination } from '@/features/accounts/Pagination' +import { PaymentForm } from '@/features/payments/PaymentForm' import { PaymentsTable } from '@/features/payments/PaymentsTable' +import { useInitiatePayment } from '@/features/payments/useInitiatePayment' import { usePayments } from '@/features/payments/usePayments' const PAGE_SIZE = 20 export function Payments() { const [page, setPage] = useState(0) + const [showForm, setShowForm] = useState(false) const { data, isPending, isError, refetch } = usePayments(page, PAGE_SIZE) + const create = useInitiatePayment() return ( @@ -26,6 +30,38 @@ export function Payments() { minHeight: 0, }} > +
+ +
+ {showForm && ( + { + create.reset() + setShowForm(false) + }} + onSubmit={(payment) => + create.mutate( + { payment, idempotencyKey: crypto.randomUUID() }, + { onSuccess: () => setShowForm(false) }, + ) + } + /> + )} + {create.isError && ( +
+ Could not create the payment. Try again. +
+ )}
{ 'page', ) }) + + it('initiates a payment via POST with a body and an Idempotency-Key header', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(ok(page(SAMPLE))) + renderPayments() + await screen.findByText('order-1') + + fireEvent.click(screen.getByRole('button', { name: 'New payment' })) + fireEvent.change(screen.getByLabelText('Amount'), { target: { value: '250.50' } }) + fireEvent.change(screen.getByLabelText('Reference'), { target: { value: 'order-42' } }) + fireEvent.click(screen.getByRole('button', { name: 'Create' })) + + await waitFor(() => { + const post = fetchMock.mock.calls.find( + (c) => String(c[0]).endsWith('/v1/payments') && c[1]?.method === 'POST', + ) + expect(post).toBeTruthy() + const init = post?.[1] as RequestInit + const headers = init.headers as Record + expect(headers['Idempotency-Key']).toBeTruthy() + const body = JSON.parse(String(init.body)) + expect(typeof body.amount).toBe('number') + expect(body).toMatchObject({ amount: 250.5, currency: 'USD', reference: 'order-42' }) + }) + }) + + it('disables Create until the form is valid', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(ok(page(SAMPLE))) + renderPayments() + await screen.findByText('order-1') + + fireEvent.click(screen.getByRole('button', { name: 'New payment' })) + expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled() + + fireEvent.change(screen.getByLabelText('Amount'), { target: { value: '0' } }) + fireEvent.change(screen.getByLabelText('Reference'), { target: { value: 'r' } }) + expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled() + + fireEvent.change(screen.getByLabelText('Amount'), { target: { value: '10' } }) + expect(screen.getByRole('button', { name: 'Create' })).toBeEnabled() + }) + + it('surfaces an error when initiation fails', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => { + if ((init as RequestInit)?.method === 'POST') return Promise.reject(new Error('boom')) + void input + return Promise.resolve(ok(page(SAMPLE))) + }) + renderPayments() + await screen.findByText('order-1') + + fireEvent.click(screen.getByRole('button', { name: 'New payment' })) + fireEvent.change(screen.getByLabelText('Amount'), { target: { value: '10' } }) + fireEvent.change(screen.getByLabelText('Reference'), { target: { value: 'order-9' } }) + fireEvent.click(screen.getByRole('button', { name: 'Create' })) + + expect(await screen.findByRole('alert')).toHaveTextContent('Could not create the payment') + }) })