Compare commits
3 Commits
feature/po
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dfa200ebf | ||
|
|
a626474c65 | ||
|
|
57e95c2a55 |
@ -16,10 +16,15 @@
|
||||
## 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 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
|
||||
- POS actions are presented as the final step after kitchen and floor service complete the restaurant order.
|
||||
|
||||
@ -18,9 +18,11 @@ 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.
|
||||
- UI copy assumes POS only acts on checks that became payable after restaurant service completion.
|
||||
|
||||
## Build
|
||||
|
||||
|
||||
@ -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, lifecycle-aware payable-check 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.
|
||||
|
||||
124
src/App.test.tsx
124
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,141 @@ 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 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 />);
|
||||
|
||||
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 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({
|
||||
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(<App />);
|
||||
|
||||
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.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();
|
||||
});
|
||||
});
|
||||
|
||||
407
src/App.tsx
407
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,36 @@ 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: <WalletOutlined /> },
|
||||
{ key: '/payments', label: 'Capture Payment', icon: <CreditCardOutlined /> },
|
||||
{ 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() {
|
||||
return (
|
||||
<SessionProvider>
|
||||
@ -53,11 +90,17 @@ function PosTransactionsShell() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [contextId, setContextId] = useState('demo-context');
|
||||
const [transactionId, setTransactionId] = useState('POS-9001');
|
||||
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 [paymentHistory, setPaymentHistory] = useState<CapturePosPaymentResponse[]>([]);
|
||||
const [globalError, setGlobalError] = useState<string | null>(null);
|
||||
const [paymentHistory, setPaymentHistory] = useState<PaymentAttempt[]>([]);
|
||||
const [lastCaptureRequest, setLastCaptureRequest] = useState<CapturePosPaymentRequest | null>(null);
|
||||
const [workflowState, setWorkflowState] = useState<WorkflowState>({ 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 +109,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 +203,14 @@ function PosTransactionsShell() {
|
||||
</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} />}
|
||||
</main>
|
||||
);
|
||||
@ -125,12 +220,7 @@ function PosTransactionsShell() {
|
||||
<Layout className="full-layout">
|
||||
<Layout.Sider width={240} breakpoint="lg" collapsedWidth={0}>
|
||||
<div className="brand">POS Transactions Web</div>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[selectedKey]}
|
||||
items={routeItems}
|
||||
onClick={(event) => navigate(event.key as AppRoute)}
|
||||
/>
|
||||
<Menu mode="inline" selectedKeys={[selectedKey]} items={routeItems} onClick={(event) => navigate(event.key as AppRoute)} />
|
||||
</Layout.Sider>
|
||||
<Layout>
|
||||
<Layout.Header className="header">
|
||||
@ -151,99 +241,201 @@ function PosTransactionsShell() {
|
||||
<Layout.Content className="content">
|
||||
<Typography.Title level={3}>POS Transactions</Typography.Title>
|
||||
<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>
|
||||
{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>
|
||||
<Route
|
||||
path="/summary"
|
||||
element={
|
||||
<Card title="Transaction Summary">
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Space wrap>
|
||||
<Input
|
||||
value={contextId}
|
||||
onChange={(event) => setContextId(event.target.value)}
|
||||
placeholder="Context Id"
|
||||
style={{ width: 280 }}
|
||||
/>
|
||||
<Button type="primary" loading={loadingSummary} onClick={() => void loadSummary()}>
|
||||
Load Summary
|
||||
</Button>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Card title="POS Overview">
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Space wrap>
|
||||
<Input value={contextId} onChange={(event) => setContextId(event.target.value)} placeholder="Context Id" style={{ width: 260 }} />
|
||||
<Input
|
||||
value={transactionId}
|
||||
onChange={(event) => setTransactionId(event.target.value)}
|
||||
placeholder="Transaction Id"
|
||||
style={{ width: 260 }}
|
||||
/>
|
||||
<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>
|
||||
{summaryPayload ? (
|
||||
</Card>
|
||||
|
||||
<Card title="Transaction Detail">
|
||||
{detailPayload?.transaction ? (
|
||||
<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="Context Id">{detailPayload.contextId}</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>
|
||||
) : (
|
||||
<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
|
||||
path="/payments"
|
||||
element={
|
||||
<Card title="Capture Payment">
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Form<CapturePosPaymentRequest>
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
contextId,
|
||||
transactionId: 'POS-9001',
|
||||
amount: 25.5,
|
||||
currency: 'USD',
|
||||
paymentMethod: 'card'
|
||||
}}
|
||||
onFinish={(values) => void capturePayment(values)}
|
||||
>
|
||||
<Form.Item name="contextId" label="Context Id" rules={[{ required: true }]}>
|
||||
<Input placeholder="Context Id" />
|
||||
</Form.Item>
|
||||
<Form.Item name="transactionId" label="Transaction Id" rules={[{ required: true }]}>
|
||||
<Input placeholder="Transaction Id" />
|
||||
</Form.Item>
|
||||
<Form.Item name="amount" label="Amount" rules={[{ required: true, type: 'number', min: 0 }]}>
|
||||
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="currency" label="Currency" rules={[{ required: true, min: 3, max: 3 }]}>
|
||||
<Input placeholder="USD" />
|
||||
</Form.Item>
|
||||
<Form.Item name="paymentMethod" label="Payment Method" rules={[{ required: true }]}>
|
||||
<Input placeholder="card" />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={submittingPayment}>
|
||||
Capture Payment Now
|
||||
</Button>
|
||||
</Form>
|
||||
{paymentResponse && (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Card title="Capture Payment">
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Form<CapturePosPaymentRequest>
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
contextId,
|
||||
transactionId: 'POS-9001',
|
||||
amount: 25.5,
|
||||
currency: 'USD',
|
||||
paymentMethod: 'card'
|
||||
}}
|
||||
onFinish={(values) => void submitCapturePayment(values)}
|
||||
>
|
||||
<Form.Item name="contextId" label="Context Id" rules={[{ required: true }]}>
|
||||
<Input placeholder="Context Id" />
|
||||
</Form.Item>
|
||||
<Form.Item name="transactionId" label="Transaction Id" rules={[{ required: true }]}>
|
||||
<Input placeholder="Transaction Id" />
|
||||
</Form.Item>
|
||||
<Form.Item name="amount" label="Amount" rules={[{ required: true, type: 'number', min: 0 }]}>
|
||||
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="currency" label="Currency" rules={[{ required: true, min: 3, max: 3 }]}>
|
||||
<Input placeholder="USD" />
|
||||
</Form.Item>
|
||||
<Form.Item name="paymentMethod" label="Payment Method" rules={[{ required: true }]}>
|
||||
<Input placeholder="card" />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={submittingPayment}>
|
||||
Capture Payment Now
|
||||
</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.Item label="Context Id">{paymentResponse.contextId}</Descriptions.Item>
|
||||
<Descriptions.Item label="Transaction Id">{paymentResponse.transactionId}</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>
|
||||
) : (
|
||||
<Empty description="Run a capture to inspect the latest POS response." />
|
||||
)}
|
||||
<Table<CapturePosPaymentResponse>
|
||||
</Card>
|
||||
|
||||
<Card title="Recent Capture Attempts">
|
||||
<Table<PaymentAttempt>
|
||||
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) => <Tag color={record.succeeded ? 'green' : 'red'}>{String(record.succeeded)}</Tag>
|
||||
title: 'Amount',
|
||||
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
|
||||
@ -263,20 +455,61 @@ function PosTransactionsShell() {
|
||||
);
|
||||
}
|
||||
|
||||
function providerLabel(provider: IdentityProvider): string {
|
||||
if (provider === 0 || provider === '0' || provider === 'InternalJwt') {
|
||||
return 'Internal JWT';
|
||||
function providerLabel(provider: IdentityProvider) {
|
||||
switch (provider) {
|
||||
case 1:
|
||||
return 'manual';
|
||||
case 2:
|
||||
return 'google';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === 1 || provider === '1' || provider === 'AzureAd') {
|
||||
return 'Azure AD';
|
||||
function paymentStatusColor(status: string) {
|
||||
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') {
|
||||
return 'Google';
|
||||
function paymentProgressHint(status: string) {
|
||||
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;
|
||||
|
||||
@ -6,7 +6,7 @@ vi.mock('./client', () => ({
|
||||
}));
|
||||
|
||||
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 });
|
||||
|
||||
|
||||
@ -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<PosTransactionSummaryResponse> {
|
||||
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> {
|
||||
return postJson<CapturePosPaymentResponse>('/api/pos/transactions/payments', request);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user