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
14 changes: 11 additions & 3 deletions web/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,18 @@ export async function apiFetch<T>(path: string): Promise<T> {
return (await response.json()) as T
}

export async function apiPost<T>(path: string): Promise<T> {
const headers: Record<string, string> = { Accept: 'application/json' }
export async function apiPost<T>(
path: string,
options: { body?: unknown; headers?: Record<string, string> } = {},
): Promise<T> {
const headers: Record<string, string> = { 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
}
73 changes: 73 additions & 0 deletions web/src/features/payments/PaymentForm.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<form onSubmit={submit} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
className="input"
aria-label="Amount"
inputMode="decimal"
value={amount}
onChange={(event) => setAmount(event.target.value)}
placeholder="Amount"
/>
<select
className="input"
aria-label="Currency"
value={currency}
onChange={(event) => setCurrency(event.target.value)}
>
{CURRENCIES.map((code) => (
<option key={code} value={code}>
{code}
</option>
))}
</select>
<input
className="input mono"
aria-label="Reference"
maxLength={MAX_REFERENCE}
value={reference}
onChange={(event) => setReference(event.target.value)}
placeholder="Reference"
style={{ flex: 1 }}
/>
<button type="submit" className="btn btn-primary" disabled={!valid || pending}>
Create
</button>
<button type="button" className="btn" onClick={onCancel} disabled={pending}>
Cancel
</button>
</form>
)
}
32 changes: 32 additions & 0 deletions web/src/features/payments/useInitiatePayment.ts
Original file line number Diff line number Diff line change
@@ -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<PaymentResponse>('/v1/payments', {
body: payment,
headers: { 'Idempotency-Key': idempotencyKey },
}),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['payments'] }),
})
}
36 changes: 36 additions & 0 deletions web/src/routes/Payments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Shell activeNav="Payments">
Expand All @@ -26,6 +30,38 @@ export function Payments() {
minHeight: 0,
}}
>
<div>
<button
type="button"
className="btn btn-primary"
onClick={() => {
create.reset()
setShowForm((open) => !open)
}}
>
{showForm ? 'Close' : 'New payment'}
</button>
</div>
{showForm && (
<PaymentForm
pending={create.isPending}
onCancel={() => {
create.reset()
setShowForm(false)
}}
onSubmit={(payment) =>
create.mutate(
{ payment, idempotencyKey: crypto.randomUUID() },
{ onSuccess: () => setShowForm(false) },
)
}
/>
)}
{create.isError && (
<div role="alert" style={{ fontSize: 12, color: 'var(--text-err)' }}>
Could not create the payment. Try again.
</div>
)}
<div
className="card"
style={{
Expand Down
57 changes: 57 additions & 0 deletions web/src/test/Payments.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,61 @@ describe('Payments', () => {
'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<string, string>
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')
})
})
Loading