feat(pos-transactions-web): deepen pos workflow experience

This commit is contained in:
José René White Enciso 2026-03-31 17:34:18 -06:00
parent 57e95c2a55
commit a626474c65
7 changed files with 452 additions and 107 deletions

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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(<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 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(<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.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 {
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: <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={record.status.toLowerCase() === 'captured' ? 'green' : 'orange'}>{record.status}</Tag>
)
},
{ title: 'Captured At', render: (_: unknown, record: PosPaymentActivity) => formatUtc(record.capturedAtUtc) }
];
function App() {
return (
<SessionProvider>
@ -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<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 +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() {
</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 +219,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,43 +240,106 @@ 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 summary lookup, transaction detail inspection, capture retries, and recent payment activity.
</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%' }}>
<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={contextId}
onChange={(event) => setContextId(event.target.value)}
placeholder="Context Id"
style={{ width: 280 }}
value={transactionId}
onChange={(event) => setTransactionId(event.target.value)}
placeholder="Transaction Id"
style={{ width: 260 }}
/>
<Button type="primary" loading={loadingSummary} onClick={() => void loadSummary()}>
<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>
) : (
<Typography.Text type="secondary">No transaction summary loaded.</Typography.Text>
<Empty description="Load a POS summary to inspect the current balance." />
)}
</Space>
</Card>
<Card title="Transaction Detail">
{detailPayload?.transaction ? (
<Descriptions bordered size="small" column={1}>
<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">{detailPayload.transaction.status}</Descriptions.Item>
<Descriptions.Item label="Captured At">{formatUtc(detailPayload.transaction.capturedAtUtc)}</Descriptions.Item>
</Descriptions>
) : (
<Empty description="Load a transaction detail to inspect capture state and method." />
)}
</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={
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Card title="Capture Payment">
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Form<CapturePosPaymentRequest>
@ -199,7 +351,7 @@ function PosTransactionsShell() {
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" />
@ -216,34 +368,63 @@ function PosTransactionsShell() {
<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>
{paymentResponse && (
<Typography.Text type="secondary">
Use retry when a payment attempt fails after verifying the transaction payload and operator session.
</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">{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={record.response.succeeded ? 'green' : 'red'}>{record.response.status}</Tag>
)
},
{ title: 'Summary', render: (_, record) => record.response.summary }
]}
/>
</Space>
</Card>
</Space>
}
/>
<Route
@ -263,20 +444,23 @@ 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';
}
if (provider === 2 || provider === '2' || provider === 'Google') {
return 'Google';
}
return String(provider);
function formatUtc(value: string) {
return new Date(value).toLocaleString('en-US', {
dateStyle: 'medium',
timeStyle: 'short',
timeZone: 'UTC'
});
}
export default App;

View File

@ -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 });

View File

@ -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);
}