diff --git a/frontend/src/components/PreflightReportPanel.tsx b/frontend/src/components/PreflightReportPanel.tsx new file mode 100644 index 00000000..90a40685 --- /dev/null +++ b/frontend/src/components/PreflightReportPanel.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import type { ValidationReport } from '../services/stellarValidation'; +import { Button, Heading, Text, Icon } from '@stellar/design-system'; + +interface Props { + report: ValidationReport; + onRetry: () => void; +} + +export const PreflightReportPanel: React.FC = ({ report, onRetry }) => { + if (report.success) return null; + + const handleDownloadCsv = () => { + const rows = [ + ['Employee Name', 'Wallet Address', 'Account Exists', 'Missing Trustlines', 'Status'], + ]; + + report.employeeResults.forEach((emp) => { + if (!emp.success) { + rows.push([ + emp.name, + emp.walletAddress || 'N/A', + emp.accountExists ? 'Yes' : 'No', + emp.missingTrustlines.join(' '), + 'Failed', + ]); + } + }); + + const csvContent = rows.map((r) => r.join(',')).join('\n'); + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', 'failed_preflight_checks.csv'); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + return ( +
+ + Preflight Validation Failed + + + {!report.orgHasSufficientXlm && ( +
+ Organization Wallet Insufficient XLM + + Required: {report.requiredXlm.toFixed(2)} XLM | Available: {report.orgXlmBalance.toFixed(2)} XLM + +
+ )} + + {report.employeeResults.filter(r => !r.success).length > 0 && ( +
+ Employee Account Issues: +
    + {report.employeeResults.filter(r => !r.success).map(emp => ( +
  • +
    + {emp.name} + {emp.walletAddress || 'No Wallet Provided'} +
    + {!emp.accountExists &&
    • Account does not exist on-chain
    } + {emp.missingTrustlines.length > 0 && ( +
    • Missing trustlines: {emp.missingTrustlines.join(', ')}
    + )} +
  • + ))} +
+
+ )} + +
+ + +
+
+ ); +}; diff --git a/frontend/src/hooks/useFeeEstimation.ts b/frontend/src/hooks/useFeeEstimation.ts index 0183d9f9..e83e741e 100644 --- a/frontend/src/hooks/useFeeEstimation.ts +++ b/frontend/src/hooks/useFeeEstimation.ts @@ -15,6 +15,11 @@ import { type FeeRecommendation, type BatchBudgetEstimate, } from '../services/feeEstimation'; +import { + validateBatchRequirements, + type BatchItem, + type ValidationReport, +} from '../services/stellarValidation'; /** Query key used by React Query for cache management */ const FEE_ESTIMATION_QUERY_KEY = ['fee-estimation'] as const; @@ -44,6 +49,17 @@ export function useFeeEstimation() { return estimateBatchPaymentBudget(count); }, []); + /** + * Validates preflight conditions (employer balance, employee trustlines/accounts) + */ + const validatePreflight = useCallback(async ( + orgPublicKey: string, + batchConfig: BatchItem[] + ): Promise => { + const feeEstimate = await estimateBatchPaymentBudget(batchConfig.length); + return validateBatchRequirements(orgPublicKey, batchConfig, feeEstimate.totalBudget); + }, []); + return { feeRecommendation, isLoading, @@ -51,5 +67,6 @@ export function useFeeEstimation() { error, refetch, estimateBatch, + validatePreflight, }; } diff --git a/frontend/src/pages/PayrollScheduler.tsx b/frontend/src/pages/PayrollScheduler.tsx index 722fe7d0..305efdd9 100644 --- a/frontend/src/pages/PayrollScheduler.tsx +++ b/frontend/src/pages/PayrollScheduler.tsx @@ -22,6 +22,7 @@ interface PayrollFormState { frequency: 'weekly' | 'monthly'; startDate: string; memo?: string; + walletAddress?: string; } const formatDate = (dateString: string) => { @@ -53,6 +54,7 @@ const initialFormState: PayrollFormState = { frequency: 'monthly', startDate: '', memo: '', + walletAddress: '', }; export default function PayrollScheduler() { @@ -69,6 +71,10 @@ export default function PayrollScheduler() { const [nextRunDate, setNextRunDate] = useState(null); const [contractError, setContractError] = useState(null); + const { validatePreflight } = useFeeEstimation(); + const [validationReport, setValidationReport] = useState(null); + const [isPreflighting, setIsPreflighting] = useState(false); + const [pendingClaims, setPendingClaims] = useState(() => { const saved = localStorage.getItem('pending-claims'); if (saved) { @@ -364,6 +370,18 @@ export default function PayrollScheduler() { /> +
+ +
+
- {isSimulating + {isPreflighting + ? 'Running Preflight Checks...' + : isSimulating ? 'Simulating...' : t('payroll.submit', 'Initialize and Validate')} @@ -446,6 +466,13 @@ export default function PayrollScheduler() { }} /> + {validationReport && validationReport.success === false && ( + + )} +
- All transactions are simulated via Stellar Horizon before submission. This catches + All transactions undergo preflight checks and simulation via Stellar Horizon before submission. This catches common errors like:
    diff --git a/frontend/src/services/stellarValidation.ts b/frontend/src/services/stellarValidation.ts new file mode 100644 index 00000000..17cb8637 --- /dev/null +++ b/frontend/src/services/stellarValidation.ts @@ -0,0 +1,111 @@ +import { Horizon } from '@stellar/stellar-sdk'; + +const BASE_RESERVE = 0.5; // XLM + +export interface BatchItem { + id: string; + name: string; + walletAddress: string; + amount: string; + assetCode: string; +} + +export interface EmployeeValidationResult { + id: string; + name: string; + walletAddress: string; + accountExists: boolean; + missingTrustlines: string[]; + success: boolean; +} + +export interface ValidationReport { + orgHasSufficientXlm: boolean; + orgXlmBalance: number; + requiredXlm: number; + employeeResults: EmployeeValidationResult[]; + success: boolean; +} + +export async function validateBatchRequirements( + orgPublicKey: string, + batchConfig: BatchItem[], + estimatedFeesStroops: number, + horizonUrl?: string +): Promise { + const url = horizonUrl || import.meta.env.PUBLIC_STELLAR_HORIZON_URL?.replace(/\/+$/, '') || 'https://horizon-testnet.stellar.org'; + const server = new Horizon.Server(url); + + let orgBalance = 0; + try { + const orgAccount = await server.loadAccount(orgPublicKey); + const xlmBalance = orgAccount.balances.find((b) => b.asset_type === 'native'); + orgBalance = xlmBalance ? parseFloat(xlmBalance.balance) : 0; + } catch (err) { + // Org account might not exist + } + + // Base Reserve + Estimated Fees (converted to XLM) + const requiredXlm = BASE_RESERVE + (estimatedFeesStroops / 10000000); + const orgHasSufficientXlm = orgBalance >= requiredXlm; + + const employeeResults: EmployeeValidationResult[] = []; + + for (const item of batchConfig) { + let accountExists = false; + const missingTrustlines: string[] = []; + + if (!item.walletAddress || item.walletAddress.length < 50) { + employeeResults.push({ + id: item.id, + name: item.name, + walletAddress: item.walletAddress || '', + accountExists: false, + missingTrustlines: item.assetCode !== 'XLM' ? [item.assetCode] : [], + success: false + }); + continue; + } + + try { + const empAccount = await server.loadAccount(item.walletAddress); + accountExists = true; + + if (item.assetCode !== 'XLM') { + const hasTrustline = empAccount.balances.some((b) => + ('asset_code' in b && b.asset_code === item.assetCode) + ); + if (!hasTrustline) { + missingTrustlines.push(item.assetCode); + } + } + } catch { + accountExists = false; + if (item.assetCode !== 'XLM') { + missingTrustlines.push(item.assetCode); + } + } + + const success = accountExists && missingTrustlines.length === 0; + + employeeResults.push({ + id: item.id, + name: item.name, + walletAddress: item.walletAddress, + accountExists, + missingTrustlines, + success, + }); + } + + const allEmployeesSuccess = employeeResults.every((r) => r.success); + const success = orgHasSufficientXlm && allEmployeesSuccess; + + return { + orgHasSufficientXlm, + orgXlmBalance: orgBalance, + requiredXlm, + employeeResults, + success, + }; +}