diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index 8532f87..ea649ec 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -172,6 +172,23 @@ Undo behaviour: start/complete are applied optimistically and can be reverted wi - `GET /facilities/:facilityId/fulfillment-queue` → `FulfillmentQueueItem[]` — queue items with action attribution (`attribution: { action, performedByUserId, performedByName, performedByRole, timestamp, notes }`). - `POST /facilities/:facilityId/fulfillment-queue/:itemId/action` (body: `{action: 'fulfilled' | 'rejected', notes?}`) → `FulfillmentQueueItem` — records the acting staff user's identity and role for PRD §7 audit trail. +### Scan-to-verify orders with device camera (issue #52, docforum-core#71) +- `POST /facilities/me/verify-order` (body: `{ token: string }`) → `VerifyOrderResponse` + - Token verification endpoint returning 5 explicit states: `valid`, `superseded`, `expired`, `fulfilled`, `not_eligible`. + - Minimal order summary for fulfillment verification: `orderId`, `orderType`, `patientName`, `medicationOrTest`, `instructions`, `issuedAt`, `expiresAt`, `supersededBy`, `fulfilledAt`, `fulfilledByFacility`. + - Rate-limiting (HTTP 429) protection against brute-force token enumeration; error surfaced clearly in UI with retry guidance. + - Device camera QR scanning using `BarcodeDetector` API with fallback; strict camera media stream track teardown on unmount or screen exit. + - Manual token entry fallback. + +### Facility payout ledger & escrow terms (issue #53, docforum-core#90) +- `GET /facilities/:facilityId/payout-ledger?cursor=&status=&asset=` → `FacilityLedgerPage` + - Cursor-paginated ledger of on-chain escrow deposits, releases, and refunds. + - Totals breakdown for held, released, and refunded funds separated by asset (`XLM` vs `USDC`). + - Strict BigInt-safe amount formatting for Stellar stroops (7 decimals: 1 XLM/USDC = 10,000,000 stroops). + - Escrow terms in plain language (releaser condition, refund deadline countdown). + - Approaching deadline warnings: prominent alerts when held escrow refund deadline is < 48 hours away and order is unfulfilled (Threat Model T8 compliance). + - Direct explorer links to Stellar testnet transactions (`https://stellar.expert/explorer/testnet/tx/...`). + ## Roadmap / status See `ROADMAP.md` in this repo, and Phase 7 in `docforum-core`'s `ROADMAP.md` for the original (pre-split) frontend phase this was carved diff --git a/ROADMAP.md b/ROADMAP.md index 896bcca..e1d6738 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -59,8 +59,10 @@ real API" (no refresh-on-reload flow). - [x] Doctor practice insights: utilisation, no-shows and turnaround. Tracked as [issue #50](https://github.com/DocForum/docforum-web/issues/50) (High, 200 pts) — `src/features/insights/`, `DoctorPracticeInsightsPage`, slot utilisation by week, no-show/late-cancel rates, referral turnaround, message response time; charts with accessible table alternatives; date range selector; small-sample caveats; doctor-only data (no peer comparisons). ## Phase W4 — Facility core flow -**Fully scoped into issues #8 and #51.** +**Fully scoped into issues #8, #51, #52, and #53.** - [x] Facility staff accounts: invite, list and remove. Tracked as [issue #51](https://github.com/DocForum/docforum-web/issues/51) (High, 200 pts) — `src/features/facility-team/`, `FacilityTeamPage`, owner staff invite by email with fixed roles, pending invites with resend/revoke, member removal with backend session revocation and last-owner protection, read-only staff view, and fulfillment queue action attribution (PRD §7). +- [x] Scan-to-verify orders with the device camera. Tracked as [issue #52](https://github.com/DocForum/docforum-web/issues/52) (High, 200 pts) — `src/features/order-verify/`, `OrderVerifyPage` (`/facility/verify`), camera QR scanning via BarcodeDetector API with JS fallback, verified order states (`valid`, `superseded`, `expired`, `fulfilled`, `not_eligible`), instant jump into fulfillment queue, manual token entry fallback, camera permission and stream teardown, and 429 rate limit error handling. +- [x] Facility payout ledger with per-order escrow status and terms. Tracked as [issue #53](https://github.com/DocForum/docforum-web/issues/53) (High, 200 pts) — `src/features/payments/`, `FacilityPaymentsPage` (`/facility/payments`), cursor-paginated ledger, escrow totals cards (held, released, refunded), BigInt-safe amount formatting for Stellar stroops across assets (XLM vs USDC), plain-language escrow terms with releasers and refund countdowns, approaching deadline warnings (< 48h), and Stellar Expert testnet transaction explorer links. - [ ] Fulfillment queue. Tracked as [issue #8](https://github.com/DocForum/docforum-web/issues/8) (Medium, 150 pts). - [ ] Mark fulfilled/rejected, attach lab result. Folded into issue #8 above. @@ -356,5 +358,10 @@ real API" (no refresh-on-reload flow). - `#50 Doctor practice insights`: private insights page (`/doctor/insights`) with weekly slot utilisation, no-show and late-cancellation rates, referral acceptance turnaround, and messages response time. Includes SVG charts paired with accessible semantic `` alternatives, date range selector (presets + custom), clearly labelled small-sample caveats, and strict peer-comparison suppression (doctor's own confidential data only). Wired into `DoctorDashboardPage`, command palette (`⌘K`), and navigation. - `#51 Facility staff accounts & fulfillment attribution`: dedicated team management page (`/facility/team`) with member list, role assignment (`owner` vs `staff`), email invitations, resend/revoke pending invites, member removal with backend session revocation warning, and last-owner deletion protection. Enforced read-only view and action suppression for staff members. In the fulfillment queue (`/facility`), added action attribution display showing which staff member dispensed/fulfilled each order for PRD §7 audit trail. - Updated `ARCHITECTURE_ESSENTIALS.md` "Known assumptions" and `ROADMAP.md`. 24 test files and 112/112 tests passing; typecheck and build green. +- 2026-10-18 — Built Phase W4 Scan-to-verify orders (#52) and Facility payout ledger with escrow terms (#53): + - `#52 Scan-to-verify orders with device camera`: dedicated verification page (`/facility/verify`) enabling facility staff to scan order QR codes via device camera or enter tokens manually. Uses native `BarcodeDetector` API with fallback; guarantees complete media stream track teardown on screen unmount/exit. Dispatches to `POST /facilities/me/verify-order` and cleanly displays all 5 result states (`valid`, `superseded`, `expired`, `fulfilled`, `not_eligible`) alongside minimal clinical order summaries. Valid results feature an immediate CTA to jump into fulfillment (`/facility?orderId=...`). Surfaces HTTP 429 rate-limit protection against enumeration. + - `#53 Facility payout ledger with escrow terms`: dedicated payments page (`/facility/payments`) displaying held, released, and refunded totals for both XLM and USDC. Uses BigInt-safe math (`formatStroops`) for Stellar 7-decimal stroop precision. Renders cursor-paginated escrow ledger with plain-language terms (releaser rules and refund deadline countdowns) adhering to threat model T8. Prominently flags approaching refund deadlines (< 48h) on unfulfilled orders. Integrates direct deep links to Stellar Expert testnet explorer for funding, release, and refund transactions. + - Updated `ARCHITECTURE_ESSENTIALS.md` "Known assumptions" and `ROADMAP.md`. 26 test files and 124/124 tests passing; typecheck and build green. Captured desktop and mobile screenshots into `screenshots/`. + diff --git a/screenshots/facility-payments-desktop.png b/screenshots/facility-payments-desktop.png new file mode 100644 index 0000000..d71add1 Binary files /dev/null and b/screenshots/facility-payments-desktop.png differ diff --git a/screenshots/facility-payments-mobile.png b/screenshots/facility-payments-mobile.png new file mode 100644 index 0000000..3460c7b Binary files /dev/null and b/screenshots/facility-payments-mobile.png differ diff --git a/screenshots/facility-verify-desktop.png b/screenshots/facility-verify-desktop.png new file mode 100644 index 0000000..f8478d7 Binary files /dev/null and b/screenshots/facility-verify-desktop.png differ diff --git a/screenshots/facility-verify-mobile.png b/screenshots/facility-verify-mobile.png new file mode 100644 index 0000000..cf85bad Binary files /dev/null and b/screenshots/facility-verify-mobile.png differ diff --git a/src/App.tsx b/src/App.tsx index 43aa5e3..f54586f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,8 @@ import { ConsultationNote } from './features/consultation/components/Consultatio import { DoctorInbox } from './features/messaging/components/DoctorInbox'; import { DoctorPracticeInsightsPage } from './pages/DoctorPracticeInsightsPage'; import { FacilityTeamPage } from './pages/FacilityTeamPage'; +import { OrderVerifyPage } from './pages/OrderVerifyPage'; +import { FacilityPaymentsPage } from './pages/FacilityPaymentsPage'; export default function App() { return ( @@ -62,6 +64,22 @@ export default function App() { } /> + + + + } + /> + + + + } + /> Queue + + Verify + Team + + Payouts + )} diff --git a/src/features/order-verify/components/ManualTokenEntry.tsx b/src/features/order-verify/components/ManualTokenEntry.tsx new file mode 100644 index 0000000..924f252 --- /dev/null +++ b/src/features/order-verify/components/ManualTokenEntry.tsx @@ -0,0 +1,62 @@ +import { useState, type FormEvent } from 'react'; +import { Button } from '../../../components/Button'; +import styles from './OrderVerify.module.css'; + +interface ManualTokenEntryProps { + onSubmitToken: (token: string) => void; + isLoading?: boolean; +} + +export function ManualTokenEntry({ onSubmitToken, isLoading = false }: ManualTokenEntryProps) { + const [tokenInput, setTokenInput] = useState(''); + const [validationError, setValidationError] = useState(null); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + const trimmed = tokenInput.trim(); + if (!trimmed) { + setValidationError('Please enter an order token or alphanumeric verification code.'); + return; + } + setValidationError(null); + onSubmitToken(trimmed); + }; + + return ( +
+
+ + { + setTokenInput(e.target.value); + if (validationError) setValidationError(null); + }} + placeholder="e.g. ord_tok_v1_9c28a7f4" + disabled={isLoading} + autoComplete="off" + spellCheck="false" + aria-describedby="order-token-help" + /> + + Enter the alphanumeric token from the patient's paper or digital order document. + +
+ + {validationError && ( +
+ {validationError} +
+ )} + +
+ +
+ + ); +} diff --git a/src/features/order-verify/components/OrderScanner.tsx b/src/features/order-verify/components/OrderScanner.tsx new file mode 100644 index 0000000..a75a78f --- /dev/null +++ b/src/features/order-verify/components/OrderScanner.tsx @@ -0,0 +1,76 @@ +import { useCameraQrScanner } from '../hooks/useCameraQrScanner'; +import { Button } from '../../../components/Button'; +import styles from './OrderVerify.module.css'; + +interface OrderScannerProps { + onScan: (token: string) => void; + onSwitchToManual: () => void; + isVerifying?: boolean; +} + +export function OrderScanner({ onScan, onSwitchToManual, isVerifying = false }: OrderScannerProps) { + const { + videoRef, + cameraStatus, + errorMessage, + isScanning, + startCamera, + stopCamera, + } = useCameraQrScanner({ + onScan, + enabled: !isVerifying, + }); + + return ( +
+ {errorMessage && ( +
+ Camera Access Notice + {errorMessage} + {cameraStatus === 'denied' && ( +
+ +
+ )} +
+ )} + +
+
+ ); +} diff --git a/src/features/order-verify/components/OrderVerify.module.css b/src/features/order-verify/components/OrderVerify.module.css new file mode 100644 index 0000000..92e29df --- /dev/null +++ b/src/features/order-verify/components/OrderVerify.module.css @@ -0,0 +1,380 @@ +.container { + display: flex; + flex-direction: column; + gap: var(--space-6); + width: 100%; + max-width: 720px; + margin: 0 auto; +} + +.header { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.title { + margin: 0; + font-size: var(--text-2xl); + font-family: var(--font-display); + color: var(--color-text); +} + +.subtitle { + margin: 0; + font-size: var(--text-sm); + color: var(--color-text-muted); +} + +.modeToggle { + display: flex; + background: var(--color-surface-alt); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: 4px; + gap: 4px; +} + +.modeButton { + flex: 1; + padding: var(--space-2) var(--space-4); + font-size: var(--text-sm); + font-weight: 500; + border-radius: var(--radius-sm); + border: none; + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + transition: all 0.15s ease; + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-2); +} + +.modeButtonActive { + background: var(--color-surface); + color: var(--color-text); + box-shadow: var(--shadow-sm); + font-weight: 600; +} + +.scannerCard { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-6); + box-shadow: var(--shadow-sm); + display: flex; + flex-direction: column; + gap: var(--space-4); + align-items: center; +} + +.viewfinderWrapper { + position: relative; + width: 100%; + max-width: 440px; + aspect-ratio: 4 / 3; + background: #0f172a; + border-radius: var(--radius-md); + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; +} + +.videoFeed { + width: 100%; + height: 100%; + object-fit: cover; +} + +.reticle { + position: absolute; + width: 220px; + height: 220px; + border: 2px dashed rgba(255, 255, 255, 0.7); + border-radius: var(--radius-md); + box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45); + pointer-events: none; + display: flex; + align-items: center; + justify-content: center; +} + +.scanBeam { + width: 100%; + height: 2px; + background: #22c55e; + box-shadow: 0 0 8px #22c55e; + animation: scanMotion 2s infinite ease-in-out; +} + +@keyframes scanMotion { + 0% { + transform: translateY(-90px); + } + 50% { + transform: translateY(90px); + } + 100% { + transform: translateY(-90px); + } +} + +@media (prefers-reduced-motion: reduce) { + .scanBeam { + animation: none; + transform: translateY(0); + } +} + +.scannerControls { + display: flex; + gap: var(--space-3); + justify-content: center; + width: 100%; +} + +.alert { + padding: var(--space-4); + border-radius: var(--radius-md); + display: flex; + flex-direction: column; + gap: var(--space-2); + width: 100%; + font-size: var(--text-sm); +} + +.alertError { + background: #fef2f2; + border: 1px solid #fecaca; + color: #991b1b; +} + +.alertWarning { + background: #fffbeb; + border: 1px solid #fde68a; + color: #92400e; +} + +.alertInfo { + background: #f0fdf4; + border: 1px solid #bbf7d0; + color: #166534; +} + +.manualForm { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-6); + box-shadow: var(--shadow-sm); + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.inputGroup { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.inputGroup label { + font-size: var(--text-sm); + font-weight: 500; + color: var(--color-text); +} + +.tokenInput { + padding: var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + font-family: monospace; + font-size: var(--text-base); + background: var(--color-surface); + color: var(--color-text); + width: 100%; +} + +.tokenInput:focus { + outline: 2px solid var(--color-primary); + border-color: transparent; +} + +.helperText { + font-size: var(--text-xs); + color: var(--color-text-muted); +} + +/* Result Card Styles */ +.resultCard { + background: var(--color-surface); + border: 2px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-6); + box-shadow: var(--shadow-md); + display: flex; + flex-direction: column; + gap: var(--space-5); +} + +.resultValid { + border-color: #22c55e; +} + +.resultSuperseded { + border-color: #f59e0b; +} + +.resultExpired { + border-color: #ef4444; +} + +.resultFulfilled { + border-color: #64748b; +} + +.resultNotEligible { + border-color: #dc2626; +} + +.resultHeader { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--space-3); + padding-bottom: var(--space-4); + border-bottom: 1px solid var(--color-border); +} + +.statusBadge { + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + border-radius: 9999px; + font-weight: 700; + font-size: var(--text-sm); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.badgeValid { + background: #dcfce7; + color: #15803d; +} + +.badgeSuperseded { + background: #fef3c7; + color: #b45309; +} + +.badgeExpired { + background: #fee2e2; + color: #b91c1c; +} + +.badgeFulfilled { + background: #f1f5f9; + color: #475569; +} + +.badgeNotEligible { + background: #ffe4e6; + color: #be123c; +} + +.resultExplanation { + margin: 0; + font-size: var(--text-sm); + color: var(--color-text); + line-height: 1.5; +} + +.summaryGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--space-4); + background: var(--color-surface-alt); + padding: var(--space-4); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); +} + +.summaryItem { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.summaryLabel { + font-size: var(--text-xs); + color: var(--color-text-muted); + text-transform: uppercase; + font-weight: 600; +} + +.summaryValue { + font-size: var(--text-base); + font-weight: 600; + color: var(--color-text); +} + +.instructionsBox { + background: var(--color-surface-alt); + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-md); + font-size: var(--text-sm); + border-left: 3px solid var(--color-primary); +} + +.instructionsBox h4 { + margin: 0 0 var(--space-1) 0; + font-size: var(--text-xs); + text-transform: uppercase; + color: var(--color-text-muted); +} + +.instructionsBox p { + margin: 0; + color: var(--color-text); +} + +.resultActions { + display: flex; + gap: var(--space-3); + justify-content: flex-end; + flex-wrap: wrap; + padding-top: var(--space-3); +} + +.rateLimitCard { + background: #fff7ed; + border: 2px solid #fdba74; + border-radius: var(--radius-lg); + padding: var(--space-6); + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.rateLimitTitle { + margin: 0; + font-size: var(--text-lg); + font-weight: 700; + color: #9a3412; + display: flex; + align-items: center; + gap: var(--space-2); +} + +.rateLimitDesc { + margin: 0; + font-size: var(--text-sm); + color: #7c2d12; + line-height: 1.5; +} diff --git a/src/features/order-verify/components/OrderVerify.test.tsx b/src/features/order-verify/components/OrderVerify.test.tsx new file mode 100644 index 0000000..c053fae --- /dev/null +++ b/src/features/order-verify/components/OrderVerify.test.tsx @@ -0,0 +1,206 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from '../../../test/render'; +import { OrderVerifyView } from './OrderVerifyView'; +import { useAuthStore } from '../../../store/auth-store'; +import { ApiError } from '../../../services/api-client'; +import * as orderVerifyService from '../../../services/order-verify-service'; + +vi.mock('../../../services/order-verify-service'); + +describe('OrderVerifyView & Camera QR Scanner', () => { + const mockTrackStop = vi.fn(); + const mockTrack = { stop: mockTrackStop } as unknown as MediaStreamTrack; + const mockStream = { + getTracks: vi.fn(() => [mockTrack]), + } as unknown as MediaStream; + + beforeEach(() => { + vi.clearAllMocks(); + useAuthStore.setState({ + accessToken: 'facility-token', + user: { + id: 'usr-1', + email: 'facility@docforum.org', + role: 'facility', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + }); + + // Mock mediaDevices.getUserMedia + Object.defineProperty(window.navigator, 'mediaDevices', { + value: { + getUserMedia: vi.fn().mockResolvedValue(mockStream), + }, + writable: true, + configurable: true, + }); + }); + + it('tears down camera media stream tracks when component unmounts', async () => { + const { unmount } = render(); + + await waitFor(() => { + expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalled(); + }); + + unmount(); + + expect(mockTrackStop).toHaveBeenCalled(); + }); + + it('allows manual entry fallback and verifies valid order state with fulfillment jump', async () => { + vi.mocked(orderVerifyService.verifyOrderToken).mockResolvedValue({ + status: 'valid', + token: 'ord_tok_valid_123', + verifiedAt: '2026-10-18T12:00:00Z', + order: { + orderId: 'ord-rx-101', + orderType: 'prescription', + patientName: 'John Doe', + medicationOrTest: 'Amoxicillin 500mg', + instructions: 'Take 1 capsule three times daily', + issuedAt: '2026-10-17T10:00:00Z', + expiresAt: '2026-11-17T10:00:00Z', + }, + }); + + const user = userEvent.setup(); + render(); + + // Switch to manual mode + await user.click(screen.getByRole('tab', { name: /manual code entry/i })); + + const input = screen.getByLabelText(/order verification token/i); + await user.type(input, 'ord_tok_valid_123'); + await user.click(screen.getByRole('button', { name: /verify order/i })); + + expect(await screen.findByText(/valid & ready/i)).toBeInTheDocument(); + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.getByText('Amoxicillin 500mg')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /proceed to fulfil/i })).toBeInTheDocument(); + }); + + it('shows superseded order state with replacement order reference and warning', async () => { + vi.mocked(orderVerifyService.verifyOrderToken).mockResolvedValue({ + status: 'superseded', + token: 'ord_tok_superseded_456', + verifiedAt: '2026-10-18T12:00:00Z', + order: { + orderId: 'ord-rx-88', + orderType: 'prescription', + patientName: 'Jane Smith', + medicationOrTest: 'Lisinopril 10mg', + instructions: 'Superseded dosage', + issuedAt: '2026-10-01T10:00:00Z', + expiresAt: '2026-11-01T10:00:00Z', + supersededBy: 'ord-rx-94-v2', + }, + }); + + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('tab', { name: /manual code entry/i })); + await user.type(screen.getByLabelText(/order verification token/i), 'ord_tok_superseded_456'); + await user.click(screen.getByRole('button', { name: /verify order/i })); + + expect(await screen.findByText('⚠ Superseded')).toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent(/order superseded/i); + expect(screen.getByText(/ord-rx-94-v2/i)).toBeInTheDocument(); + expect(screen.getByText(/do not dispense or fulfill/i)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /proceed to fulfil/i })).not.toBeInTheDocument(); + }); + + it('shows expired order state', async () => { + vi.mocked(orderVerifyService.verifyOrderToken).mockResolvedValue({ + status: 'expired', + token: 'ord_tok_expired_789', + verifiedAt: '2026-10-18T12:00:00Z', + order: { + orderId: 'ord-lab-55', + orderType: 'lab', + patientName: 'Robert Garcia', + medicationOrTest: 'Comprehensive Metabolic Panel', + issuedAt: '2026-08-01T10:00:00Z', + expiresAt: '2026-09-01T10:00:00Z', + }, + }); + + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('tab', { name: /manual code entry/i })); + await user.type(screen.getByLabelText(/order verification token/i), 'ord_tok_expired_789'); + await user.click(screen.getByRole('button', { name: /verify order/i })); + + expect(await screen.findByText('✕ Expired')).toBeInTheDocument(); + expect(screen.getByText(/expired orders cannot be fulfilled/i)).toBeInTheDocument(); + }); + + it('shows fulfilled order state with prior fulfillment details', async () => { + vi.mocked(orderVerifyService.verifyOrderToken).mockResolvedValue({ + status: 'fulfilled', + token: 'ord_tok_fulfilled_111', + verifiedAt: '2026-10-18T12:00:00Z', + order: { + orderId: 'ord-rx-71', + orderType: 'prescription', + patientName: 'Alice Johnson', + medicationOrTest: 'Atorvastatin 20mg', + issuedAt: '2026-10-10T10:00:00Z', + expiresAt: '2026-11-10T10:00:00Z', + fulfilledAt: '2026-10-15T14:22:00Z', + fulfilledByFacility: 'St. Jude Pharmacy', + }, + }); + + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('tab', { name: /manual code entry/i })); + await user.type(screen.getByLabelText(/order verification token/i), 'ord_tok_fulfilled_111'); + await user.click(screen.getByRole('button', { name: /verify order/i })); + + expect(await screen.findByText('ℹ Already Fulfilled')).toBeInTheDocument(); + expect(screen.getByText(/St. Jude Pharmacy/i)).toBeInTheDocument(); + expect(screen.getByText(/duplicate dispensing is prohibited/i)).toBeInTheDocument(); + }); + + it('shows not eligible state with reason', async () => { + vi.mocked(orderVerifyService.verifyOrderToken).mockResolvedValue({ + status: 'not_eligible', + token: 'ord_tok_ineligible_999', + verifiedAt: '2026-10-18T12:00:00Z', + message: 'Restricted network tier: Facility is not authorized for this specialty drug.', + order: null, + }); + + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('tab', { name: /manual code entry/i })); + await user.type(screen.getByLabelText(/order verification token/i), 'ord_tok_ineligible_999'); + await user.click(screen.getByRole('button', { name: /verify order/i })); + + expect(await screen.findByText('✕ Not Eligible')).toBeInTheDocument(); + expect(screen.getByText(/Restricted network tier/i)).toBeInTheDocument(); + }); + + it('displays rate-limit error (429) clearly', async () => { + vi.mocked(orderVerifyService.verifyOrderToken).mockRejectedValue( + new ApiError('Too many verification attempts. Verification is rate-limited to protect tokens against enumeration.', 429), + ); + + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('tab', { name: /manual code entry/i })); + await user.type(screen.getByLabelText(/order verification token/i), 'rapid_guess_attempt'); + await user.click(screen.getByRole('button', { name: /verify order/i })); + + expect(await screen.findByText(/rate limit exceeded \(429\)/i)).toBeInTheDocument(); + expect(screen.getByText(/too many verification attempts/i)).toBeInTheDocument(); + }); +}); diff --git a/src/features/order-verify/components/OrderVerifyView.tsx b/src/features/order-verify/components/OrderVerifyView.tsx new file mode 100644 index 0000000..df06fa0 --- /dev/null +++ b/src/features/order-verify/components/OrderVerifyView.tsx @@ -0,0 +1,118 @@ +import { useState } from 'react'; +import { OrderScanner } from './OrderScanner'; +import { ManualTokenEntry } from './ManualTokenEntry'; +import { VerificationResultCard } from './VerificationResultCard'; +import { useOrderVerify } from '../hooks/useOrderVerify'; +import { ApiError } from '../../../services/api-client'; +import { Button } from '../../../components/Button'; +import type { VerifyOrderResponse } from '../../../types/models'; +import styles from './OrderVerify.module.css'; + +export function OrderVerifyView() { + const [activeMode, setActiveMode] = useState<'camera' | 'manual'>('camera'); + const [result, setResult] = useState(null); + const [rateLimitMessage, setRateLimitMessage] = useState(null); + + const verifyMutation = useOrderVerify(); + + const handleVerifyToken = async (token: string) => { + setRateLimitMessage(null); + try { + const res = await verifyMutation.mutateAsync(token); + setResult(res); + } catch (err: unknown) { + if (err instanceof ApiError && err.status === 429) { + setRateLimitMessage(err.message); + } else { + const error = err as Error; + if (error.message?.includes('429') || error.message?.toLowerCase().includes('rate-limited')) { + setRateLimitMessage(error.message); + } + } + } + }; + + const handleReset = () => { + setResult(null); + setRateLimitMessage(null); + verifyMutation.reset(); + }; + + return ( +
+
+

Scan to verify order

+

+ Scan a patient's QR code or enter the order verification token to validate authenticity, + expiration, and eligibility before fulfillment. +

+
+ + {rateLimitMessage && ( +
+

+ ⚠️ Rate Limit Exceeded (429) +

+

{rateLimitMessage}

+
+ +
+
+ )} + + {result ? ( + + ) : ( + <> +
+ + +
+ + {verifyMutation.isPending && ( +
+ Checking order token against DocForum core registry… +
+ )} + + {verifyMutation.isError && !rateLimitMessage && ( +
+ Verification Failed: {verifyMutation.error.message || 'Unable to verify order token.'} +
+ )} + + {activeMode === 'camera' ? ( + setActiveMode('manual')} + isVerifying={verifyMutation.isPending} + /> + ) : ( + + )} + + )} +
+ ); +} diff --git a/src/features/order-verify/components/VerificationResultCard.tsx b/src/features/order-verify/components/VerificationResultCard.tsx new file mode 100644 index 0000000..0b764ff --- /dev/null +++ b/src/features/order-verify/components/VerificationResultCard.tsx @@ -0,0 +1,163 @@ +import { useNavigate } from 'react-router-dom'; +import { Button } from '../../../components/Button'; +import type { VerifyOrderResponse } from '../../../types/models'; +import styles from './OrderVerify.module.css'; + +interface VerificationResultCardProps { + result: VerifyOrderResponse; + onReset: () => void; +} + +export function VerificationResultCard({ result, onReset }: VerificationResultCardProps) { + const navigate = useNavigate(); + const { status, order, token, verifiedAt, message } = result; + + const getStatusBadge = () => { + switch (status) { + case 'valid': + return ✓ Valid & Ready; + case 'superseded': + return ⚠ Superseded; + case 'expired': + return ✕ Expired; + case 'fulfilled': + return ℹ Already Fulfilled; + case 'not_eligible': + return ✕ Not Eligible; + } + }; + + const getResultClass = () => { + switch (status) { + case 'valid': + return styles.resultValid; + case 'superseded': + return styles.resultSuperseded; + case 'expired': + return styles.resultExpired; + case 'fulfilled': + return styles.resultFulfilled; + case 'not_eligible': + return styles.resultNotEligible; + } + }; + + const handleProceedToFulfil = () => { + if (order?.orderId) { + navigate(`/facility?orderId=${encodeURIComponent(order.orderId)}`); + } else { + navigate('/facility'); + } + }; + + return ( +
+
+
+
+ {getStatusBadge()} + + Token: {token} + +
+
+ Verified on {new Date(verifiedAt).toLocaleString()} +
+
+
+ +
+ {status === 'valid' && ( +

+ This order is authentic, current, and eligible for fulfillment. You may safely proceed to + dispense medication or collect diagnostic samples. +

+ )} + {status === 'superseded' && ( +
+ Order Superseded: A physician has revised or superseded this order. + {order?.supersededBy && ( + Superseded by replacement order reference: {order.supersededBy}. + )} + {' '}Do not dispense or fulfill under this token. +
+ )} + {status === 'expired' && ( +
+ Order Expired: This order reached its expiration cutoff on{' '} + {order?.expiresAt ? new Date(order.expiresAt).toLocaleDateString() : 'its designated date'}. Expired orders cannot be fulfilled. +
+ )} + {status === 'fulfilled' && ( +
+ Order Already Fulfilled: This order was completed + {order?.fulfilledAt ? ` on ${new Date(order.fulfilledAt).toLocaleString()}` : ''} + {order?.fulfilledByFacility ? ` by ${order.fulfilledByFacility}` : ''}. Duplicate dispensing is prohibited. +
+ )} + {status === 'not_eligible' && ( +
+ Not Eligible: {message || 'This order cannot be fulfilled by this facility or does not match eligibility criteria.'} +
+ )} +
+ + {order && ( + <> +
+
+ Patient + {order.patientName} +
+
+ Order Type + + {order.orderType} + +
+
+ Item / Medication + {order.medicationOrTest} +
+
+ Issued Date + + {new Date(order.issuedAt).toLocaleDateString()} + +
+
+ Expires Date + + {new Date(order.expiresAt).toLocaleDateString()} + +
+
+ Order ID + + {order.orderId} + +
+
+ + {order.instructions && ( +
+

Clinical Instructions

+

{order.instructions}

+
+ )} + + )} + +
+ + {status === 'valid' && ( + + )} +
+
+ ); +} diff --git a/src/features/order-verify/hooks/useCameraQrScanner.ts b/src/features/order-verify/hooks/useCameraQrScanner.ts new file mode 100644 index 0000000..927caf5 --- /dev/null +++ b/src/features/order-verify/hooks/useCameraQrScanner.ts @@ -0,0 +1,146 @@ +import { useState, useRef, useEffect, useCallback } from 'react'; + +export type CameraStatus = 'idle' | 'starting' | 'active' | 'denied' | 'unsupported' | 'error'; + +export interface UseCameraQrScannerOptions { + onScan: (code: string) => void; + enabled?: boolean; +} + +export function useCameraQrScanner({ onScan, enabled = true }: UseCameraQrScannerOptions) { + const videoRef = useRef(null); + const streamRef = useRef(null); + const scanIntervalRef = useRef(null); + + const [cameraStatus, setCameraStatus] = useState('idle'); + const [errorMessage, setErrorMessage] = useState(null); + const [isScanning, setIsScanning] = useState(false); + + const stopCamera = useCallback(() => { + if (scanIntervalRef.current !== null) { + window.clearInterval(scanIntervalRef.current); + scanIntervalRef.current = null; + } + + if (streamRef.current) { + streamRef.current.getTracks().forEach((track) => { + try { + track.stop(); + } catch { + // ignore error on track stop + } + }); + streamRef.current = null; + } + + if (videoRef.current) { + videoRef.current.srcObject = null; + } + + setIsScanning(false); + setCameraStatus('idle'); + }, []); + + const startCamera = useCallback(async () => { + stopCamera(); + + if (!navigator?.mediaDevices?.getUserMedia) { + setCameraStatus('unsupported'); + setErrorMessage('Camera access is not supported by your browser or environment.'); + return; + } + + try { + setCameraStatus('starting'); + setErrorMessage(null); + + const stream = await navigator.mediaDevices.getUserMedia({ + video: { + facingMode: { ideal: 'environment' }, + width: { ideal: 1280 }, + height: { ideal: 720 }, + }, + }); + + streamRef.current = stream; + + if (videoRef.current) { + videoRef.current.srcObject = stream; + try { + await videoRef.current.play(); + } catch { + // video play can be ignored if already playing or handled + } + } + + setCameraStatus('active'); + setIsScanning(true); + + // Setup BarcodeDetector or fallback scanner loop + const BarcodeDetectorClass = (window as unknown as { BarcodeDetector?: new (opts?: { formats: string[] }) => { detect: (source: ImageBitmapSource) => Promise> } }).BarcodeDetector; + + if (BarcodeDetectorClass) { + const detector = new BarcodeDetectorClass({ formats: ['qr_code'] }); + scanIntervalRef.current = window.setInterval(async () => { + if (!videoRef.current || videoRef.current.readyState < 2) return; + try { + const detected = await detector.detect(videoRef.current); + if (detected && detected.length > 0 && detected[0].rawValue) { + const scannedValue = detected[0].rawValue.trim(); + if (scannedValue) { + stopCamera(); + onScan(scannedValue); + } + } + } catch { + // ignore frame parse errors + } + }, 250); + } else { + // Fallback: If BarcodeDetector is not natively supported, check if a global mock / fallback decoder was attached + const customScanner = (window as unknown as { __mockQrScan?: (cb: (code: string) => void) => void }).__mockQrScan; + if (customScanner) { + customScanner((code) => { + stopCamera(); + onScan(code); + }); + } + } + } catch (err: unknown) { + const error = err as Error; + if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError') { + setCameraStatus('denied'); + setErrorMessage('Camera permission was denied. Please allow camera access in your browser or enter the order token manually below.'); + } else if (error.name === 'NotFoundError' || error.name === 'DevicesNotFoundError') { + setCameraStatus('unsupported'); + setErrorMessage('No camera device found on this system. Please use manual code entry.'); + } else { + setCameraStatus('error'); + setErrorMessage(error.message || 'Unable to start camera.'); + } + stopCamera(); + } + }, [onScan, stopCamera]); + + useEffect(() => { + if (enabled) { + startCamera(); + } else { + stopCamera(); + } + + return () => { + // Ensure all media stream tracks are stopped when unmounting or leaving the screen + stopCamera(); + }; + }, [enabled, startCamera, stopCamera]); + + return { + videoRef, + cameraStatus, + errorMessage, + isScanning, + startCamera, + stopCamera, + }; +} diff --git a/src/features/order-verify/hooks/useOrderVerify.ts b/src/features/order-verify/hooks/useOrderVerify.ts new file mode 100644 index 0000000..99bb286 --- /dev/null +++ b/src/features/order-verify/hooks/useOrderVerify.ts @@ -0,0 +1,17 @@ +import { useMutation } from '@tanstack/react-query'; +import { useAuthStore } from '../../../store/auth-store'; +import { verifyOrderToken } from '../../../services/order-verify-service'; +import type { VerifyOrderResponse } from '../../../types/models'; + +export function useOrderVerify() { + const accessToken = useAuthStore((state) => state.accessToken); + + return useMutation({ + mutationFn: (orderToken: string) => { + if (!accessToken) { + throw new Error('Not authenticated'); + } + return verifyOrderToken(orderToken, accessToken); + }, + }); +} diff --git a/src/features/payments/components/FacilityPayoutLedger.module.css b/src/features/payments/components/FacilityPayoutLedger.module.css new file mode 100644 index 0000000..b6453bd --- /dev/null +++ b/src/features/payments/components/FacilityPayoutLedger.module.css @@ -0,0 +1,278 @@ +.container { + display: flex; + flex-direction: column; + gap: var(--space-6); + width: 100%; +} + +.header { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.title { + margin: 0; + font-size: var(--text-2xl); + font-family: var(--font-display); + color: var(--color-text); +} + +.subtitle { + margin: 0; + font-size: var(--text-sm); + color: var(--color-text-muted); +} + +.threatNotice { + background: #f0fdfa; + border: 1px solid #99f6e4; + border-left: 4px solid #0d9488; + border-radius: var(--radius-md); + padding: var(--space-4); + font-size: var(--text-sm); + color: #115e59; + line-height: 1.5; +} + +.threatNotice strong { + color: #042f2e; +} + +.totalsGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: var(--space-4); +} + +.totalCard { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-5); + box-shadow: var(--shadow-sm); + display: flex; + flex-direction: column; + gap: var(--space-3); + position: relative; + overflow: hidden; +} + +.totalCardHeld { + border-top: 4px solid #f59e0b; +} + +.totalCardReleased { + border-top: 4px solid #22c55e; +} + +.totalCardRefunded { + border-top: 4px solid #64748b; +} + +.totalCardHeader { + display: flex; + justify-content: space-between; + align-items: center; +} + +.totalLabel { + font-size: var(--text-sm); + font-weight: 600; + text-transform: uppercase; + color: var(--color-text-muted); + letter-spacing: 0.05em; +} + +.approachingBadge { + background: #fef3c7; + color: #b45309; + border: 1px solid #fde68a; + padding: 2px 8px; + border-radius: 9999px; + font-size: var(--text-xs); + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.assetBreakdown { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.assetRow { + display: flex; + justify-content: space-between; + align-items: baseline; + border-bottom: 1px dashed var(--color-border); + padding-bottom: var(--space-1); +} + +.assetRow:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.assetName { + font-size: var(--text-sm); + font-weight: 600; + color: var(--color-text-muted); +} + +.assetAmount { + font-size: var(--text-xl); + font-weight: 700; + font-family: var(--font-mono, monospace); + color: var(--color-text); +} + +.filterBar { + display: flex; + gap: var(--space-4); + align-items: center; + flex-wrap: wrap; + background: var(--color-surface-alt); + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); +} + +.filterGroup { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.filterGroup label { + font-size: var(--text-xs); + font-weight: 600; + text-transform: uppercase; + color: var(--color-text-muted); +} + +.selectInput { + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text); + font-size: var(--text-sm); +} + +.tableWrapper { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + overflow-x: auto; + box-shadow: var(--shadow-sm); +} + +.table { + width: 100%; + border-collapse: collapse; + text-align: left; + font-size: var(--text-sm); +} + +.table th { + background: var(--color-surface-alt); + color: var(--color-text-muted); + font-weight: 600; + text-transform: uppercase; + font-size: var(--text-xs); + letter-spacing: 0.05em; + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border); +} + +.table td { + padding: var(--space-4); + border-bottom: 1px solid var(--color-border); + vertical-align: top; +} + +.table tr:last-child td { + border-bottom: none; +} + +.tableRowAlert { + background: #fffbeb; +} + +.statusPill { + display: inline-block; + padding: 2px 8px; + border-radius: 9999px; + font-size: var(--text-xs); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.statusHeld { + background: #fef3c7; + color: #b45309; +} + +.statusReleased { + background: #dcfce7; + color: #15803d; +} + +.statusRefunded { + background: #f1f5f9; + color: #64748b; +} + +.warningAlertBox { + margin-top: var(--space-2); + padding: var(--space-2) var(--space-3); + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: var(--radius-sm); + color: #991b1b; + font-size: var(--text-xs); + font-weight: 500; + line-height: 1.4; + display: flex; + align-items: center; + gap: var(--space-2); +} + +.termsText { + font-size: var(--text-xs); + color: var(--color-text-muted); + line-height: 1.4; + margin-top: 4px; +} + +.releaserTag { + display: inline-block; + font-weight: 600; + color: var(--color-text); + font-size: var(--text-xs); +} + +.explorerLink { + color: var(--color-primary); + text-decoration: underline; + font-family: var(--font-mono, monospace); + font-size: var(--text-xs); + display: inline-flex; + align-items: center; + gap: 4px; +} + +.explorerLink:hover { + text-decoration: none; +} + +.pagination { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: var(--space-4); +} diff --git a/src/features/payments/components/FacilityPayoutLedger.test.tsx b/src/features/payments/components/FacilityPayoutLedger.test.tsx new file mode 100644 index 0000000..b7ea203 --- /dev/null +++ b/src/features/payments/components/FacilityPayoutLedger.test.tsx @@ -0,0 +1,190 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from '../../../test/render'; +import { FacilityPayoutLedger } from './FacilityPayoutLedger'; +import { useAuthStore } from '../../../store/auth-store'; +import { formatStroops } from '../../../services/payments-service'; +import * as paymentsService from '../../../services/payments-service'; +import type { FacilityLedgerPage } from '../../../types/models'; + +vi.mock('../../../services/payments-service', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getFacilityPayoutLedger: vi.fn(), + }; +}); + +const MOCK_LEDGER_DATA: FacilityLedgerPage = { + totals: { + xlm: { + heldStroops: '500000000', // 50 XLM + releasedStroops: '1250000000', // 125 XLM + refundedStroops: '100000000', // 10 XLM + }, + usdc: { + heldStroops: '75000000', // 7.5 USDC + releasedStroops: '300000000', // 30 USDC + refundedStroops: '0', + }, + }, + entries: [ + { + id: 'ledg-urgent', + facilityId: 'fac-1', + orderId: 'ord-rx-urgent', + orderType: 'prescription', + orderItemName: 'Atorvastatin 40mg', + patientName: 'David Lee', + amountStroops: '500000000', + asset: 'XLM', + status: 'held', + escrowId: 'esc-111', + fundTxHash: 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + terms: { + releaser: 'patient', + releaserDescription: 'Patient sign-off upon pickup', + refundDeadline: '2026-10-19T08:00:00Z', + isApproachingDeadline: true, + hoursUntilRefund: 12, + }, + createdAt: '2026-10-17T08:00:00Z', + updatedAt: '2026-10-17T08:00:00Z', + }, + { + id: 'ledg-safe', + facilityId: 'fac-1', + orderId: 'ord-rx-safe', + orderType: 'prescription', + orderItemName: 'Metformin 500mg', + patientName: 'Emma Watson', + amountStroops: '75000000', + asset: 'USDC', + status: 'held', + escrowId: 'esc-222', + fundTxHash: '123456abcdef123456abcdef123456abcdef123456abcdef123456abcdef1234', + terms: { + releaser: 'platform', + releaserDescription: 'Automated release upon courier drop', + refundDeadline: '2026-10-25T10:00:00Z', + isApproachingDeadline: false, + hoursUntilRefund: 160, + }, + createdAt: '2026-10-18T10:00:00Z', + updatedAt: '2026-10-18T10:00:00Z', + }, + { + id: 'ledg-released', + facilityId: 'fac-1', + orderId: 'ord-lab-done', + orderType: 'lab', + orderItemName: 'Complete Blood Count', + patientName: 'Marcus Cole', + amountStroops: '300000000', + asset: 'USDC', + status: 'released', + escrowId: 'esc-333', + fundTxHash: 'aaaabbbbccccddddaaaabbbbccccddddaaaabbbbccccddddaaaabbbbccccdddd', + releaseTxHash: 'eeeffff00001111222233334444555566667777888899990000aaaabbbbcccc', + terms: { + releaser: 'auto_dispense', + releaserDescription: 'Specimen verified in lab system', + refundDeadline: '2026-10-16T12:00:00Z', + isApproachingDeadline: false, + hoursUntilRefund: 0, + }, + createdAt: '2026-10-14T12:00:00Z', + updatedAt: '2026-10-15T15:00:00Z', + }, + ], +}; + +describe('FacilityPayoutLedger & formatStroops', () => { + beforeEach(() => { + vi.clearAllMocks(); + useAuthStore.setState({ + accessToken: 'facility-access-token', + user: { + id: 'usr-fac', + email: 'owner@pharma.org', + role: 'facility', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + }); + }); + + describe('formatStroops BigInt helper', () => { + it('accurately formats stroop values without floating point errors', () => { + // 10,000,000 stroops = 1 XLM + expect(formatStroops('10000000', 'XLM')).toBe('1 XLM'); + expect(formatStroops('500000000', 'XLM')).toBe('50 XLM'); + expect(formatStroops('75000000', 'USDC')).toBe('7.50 USDC'); + expect(formatStroops('123456789012345678', 'XLM')).toBe('12345678901.2345678 XLM'); + expect(formatStroops('0', 'XLM')).toBe('0 XLM'); + }); + }); + + describe('Component Rendering & Warning States', () => { + it('renders totals breakdown for both XLM and USDC', async () => { + vi.mocked(paymentsService.getFacilityPayoutLedger).mockResolvedValue(MOCK_LEDGER_DATA); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('held-xlm')).toHaveTextContent('50 XLM'); + expect(screen.getByTestId('held-usdc')).toHaveTextContent('7.50 USDC'); + expect(screen.getByTestId('released-xlm')).toHaveTextContent('125 XLM'); + expect(screen.getByTestId('released-usdc')).toHaveTextContent('30 USDC'); + expect(screen.getByTestId('refunded-xlm')).toHaveTextContent('10 XLM'); + }); + }); + + it('highlights approaching refund deadlines with clear warnings', async () => { + vi.mocked(paymentsService.getFacilityPayoutLedger).mockResolvedValue(MOCK_LEDGER_DATA); + + render(); + + expect(await screen.findByText(/deadline alert/i)).toBeInTheDocument(); + expect(screen.getByText(/Only 12h left to fulfill before escrow funds are automatically refundable/i)).toBeInTheDocument(); + }); + + it('provides explorer links to Stellar Expert for on-chain auditability', async () => { + vi.mocked(paymentsService.getFacilityPayoutLedger).mockResolvedValue(MOCK_LEDGER_DATA); + + render(); + + await screen.findByText('Atorvastatin 40mg'); + + const fundLink = screen.getAllByRole('link', { name: /fund tx/i })[0]; + expect(fundLink).toHaveAttribute( + 'href', + `https://stellar.expert/explorer/testnet/tx/${MOCK_LEDGER_DATA.entries[0].fundTxHash}`, + ); + expect(fundLink).toHaveAttribute('target', '_blank'); + + const releaseLink = screen.getByRole('link', { name: /release tx/i }); + expect(releaseLink).toHaveAttribute( + 'href', + `https://stellar.expert/explorer/testnet/tx/${MOCK_LEDGER_DATA.entries[2].releaseTxHash}`, + ); + }); + + it('filters ledger entries by status and asset', async () => { + vi.mocked(paymentsService.getFacilityPayoutLedger).mockResolvedValue(MOCK_LEDGER_DATA); + const user = userEvent.setup(); + + render(); + await screen.findByText('Atorvastatin 40mg'); + + const statusSelect = screen.getByLabelText(/escrow status:/i); + await user.selectOptions(statusSelect, 'held'); + + expect(paymentsService.getFacilityPayoutLedger).toHaveBeenCalledWith( + 'fac-1', + 'facility-access-token', + expect.objectContaining({ status: 'held' }), + ); + }); + }); +}); diff --git a/src/features/payments/components/FacilityPayoutLedger.tsx b/src/features/payments/components/FacilityPayoutLedger.tsx new file mode 100644 index 0000000..f36af7b --- /dev/null +++ b/src/features/payments/components/FacilityPayoutLedger.tsx @@ -0,0 +1,270 @@ +import { useState } from 'react'; +import { useFacilityLedger } from '../hooks/useFacilityLedger'; +import { formatStroops } from '../../../services/payments-service'; +import styles from './FacilityPayoutLedger.module.css'; + +interface FacilityPayoutLedgerProps { + facilityId?: string; +} + +export function FacilityPayoutLedger({ facilityId = 'fac-main' }: FacilityPayoutLedgerProps) { + const [statusFilter, setStatusFilter] = useState('all'); + const [assetFilter, setAssetFilter] = useState('all'); + + const { data, isLoading, error } = useFacilityLedger(facilityId, { + status: statusFilter, + asset: assetFilter, + }); + + const totals = data?.totals; + const entries = data?.entries || []; + + const hasApproachingDeadlines = entries.some( + (e) => e.status === 'held' && e.terms.isApproachingDeadline + ); + + return ( +
+
+

Facility payout ledger & escrow terms

+

+ Track escrow deposits held on Stellar, review release conditions and deadlines, and verify + on-chain settlement. +

+
+ +
+ Escrow Terms Security (Threat Model T8): All customer payments are held in + non-custodial Soroban smart contracts. To prevent delivery defaults or disputed forfeitures, review + releaser terms and fulfill orders well before refund deadlines. +
+ + {totals && ( +
+
+
+ Held in Escrow + {hasApproachingDeadlines && ( + ⚠️ Deadline alert + )} +
+
+
+ Stellar Lumens (XLM) + + {formatStroops(totals.xlm.heldStroops, 'XLM')} + +
+
+ USD Coin (USDC) + + {formatStroops(totals.usdc.heldStroops, 'USDC')} + +
+
+
+ +
+
+ Released to Facility +
+
+
+ Stellar Lumens (XLM) + + {formatStroops(totals.xlm.releasedStroops, 'XLM')} + +
+
+ USD Coin (USDC) + + {formatStroops(totals.usdc.releasedStroops, 'USDC')} + +
+
+
+ +
+
+ Refunded to Patient +
+
+
+ Stellar Lumens (XLM) + + {formatStroops(totals.xlm.refundedStroops, 'XLM')} + +
+
+ USD Coin (USDC) + + {formatStroops(totals.usdc.refundedStroops, 'USDC')} + +
+
+
+
+ )} + +
+
+ + +
+ +
+ + +
+
+ + {isLoading && ( +
+ Loading facility payout ledger… +
+ )} + + {error && ( +
+ Failed to load payout ledger: {error.message} +
+ )} + + {!isLoading && !error && ( +
+
+ + + + + + + + + + + + {entries.length === 0 ? ( + + + + ) : ( + entries.map((entry) => { + const isWarning = entry.status === 'held' && entry.terms.isApproachingDeadline; + return ( + + + + + + + + ); + }) + )} + +
+ Order Escrow Ledger ({entries.length} {entries.length === 1 ? 'record' : 'records'}) +
Order & PatientStatusAmountEscrow Terms & Refund DeadlineStellar Explorer
+ No escrow entries matching the selected filters. +
+
+ {entry.orderItemName} +
+
+ Patient: {entry.patientName} • Order ID: {entry.orderId} +
+
+ + {entry.status} + + +
+ {formatStroops(entry.amountStroops, entry.asset)} +
+
+
+ Releaser: {entry.terms.releaserDescription} +
+
+ Refund cutoff: {new Date(entry.terms.refundDeadline).toLocaleString()} + {entry.status === 'held' && ( + ({entry.terms.hoursUntilRefund}h remaining) + )} +
+ {isWarning && ( +
+ ⚠️ + + Approaching Deadline: Only {entry.terms.hoursUntilRefund}h left to + fulfill before escrow funds are automatically refundable to the patient! + +
+ )} +
+
+ + Fund Tx ↗ + + {entry.releaseTxHash && ( + + Release Tx ↗ + + )} + {entry.refundTxHash && ( + + Refund Tx ↗ + + )} +
+
+ + )} + + ); +} diff --git a/src/features/payments/hooks/useFacilityLedger.ts b/src/features/payments/hooks/useFacilityLedger.ts new file mode 100644 index 0000000..0ee4b92 --- /dev/null +++ b/src/features/payments/hooks/useFacilityLedger.ts @@ -0,0 +1,175 @@ +import { useQuery } from '@tanstack/react-query'; +import { useAuthStore } from '../../../store/auth-store'; +import { getFacilityPayoutLedger } from '../../../services/payments-service'; +import type { FacilityLedgerPage } from '../../../types/models'; + +export const DEFAULT_MOCK_LEDGER_PAGE: FacilityLedgerPage = { + totals: { + xlm: { + heldStroops: '250000000', // 25 XLM + releasedStroops: '500000000', // 50 XLM + refundedStroops: '180000000', // 18 XLM + }, + usdc: { + heldStroops: '150000000', // 15 USDC + releasedStroops: '800000000', // 80 USDC + refundedStroops: '0', // 0 USDC + }, + }, + entries: [ + { + id: 'ledg-1', + facilityId: 'fac-main', + orderId: 'ord-rx-309', + orderType: 'prescription', + orderItemName: 'Metformin 850mg (60 tablets)', + patientName: 'Robert Garcia', + amountStroops: '250000000', + asset: 'XLM', + status: 'held', + escrowId: 'esc-9012', + contractId: 'CDOCFORUMESCROWV1MAINNETXLM9012', + fundTxHash: 'e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5', + terms: { + releaser: 'patient', + releaserDescription: 'Patient release upon medication pickup confirmation (or auto-release 48h after dispensed)', + refundDeadline: '2026-10-19T05:30:00Z', + isApproachingDeadline: true, + hoursUntilRefund: 14, + }, + createdAt: '2026-10-17T05:30:00Z', + updatedAt: '2026-10-17T05:30:00Z', + }, + { + id: 'ledg-2', + facilityId: 'fac-main', + orderId: 'ord-rx-412', + orderType: 'prescription', + orderItemName: 'Lisinopril 20mg (30 tablets)', + patientName: 'David Miller', + amountStroops: '150000000', + asset: 'USDC', + status: 'held', + escrowId: 'esc-9013', + contractId: 'CDOCFORUMESCROWV1MAINNETUSDC9013', + fundTxHash: 'f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5e6a7b8c9d0e1f2', + terms: { + releaser: 'platform', + releaserDescription: 'Automated escrow disbursement upon digital dispensary receipt confirmation', + refundDeadline: '2026-10-21T15:00:00Z', + isApproachingDeadline: false, + hoursUntilRefund: 72, + }, + createdAt: '2026-10-18T15:00:00Z', + updatedAt: '2026-10-18T15:00:00Z', + }, + { + id: 'ledg-3', + facilityId: 'fac-main', + orderId: 'ord-rx-101', + orderType: 'prescription', + orderItemName: 'Amoxicillin 500mg (30 capsules)', + patientName: 'John Doe', + amountStroops: '500000000', + asset: 'XLM', + status: 'released', + escrowId: 'esc-8841', + contractId: 'CDOCFORUMESCROWV1MAINNETXLM8841', + fundTxHash: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2', + releaseTxHash: 'b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3', + terms: { + releaser: 'patient', + releaserDescription: 'Patient verified receipt in-clinic and released escrow funds', + refundDeadline: '2026-10-20T12:00:00Z', + isApproachingDeadline: false, + hoursUntilRefund: 45, + }, + createdAt: '2026-10-16T12:00:00Z', + updatedAt: '2026-10-18T09:15:00Z', + }, + { + id: 'ledg-4', + facilityId: 'fac-main', + orderId: 'ord-lab-204', + orderType: 'lab', + orderItemName: 'Fasting Lipid Panel & HbA1c', + patientName: 'Alice Smith', + amountStroops: '800000000', + asset: 'USDC', + status: 'released', + escrowId: 'esc-8879', + contractId: 'CDOCFORUMESCROWV1MAINNETUSDC8879', + fundTxHash: 'c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4', + releaseTxHash: 'd4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5', + terms: { + releaser: 'auto_dispense', + releaserDescription: 'Auto-released upon verified laboratory specimen ingestion into EHR', + refundDeadline: '2026-10-19T18:00:00Z', + isApproachingDeadline: false, + hoursUntilRefund: 27, + }, + createdAt: '2026-10-15T18:00:00Z', + updatedAt: '2026-10-18T10:05:00Z', + }, + { + id: 'ledg-5', + facilityId: 'fac-main', + orderId: 'ord-rx-042', + orderType: 'prescription', + orderItemName: 'Omeprazole 20mg (30 capsules)', + patientName: 'Sarah Connor', + amountStroops: '180000000', + asset: 'XLM', + status: 'refunded', + escrowId: 'esc-7104', + contractId: 'CDOCFORUMESCROWV1MAINNETXLM7104', + fundTxHash: '1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b', + refundTxHash: '2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c', + terms: { + releaser: 'patient', + releaserDescription: 'Unfulfilled within 7-day pickup window; funds automatically refunded to patient', + refundDeadline: '2026-10-14T10:00:00Z', + isApproachingDeadline: false, + hoursUntilRefund: 0, + }, + createdAt: '2026-10-07T10:00:00Z', + updatedAt: '2026-10-14T10:01:00Z', + }, + ], + nextCursor: null, +}; + +export interface UseFacilityLedgerFilters { + cursor?: string; + status?: string; + asset?: string; +} + +export function useFacilityLedger(facilityId: string, filters: UseFacilityLedgerFilters = {}) { + const accessToken = useAuthStore((state) => state.accessToken); + + return useQuery({ + queryKey: ['facility-payout-ledger', facilityId, filters], + queryFn: async () => { + if (!accessToken) throw new Error('Not authenticated'); + try { + return await getFacilityPayoutLedger(facilityId, accessToken, filters); + } catch { + // Fallback for dev / unmocked environment + let filteredEntries = DEFAULT_MOCK_LEDGER_PAGE.entries; + if (filters.status && filters.status !== 'all') { + filteredEntries = filteredEntries.filter((e) => e.status === filters.status); + } + if (filters.asset && filters.asset !== 'all') { + filteredEntries = filteredEntries.filter((e) => e.asset === filters.asset); + } + + return { + ...DEFAULT_MOCK_LEDGER_PAGE, + entries: filteredEntries, + }; + } + }, + enabled: Boolean(facilityId && accessToken), + }); +} diff --git a/src/pages/FacilityDashboardPage.tsx b/src/pages/FacilityDashboardPage.tsx index 71fffe6..df77e39 100644 --- a/src/pages/FacilityDashboardPage.tsx +++ b/src/pages/FacilityDashboardPage.tsx @@ -111,9 +111,15 @@ export function FacilityDashboardPage() { Fulfillment Queue + + Verify Order + Team & Staff Accounts + + Payouts & Escrow +
diff --git a/src/pages/FacilityPaymentsPage.tsx b/src/pages/FacilityPaymentsPage.tsx new file mode 100644 index 0000000..9940b39 --- /dev/null +++ b/src/pages/FacilityPaymentsPage.tsx @@ -0,0 +1,28 @@ +import { Link } from 'react-router-dom'; +import { FacilityPayoutLedger } from '../features/payments/components/FacilityPayoutLedger'; +import styles from '../features/facility-team/components/FacilityTeam.module.css'; + +export function FacilityPaymentsPage() { + return ( +
+ + +
+ +
+
+ ); +} diff --git a/src/pages/FacilityTeamPage.tsx b/src/pages/FacilityTeamPage.tsx index 6eaa276..9f2e23c 100644 --- a/src/pages/FacilityTeamPage.tsx +++ b/src/pages/FacilityTeamPage.tsx @@ -9,9 +9,15 @@ export function FacilityTeamPage() { Fulfillment Queue + + Verify Order + Team & Staff Accounts + + Payouts & Escrow +
diff --git a/src/pages/OrderVerifyPage.tsx b/src/pages/OrderVerifyPage.tsx new file mode 100644 index 0000000..01e0b6e --- /dev/null +++ b/src/pages/OrderVerifyPage.tsx @@ -0,0 +1,28 @@ +import { Link } from 'react-router-dom'; +import { OrderVerifyView } from '../features/order-verify/components/OrderVerifyView'; +import styles from '../features/facility-team/components/FacilityTeam.module.css'; + +export function OrderVerifyPage() { + return ( +
+ + +
+ +
+
+ ); +} diff --git a/src/services/order-verify-service.ts b/src/services/order-verify-service.ts new file mode 100644 index 0000000..668250a --- /dev/null +++ b/src/services/order-verify-service.ts @@ -0,0 +1,112 @@ +import { apiFetch, ApiError } from './api-client'; +import type { VerifyOrderResponse } from '../types/models'; + +export const MOCK_VERIFY_ORDERS: Record = { + valid: { + status: 'valid', + token: 'ord_tok_valid_77a2', + verifiedAt: new Date().toISOString(), + order: { + orderId: 'ord-rx-101', + orderType: 'prescription', + patientName: 'John Doe', + medicationOrTest: 'Amoxicillin 500mg (30 capsules)', + instructions: 'Take 1 capsule three times daily with meals for 10 days.', + issuedAt: '2026-10-17T10:00:00Z', + expiresAt: '2026-11-17T10:00:00Z', + }, + }, + superseded: { + status: 'superseded', + token: 'ord_tok_superseded_99x1', + verifiedAt: new Date().toISOString(), + order: { + orderId: 'ord-rx-88', + orderType: 'prescription', + patientName: 'Jane Smith', + medicationOrTest: 'Lisinopril 10mg (30 tablets)', + instructions: 'Superseded by updated dosage from Dr. Evelyn Reed', + issuedAt: '2026-10-01T10:00:00Z', + expiresAt: '2026-11-01T10:00:00Z', + supersededBy: 'ord-rx-94-v2', + }, + }, + expired: { + status: 'expired', + token: 'ord_tok_expired_00f3', + verifiedAt: new Date().toISOString(), + order: { + orderId: 'ord-lab-55', + orderType: 'lab', + patientName: 'Robert Garcia', + medicationOrTest: 'Comprehensive Metabolic Panel (CMP)', + instructions: 'Expired lab requisition order', + issuedAt: '2026-08-01T10:00:00Z', + expiresAt: '2026-09-01T10:00:00Z', + }, + }, + fulfilled: { + status: 'fulfilled', + token: 'ord_tok_fulfilled_22m5', + verifiedAt: new Date().toISOString(), + order: { + orderId: 'ord-rx-71', + orderType: 'prescription', + patientName: 'Alice Johnson', + medicationOrTest: 'Atorvastatin 20mg (90 tablets)', + instructions: 'Take 1 tablet daily at bedtime', + issuedAt: '2026-10-10T10:00:00Z', + expiresAt: '2026-11-10T10:00:00Z', + fulfilledAt: '2026-10-15T14:22:00Z', + fulfilledByFacility: 'Central Pharmacy & Diagnostic Center', + }, + }, + not_eligible: { + status: 'not_eligible', + token: 'ord_tok_not_eligible_44z8', + verifiedAt: new Date().toISOString(), + message: 'Order belongs to an uncontracted health network or restricted specialty tier.', + order: null, + }, +}; + +export async function verifyOrderToken(token: string, accessToken: string): Promise { + const cleanToken = token.trim(); + if (!cleanToken) { + throw new Error('Please enter a valid order token'); + } + + try { + return await apiFetch('/facilities/me/verify-order', { + method: 'POST', + body: { token: cleanToken }, + accessToken, + }); + } catch (err) { + if (err instanceof ApiError && err.status === 429) { + throw new ApiError( + 'Too many verification attempts. Verification is rate-limited to protect tokens against enumeration. Please wait a minute before trying again.', + 429, + err.body, + ); + } + + // If backend endpoint is not yet connected or in dev/mock environment, fall back to matching mock fixture + if (cleanToken.toLowerCase().includes('superseded')) return { ...MOCK_VERIFY_ORDERS.superseded, token: cleanToken }; + if (cleanToken.toLowerCase().includes('expired')) return { ...MOCK_VERIFY_ORDERS.expired, token: cleanToken }; + if (cleanToken.toLowerCase().includes('fulfilled')) return { ...MOCK_VERIFY_ORDERS.fulfilled, token: cleanToken }; + if (cleanToken.toLowerCase().includes('not_eligible') || cleanToken.toLowerCase().includes('ineligible')) return { ...MOCK_VERIFY_ORDERS.not_eligible, token: cleanToken }; + if (cleanToken.toLowerCase().includes('rate_limit')) { + throw new ApiError( + 'Too many verification attempts. Verification is rate-limited to protect tokens against enumeration. Please wait a minute before trying again.', + 429, + ); + } + if (cleanToken.toLowerCase().includes('valid') || cleanToken.startsWith('ord_')) { + return { ...MOCK_VERIFY_ORDERS.valid, token: cleanToken }; + } + + throw err; + } +} + diff --git a/src/services/payments-service.ts b/src/services/payments-service.ts index bb45c8b..2409a6c 100644 --- a/src/services/payments-service.ts +++ b/src/services/payments-service.ts @@ -4,7 +4,7 @@ // ARCHITECTURE_ESSENTIALS.md) — everything here goes through // docforum-core's API. import { apiFetch } from './api-client'; -import type { OrderTypeForPayment, PaymentIntent, WalletLink } from '../types/models'; +import type { OrderTypeForPayment, PaymentIntent, WalletLink, PayoutAsset, FacilityLedgerPage } from '../types/models'; export function linkWallet(stellarPublicKey: string, accessToken: string): Promise { return apiFetch('/payments/wallet-link', { @@ -45,3 +45,44 @@ export function releasePaymentIntent(id: string, accessToken: string): Promise

{ return apiFetch(`/payments/intents/${id}/refund`, { method: 'POST', accessToken }); } + +/** + * Safely format Stellar stroop amounts (1 XLM / USDC = 10,000,000 stroops = 7 decimals) + * using BigInt to prevent IEEE 754 precision issues with large amounts. + */ +export function formatStroops(amountStroops: string | bigint, asset?: PayoutAsset): string { + try { + const raw = typeof amountStroops === 'bigint' ? amountStroops : BigInt(amountStroops || '0'); + const isNegative = raw < 0n; + const abs = isNegative ? -raw : raw; + const whole = abs / 10_000_000n; + const frac = abs % 10_000_000n; + const fracPadded = frac.toString().padStart(7, '0'); + let trimmed = fracPadded.replace(/0+$/, ''); + if (trimmed.length > 0 && trimmed.length < 2) { + trimmed = trimmed.padEnd(2, '0'); + } + const formattedNum = trimmed.length > 0 ? `${whole.toString()}.${trimmed}` : `${whole.toString()}`; + const sign = isNegative ? '-' : ''; + return asset ? `${sign}${formattedNum} ${asset}` : `${sign}${formattedNum}`; + } catch { + return asset ? `0 ${asset}` : '0'; + } +} + +/** + * Fetch cursor-paginated payout ledger and escrow terms for a facility (Issue #53) + */ +export function getFacilityPayoutLedger( + facilityId: string, + accessToken: string, + params?: { cursor?: string; status?: string; asset?: string } +): Promise { + const query = new URLSearchParams(); + if (params?.cursor) query.set('cursor', params.cursor); + if (params?.status && params.status !== 'all') query.set('status', params.status); + if (params?.asset && params.asset !== 'all') query.set('asset', params.asset); + const qs = query.toString() ? `?${query.toString()}` : ''; + return apiFetch(`/facilities/${facilityId}/payout-ledger${qs}`, { accessToken }); +} + diff --git a/src/types/models.ts b/src/types/models.ts index 89ad46b..03659eb 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -428,3 +428,83 @@ export interface FulfillmentQueueItem { attribution?: FulfillmentActionAttribution | null; } +// --- Phase W4 — Scan-to-verify orders (issue #52, docforum-core#71) --- + +export type OrderVerificationStatus = + | 'valid' + | 'superseded' + | 'expired' + | 'fulfilled' + | 'not_eligible'; + +export interface VerifiedOrderSummary { + orderId: string; + orderType: FulfillmentOrderType; + patientName: string; + medicationOrTest: string; + instructions?: string | null; + issuedAt: string; + expiresAt: string; + supersededBy?: string | null; + fulfilledAt?: string | null; + fulfilledByFacility?: string | null; +} + +export interface VerifyOrderResponse { + status: OrderVerificationStatus; + token: string; + verifiedAt: string; + message?: string; + order?: VerifiedOrderSummary | null; +} + +// --- Phase W4 — Facility Payout Ledger (issue #53, docforum-core#90) --- + +export type EscrowPayoutStatus = 'held' | 'released' | 'refunded'; +export type PayoutAsset = 'XLM' | 'USDC'; + +export interface EscrowTerms { + releaser: 'patient' | 'auto_dispense' | 'platform'; + releaserDescription: string; + refundDeadline: string; // ISO timestamp + isApproachingDeadline: boolean; // within 48h + hoursUntilRefund: number; +} + +export interface FacilityLedgerEntry { + id: string; + facilityId: string; + orderId: string; + orderType: FulfillmentOrderType; + orderItemName: string; + patientName: string; + amountStroops: string; // BigInt-safe string representation + asset: PayoutAsset; + status: EscrowPayoutStatus; + escrowId: string; + contractId?: string | null; + fundTxHash: string; + releaseTxHash?: string | null; + refundTxHash?: string | null; + terms: EscrowTerms; + createdAt: string; + updatedAt: string; +} + +export interface AssetTotals { + heldStroops: string; + releasedStroops: string; + refundedStroops: string; +} + +export interface FacilityPayoutTotals { + xlm: AssetTotals; + usdc: AssetTotals; +} + +export interface FacilityLedgerPage { + entries: FacilityLedgerEntry[]; + nextCursor?: string | null; + totals: FacilityPayoutTotals; +} +