diff --git a/docs/architecture/frontend-boundary.md b/docs/architecture/frontend-boundary.md index 5dba720..4f28bfa 100644 --- a/docs/architecture/frontend-boundary.md +++ b/docs/architecture/frontend-boundary.md @@ -16,10 +16,14 @@ ## Protected Workflow Endpoints - `GET /api/pos/transactions/summary?contextId=...` +- `GET /api/pos/transactions/{transactionId}?contextId=...` +- `GET /api/pos/transactions/recent-payments?contextId=...` - `POST /api/pos/transactions/payments` ## UI Workflow Coverage -- POS transaction summary lookup -- POS payment capture +- POS transaction summary lookup with open balance visibility +- Transaction detail inspection for a selected transaction id +- Recent payment activity review +- Payment capture with retry-ready local session history - Protected route shell for summary, payment capture, and session inspection diff --git a/docs/runbooks/local-development.md b/docs/runbooks/local-development.md index 2778eab..1ac6f1d 100644 --- a/docs/runbooks/local-development.md +++ b/docs/runbooks/local-development.md @@ -18,9 +18,10 @@ npm run dev ## Auth Model -- Login is executed via central Thalos OIDC start endpoint. +- Login is executed via the central Thalos OIDC start endpoint. - Business calls are gated behind session checks. - Session cookies are sent with `credentials: include`. +- Summary, detail, recent-payment, and capture actions all surface session-expired guidance before retry. ## Build diff --git a/docs/runbooks/testing.md b/docs/runbooks/testing.md index 751a3d7..3924511 100644 --- a/docs/runbooks/testing.md +++ b/docs/runbooks/testing.md @@ -17,8 +17,9 @@ npm run test:ci - `src/api/client.test.ts`: runtime-config precedence and fallback behavior. - `src/api/dashboardApi.test.ts`: endpoint path/query composition and payload mapping. - `src/auth/oidcLogin.test.ts`: OIDC start-url generation and safe return-url fallback. -- `src/App.test.tsx`: central login screen, protected summary flow, and payment capture workflow. +- `src/App.test.tsx`: central login screen, protected summary/detail flow, payment capture, and session-expired recovery guidance. ## Notes - Use containerized Node execution when host `npm` is unavailable. +- Container-first validation for this repo is `npm ci && npm run test:ci && npm run build` inside Docker. diff --git a/src/App.test.tsx b/src/App.test.tsx index 64d782d..9131809 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,5 +1,6 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ApiError } from './api/client'; vi.mock('./api/sessionApi', () => ({ getSessionMe: vi.fn(), @@ -10,16 +11,20 @@ vi.mock('./api/sessionApi', () => ({ vi.mock('./api/dashboardApi', () => ({ loadDashboard: vi.fn(), + loadTransactionDetail: vi.fn(), + loadRecentPayments: vi.fn(), capturePosPayment: vi.fn() })); -import { capturePosPayment, loadDashboard } from './api/dashboardApi'; +import { capturePosPayment, loadDashboard, loadRecentPayments, loadTransactionDetail } from './api/dashboardApi'; import { getSessionMe } from './api/sessionApi'; import App from './App'; describe('POS Transactions App', () => { beforeEach(() => { vi.mocked(loadDashboard).mockReset(); + vi.mocked(loadTransactionDetail).mockReset(); + vi.mocked(loadRecentPayments).mockReset(); vi.mocked(capturePosPayment).mockReset(); vi.mocked(getSessionMe).mockReset(); window.__APP_CONFIG__ = { @@ -42,44 +47,138 @@ describe('POS Transactions App', () => { expect(link.href).toContain('tenantId=demo-tenant'); }); - it('loads summary for authenticated users', async () => { + it('loads summary, detail, and recent payments for authenticated users', async () => { vi.mocked(getSessionMe).mockResolvedValue({ isAuthenticated: true, subjectId: 'demo-user', tenantId: 'demo-tenant', - provider: 0 + provider: 2 + }); + vi.mocked(loadDashboard).mockResolvedValue({ + contextId: 'demo-context', + summary: '2 payments awaiting reconciliation', + openBalance: 52.3, + currency: 'USD', + recentPayments: [] + }); + vi.mocked(loadTransactionDetail).mockResolvedValue({ + contextId: 'demo-context', + summary: 'detail loaded', + openBalance: 52.3, + currency: 'USD', + transaction: { + transactionId: 'POS-22', + paymentMethod: 'card', + amount: 25, + currency: 'USD', + status: 'Captured', + capturedAtUtc: '2026-03-27T12:00:00Z' + } + }); + vi.mocked(loadRecentPayments).mockResolvedValue({ + contextId: 'demo-context', + summary: 'recent loaded', + openBalance: 52.3, + currency: 'USD', + recentPayments: [ + { + transactionId: 'POS-21', + paymentMethod: 'cash', + amount: 10, + currency: 'USD', + status: 'Captured', + capturedAtUtc: '2026-03-27T11:00:00Z' + } + ] }); - vi.mocked(loadDashboard).mockResolvedValue({ contextId: 'demo-context', summary: 'ok' }); render(); - await waitFor(() => expect(screen.getByRole('button', { name: 'Load Summary' })).toBeInTheDocument()); - fireEvent.click(screen.getByRole('button', { name: 'Load Summary' })); + await waitFor(() => expect(screen.getByPlaceholderText('Context Id')).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: /Load Summary/ })); + fireEvent.click(screen.getByRole('button', { name: /Load Transaction Detail/ })); + fireEvent.click(screen.getByRole('button', { name: /Load Recent Payments/ })); await waitFor(() => expect(loadDashboard).toHaveBeenCalledWith('demo-context')); + await waitFor(() => expect(loadTransactionDetail).toHaveBeenCalledWith('demo-context', 'POS-9001')); + await waitFor(() => expect(loadRecentPayments).toHaveBeenCalledWith('demo-context')); + + expect(await screen.findByText('2 payments awaiting reconciliation')).toBeInTheDocument(); + expect(await screen.findByText('POS-22')).toBeInTheDocument(); + expect(await screen.findByText('POS-21')).toBeInTheDocument(); }); - it('captures payment from action route', async () => { + it('captures payments and records retry-ready history', async () => { vi.mocked(getSessionMe).mockResolvedValue({ isAuthenticated: true, subjectId: 'demo-user', tenantId: 'demo-tenant', - provider: 0 + provider: 2 + }); + vi.mocked(loadDashboard).mockResolvedValue({ + contextId: 'demo-context', + summary: 'summary refreshed', + openBalance: 10, + currency: 'USD', + recentPayments: [] + }); + vi.mocked(loadTransactionDetail).mockResolvedValue({ + contextId: 'demo-context', + summary: 'detail refreshed', + openBalance: 10, + currency: 'USD', + transaction: null + }); + vi.mocked(loadRecentPayments).mockResolvedValue({ + contextId: 'demo-context', + summary: 'recent refreshed', + openBalance: 10, + currency: 'USD', + recentPayments: [] }); vi.mocked(capturePosPayment).mockResolvedValue({ contextId: 'demo-context', transactionId: 'POS-2200', succeeded: true, - summary: 'captured' + summary: 'captured', + status: 'Captured', + capturedAtUtc: '2026-03-27T13:00:00Z' }); + window.history.pushState({}, '', '/payments'); render(); - await waitFor(() => expect(screen.getByText('Capture Payment')).toBeInTheDocument()); - fireEvent.click(screen.getByText('Capture Payment')); + await waitFor(() => expect(screen.getByRole('button', { name: 'Capture Payment Now' })).toBeInTheDocument()); fireEvent.change(screen.getByPlaceholderText('Transaction Id'), { target: { value: 'POS-2200' } }); fireEvent.click(screen.getByRole('button', { name: 'Capture Payment Now' })); await waitFor(() => expect(capturePosPayment).toHaveBeenCalledTimes(1)); + expect(await screen.findAllByText('POS-2200')).toHaveLength(2); + expect(screen.getByRole('button', { name: 'Retry Last Capture' })).not.toBeDisabled(); + }); + + it('shows reauthentication guidance when summary loading returns session expired', async () => { + vi.mocked(getSessionMe) + .mockResolvedValueOnce({ + isAuthenticated: true, + subjectId: 'demo-user', + tenantId: 'demo-tenant', + provider: 2 + }) + .mockResolvedValueOnce({ + isAuthenticated: false, + subjectId: '', + tenantId: '', + provider: 2 + }); + vi.mocked(loadDashboard).mockRejectedValue(new ApiError(401, 'No active session.', 'session_missing', 'corr-1')); + + render(); + + await waitFor(() => expect(screen.getByPlaceholderText('Context Id')).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: /Load Summary/ })); + + await waitFor(() => expect(screen.getByText('Session expired')).toBeInTheDocument()); + expect(screen.getByRole('link', { name: 'Continue with Google' })).toBeInTheDocument(); }); }); diff --git a/src/App.tsx b/src/App.tsx index 1e440c0..d06a195 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,16 @@ -import { CreditCardOutlined, DeploymentUnitOutlined, ReloadOutlined, WalletOutlined } from '@ant-design/icons'; +import { + CreditCardOutlined, + DeploymentUnitOutlined, + ReloadOutlined, + SearchOutlined, + WalletOutlined +} from '@ant-design/icons'; import { Alert, Button, Card, Descriptions, + Empty, Form, Input, InputNumber, @@ -18,12 +25,18 @@ import { } from 'antd'; import { type ReactNode, useMemo, useState } from 'react'; import { BrowserRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; +import { ApiError } from './api/client'; import { capturePosPayment, loadDashboard, + loadRecentPayments, + loadTransactionDetail, type CapturePosPaymentRequest, type CapturePosPaymentResponse, - type PosTransactionSummaryResponse + type PosPaymentActivity, + type PosTransactionDetailResponse, + type PosTransactionSummaryResponse, + type RecentPosPaymentsResponse } from './api/dashboardApi'; import type { IdentityProvider } from './api/sessionApi'; import { buildGoogleOidcStartUrl } from './auth/oidcLogin'; @@ -31,12 +44,35 @@ import { SessionProvider, useSessionContext } from './auth/sessionContext'; type AppRoute = '/summary' | '/payments' | '/session'; +type WorkflowState = { + error: string | null; + sessionExpired: boolean; +}; + +type PaymentAttempt = { + request: CapturePosPaymentRequest; + response: CapturePosPaymentResponse; +}; + const routeItems: Array<{ key: AppRoute; label: string; icon: ReactNode }> = [ { key: '/summary', label: 'Transaction Summary', icon: }, { key: '/payments', label: 'Capture Payment', icon: }, { key: '/session', label: 'Session', icon: } ]; +const paymentColumns = [ + { title: 'Transaction Id', dataIndex: 'transactionId' }, + { title: 'Method', dataIndex: 'paymentMethod' }, + { title: 'Amount', render: (_: unknown, record: PosPaymentActivity) => `${record.amount.toFixed(2)} ${record.currency}` }, + { + title: 'Status', + render: (_: unknown, record: PosPaymentActivity) => ( + {record.status} + ) + }, + { title: 'Captured At', render: (_: unknown, record: PosPaymentActivity) => formatUtc(record.capturedAtUtc) } +]; + function App() { return ( @@ -53,11 +89,17 @@ function PosTransactionsShell() { const navigate = useNavigate(); const [contextId, setContextId] = useState('demo-context'); + const [transactionId, setTransactionId] = useState('POS-9001'); const [summaryPayload, setSummaryPayload] = useState(null); + const [detailPayload, setDetailPayload] = useState(null); + const [recentPayload, setRecentPayload] = useState(null); const [paymentResponse, setPaymentResponse] = useState(null); - const [paymentHistory, setPaymentHistory] = useState([]); - const [globalError, setGlobalError] = useState(null); + const [paymentHistory, setPaymentHistory] = useState([]); + const [lastCaptureRequest, setLastCaptureRequest] = useState(null); + const [workflowState, setWorkflowState] = useState({ error: null, sessionExpired: false }); const [loadingSummary, setLoadingSummary] = useState(false); + const [loadingDetail, setLoadingDetail] = useState(false); + const [loadingRecentPayments, setLoadingRecentPayments] = useState(false); const [submittingPayment, setSubmittingPayment] = useState(false); const loginUrl = useMemo(() => buildGoogleOidcStartUrl(window.location.href), []); @@ -66,30 +108,74 @@ function PosTransactionsShell() { return candidate?.key ?? '/summary'; }, [location.pathname]); + const clearWorkflowError = () => setWorkflowState({ error: null, sessionExpired: false }); + + const handleWorkflowFailure = async (err: unknown, fallbackMessage: string) => { + if (err instanceof ApiError && err.status === 401) { + setWorkflowState({ error: 'Your session expired. Sign in again to continue POS operations.', sessionExpired: true }); + await session.revalidate(); + return; + } + + setWorkflowState({ + error: err instanceof Error ? err.message : fallbackMessage, + sessionExpired: false + }); + }; + const loadSummary = async () => { setLoadingSummary(true); - setGlobalError(null); + clearWorkflowError(); try { const payload = await loadDashboard(contextId); setSummaryPayload(payload); } catch (err) { - setGlobalError(err instanceof Error ? err.message : 'Failed to load transaction summary.'); + await handleWorkflowFailure(err, 'Failed to load POS summary.'); } finally { setLoadingSummary(false); } }; - const capturePayment = async (request: CapturePosPaymentRequest) => { + const loadDetail = async () => { + setLoadingDetail(true); + clearWorkflowError(); + try { + const payload = await loadTransactionDetail(contextId, transactionId); + setDetailPayload(payload); + } catch (err) { + await handleWorkflowFailure(err, 'Failed to load transaction detail.'); + } finally { + setLoadingDetail(false); + } + }; + + const refreshRecentPayments = async () => { + setLoadingRecentPayments(true); + clearWorkflowError(); + try { + const payload = await loadRecentPayments(contextId); + setRecentPayload(payload); + } catch (err) { + await handleWorkflowFailure(err, 'Failed to load recent payments.'); + } finally { + setLoadingRecentPayments(false); + } + }; + + const submitCapturePayment = async (request: CapturePosPaymentRequest) => { setSubmittingPayment(true); - setGlobalError(null); + clearWorkflowError(); + setLastCaptureRequest(request); try { const payload = await capturePosPayment(request); setPaymentResponse(payload); - // Keep recent responses bounded so the session view stays readable over long demos. - setPaymentHistory((previous) => [payload, ...previous].slice(0, 8)); + setPaymentHistory((previous) => [{ request, response: payload }, ...previous].slice(0, 8)); + await refreshRecentPayments(); + await loadSummary(); + await loadDetail(); } catch (err) { - setGlobalError(err instanceof Error ? err.message : 'Failed to capture payment.'); + await handleWorkflowFailure(err, 'Failed to capture payment.'); } finally { setSubmittingPayment(false); } @@ -116,6 +202,14 @@ function PosTransactionsShell() { } /> + {workflowState.sessionExpired && ( + + )} {session.error && } ); @@ -125,12 +219,7 @@ function PosTransactionsShell() {
POS Transactions Web
- navigate(event.key as AppRoute)} - /> + navigate(event.key as AppRoute)} /> @@ -151,99 +240,191 @@ function PosTransactionsShell() { POS Transactions - Protected POS workflows for summary lookup and payment capture. + Protected POS workflows for summary lookup, transaction detail inspection, capture retries, and recent payment activity. {session.error && } - {globalError && } + {workflowState.error && } + {workflowState.sessionExpired && ( + + + + + } + /> + )} - - - setContextId(event.target.value)} - placeholder="Context Id" - style={{ width: 280 }} - /> - + + + + + setContextId(event.target.value)} placeholder="Context Id" style={{ width: 260 }} /> + setTransactionId(event.target.value)} + placeholder="Transaction Id" + style={{ width: 260 }} + /> + + + + + {summaryPayload ? ( + + {summaryPayload.contextId} + {summaryPayload.summary} + + {summaryPayload.openBalance.toFixed(2)} {summaryPayload.currency} + + + ) : ( + + )} - {summaryPayload ? ( + + + + {detailPayload?.transaction ? ( - {summaryPayload.contextId} - {summaryPayload.summary} + {detailPayload.contextId} + {detailPayload.summary} + {detailPayload.transaction.transactionId} + {detailPayload.transaction.paymentMethod} + + {detailPayload.transaction.amount.toFixed(2)} {detailPayload.transaction.currency} + + {detailPayload.transaction.status} + {formatUtc(detailPayload.transaction.capturedAtUtc)} ) : ( - No transaction summary loaded. + )} - - + + + + {recentPayload?.recentPayments.length ? ( + + pagination={false} + rowKey={(record) => `${record.transactionId}-${record.capturedAtUtc}`} + dataSource={recentPayload.recentPayments} + columns={paymentColumns} + /> + ) : ( + + )} + + } /> - - - layout="vertical" - initialValues={{ - contextId, - transactionId: 'POS-9001', - amount: 25.5, - currency: 'USD', - paymentMethod: 'card' - }} - onFinish={(values) => void capturePayment(values)} - > - - - - - - - - - - - - - - - - - - {paymentResponse && ( + + + + + layout="vertical" + initialValues={{ + contextId, + transactionId: 'POS-9001', + amount: 25.5, + currency: 'USD', + paymentMethod: 'card' + }} + onFinish={(values) => void submitCapturePayment(values)} + > + + + + + + + + + + + + + + + + + + + + + + Use retry when a payment attempt fails after verifying the transaction payload and operator session. + + + + + + {paymentResponse ? ( {paymentResponse.contextId} {paymentResponse.transactionId} {String(paymentResponse.succeeded)} + {paymentResponse.status} + {formatUtc(paymentResponse.capturedAtUtc)} {paymentResponse.summary} + ) : ( + )} - + + + + pagination={false} - rowKey={(record) => `${record.contextId}-${record.transactionId}`} + rowKey={(record) => `${record.response.transactionId}-${record.response.capturedAtUtc}`} dataSource={paymentHistory} + locale={{ emptyText: 'No capture attempts recorded in this session.' }} columns={[ - { title: 'Transaction Id', dataIndex: 'transactionId' }, - { title: 'Context Id', dataIndex: 'contextId' }, + { title: 'Transaction Id', render: (_, record) => record.response.transactionId }, + { title: 'Method', render: (_, record) => record.request.paymentMethod }, { - title: 'Succeeded', - render: (_, record) => {String(record.succeeded)} + title: 'Amount', + render: (_, record) => `${record.request.amount.toFixed(2)} ${record.request.currency}` }, - { title: 'Summary', dataIndex: 'summary' } + { + title: 'Status', + render: (_, record) => ( + {record.response.status} + ) + }, + { title: 'Summary', render: (_, record) => record.response.summary } ]} /> - - + + } /> ({ })); import { getJson, postJson } from './client'; -import { capturePosPayment, loadDashboard } from './dashboardApi'; +import { capturePosPayment, loadDashboard, loadRecentPayments, loadTransactionDetail } from './dashboardApi'; describe('pos transactions dashboard api', () => { it('builds encoded summary endpoint path', async () => { @@ -17,6 +17,22 @@ describe('pos transactions dashboard api', () => { expect(getJson).toHaveBeenCalledWith('/api/pos/transactions/summary?contextId=ctx%20pos%2F1'); }); + it('builds encoded transaction detail path', async () => { + vi.mocked(getJson).mockResolvedValue({ ok: true }); + + await loadTransactionDetail('ctx pos/1', 'POS/22'); + + expect(getJson).toHaveBeenCalledWith('/api/pos/transactions/POS%2F22?contextId=ctx%20pos%2F1'); + }); + + it('builds encoded recent payments path', async () => { + vi.mocked(getJson).mockResolvedValue({ ok: true }); + + await loadRecentPayments('ctx pos/1'); + + expect(getJson).toHaveBeenCalledWith('/api/pos/transactions/recent-payments?contextId=ctx%20pos%2F1'); + }); + it('posts payment capture payload', async () => { vi.mocked(postJson).mockResolvedValue({ succeeded: true }); diff --git a/src/api/dashboardApi.ts b/src/api/dashboardApi.ts index 09f89cc..3dcdb59 100644 --- a/src/api/dashboardApi.ts +++ b/src/api/dashboardApi.ts @@ -1,8 +1,36 @@ import { getJson, postJson } from './client'; +export type PosPaymentActivity = { + transactionId: string; + paymentMethod: string; + amount: number; + currency: string; + status: string; + capturedAtUtc: string; +}; + export type PosTransactionSummaryResponse = { contextId: string; summary: string; + openBalance: number; + currency: string; + recentPayments: PosPaymentActivity[]; +}; + +export type PosTransactionDetailResponse = { + contextId: string; + summary: string; + openBalance: number; + currency: string; + transaction: PosPaymentActivity | null; +}; + +export type RecentPosPaymentsResponse = { + contextId: string; + summary: string; + openBalance: number; + currency: string; + recentPayments: PosPaymentActivity[]; }; export type CapturePosPaymentRequest = { @@ -18,12 +46,24 @@ export type CapturePosPaymentResponse = { transactionId: string; succeeded: boolean; summary: string; + status: string; + capturedAtUtc: string; }; export async function loadDashboard(contextId: string): Promise { return getJson(`/api/pos/transactions/summary?contextId=${encodeURIComponent(contextId)}`); } +export async function loadTransactionDetail(contextId: string, transactionId: string): Promise { + return getJson( + `/api/pos/transactions/${encodeURIComponent(transactionId)}?contextId=${encodeURIComponent(contextId)}` + ); +} + +export async function loadRecentPayments(contextId: string): Promise { + return getJson(`/api/pos/transactions/recent-payments?contextId=${encodeURIComponent(contextId)}`); +} + export async function capturePosPayment(request: CapturePosPaymentRequest): Promise { return postJson('/api/pos/transactions/payments', request); }