Compare commits

..

3 Commits

Author SHA1 Message Date
José René White Enciso
0dfa200ebf feat(pos-transactions-web): show payable payment progression 2026-03-31 19:08:40 -06:00
José René White Enciso
a626474c65 feat(pos-transactions-web): deepen pos workflow experience 2026-03-31 17:34:18 -06:00
José René White Enciso
57e95c2a55 merge: integrate pos-transactions-web auth and web updates 2026-03-11 12:39:36 -06:00
7 changed files with 502 additions and 103 deletions

View File

@ -16,10 +16,15 @@
## Protected Workflow Endpoints ## Protected Workflow Endpoints
- `GET /api/pos/transactions/summary?contextId=...` - `GET /api/pos/transactions/summary?contextId=...`
- `GET /api/pos/transactions/{transactionId}?contextId=...`
- `GET /api/pos/transactions/recent-payments?contextId=...`
- `POST /api/pos/transactions/payments` - `POST /api/pos/transactions/payments`
## UI Workflow Coverage ## UI Workflow Coverage
- POS transaction summary lookup - POS transaction summary lookup with open balance visibility
- POS payment capture - Transaction detail inspection for a selected payable check or transaction id
- Recent payment activity review
- Payment capture with retry-ready local session history and lifecycle-aware payment hints
- Protected route shell for summary, payment capture, and session inspection - Protected route shell for summary, payment capture, and session inspection
- POS actions are presented as the final step after kitchen and floor service complete the restaurant order.

View File

@ -18,9 +18,11 @@ npm run dev
## Auth Model ## 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. - Business calls are gated behind session checks.
- Session cookies are sent with `credentials: include`. - Session cookies are sent with `credentials: include`.
- Summary, detail, recent-payment, and capture actions all surface session-expired guidance before retry.
- UI copy assumes POS only acts on checks that became payable after restaurant service completion.
## Build ## Build

View File

