Compare commits
No commits in common. "development" and "feature/pos-transactions-web-ant-protected-routes" have entirely different histories.
developmen
...
feature/po
@ -16,15 +16,10 @@
|
|||||||
## 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 with open balance visibility
|
- POS transaction summary lookup
|
||||||
- Transaction detail inspection for a selected payable check or transaction id
|
- POS payment capture
|
||||||
- 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.
|
|
||||||
|
|||||||
@ -18,11 +18,9 @@ npm run dev
|
|||||||
|
|
||||||
## Auth Model
|
## Auth Model
|
||||||
|
|
||||||
- Login is executed via the central Thalos OIDC start endpoint.
|
- Login is executed via 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
|
||||||
|
|
||||||
|
|||||||
@ -17,9 +17,8 @@ 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, lifecycle-aware payable-check summary/detail flow, payment capture, and session-expired recovery guidance.
|
- `src/App.test.tsx`: central login screen, protected summary flow, and payment capture workflow.
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|||||||
124
src/App.test.tsx
124
src/App.test.tsx
@ -1,6 +1,5 @@
|
|||||||
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(),
|
||||||
@ -11,20 +10,16 @@ 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, loadRecentPayments, loadTransactionDetail } from './api/dashboardApi';
|
import { capturePosPayment, loadDashboard } 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__ = {
|
||||||
@ -47,141 +42,44 @@ describe('POS Transactions App', () => {
|
|||||||
expect(link.href).toContain('tenantId=demo-tenant');
|
expect(link.href).toContain('tenantId=demo-tenant');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads summary, detail, and recent payments for authenticated users', async () => {
|
it('loads summary 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: 2
|
provider: 0
|
||||||
});
|
|
||||||
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.getByPlaceholderText('Context Id')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByRole('button', { name: 'Load Summary' })).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 payments and records retry-ready history', async () => {
|
it('captures payment from action route', 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: 2
|
provider: 0
|
||||||
});
|
|
||||||
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.getByRole('button', { name: 'Capture Payment Now' })).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('Capture Payment')).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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
321
src/App.tsx
321
src/App.tsx
@ -1,16 +1,9 @@
|
|||||||
import {
|
import { CreditCardOutlined, DeploymentUnitOutlined, ReloadOutlined, WalletOutlined } from '@ant-design/icons';
|
||||||
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,
|
||||||
@ -25,18 +18,12 @@ 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 PosPaymentActivity,
|
type PosTransactionSummaryResponse
|
||||||
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';
|
||||||
@ -44,36 +31,12 @@ 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>
|
||||||
@ -90,17 +53,11 @@ 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<PaymentAttempt[]>([]);
|
const [paymentHistory, setPaymentHistory] = useState<CapturePosPaymentResponse[]>([]);
|
||||||
const [lastCaptureRequest, setLastCaptureRequest] = useState<CapturePosPaymentRequest | null>(null);
|
const [globalError, setGlobalError] = useState<string | 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), []);
|
||||||
@ -109,74 +66,30 @@ 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);
|
||||||
clearWorkflowError();
|
setGlobalError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await loadDashboard(contextId);
|
const payload = await loadDashboard(contextId);
|
||||||
setSummaryPayload(payload);
|
setSummaryPayload(payload);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await handleWorkflowFailure(err, 'Failed to load POS summary.');
|
setGlobalError(err instanceof Error ? err.message : 'Failed to load transaction summary.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingSummary(false);
|
setLoadingSummary(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadDetail = async () => {
|
const capturePayment = async (request: CapturePosPaymentRequest) => {
|
||||||
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);
|
||||||
clearWorkflowError();
|
setGlobalError(null);
|
||||||
setLastCaptureRequest(request);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await capturePosPayment(request);
|
const payload = await capturePosPayment(request);
|
||||||
setPaymentResponse(payload);
|
setPaymentResponse(payload);
|
||||||
setPaymentHistory((previous) => [{ request, response: payload }, ...previous].slice(0, 8));
|
// Keep recent responses bounded so the session view stays readable over long demos.
|
||||||
await refreshRecentPayments();
|
setPaymentHistory((previous) => [payload, ...previous].slice(0, 8));
|
||||||
await loadSummary();
|
|
||||||
await loadDetail();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await handleWorkflowFailure(err, 'Failed to capture payment.');
|
setGlobalError(err instanceof Error ? err.message : 'Failed to capture payment.');
|
||||||
} finally {
|
} finally {
|
||||||
setSubmittingPayment(false);
|
setSubmittingPayment(false);
|
||||||
}
|
}
|
||||||
@ -203,14 +116,6 @@ 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>
|
||||||
);
|
);
|
||||||
@ -220,7 +125,12 @@ 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 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.Sider>
|
||||||
<Layout>
|
<Layout>
|
||||||
<Layout.Header className="header">
|
<Layout.Header className="header">
|
||||||
@ -241,112 +151,43 @@ 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 payable restaurant checks, capture retries, and recent payment activity once service is complete.
|
Protected POS workflows for summary lookup and payment capture.
|
||||||
</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} />}
|
||||||
{workflowState.error && <Alert className="stack-gap" type="error" showIcon message={workflowState.error} />}
|
{globalError && <Alert className="stack-gap" type="error" showIcon message={globalError} />}
|
||||||
{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={
|
||||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
<Card title="Transaction Summary">
|
||||||
<Card title="POS Overview">
|
|
||||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Input value={contextId} onChange={(event) => setContextId(event.target.value)} placeholder="Context Id" style={{ width: 260 }} />
|
|
||||||
<Input
|
<Input
|
||||||
value={transactionId}
|
value={contextId}
|
||||||
onChange={(event) => setTransactionId(event.target.value)}
|
onChange={(event) => setContextId(event.target.value)}
|
||||||
placeholder="Transaction Id"
|
placeholder="Context Id"
|
||||||
style={{ width: 260 }}
|
style={{ width: 280 }}
|
||||||
/>
|
/>
|
||||||
<Button type="primary" icon={<SearchOutlined />} loading={loadingSummary} onClick={() => void loadSummary()}>
|
<Button type="primary" loading={loadingSummary} onClick={() => void loadSummary()}>
|
||||||
Load Summary
|
Load Summary
|
||||||
</Button>
|
</Button>
|
||||||
<Button loading={loadingDetail} onClick={() => void loadDetail()}>
|
|
||||||
Load Transaction Detail
|
|
||||||
</Button>
|
|
||||||
<Button loading={loadingRecentPayments} onClick={() => void refreshRecentPayments()}>
|
|
||||||
Load Recent Payments
|
|
||||||
</Button>
|
|
||||||
</Space>
|
</Space>
|
||||||
{summaryPayload ? (
|
{summaryPayload ? (
|
||||||
<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">{summaryPayload.contextId}</Descriptions.Item>
|
||||||
<Descriptions.Item label="Summary">{summaryPayload.summary}</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>
|
</Descriptions>
|
||||||
) : (
|
) : (
|
||||||
<Empty description="Load a POS summary to inspect which served orders are ready for payment capture." />
|
<Typography.Text type="secondary">No transaction summary loaded.</Typography.Text>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</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">
|
|
||||||
<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>
|
|
||||||
) : (
|
|
||||||
<Empty description="Load a transaction detail to inspect payable-check state and capture readiness." />
|
|
||||||
)}
|
|
||||||
</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={
|
||||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
|
||||||
<Card title="Capture Payment">
|
<Card title="Capture Payment">
|
||||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||||
<Form<CapturePosPaymentRequest>
|
<Form<CapturePosPaymentRequest>
|
||||||
@ -358,7 +199,7 @@ function PosTransactionsShell() {
|
|||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
paymentMethod: 'card'
|
paymentMethod: 'card'
|
||||||
}}
|
}}
|
||||||
onFinish={(values) => void submitCapturePayment(values)}
|
onFinish={(values) => void capturePayment(values)}
|
||||||
>
|
>
|
||||||
<Form.Item name="contextId" label="Context Id" rules={[{ required: true }]}>
|
<Form.Item name="contextId" label="Context Id" rules={[{ required: true }]}>
|
||||||
<Input placeholder="Context Id" />
|
<Input placeholder="Context Id" />
|
||||||
@ -375,67 +216,34 @@ function PosTransactionsShell() {
|
|||||||
<Form.Item name="paymentMethod" label="Payment Method" rules={[{ required: true }]}>
|
<Form.Item name="paymentMethod" label="Payment Method" rules={[{ required: true }]}>
|
||||||
<Input placeholder="card" />
|
<Input placeholder="card" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Space>
|
|
||||||
<Button type="primary" htmlType="submit" loading={submittingPayment}>
|
<Button type="primary" htmlType="submit" loading={submittingPayment}>
|
||||||
Capture Payment Now
|
Capture Payment Now
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
disabled={!lastCaptureRequest}
|
|
||||||
onClick={() => lastCaptureRequest && void submitCapturePayment(lastCaptureRequest)}
|
|
||||||
>
|
|
||||||
Retry Last Capture
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</Form>
|
</Form>
|
||||||
<Typography.Text type="secondary">
|
{paymentResponse && (
|
||||||
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." />
|
|
||||||
)}
|
)}
|
||||||
</Card>
|
<Table<CapturePosPaymentResponse>
|
||||||
|
|
||||||
<Card title="Recent Capture Attempts">
|
|
||||||
<Table<PaymentAttempt>
|
|
||||||
pagination={false}
|
pagination={false}
|
||||||
rowKey={(record) => `${record.response.transactionId}-${record.response.capturedAtUtc}`}
|
rowKey={(record) => `${record.contextId}-${record.transactionId}`}
|
||||||
dataSource={paymentHistory}
|
dataSource={paymentHistory}
|
||||||
locale={{ emptyText: 'No capture attempts recorded in this session.' }}
|
|
||||||
columns={[
|
columns={[
|
||||||
{ title: 'Transaction Id', render: (_, record) => record.response.transactionId },
|
{ title: 'Transaction Id', dataIndex: 'transactionId' },
|
||||||
{ title: 'Method', render: (_, record) => record.request.paymentMethod },
|
{ title: 'Context Id', dataIndex: 'contextId' },
|
||||||
{
|
{
|
||||||
title: 'Amount',
|
title: 'Succeeded',
|
||||||
render: (_, record) => `${record.request.amount.toFixed(2)} ${record.request.currency}`
|
render: (_, record) => <Tag color={record.succeeded ? 'green' : 'red'}>{String(record.succeeded)}</Tag>
|
||||||
},
|
},
|
||||||
{
|
{ 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) }
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Card>
|
|
||||||
</Space>
|
</Space>
|
||||||
|
</Card>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
@ -455,61 +263,20 @@ function PosTransactionsShell() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function providerLabel(provider: IdentityProvider) {
|
function providerLabel(provider: IdentityProvider): string {
|
||||||
switch (provider) {
|
if (provider === 0 || provider === '0' || provider === 'InternalJwt') {
|
||||||
case 1:
|
return 'Internal JWT';
|
||||||
return 'manual';
|
|
||||||
case 2:
|
|
||||||
return 'google';
|
|
||||||
default:
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function paymentStatusColor(status: string) {
|
if (provider === 1 || provider === '1' || provider === 'AzureAd') {
|
||||||
switch (status.toLowerCase()) {
|
return 'Azure AD';
|
||||||
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';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function paymentProgressHint(status: string) {
|
if (provider === 2 || provider === '2' || provider === 'Google') {
|
||||||
switch (status.toLowerCase()) {
|
return 'Google';
|
||||||
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';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatUtc(value: string) {
|
return String(provider);
|
||||||
return new Date(value).toLocaleString('en-US', {
|
|
||||||
dateStyle: 'medium',
|
|
||||||
timeStyle: 'short',
|
|
||||||
timeZone: 'UTC'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@ -6,7 +6,7 @@ vi.mock('./client', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { getJson, postJson } from './client';
|
import { getJson, postJson } from './client';
|
||||||
import { capturePosPayment, loadDashboard, loadRecentPayments, loadTransactionDetail } from './dashboardApi';
|
import { capturePosPayment, loadDashboard } 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,22 +17,6 @@ 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 });
|
||||||
|
|
||||||
|
|||||||
@ -1,36 +1,8 @@
|
|||||||
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 = {
|
||||||
@ -46,24 +18,12 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user