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
-
@@ -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 }}
+ />
+ } loading={loadingSummary} onClick={() => void loadSummary()}>
+ Load Summary
+
+
+
+
+ {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}
+ />
+ ) : (
+
+ )}
+
+
}
/>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {paymentResponse && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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);
}