@ -17,8 +17,9 @@ npm run test:ci
- `src/api/client.test.ts`: runtime-config precedence and fallback behavior. - `src/api/client.test.ts`: runtime-config precedence and fallback behavior.
- `src/api/dashboardApi.test.ts`: endpoint path/query composition and payload mapping. - `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/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, lifecycle-aware payable-check summary/detail flow, payment capture, and session-expired recovery guidance.
## Notes ## Notes
- Use containerized Node execution when host `npm` is unavailable. - 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.

View File

@ -1,5 +1,6 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiError } from './api/client';
vi.mock('./api/sessionApi', () => ({ vi.mock('./api/sessionApi', () => ({
getSessionMe: vi.fn(), getSessionMe: vi.fn(),
@ -10,16 +11,20 @@ vi.mock('./api/sessionApi', () => ({
vi.mock('./api/dashboardApi', () => ({ vi.mock('./api/dashboardApi', () => ({
loadDashboard: vi.fn(), loadDashboard: vi.fn(),
loadTransactionDetail: vi.fn(),
loadRecentPayments: vi.fn(),
capturePosPayment: 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 { getSessionMe } from './api/sessionApi';
import App from './App'; import App from './App';
describe('POS Transactions App', () => { describe('POS Transactions App', () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(loadDashboard).mockReset(); vi.mocked(loadDashboard).mockReset();
vi.mocked(loadTransactionDetail).mockReset();
vi.mocked(loadRecentPayments).mockReset();
vi.mocked(capturePosPayment).mockReset(); vi.mocked(capturePosPayment).mockReset();
vi.mocked(getSessionMe).mockReset(); vi.mocked(getSessionMe).mockReset();
window.__APP_CONFIG__ = { window.__APP_CONFIG__ = {
@ -42,44 +47,141 @@ describe('POS Transactions App', () => {
expect(link.href).toContain('tenantId=demo-tenant'); 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({ vi.mocked(getSessionMe).mockResolvedValue({
isAuthenticated: true, isAuthenticated: true,
subjectId: 'demo-user', subjectId: 'demo-user',
tenantId: 'demo-tenant', tenantId: 'demo-tenant',
provider: 0 provider: 2
});
vi.mocked(loadDashboard).mockResolvedValue({
contextId: 'demo-context',
summary: '2 served orders are payable',
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: 'Payable',
capturedAtUtc: '2026-03-27T11:00:00Z'
}
]
}); });
vi.mocked(loadDashboard).mockResolvedValue({ contextId: 'demo-context', summary: 'ok' });
render(<App />); render(<App />);
await waitFor(() => expect(screen.getByRole('button', { name: 'Load Summary' })).toBeInTheDocument()); await waitFor(() => expect(screen.getByPlaceholderText('Context Id')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Load Summary' })); 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(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 served orders are payable')).toBeInTheDocument();
expect(screen.getByText('Only restaurant orders that have completed service should appear here as payable checks for capture.')).toBeInTheDocument();
expect(await screen.findByText('POS-22')).toBeInTheDocument();
expect(await screen.findByText('POS-21')).toBeInTheDocument();
expect(await screen.findByText('ready for payment capture after service completion')).toBeInTheDocument();
}); });
it('captures payment from action route', async () => { it('captures payments and records retry-ready history', async () => {
vi.mocked(getSessionMe).mockResolvedValue({ vi.mocked(getSessionMe).mockResolvedValue({
isAuthenticated: true, isAuthenticated: true,
subjectId: 'demo-user', subjectId: 'demo-user',
tenantId: 'demo-tenant', 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({ vi.mocked(capturePosPayment).mockResolvedValue({
contextId: 'demo-context', contextId: 'demo-context',
transactionId: 'POS-2200', transactionId: 'POS-2200',
succeeded: true, succeeded: true,
summary: 'captured' summary: 'captured',
status: 'Captured',
capturedAtUtc: '2026-03-27T13:00:00Z'
}); });
window.history.pushState({}, '', '/payments');
render(<App />); render(<App />);
await waitFor(() => expect(screen.getByText('Capture Payment')).toBeInTheDocument()); await waitFor(() => expect(screen.getByRole('button', { name: 'Capture Payment Now' })).toBeInTheDocument());
fireEvent.click(screen.getByText('Capture Payment'));
fireEvent.change(screen.getByPlaceholderText('Transaction Id'), { target: { value: 'POS-2200' } }); fireEvent.change(screen.getByPlaceholderText('Transaction Id'), { target: { value: 'POS-2200' } });
fireEvent.click(screen.getByRole('button', { name: 'Capture Payment Now' })); fireEvent.click(screen.getByRole('button', { name: 'Capture Payment Now' }));
await waitFor(() => expect(capturePosPayment).toHaveBeenCalledTimes(1)); await waitFor(() => expect(capturePosPayment).toHaveBeenCalledTimes(1));
expect(await screen.findAllByText('POS-2200')).toHaveLength(2);
expect(screen.getAllByText('payment is complete and the restaurant workflow can close').length).toBeGreaterThan(0);
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(<App />);
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();
}); });
}); });

View File

@ -1,9 +1,16 @@
import { CreditCardOutlined, DeploymentUnitOutlined, ReloadOutlined, WalletOutlined } from '@ant-design/icons'; import {
CreditCardOutlined,
DeploymentUnitOutlined,
ReloadOutlined,
SearchOutlined,
WalletOutlined
} from '@ant-design/icons';
import { import {
Alert, Alert,
Button, Button,
Card, Card,
Descriptions, Descriptions,
Empty,
Form, Form,
Input, Input,
InputNumber, InputNumber,
@ -18,12 +25,18 @@ import {
} from 'antd'; } from 'antd';
import { type ReactNode, useMemo, useState } from 'react'; import { type ReactNode, useMemo, useState } from 'react';
import { BrowserRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; import { BrowserRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import { ApiError } from './api/client';
import { import {
capturePosPayment, capturePosPayment,
loadDashboard, loadDashboard,
loadRecentPayments,
loadTransactionDetail,
type CapturePosPaymentRequest, type CapturePosPaymentRequest,
type CapturePosPaymentResponse, type CapturePosPaymentResponse,
type PosTransactionSummaryResponse type PosPaymentActivity,
type PosTransactionDetailResponse,
type PosTransactionSummaryResponse,
type RecentPosPaymentsResponse
} from './api/dashboardApi'; } from './api/dashboardApi';
import type { IdentityProvider } from './api/sessionApi'; import type { IdentityProvider } from './api/sessionApi';
import { buildGoogleOidcStartUrl } from './auth/oidcLogin'; import { buildGoogleOidcStartUrl } from './auth/oidcLogin';
@ -31,12 +44,36 @@ import { SessionProvider, useSessionContext } from './auth/sessionContext';
type AppRoute = '/summary' | '/payments' | '/session'; 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 }> = [ const routeItems: Array<{ key: AppRoute; label: string; icon: ReactNode }> = [
{ key: '/summary', label: 'Transaction Summary', icon: <WalletOutlined /> }, { key: '/summary', label: 'Transaction Summary', icon: <WalletOutlined /> },
{ key: '/payments', label: 'Capture Payment', icon: <CreditCardOutlined /> }, { key: '/payments', label: 'Capture Payment', icon: <CreditCardOutlined /> },
{ key: '/session', label: 'Session', icon: <DeploymentUnitOutlined /> } { key: '/session', label: 'Session', icon: <DeploymentUnitOutlined /> }
]; ];
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) => (
<Tag color={paymentStatusColor(record.status)}>{record.status}</Tag>
)
},
{ title: 'Lifecycle Step', render: (_: unknown, record: PosPaymentActivity) => paymentProgressHint(record.status) },
{ title: 'Captured At', render: (_: unknown, record: PosPaymentActivity) => formatUtc(record.capturedAtUtc) }
];
function App() { function App() {
return ( return (
<SessionProvider> <SessionProvider>
@ -53,11 +90,17 @@ function PosTransactionsShell() {
const navigate = useNavigate(); const navigate = useNavigate();
const [contextId, setContextId] = useState('demo-context'); const [contextId, setContextId] = useState('demo-context');
const [transactionId, setTransactionId] = useState('POS-9001');
const [summaryPayload, setSummaryPayload] = useState<PosTransactionSummaryResponse | null>(null); const [summaryPayload, setSummaryPayload] = useState<PosTransactionSummaryResponse | null>(null);
const [detailPayload, setDetailPayload] = useState<PosTransactionDetailResponse | null>(null);
const [recentPayload, setRecentPayload] = useState<RecentPosPaymentsResponse | null>(null);
const [paymentResponse, setPaymentResponse] = useState<CapturePosPaymentResponse | null>(null); const [paymentResponse, setPaymentResponse] = useState<CapturePosPaymentResponse | null>(null);
const [paymentHistory, setPaymentHistory] = useState<CapturePosPaymentResponse[]>([]); const [paymentHistory, setPaymentHistory] = useState<PaymentAttempt[]>([]);
const [globalError, setGlobalError] = useState<string | null>(null); const [lastCaptureRequest, setLastCaptureRequest] = useState<CapturePosPaymentRequest | null>(null);
const [workflowState, setWorkflowState] = useState<WorkflowState>({ error: null, sessionExpired: false });
const [loadingSummary, setLoadingSummary] = useState(false); const [loadingSummary, setLoadingSummary] = useState(false);
const [loadingDetail, setLoadingDetail] = useState(false);
const [loadingRecentPayments, setLoadingRecentPayments] = useState(false);
const [submittingPayment, setSubmittingPayment] = useState(false); const [submittingPayment, setSubmittingPayment] = useState(false);
const loginUrl = useMemo(() => buildGoogleOidcStartUrl(window.location.href), []); const loginUrl = useMemo(() => buildGoogleOidcStartUrl(window.location.href), []);
@ -66,30 +109,74 @@ function PosTransactionsShell() {
return candidate?.key ?? '/summary'; return candidate?.key ?? '/summary';
}, [location.pathname]); }, [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 () => { const loadSummary = async () => {
setLoadingSummary(true); setLoadingSummary(true);
setGlobalError(null); clearWorkflowError();
try { try {
const payload = await loadDashboard(contextId); const payload = await loadDashboard(contextId);
setSummaryPayload(payload); setSummaryPayload(payload);
} catch (err) { } catch (err) {
setGlobalError(err instanceof Error ? err.message : 'Failed to load transaction summary.'); await handleWorkflowFailure(err, 'Failed to load POS summary.');
} finally { } finally {
setLoadingSummary(false); 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); setSubmittingPayment(true);
setGlobalError(null); clearWorkflowError();
setLastCaptureRequest(request);
try { try {
const payload = await capturePosPayment(request); const payload = await capturePosPayment(request);
setPaymentResponse(payload); setPaymentResponse(payload);
// Keep recent responses bounded so the session view stays readable over long demos. setPaymentHistory((previous) => [{ request, response: payload }, ...previous].slice(0, 8));
setPaymentHistory((previous) => [payload, ...previous].slice(0, 8)); await refreshRecentPayments();
await loadSummary();
await loadDetail();
} catch (err) { } catch (err) {
setGlobalError(err instanceof Error ? err.message : 'Failed to capture payment.'); await handleWorkflowFailure(err, 'Failed to capture payment.');
} finally { } finally {
setSubmittingPayment(false); setSubmittingPayment(false);
} }
@ -116,6 +203,14 @@ function PosTransactionsShell() {
</Button> </Button>
} }
/> />
{workflowState.sessionExpired && (
<Alert
type="warning"
showIcon
message="Session expired"
description="Sign in again through the central auth host before retrying POS actions."
/>
)}
{session.error && <Alert type="error" showIcon message={session.error} />} {session.error && <Alert type="error" showIcon message={session.error} />}
</main> </main>
); );
@ -125,12 +220,7 @@ function PosTransactionsShell() {
<Layout className="full-layout"> <Layout className="full-layout">
<Layout.Sider width={240} breakpoint="lg" collapsedWidth={0}> <Layout.Sider width={240} breakpoint="lg" collapsedWidth={0}>
<div className="brand">POS Transactions Web</div> <div className="brand">POS Transactions Web</div>
<Menu <Menu mode="inline" selectedKeys={[selectedKey]} items={routeItems} onClick={(event) => navigate(event.key as AppRoute)} />
mode="inline"
selectedKeys={[selectedKey]}
items={routeItems}
onClick={(event) => navigate(event.key as AppRoute)}
/>
</Layout.Sider> </Layout.Sider>
<Layout> <Layout>
<Layout.Header className="header"> <Layout.Header className="header">
@ -151,99 +241,201 @@ function PosTransactionsShell() {
<Layout.Content className="content"> <Layout.Content className="content">
<Typography.Title level={3}>POS Transactions</Typography.Title> <Typography.Title level={3}>POS Transactions</Typography.Title>
<Typography.Paragraph type="secondary"> <Typography.Paragraph type="secondary">
Protected POS workflows for summary lookup and payment capture. Protected POS workflows for payable restaurant checks, capture retries, and recent payment activity once service is complete.
</Typography.Paragraph> </Typography.Paragraph>
{session.error && <Alert className="stack-gap" type="warning" showIcon message={session.error} />} {session.error && <Alert className="stack-gap" type="warning" showIcon message={session.error} />}
{globalError && <Alert className="stack-gap" type="error" showIcon message={globalError} />} {workflowState.error && <Alert className="stack-gap" type="error" showIcon message={workflowState.error} />}
{workflowState.sessionExpired && (
<Alert
className="stack-gap"
type="warning"
showIcon
message="Session expired"
description="Refresh your session or sign in again before retrying POS actions."
action={
<Space>
<Button size="small" onClick={() => void session.refresh()}>
Refresh Session
</Button>
<Button size="small" type="primary" href={loginUrl}>
Reauthenticate
</Button>
</Space>
}
/>
)}
<Routes> <Routes>
<Route <Route
path="/summary" path="/summary"
element={ element={
<Card title="Transaction Summary"> <Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Space direction="vertical" size="middle" style={{ width: '100%' }}> <Card title="POS Overview">
<Space wrap> <Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Input <Space wrap>
value={contextId} <Input value={contextId} onChange={(event) => setContextId(event.target.value)} placeholder="Context Id" style={{ width: 260 }} />
onChange={(event) => setContextId(event.target.value)} <Input
placeholder="Context Id" value={transactionId}
style={{ width: 280 }} onChange={(event) => setTransactionId(event.target.value)}
/> placeholder="Transaction Id"
<Button type="primary" loading={loadingSummary} onClick={() => void loadSummary()}> style={{ width: 260 }}
Load Summary />
</Button> <Button type="primary" icon={<SearchOutlined />} loading={loadingSummary} onClick={() => void loadSummary()}>
Load Summary
</Button>
<Button loading={loadingDetail} onClick={() => void loadDetail()}>
Load Transaction Detail
</Button>
<Button loading={loadingRecentPayments} onClick={() => void refreshRecentPayments()}>
Load Recent Payments
</Button>
</Space>
{summaryPayload ? (
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="Context Id">{summaryPayload.contextId}</Descriptions.Item>
<Descriptions.Item label="Summary">{summaryPayload.summary}</Descriptions.Item>
<Descriptions.Item label="Open Balance">
{summaryPayload.openBalance.toFixed(2)} {summaryPayload.currency}
</Descriptions.Item>
<Descriptions.Item label="Lifecycle Note">
Only restaurant orders that have completed service should appear here as payable checks for capture.
</Descriptions.Item>
</Descriptions>
) : (
<Empty description="Load a POS summary to inspect which served orders are ready for payment capture." />
)}
</Space> </Space>
{summaryPayload ? ( </Card>
<Card title="Transaction Detail">
{detailPayload?.transaction ? (
<Descriptions bordered size="small" column={1}> <Descriptions bordered size="small" column={1}>
<Descriptions.Item label="Context Id">{summaryPayload.contextId}</Descriptions.Item> <Descriptions.Item label="Context Id">{detailPayload.contextId}</Descriptions.Item>
<Descriptions.Item label="Summary">{summaryPayload.summary}</Descriptions.Item> <Descriptions.Item label="Summary">{detailPayload.summary}</Descriptions.Item>
<Descriptions.Item label="Transaction Id">{detailPayload.transaction.transactionId}</Descriptions.Item>
<Descriptions.Item label="Method">{detailPayload.transaction.paymentMethod}</Descriptions.Item>
<Descriptions.Item label="Amount">
{detailPayload.transaction.amount.toFixed(2)} {detailPayload.transaction.currency}
</Descriptions.Item>
<Descriptions.Item label="Status">
<Tag color={paymentStatusColor(detailPayload.transaction.status)}>{detailPayload.transaction.status}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Next Step">{paymentProgressHint(detailPayload.transaction.status)}</Descriptions.Item>
<Descriptions.Item label="Captured At">{formatUtc(detailPayload.transaction.capturedAtUtc)}</Descriptions.Item>
</Descriptions> </Descriptions>
) : ( ) : (
<Typography.Text type="secondary">No transaction summary loaded.</Typography.Text> <Empty description="Load a transaction detail to inspect payable-check state and capture readiness." />
)} )}
</Space> </Card>
</Card>
<Card title="Recent Payment Activity">
{recentPayload?.recentPayments.length ? (
<Table<PosPaymentActivity>
pagination={false}
rowKey={(record) => `${record.transactionId}-${record.capturedAtUtc}`}
dataSource={recentPayload.recentPayments}
columns={paymentColumns}
/>
) : (
<Empty description="Load recent payments to inspect the latest captured activity." />
)}
</Card>
</Space>
} }
/> />
<Route <Route
path="/payments" path="/payments"
element={ element={
<Card title="Capture Payment"> <Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Space direction="vertical" size="middle" style={{ width: '100%' }}> <Card title="Capture Payment">
<Form<CapturePosPaymentRequest> <Space direction="vertical" size="middle" style={{ width: '100%' }}>
layout="vertical" <Form<CapturePosPaymentRequest>
initialValues={{ layout="vertical"
contextId, initialValues={{
transactionId: 'POS-9001', contextId,
amount: 25.5, transactionId: 'POS-9001',
currency: 'USD', amount: 25.5,
paymentMethod: 'card' currency: 'USD',
}} paymentMethod: 'card'
onFinish={(values) => void capturePayment(values)} }}
> onFinish={(values) => void submitCapturePayment(values)}
<Form.Item name="contextId" label="Context Id" rules={[{ required: true }]}> >
<Input placeholder="Context Id" /> <Form.Item name="contextId" label="Context Id" rules={[{ required: true }]}>
</Form.Item> <Input placeholder="Context Id" />
<Form.Item name="transactionId" label="Transaction Id" rules={[{ required: true }]}> </Form.Item>
<Input placeholder="Transaction Id" /> <Form.Item name="transactionId" label="Transaction Id" rules={[{ required: true }]}>
</Form.Item> <Input placeholder="Transaction Id" />
<Form.Item name="amount" label="Amount" rules={[{ required: true, type: 'number', min: 0 }]}> </Form.Item>
<InputNumber min={0} step={0.01} style={{ width: '100%' }} /> <Form.Item name="amount" label="Amount" rules={[{ required: true, type: 'number', min: 0 }]}>
</Form.Item> <InputNumber min={0} step={0.01} style={{ width: '100%' }} />
<Form.Item name="currency" label="Currency" rules={[{ required: true, min: 3, max: 3 }]}> </Form.Item>
<Input placeholder="USD" /> <Form.Item name="currency" label="Currency" rules={[{ required: true, min: 3, max: 3 }]}>
</Form.Item> <Input placeholder="USD" />
<Form.Item name="paymentMethod" label="Payment Method" rules={[{ required: true }]}> </Form.Item>
<Input placeholder="card" /> <Form.Item name="paymentMethod" label="Payment Method" rules={[{ required: true }]}>
</Form.Item> <Input placeholder="card" />
<Button type="primary" htmlType="submit" loading={submittingPayment}> </Form.Item>
Capture Payment Now <Space>
</Button> <Button type="primary" htmlType="submit" loading={submittingPayment}>
</Form> Capture Payment Now
{paymentResponse && ( </Button>
<Button
disabled={!lastCaptureRequest}
onClick={() => lastCaptureRequest && void submitCapturePayment(lastCaptureRequest)}
>
Retry Last Capture
</Button>
</Space>
</Form>
<Typography.Text type="secondary">
Capture should be used only after kitchen and floor service complete the order, making the check payable.
</Typography.Text>
</Space>
</Card>
<Card title="Latest Capture Result">
{paymentResponse ? (
<Descriptions bordered size="small" column={1}> <Descriptions bordered size="small" column={1}>
<Descriptions.Item label="Context Id">{paymentResponse.contextId}</Descriptions.Item> <Descriptions.Item label="Context Id">{paymentResponse.contextId}</Descriptions.Item>
<Descriptions.Item label="Transaction Id">{paymentResponse.transactionId}</Descriptions.Item> <Descriptions.Item label="Transaction Id">{paymentResponse.transactionId}</Descriptions.Item>
<Descriptions.Item label="Succeeded">{String(paymentResponse.succeeded)}</Descriptions.Item> <Descriptions.Item label="Succeeded">{String(paymentResponse.succeeded)}</Descriptions.Item>
<Descriptions.Item label="Status">
<Tag color={paymentStatusColor(paymentResponse.status)}>{paymentResponse.status}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Next Step">{paymentProgressHint(paymentResponse.status)}</Descriptions.Item>
<Descriptions.Item label="Captured At">{formatUtc(paymentResponse.capturedAtUtc)}</Descriptions.Item>
<Descriptions.Item label="Summary">{paymentResponse.summary}</Descriptions.Item> <Descriptions.Item label="Summary">{paymentResponse.summary}</Descriptions.Item>
</Descriptions> </Descriptions>
) : (
<Empty description="Run a capture to inspect the latest POS response." />
)} )}
<Table<CapturePosPaymentResponse> </Card>
<Card title="Recent Capture Attempts">
<Table<PaymentAttempt>
pagination={false} pagination={false}
rowKey={(record) => `${record.contextId}-${record.transactionId}`} rowKey={(record) => `${record.response.transactionId}-${record.response.capturedAtUtc}`}
dataSource={paymentHistory} dataSource={paymentHistory}
locale={{ emptyText: 'No capture attempts recorded in this session.' }}
columns={[ columns={[
{ title: 'Transaction Id', dataIndex: 'transactionId' }, { title: 'Transaction Id', render: (_, record) => record.response.transactionId },
{ title: 'Context Id', dataIndex: 'contextId' }, { title: 'Method', render: (_, record) => record.request.paymentMethod },
{ {
title: 'Succeeded', title: 'Amount',
render: (_, record) => <Tag color={record.succeeded ? 'green' : 'red'}>{String(record.succeeded)}</Tag> render: (_, record) => `${record.request.amount.toFixed(2)} ${record.request.currency}`
}, },
{ title: 'Summary', dataIndex: 'summary' } {
title: 'Status',
render: (_, record) => (
<Tag color={paymentStatusColor(record.response.status)}>{record.response.status}</Tag>
)
},
{ title: 'Summary', render: (_, record) => record.response.summary },
{ title: 'Lifecycle Step', render: (_, record) => paymentProgressHint(record.response.status) }
]} ]}
/> />
</Space> </Card>
</Card> </Space>
} }
/> />
<Route <Route
@ -263,20 +455,61 @@ function PosTransactionsShell() {
); );
} }
function providerLabel(provider: IdentityProvider): string { function providerLabel(provider: IdentityProvider) {
if (provider === 0 || provider === '0' || provider === 'InternalJwt') { switch (provider) {
return 'Internal JWT'; case 1:
return 'manual';
case 2:
return 'google';
default:
return 'unknown';
} }
}
if (provider === 1 || provider === '1' || provider === 'AzureAd') { function paymentStatusColor(status: string) {
return 'Azure AD'; switch (status.toLowerCase()) {
case 'captured':
case 'paid':
return 'green';
case 'payable':
case 'ready':
return 'blue';
case 'pending':
case 'authorized':
return 'orange';
case 'failed':
case 'blocked':
return 'red';
default:
return 'default';
} }
}
if (provider === 2 || provider === '2' || provider === 'Google') { function paymentProgressHint(status: string) {
return 'Google'; switch (status.toLowerCase()) {
case 'payable':
case 'ready':
return 'ready for payment capture after service completion';
case 'pending':
case 'authorized':
return 'payment is in progress and should be monitored before retrying';
case 'captured':
case 'paid':
return 'payment is complete and the restaurant workflow can close';
case 'failed':
case 'blocked':
return 'operator review is required before attempting another capture';
default:
return 'payment state is being resolved against the restaurant lifecycle';
} }
}
return String(provider); function formatUtc(value: string) {
return new Date(value).toLocaleString('en-US', {
dateStyle: 'medium',
timeStyle: 'short',
timeZone: 'UTC'
});
} }
export default App; export default App;

View File

@ -6,7 +6,7 @@ vi.mock('./client', () => ({
})); }));
import { getJson, postJson } from './client'; import { getJson, postJson } from './client';
import { capturePosPayment, loadDashboard } from './dashboardApi'; import { capturePosPayment, loadDashboard, loadRecentPayments, loadTransactionDetail } from './dashboardApi';
describe('pos transactions dashboard api', () => { describe('pos transactions dashboard api', () => {
it('builds encoded summary endpoint path', async () => { 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'); 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 () => { it('posts payment capture payload', async () => {
vi.mocked(postJson).mockResolvedValue({ succeeded: true }); vi.mocked(postJson).mockResolvedValue({ succeeded: true });

View File

@ -1,8 +1,36 @@
import { getJson, postJson } from './client'; import { getJson, postJson } from './client';
export type PosPaymentActivity = {
transactionId: string;
paymentMethod: string;
amount: number;
currency: string;
status: string;
capturedAtUtc: string;
};
export type PosTransactionSummaryResponse = { export type PosTransactionSummaryResponse = {
contextId: string; contextId: string;
summary: 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 = { export type CapturePosPaymentRequest = {
@ -18,12 +46,24 @@ export type CapturePosPaymentResponse = {
transactionId: string; transactionId: string;
succeeded: boolean; succeeded: boolean;
summary: string; summary: string;
status: string;
capturedAtUtc: string;
}; };
export async function loadDashboard(contextId: string): Promise<PosTransactionSummaryResponse> { export async function loadDashboard(contextId: string): Promise<PosTransactionSummaryResponse> {
return getJson<PosTransactionSummaryResponse>(`/api/pos/transactions/summary?contextId=${encodeURIComponent(contextId)}`); return getJson<PosTransactionSummaryResponse>(`/api/pos/transactions/summary?contextId=${encodeURIComponent(contextId)}`);
} }
export async function loadTransactionDetail(contextId: string, transactionId: string): Promise<PosTransactionDetailResponse> {
return getJson<PosTransactionDetailResponse>(
`/api/pos/transactions/${encodeURIComponent(transactionId)}?contextId=${encodeURIComponent(contextId)}`
);
}
export async function loadRecentPayments(contextId: string): Promise<RecentPosPaymentsResponse> {
return getJson<RecentPosPaymentsResponse>(`/api/pos/transactions/recent-payments?contextId=${encodeURIComponent(contextId)}`);
}
export async function capturePosPayment(request: CapturePosPaymentRequest): Promise<CapturePosPaymentResponse> { export async function capturePosPayment(request: CapturePosPaymentRequest): Promise<CapturePosPaymentResponse> {
return postJson<CapturePosPaymentResponse>('/api/pos/transactions/payments', request); return postJson<CapturePosPaymentResponse>('/api/pos/transactions/payments', request);
} }