diff --git a/frontend/src/components/PreflightCheckPanel.tsx b/frontend/src/components/PreflightCheckPanel.tsx new file mode 100644 index 00000000..62b3b400 --- /dev/null +++ b/frontend/src/components/PreflightCheckPanel.tsx @@ -0,0 +1,184 @@ +import { CheckCircle, AlertTriangle, Download, Loader2 } from 'lucide-react'; +import { usePreflightCheck } from '../hooks/usePreflightCheck'; +import type { PreflightCheckEmployee, PreflightCheckResult } from '../services/preflightCheck'; + +interface PreflightCheckPanelProps { + batch: PreflightCheckEmployee[]; +} + +function generateFailureCsv(results: PreflightCheckResult[]): void { + const failed = results.filter((r) => r.status === 'fail'); + if (failed.length === 0) return; + + const rows = failed.map( + (r) => + `"${r.employeeName.replace(/"/g, '""')}","${r.walletAddress}","${r.issues.map((i) => i.message.replace(/"/g, '""')).join('; ')}"` + ); + const csvContent = ['"Employee Name","Wallet Address","Issues"', ...rows].join('\n'); + + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.setAttribute('href', url); + link.setAttribute( + 'download', + `preflight-failures-${new Date().toISOString().split('T')[0]}.csv` + ); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +export function PreflightCheckPanel({ batch }: PreflightCheckPanelProps) { + const { results, isRunning, summary, rerun } = usePreflightCheck(batch); + + if (batch.length === 0) return null; + + const failedCount = results.filter((r) => r.status === 'fail').length; + + return ( +
+
+
+ {isRunning ? ( +
+ {!isRunning && failedCount > 0 && ( +
+ + Resolve the issues below before submitting the batch. + +
+ )} +
+ + {results.length > 0 && ( +
+
+

+ Preflight Check Results +

+
+ {failedCount > 0 && ( + + )} + +
+
+ +
+ + + + + + + + + + + {results.map((result) => ( + + + + + + + ))} + +
+ Employee + + Wallet Address + + Status + + Issues +
+ {result.employeeName} + + {result.walletAddress} + + {result.status === 'pass' ? ( + + + ) : ( + + + )} + + {result.issues.length > 0 ? ( +
    + {result.issues.map((issue) => ( +
  • + {issue.message} +
  • + ))} +
+ ) : ( + No issues + )} +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/components/__tests__/PreflightCheckPanel.test.tsx b/frontend/src/components/__tests__/PreflightCheckPanel.test.tsx new file mode 100644 index 00000000..229a1d72 --- /dev/null +++ b/frontend/src/components/__tests__/PreflightCheckPanel.test.tsx @@ -0,0 +1,178 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { PreflightCheckPanel } from '../PreflightCheckPanel'; +import type { PreflightCheckResult } from '../../services/preflightCheck'; + +let mockHookData: { + results: PreflightCheckResult[]; + isRunning: boolean; + summary: { totalPassed: number; totalFailed: number; hasIssues: boolean; readyToSubmit: boolean }; + rerun: ReturnType; +}; + +vi.mock('../../hooks/usePreflightCheck', () => ({ + usePreflightCheck: () => mockHookData, +})); + +function createMockResult( + name: string, + status: 'pass' | 'fail', + issues: { type: string; message: string }[] = [] +): PreflightCheckResult { + return { + employeeName: name, + walletAddress: `G${name.toUpperCase()}1234567890`, + issues, + status, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockHookData = { + results: [], + isRunning: false, + summary: { totalPassed: 0, totalFailed: 0, hasIssues: false, readyToSubmit: false }, + rerun: vi.fn(), + }; +}); + +describe('PreflightCheckPanel', () => { + it('renders nothing when batch is empty', () => { + const { container } = render(); + expect(container.innerHTML).toBe(''); + }); + + it('shows loading state when checks are running', () => { + mockHookData = { + results: [], + isRunning: true, + summary: { totalPassed: 0, totalFailed: 0, hasIssues: false, readyToSubmit: false }, + rerun: vi.fn(), + }; + render(); + expect(screen.getByText(/running preflight checks/i)).toBeInTheDocument(); + }); + + it('shows ready to submit banner when all pass', () => { + mockHookData = { + results: [ + createMockResult('Alice', 'pass'), + createMockResult('Bob', 'pass'), + ], + isRunning: false, + summary: { totalPassed: 2, totalFailed: 0, hasIssues: false, readyToSubmit: true }, + rerun: vi.fn(), + }; + render(); + expect(screen.getByText('Ready to Submit')).toBeInTheDocument(); + expect(screen.getByText(/2 passed, 0 failed/i)).toBeInTheDocument(); + }); + + it('shows issues detected banner when checks fail', () => { + mockHookData = { + results: [ + createMockResult('Alice', 'pass'), + createMockResult('Bob', 'fail', [ + { type: 'account_not_found', message: 'Account does not exist' }, + ]), + ], + isRunning: false, + summary: { totalPassed: 1, totalFailed: 1, hasIssues: true, readyToSubmit: false }, + rerun: vi.fn(), + }; + render(); + expect(screen.getByText('Issues Detected')).toBeInTheDocument(); + expect(screen.getByText(/1 passed, 1 failed/i)).toBeInTheDocument(); + }); + + it('renders employee names in the table', () => { + mockHookData = { + results: [ + createMockResult('Alice', 'pass'), + createMockResult('Bob', 'fail', [ + { type: 'no_trustline', message: 'No trustline for USDC' }, + ]), + ], + isRunning: false, + summary: { totalPassed: 1, totalFailed: 1, hasIssues: true, readyToSubmit: false }, + rerun: vi.fn(), + }; + render(); + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.getByText('Bob')).toBeInTheDocument(); + }); + + it('shows failure reasons in the table', () => { + mockHookData = { + results: [ + createMockResult('Alice', 'fail', [ + { type: 'account_not_found', message: 'Account does not exist on the Stellar network' }, + { type: 'no_trustline', message: 'No trustline for USDC' }, + ]), + ], + isRunning: false, + summary: { totalPassed: 0, totalFailed: 1, hasIssues: true, readyToSubmit: false }, + rerun: vi.fn(), + }; + render(); + expect(screen.getByText('Account does not exist on the Stellar network')).toBeInTheDocument(); + expect(screen.getByText('No trustline for USDC')).toBeInTheDocument(); + }); + + it('shows export button when there are failures', () => { + mockHookData = { + results: [ + createMockResult('Alice', 'fail', [ + { type: 'account_not_found', message: 'Account does not exist' }, + ]), + ], + isRunning: false, + summary: { totalPassed: 0, totalFailed: 1, hasIssues: true, readyToSubmit: false }, + rerun: vi.fn(), + }; + render(); + expect(screen.getByTitle(/download failure report/i)).toBeInTheDocument(); + }); + + it('hides export button when all pass', () => { + mockHookData = { + results: [ + createMockResult('Alice', 'pass'), + ], + isRunning: false, + summary: { totalPassed: 1, totalFailed: 0, hasIssues: false, readyToSubmit: true }, + rerun: vi.fn(), + }; + render(); + expect(screen.queryByTitle(/download failure report/i)).not.toBeInTheDocument(); + }); + + it('shows re-run button', () => { + mockHookData = { + results: [createMockResult('Alice', 'pass')], + isRunning: false, + summary: { totalPassed: 1, totalFailed: 0, hasIssues: false, readyToSubmit: true }, + rerun: vi.fn(), + }; + render(); + expect(screen.getByTitle(/re-run preflight checks/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/hooks/usePreflightCheck.ts b/frontend/src/hooks/usePreflightCheck.ts new file mode 100644 index 00000000..37a1ff46 --- /dev/null +++ b/frontend/src/hooks/usePreflightCheck.ts @@ -0,0 +1,55 @@ +import { useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { + runPreflightChecks, + type PreflightCheckEmployee, + type PreflightCheckResult, +} from '../services/preflightCheck'; + +const PREFLIGHT_CHECK_KEY = 'preflight-check'; + +export interface PreflightCheckSummary { + totalPassed: number; + totalFailed: number; + hasIssues: boolean; + readyToSubmit: boolean; +} + +export function usePreflightCheck(batch: PreflightCheckEmployee[]) { + const queryKey = useMemo( + () => [PREFLIGHT_CHECK_KEY, batch] as const, + [batch] + ); + + const { + data, + isLoading, + refetch, + } = useQuery({ + queryKey, + queryFn: () => runPreflightChecks(batch), + enabled: batch.length > 0, + staleTime: Infinity, + }); + + const summary = useMemo((): PreflightCheckSummary => { + const results = data ?? []; + const totalPassed = results.filter((r) => r.status === 'pass').length; + const totalFailed = results.filter((r) => r.status === 'fail').length; + return { + totalPassed, + totalFailed, + hasIssues: totalFailed > 0, + readyToSubmit: !isLoading && results.length > 0 && totalFailed === 0, + }; + }, [data, isLoading]); + + return { + results: data ?? [], + isRunning: isLoading, + summary, + rerun: () => { + void refetch(); + }, + }; +} diff --git a/frontend/src/pages/BulkPayrollUpload.tsx b/frontend/src/pages/BulkPayrollUpload.tsx index 1a9cd37c..0a360f0e 100644 --- a/frontend/src/pages/BulkPayrollUpload.tsx +++ b/frontend/src/pages/BulkPayrollUpload.tsx @@ -2,6 +2,8 @@ import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { StrKey } from '@stellar/stellar-sdk'; import { CSVUploader, CSVRow } from '../components/CSVUploader'; +import { PreflightCheckPanel } from '../components/PreflightCheckPanel'; +import { usePreflightCheck } from '../hooks/usePreflightCheck'; import { Button, Card } from '@stellar/design-system'; import { IssuerMultisigBanner } from '../components/IssuerMultisigBanner'; import { SUPPORTED_ASSETS } from '../config/assets'; @@ -47,6 +49,19 @@ export default function BulkPayrollUpload() { const validRows = parsedRows.filter((r) => r.isValid); const invalidRows = parsedRows.filter((r) => !r.isValid); + const preflightBatch = useMemo( + () => + validRows.map((r) => ({ + name: r.data.name, + walletAddress: r.data.wallet_address, + amount: r.data.amount, + currency: r.data.currency, + })), + [validRows] + ); + + const { summary, isRunning } = usePreflightCheck(preflightBatch); + const handleSubmit = async () => { if (validRows.length === 0 || isSubmitting) return; setIsSubmitting(true); @@ -151,6 +166,8 @@ export default function BulkPayrollUpload() { + {validRows.length > 0 && } + {parsedRows.length > 0 && (
@@ -176,7 +193,7 @@ export default function BulkPayrollUpload() { onClick={() => { void handleSubmit(); }} - disabled={validRows.length === 0 || isSubmitting} + disabled={validRows.length === 0 || isSubmitting || isRunning || summary.hasIssues} aria-label={ isSubmitting ? 'Submitting payroll batch…' diff --git a/frontend/src/services/__tests__/preflightCheck.test.ts b/frontend/src/services/__tests__/preflightCheck.test.ts new file mode 100644 index 00000000..4e936cec --- /dev/null +++ b/frontend/src/services/__tests__/preflightCheck.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { + checkAccountExists, + checkTrustline, + checkBalance, + runPreflightChecks, + type PreflightCheckEmployee, +} from '../preflightCheck'; + +const mockFetch = vi.fn(); +const HORIZON_URL = 'https://horizon-testnet.stellar.org'; + +const createJsonResponse = (body: unknown, status = 200) => + Promise.resolve({ + ok: status >= 200 && status < 300, + status, + statusText: status === 404 ? 'Not Found' : status === 500 ? 'Server Error' : 'OK', + json: () => Promise.resolve(body), + }); + +beforeEach(() => { + vi.clearAllMocks(); + globalThis.fetch = mockFetch; +}); + +describe('checkAccountExists', () => { + it('returns true when account exists', async () => { + mockFetch.mockResolvedValueOnce(createJsonResponse({ id: 'GABCDEF' })); + const result = await checkAccountExists('GABCDEF', HORIZON_URL); + expect(result).toBe(true); + expect(mockFetch).toHaveBeenCalledWith( + `${HORIZON_URL}/accounts/GABCDEF`, + { headers: { Accept: 'application/json' } } + ); + }); + + it('returns false on 404', async () => { + mockFetch.mockResolvedValueOnce(createJsonResponse({}, 404)); + const result = await checkAccountExists('GABCDEF', HORIZON_URL); + expect(result).toBe(false); + }); + + it('throws on server error', async () => { + mockFetch.mockResolvedValueOnce(createJsonResponse({}, 500)); + await expect(checkAccountExists('GABCDEF', HORIZON_URL)).rejects.toThrow( + 'Horizon account request failed: 500 Server Error' + ); + }); + + it('encodes the account ID', async () => { + mockFetch.mockResolvedValueOnce(createJsonResponse({ id: 'test' })); + await checkAccountExists('GABC+DEF', HORIZON_URL); + expect(mockFetch).toHaveBeenCalledWith( + `${HORIZON_URL}/accounts/GABC%2BDEF`, + expect.any(Object) + ); + }); +}); + +describe('checkTrustline', () => { + const usdcIssuer = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; + + it('returns true when trustline exists', async () => { + mockFetch.mockResolvedValueOnce( + createJsonResponse({ + id: 'GABCDEF', + balances: [ + { + balance: '100.0000000', + asset_type: 'credit_alphanum4', + asset_code: 'USDC', + asset_issuer: usdcIssuer, + }, + ], + }) + ); + const result = await checkTrustline('GABCDEF', 'USDC', usdcIssuer, HORIZON_URL); + expect(result).toBe(true); + }); + + it('returns false when trustline does not exist', async () => { + mockFetch.mockResolvedValueOnce( + createJsonResponse({ + id: 'GABCDEF', + balances: [ + { + balance: '100.0000000', + asset_type: 'native', + }, + ], + }) + ); + const result = await checkTrustline('GABCDEF', 'USDC', usdcIssuer, HORIZON_URL); + expect(result).toBe(false); + }); + + it('returns true for native XLM (no trustline needed)', async () => { + const result = await checkTrustline('GABCDEF', 'XLM', null, HORIZON_URL); + expect(result).toBe(true); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('returns false on 404', async () => { + mockFetch.mockResolvedValueOnce(createJsonResponse({}, 404)); + const result = await checkTrustline('GABCDEF', 'USDC', usdcIssuer, HORIZON_URL); + expect(result).toBe(false); + }); +}); + +describe('checkBalance', () => { + it('returns true when native balance meets minimum', async () => { + mockFetch.mockResolvedValueOnce( + createJsonResponse({ + id: 'GABCDEF', + balances: [ + { balance: '5.0000000', asset_type: 'native' }, + ], + }) + ); + const result = await checkBalance('GABCDEF', '1', HORIZON_URL); + expect(result).toBe(true); + }); + + it('returns false when native balance is below minimum', async () => { + mockFetch.mockResolvedValueOnce( + createJsonResponse({ + id: 'GABCDEF', + balances: [ + { balance: '0.5000000', asset_type: 'native' }, + ], + }) + ); + const result = await checkBalance('GABCDEF', '1', HORIZON_URL); + expect(result).toBe(false); + }); + + it('returns false on 404', async () => { + mockFetch.mockResolvedValueOnce(createJsonResponse({}, 404)); + const result = await checkBalance('GABCDEF', '1', HORIZON_URL); + expect(result).toBe(false); + }); + + it('returns false when account has no native balance entry', async () => { + mockFetch.mockResolvedValueOnce( + createJsonResponse({ + id: 'GABCDEF', + balances: [ + { balance: '100.0000000', asset_type: 'credit_alphanum4', asset_code: 'USDC' }, + ], + }) + ); + const result = await checkBalance('GABCDEF', '1', HORIZON_URL); + expect(result).toBe(false); + }); + + it('returns true when balance exactly equals minimum', async () => { + mockFetch.mockResolvedValueOnce( + createJsonResponse({ + id: 'GABCDEF', + balances: [ + { balance: '1.0000000', asset_type: 'native' }, + ], + }) + ); + const result = await checkBalance('GABCDEF', '1', HORIZON_URL); + expect(result).toBe(true); + }); +}); + +describe('runPreflightChecks', () => { + const batch: PreflightCheckEmployee[] = [ + { name: 'Alice', walletAddress: 'GAICE3EYV3KGI7ND4GM4J3K4Y5K6J7K8L9M0N1O2P3Q4R5S6T7U8V9W0X', amount: '100', currency: 'USDC' }, + { name: 'Bob', walletAddress: 'GBOB4EYV3KGI7ND4GM4J3K4Y5K6J7K8L9M0N1O2P3Q4R5S6T7U8V9W0X', amount: '200', currency: 'XLM' }, + { name: 'Charlie', walletAddress: 'GCH4R4EYV3KGI7ND4GM4J3K4Y5K6J7K8L9M0N1O2P3Q4R5S6T7U8V9W0X', amount: '300', currency: 'USDC' }, + ]; + + it('returns pass for employees with valid accounts', async () => { + mockFetch.mockResolvedValue( + createJsonResponse({ + id: 'test', + balances: [ + { balance: '10.0000000', asset_type: 'native' }, + { balance: '500.0000000', asset_type: 'credit_alphanum4', asset_code: 'USDC', asset_issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' }, + ], + }) + ); + const results = await runPreflightChecks(batch, HORIZON_URL); + expect(results).toHaveLength(3); + results.forEach((r) => { + expect(r.status).toBe('pass'); + expect(r.issues).toHaveLength(0); + }); + }); + + it('returns fail for non-existent account', async () => { + mockFetch.mockResolvedValue(createJsonResponse({}, 404)); + const results = await runPreflightChecks(batch, HORIZON_URL); + expect(results[0].status).toBe('fail'); + expect(results[0].issues[0].type).toBe('account_not_found'); + }); + + it('reports trustline issues for non-XLM assets', async () => { + let callCount = 0; + mockFetch.mockImplementation(() => { + callCount++; + if (callCount <= 3) { + return createJsonResponse({ + id: 'test', + balances: [{ balance: '10.0000000', asset_type: 'native' }], + }); + } + return createJsonResponse({}, 200); + }); + const results = await runPreflightChecks(batch, HORIZON_URL); + expect(results[0].status).toBe('fail'); + expect(results[0].issues.some((i) => i.type === 'no_trustline')).toBe(true); + expect(results[1].status).toBe('pass'); + }); + + it('handles mixed results across employees', async () => { + let callCount = 0; + mockFetch.mockImplementation(() => { + callCount++; + if (callCount === 1) { + return createJsonResponse({}, 404); + } + return createJsonResponse({ + id: 'test', + balances: [ + { balance: '10.0000000', asset_type: 'native' }, + { balance: '500.0000000', asset_type: 'credit_alphanum4', asset_code: 'USDC', asset_issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' }, + ], + }); + }); + const results = await runPreflightChecks(batch, HORIZON_URL); + expect(results[0].status).toBe('fail'); + expect(results[1].status).toBe('pass'); + expect(results[2].status).toBe('pass'); + }); +}); diff --git a/frontend/src/services/preflightCheck.ts b/frontend/src/services/preflightCheck.ts new file mode 100644 index 00000000..5e4623d3 --- /dev/null +++ b/frontend/src/services/preflightCheck.ts @@ -0,0 +1,177 @@ +import { getAssetByCode } from '../config/assets'; + +export interface PreflightCheckEmployee { + name: string; + walletAddress: string; + amount: string; + currency: string; +} + +export interface PreflightIssue { + type: string; + message: string; +} + +export interface PreflightCheckResult { + employeeName: string; + walletAddress: string; + issues: PreflightIssue[]; + status: 'pass' | 'fail'; +} + +interface HorizonAccountResponse { + id: string; + balances: HorizonBalanceItem[]; +} + +interface HorizonBalanceItem { + balance: string; + asset_type: string; + asset_code?: string; + asset_issuer?: string; +} + +export function getHorizonUrl(): string { + const envUrl = import.meta.env.PUBLIC_STELLAR_HORIZON_URL as string | undefined; + return envUrl?.replace(/\/+$/, '') || 'https://horizon-testnet.stellar.org'; +} + +export async function checkAccountExists( + accountId: string, + horizonUrl: string +): Promise { + const response = await fetch( + `${horizonUrl}/accounts/${encodeURIComponent(accountId)}`, + { headers: { Accept: 'application/json' } } + ); + + if (response.status === 404) return false; + if (!response.ok) { + throw new Error( + `Horizon account request failed: ${response.status} ${response.statusText}` + ); + } + + return true; +} + +export async function checkTrustline( + accountId: string, + assetCode: string, + assetIssuer: string | null, + horizonUrl: string +): Promise { + if (!assetIssuer) return true; + + const response = await fetch( + `${horizonUrl}/accounts/${encodeURIComponent(accountId)}`, + { headers: { Accept: 'application/json' } } + ); + + if (response.status === 404) return false; + if (!response.ok) { + throw new Error( + `Horizon account request failed: ${response.status} ${response.statusText}` + ); + } + + const data = (await response.json()) as HorizonAccountResponse; + + return data.balances.some( + (b) => + b.asset_type !== 'native' && + b.asset_code === assetCode && + b.asset_issuer === assetIssuer + ); +} + +export async function checkBalance( + accountId: string, + minBalance: string, + horizonUrl: string +): Promise { + const response = await fetch( + `${horizonUrl}/accounts/${encodeURIComponent(accountId)}`, + { headers: { Accept: 'application/json' } } + ); + + if (response.status === 404) return false; + if (!response.ok) { + throw new Error( + `Horizon account request failed: ${response.status} ${response.statusText}` + ); + } + + const data = (await response.json()) as HorizonAccountResponse; + + const nativeBalance = data.balances.find((b) => b.asset_type === 'native'); + if (!nativeBalance) return false; + + return parseFloat(nativeBalance.balance) >= parseFloat(minBalance); +} + +export async function runPreflightChecks( + batch: PreflightCheckEmployee[], + horizonUrl?: string +): Promise { + const url = horizonUrl ?? getHorizonUrl(); + + const results = await Promise.all( + batch.map(async (employee) => { + const issues: PreflightIssue[] = []; + + try { + const accountExists = await checkAccountExists(employee.walletAddress, url); + + if (!accountExists) { + issues.push({ + type: 'account_not_found', + message: `Account ${employee.walletAddress} does not exist on the Stellar network`, + }); + } else { + const asset = getAssetByCode(employee.currency.toUpperCase()); + if (asset && asset.code !== 'XLM') { + const hasTrustline = await checkTrustline( + employee.walletAddress, + asset.code, + asset.issuer, + url + ); + + if (!hasTrustline) { + issues.push({ + type: 'no_trustline', + message: `Account does not have a trustline for ${employee.currency}`, + }); + } + } + + const hasMinBalance = await checkBalance(employee.walletAddress, '1', url); + if (!hasMinBalance) { + issues.push({ + type: 'insufficient_balance', + message: + 'Account may not have enough XLM to cover minimum balance requirements', + }); + } + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error during preflight check'; + issues.push({ + type: 'check_error', + message: errorMessage, + }); + } + + return { + employeeName: employee.name, + walletAddress: employee.walletAddress, + issues, + status: issues.length > 0 ? 'fail' : 'pass', + }; + }) + ); + + return results; +}