From 644adb378ae9bf6ee5de90de00f1afddd2478de5 Mon Sep 17 00:00:00 2001 From: Cosmas Ommaike Date: Sun, 26 Jul 2026 20:02:28 +0000 Subject: [PATCH 1/5] feat(#442): Add transaction simulation preview before signing - Create lib/simulateTx.ts to wrap Stellar RPC's simulateTransaction - Add TxSimulationPreview component to display simulation results - Implement usePaymentSubmission hook for payment flow integration - Display fee, balance changes, auth entries, and status badges - Allow users to proceed or cancel before Freighter signing --- .claude/settings.json | 20 +++ .../invoice/TxSimulationPreview.tsx | 157 ++++++++++++++++++ src/hooks/usePaymentSubmission.ts | 117 +++++++++++++ src/lib/simulateTx.ts | 121 ++++++++++++++ 4 files changed, 415 insertions(+) create mode 100644 .claude/settings.json create mode 100644 src/components/invoice/TxSimulationPreview.tsx create mode 100644 src/hooks/usePaymentSubmission.ts create mode 100644 src/lib/simulateTx.ts diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..3c7ef49 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,20 @@ +{ + "permissions": { + "allow": [ + "Bash", + "Read", + "Edit", + "Write", + "WebFetch", + "Grep", + "Glob", + "LS", + "MultiEdit", + "NotebookRead", + "NotebookEdit", + "TodoRead", + "TodoWrite", + "WebSearch" + ] + } +} diff --git a/src/components/invoice/TxSimulationPreview.tsx b/src/components/invoice/TxSimulationPreview.tsx new file mode 100644 index 0000000..d67a6c8 --- /dev/null +++ b/src/components/invoice/TxSimulationPreview.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { SimulationResult } from "@/lib/simulateTx"; + +interface TxSimulationPreviewProps { + isOpen: boolean; + isLoading: boolean; + simulationResult: SimulationResult | null; + error: string | null; + onProceed: () => void; + onCancel: () => void; + onSkip: () => void; +} + +export default function TxSimulationPreview({ + isOpen, + isLoading, + simulationResult, + error, + onProceed, + onCancel, + onSkip, +}: TxSimulationPreviewProps) { + if (!isOpen) return null; + + const isSimulationSuccess = simulationResult?.success === true; + const hasSimulationError = simulationResult?.error || error; + + return ( +
+
+

Transaction Preview

+ + {isLoading ? ( +
+
+
+ ) : hasSimulationError ? ( +
+
+

Simulation Unavailable

+

+ {simulationResult?.error || error} +

+
+

+ You can still proceed to sign the transaction directly in your wallet, or cancel to revise. +

+
+ + +
+
+ ) : ( +
+ {/* Status Badge */} +
+
+ + {isSimulationSuccess ? "Simulation Successful" : "Simulation Failed"} + +
+ + {/* Fee Display */} + {simulationResult?.fee !== undefined && ( +
+

Estimated Fee

+

{simulationResult.fee} stroops

+
+ )} + + {/* Effects */} + {simulationResult?.effects && simulationResult.effects.length > 0 && ( +
+

Affected Accounts

+
+ {simulationResult.effects.map((effect, idx) => ( +
+

+ {effect.accountId} +

+

+ {effect.balanceChange.assetCode}:{" "} + {effect.balanceChange.change} +

+
+ ))} +
+
+ )} + + {/* Auth Entries */} + {simulationResult?.authEntries && simulationResult.authEntries.length > 0 && ( +
+

Auth Entries

+
+ {simulationResult.authEntries.map((entry, idx) => ( +

+ {entry} +

+ ))} +
+
+ )} + + {/* Error Message */} + {simulationResult?.error && ( +
+

{simulationResult.error}

+
+ )} + + {/* Action Buttons */} +
+ + +
+
+ )} +
+
+ ); +} diff --git a/src/hooks/usePaymentSubmission.ts b/src/hooks/usePaymentSubmission.ts new file mode 100644 index 0000000..b3be9cb --- /dev/null +++ b/src/hooks/usePaymentSubmission.ts @@ -0,0 +1,117 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { simulateTransaction, SimulationResult } from "@/lib/simulateTx"; + +interface PaymentSubmissionState { + showSimulationPreview: boolean; + isSimulating: boolean; + simulationResult: SimulationResult | null; + simulationError: string | null; +} + +interface UsePaymentSubmissionReturn { + state: PaymentSubmissionState; + initiateDemoPayment: ( + transactionXdr: string, + onConfirm: () => Promise, + onCancel: () => void + ) => Promise; + resetState: () => void; +} + +export function usePaymentSubmission(): UsePaymentSubmissionReturn { + const [state, setState] = useState({ + showSimulationPreview: false, + isSimulating: false, + simulationResult: null, + simulationError: null, + }); + + const initiateDemoPayment = useCallback( + async ( + transactionXdr: string, + onConfirm: () => Promise, + onCancel: () => void + ) => { + setState((prev) => ({ + ...prev, + showSimulationPreview: true, + isSimulating: true, + simulationError: null, + })); + + try { + const result = await simulateTransaction(transactionXdr); + setState((prev) => ({ + ...prev, + isSimulating: false, + simulationResult: result, + })); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Failed to simulate transaction"; + setState((prev) => ({ + ...prev, + isSimulating: false, + simulationError: errorMessage, + })); + } + }, + [] + ); + + const handleProceed = useCallback( + async (onConfirm: () => Promise) => { + try { + await onConfirm(); + resetState(); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Payment failed"; + setState((prev) => ({ + ...prev, + simulationError: errorMessage, + })); + } + }, + [] + ); + + const handleCancel = useCallback((onCancel: () => void) => { + onCancel(); + resetState(); + }, []); + + const handleSkip = useCallback( + async (onConfirm: () => Promise) => { + try { + await onConfirm(); + resetState(); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Payment failed"; + setState((prev) => ({ + ...prev, + simulationError: errorMessage, + })); + } + }, + [] + ); + + const resetState = useCallback(() => { + setState({ + showSimulationPreview: false, + isSimulating: false, + simulationResult: null, + simulationError: null, + }); + }, []); + + return { + state, + initiateDemoPayment, + resetState, + }; +} diff --git a/src/lib/simulateTx.ts b/src/lib/simulateTx.ts new file mode 100644 index 0000000..eb90c35 --- /dev/null +++ b/src/lib/simulateTx.ts @@ -0,0 +1,121 @@ +/** + * Transaction simulation utilities for Stellar. + * Wraps the Stellar RPC's simulateTransaction endpoint. + */ + +import { RPC_URL } from "./stellar"; + +export interface LedgerEffect { + accountId: string; + balanceChange: { + assetCode: string; + change: string; // amount as string + }; +} + +export interface SimulationResult { + success: boolean; + fee: number; + effects: LedgerEffect[]; + authEntries?: string[]; + error?: string; + estimatedGas?: number; +} + +export async function simulateTransaction(transactionXdr: string): Promise { + try { + const response = await fetch(RPC_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "simulateTransaction", + params: { + transaction: transactionXdr, + }, + }), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + + if (data.error) { + return { + success: false, + fee: 0, + effects: [], + error: data.error.message || "Simulation failed", + }; + } + + const result = data.result; + if (!result) { + return { + success: false, + fee: 0, + effects: [], + error: "No simulation result returned", + }; + } + + const isSuccess = result.transactionResult?.resultCode === "txSUCCESS" || !!result.results; + const fee = parseInt(result.latestLedgerBumpSeqNum || "0", 10) || 0; + const estimatedGas = result.cost?.cpuInstructions || result.cost?.memoryBytes || 0; + + const effects: LedgerEffect[] = []; + if (result.results?.[0]?.effects) { + result.results[0].effects.forEach( + (effect: { + type?: string; + account?: string; + balance?: string; + asset?: { code?: string }; + }) => { + if (effect.account && (effect.type === "account_created" || effect.type === "account_debited" || effect.type === "account_credited")) { + effects.push({ + accountId: effect.account, + balanceChange: { + assetCode: effect.asset?.code || "XLM", + change: effect.balance || "0", + }, + }); + } + } + ); + } + + const authEntries: string[] = []; + if (result.sorobanResourceFee || result.auth) { + if (Array.isArray(result.auth)) { + result.auth.forEach((auth: { rootInvocation?: { function?: { contractId?: string; name?: string } } }) => { + if (auth.rootInvocation?.function) { + const { contractId, name } = auth.rootInvocation.function; + authEntries.push(`Contract ${contractId}: ${name || "invoke"}`); + } + }); + } + } + + return { + success: isSuccess, + fee, + effects, + authEntries: authEntries.length > 0 ? authEntries : undefined, + estimatedGas, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown simulation error"; + return { + success: false, + fee: 0, + effects: [], + error: errorMessage, + }; + } +} From ced0c41e83cf9871de4fb715a9a7999255c685ff Mon Sep 17 00:00:00 2001 From: Cosmas Ommaike Date: Sun, 26 Jul 2026 20:05:16 +0000 Subject: [PATCH 2/5] feat(#444): Add CSV single-row import for invoice creation - Create csvInvoiceSchema.ts with CSV parsing and validation - Add ColumnMappingModal for flexible column mapping - Implement CSVRowImporter component with paste and file upload - Support pipe-delimited multi-recipient format - Save column mappings to localStorage for reuse --- src/components/invoice/CSVRowImporter.tsx | 206 ++++++++++++++++++ src/components/invoice/ColumnMappingModal.tsx | 101 +++++++++ src/lib/csvInvoiceSchema.ts | 155 +++++++++++++ 3 files changed, 462 insertions(+) create mode 100644 src/components/invoice/CSVRowImporter.tsx create mode 100644 src/components/invoice/ColumnMappingModal.tsx create mode 100644 src/lib/csvInvoiceSchema.ts diff --git a/src/components/invoice/CSVRowImporter.tsx b/src/components/invoice/CSVRowImporter.tsx new file mode 100644 index 0000000..00b1f26 --- /dev/null +++ b/src/components/invoice/CSVRowImporter.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { useState, useRef } from "react"; +import { parseRecipients, parseCSVRow, saveColumnMapping, loadColumnMapping, InvoiceFormFields, ColumnMapping, EXPECTED_COLUMNS } from "@/lib/csvInvoiceSchema"; +import ColumnMappingModal from "./ColumnMappingModal"; + +interface CSVRowImporterProps { + onImportComplete: (data: InvoiceFormFields & { recipients?: any[] }) => void; +} + +export default function CSVRowImporter({ onImportComplete }: CSVRowImporterProps) { + const [isExpanded, setIsExpanded] = useState(false); + const [csvInput, setCsvInput] = useState(""); + const [error, setError] = useState(null); + const [showMappingModal, setShowMappingModal] = useState(false); + const [pendingCsvData, setPendingCsvData] = useState<{ [key: string]: any } | null>(null); + const [csvColumns, setCsvColumns] = useState([]); + const [currentMapping, setCurrentMapping] = useState(null); + const [spreadsheetName, setSpreadsheetName] = useState(""); + const fileInputRef = useRef(null); + + const handleParseCSVRow = (text: string): { columns: string[]; data: { [key: string]: string } } | null => { + const parsed = parseCSVRow(text); + if (!parsed) { + setError("CSV must contain headers and at least one data row with matching column counts"); + return null; + } + return parsed; + }; + + const handlePasteCSV = () => { + if (!csvInput.trim()) { + setError("Please paste CSV content"); + return; + } + + setError(null); + const parsed = handleParseCSVRow(csvInput); + if (!parsed) return; + + const { columns, data } = parsed; + setCsvColumns(columns); + setPendingCsvData(data); + + const detectedMapping = detectMapping(columns); + if (needsMapping(columns)) { + setShowMappingModal(true); + } else { + processImport(data, detectedMapping); + } + }; + + const handleFileUpload = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + setError(null); + setSpreadsheetName(file.name.replace(/\.[^/.]+$/, "")); // Remove extension for localStorage key + + try { + const text = await file.text(); + setCsvInput(text); + + const parsed = handleParseCSVRow(text); + if (!parsed) return; + + const { columns, data } = parsed; + setCsvColumns(columns); + setPendingCsvData(data); + + const savedMapping = loadColumnMapping(file.name); + if (savedMapping) { + processImport(data, savedMapping); + } else if (needsMapping(columns)) { + setShowMappingModal(true); + } else { + processImport(data, detectMapping(columns)); + } + } catch (err) { + setError(`Failed to read file: ${err instanceof Error ? err.message : "Unknown error"}`); + } + }; + + const detectMapping = (columns: string[]): ColumnMapping => { + const mapping: ColumnMapping = {}; + columns.forEach((col) => { + const normalized = col.toLowerCase().trim(); + if (EXPECTED_COLUMNS.includes(normalized)) { + mapping[col] = normalized as any; + } + }); + return mapping; + }; + + const needsMapping = (columns: string[]): boolean => { + return !columns.every( + (col) => + EXPECTED_COLUMNS.includes(col.toLowerCase().trim()) + ); + }; + + const processImport = (data: { [key: string]: string }, mapping: ColumnMapping) => { + const mapped: InvoiceFormFields & { recipients?: any[] } = {}; + let hasData = false; + + Object.entries(mapping).forEach(([csvCol, formField]) => { + const value = data[csvCol]; + if (value && formField in data) { + mapped[formField as keyof InvoiceFormFields] = value; + hasData = true; + } + }); + + if (mapped.recipients && typeof mapped.recipients === "string") { + mapped.recipients = parseRecipients(mapped.recipients); + } + + if (!hasData) { + setError("No data found in CSV row"); + return; + } + + if (spreadsheetName && currentMapping) { + saveColumnMapping(spreadsheetName, currentMapping); + } + + setCsvInput(""); + setIsExpanded(false); + onImportComplete(mapped); + }; + + const handleMappingConfirm = (mapping: ColumnMapping) => { + setCurrentMapping(mapping); + setShowMappingModal(false); + if (pendingCsvData) { + processImport(pendingCsvData, mapping); + } + }; + + return ( +
+ + + {isExpanded && ( +
+
+ +