From 5321d09c2fa2be3f2dcd1abe9a52656e8aaf6177 Mon Sep 17 00:00:00 2001 From: Atharva0506 Date: Wed, 1 Jul 2026 21:18:57 +0530 Subject: [PATCH 1/7] fix: comprehensive invoice validation and UI enhancements --- frontend/src/components/AmountTypeToggle.jsx | 41 ++ frontend/src/components/TokenPicker.jsx | 59 ++- frontend/src/components/ui/copyButton.jsx | 17 +- frontend/src/page/CreateInvoice.jsx | 482 ++++++++++++----- frontend/src/page/CreateInvoice_main.jsx | Bin 0 -> 108534 bytes frontend/src/page/CreateInvoicesBatch.jsx | 483 +++++++++++++----- .../src/page/CreateInvoicesBatch_main.jsx | Bin 0 -> 99468 bytes frontend/src/utils/invoiceCalculations.js | 88 ++++ frontend/src/utils/invoiceValidation.js | 310 +++++++++++ .../src/utils/productCatalogInvoiceHelpers.js | 30 +- 10 files changed, 1219 insertions(+), 291 deletions(-) create mode 100644 frontend/src/components/AmountTypeToggle.jsx create mode 100644 frontend/src/page/CreateInvoice_main.jsx create mode 100644 frontend/src/page/CreateInvoicesBatch_main.jsx create mode 100644 frontend/src/utils/invoiceCalculations.js create mode 100644 frontend/src/utils/invoiceValidation.js diff --git a/frontend/src/components/AmountTypeToggle.jsx b/frontend/src/components/AmountTypeToggle.jsx new file mode 100644 index 00000000..66d67ecf --- /dev/null +++ b/frontend/src/components/AmountTypeToggle.jsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { ArrowRightLeft } from 'lucide-react'; + +export const AmountTypeToggle = ({ value, onChange, className = "" }) => { + return ( +
+ + + + + +
+ ); +}; diff --git a/frontend/src/components/TokenPicker.jsx b/frontend/src/components/TokenPicker.jsx index ea41764e..e0144e2f 100644 --- a/frontend/src/components/TokenPicker.jsx +++ b/frontend/src/components/TokenPicker.jsx @@ -115,19 +115,24 @@ const TokenItem = memo(function TokenItem({ query, isSelected, onSelect, + disabled = false, + isLoading = false, }) { const handleClick = useCallback(() => { + if (disabled) return; onSelect(token); - }, [onSelect, token]); + }, [disabled, onSelect, token]); return ( ); }); @@ -181,6 +188,8 @@ export function TokenPicker({ onCustomTokenClick, }) { const [open, setOpen] = useState(false); + const [isSelecting, setIsSelecting] = useState(false); + const [selectingTokenAddress, setSelectingTokenAddress] = useState(null); const inputRef = useRef(null); const { tokens, @@ -202,12 +211,28 @@ export function TokenPicker({ } }, [open, setQuery]); - const handleSelect = (token) => { - onSelect(token); - setOpen(false); + const handleSelect = async (token) => { + if (isSelecting) return; + + const tokenAddress = token.contract_address || token.address; + setIsSelecting(true); + setSelectingTokenAddress(tokenAddress); + + try { + const shouldClose = await Promise.resolve(onSelect(token)); + if (shouldClose !== false) { + setOpen(false); + } + } catch { + // Parent handler is responsible for surfacing a user-facing error. + } finally { + setIsSelecting(false); + setSelectingTokenAddress(null); + } }; const handleCustomTokenClick = () => { + if (isSelecting) return; if (onCustomTokenClick) { onCustomTokenClick(); } @@ -221,7 +246,7 @@ export function TokenPicker({ + ); }; diff --git a/frontend/src/page/CreateInvoice.jsx b/frontend/src/page/CreateInvoice.jsx index 2c31896e..f9d8b0f6 100644 --- a/frontend/src/page/CreateInvoice.jsx +++ b/frontend/src/page/CreateInvoice.jsx @@ -7,7 +7,6 @@ import { ethers, formatUnits, JsonRpcProvider, - parseUnits, } from "ethers"; import { useAccount, useWalletClient } from "wagmi"; import { ChainvoiceABI } from "../contractsABI/ChainvoiceABI"; @@ -41,9 +40,19 @@ import TokenPicker, { ToggleSwitch } from "@/components/TokenPicker"; import { CopyButton } from "@/components/ui/copyButton"; import CountryPicker from "@/components/CountryPicker"; import { useTokenList } from "@/hooks/useTokenList"; +import { + getLineAmountDetails, + getSafeLineAmountDisplay, + INVOICE_DECIMALS, +} from "@/utils/invoiceCalculations"; +import { + getClientAddressError, + validateSingleInvoiceData, +} from "@/utils/invoiceValidation"; import toast from "react-hot-toast"; -import ProductCatalogImport from "@/components/ProductCatalogImport"; +import ProductCatalogImport from "../components/ProductCatalogImport"; +import { AmountTypeToggle } from "../components/AmountTypeToggle"; import ProductAutocompleteInput from "@/components/ProductAutocompleteInput"; import { useProductCatalog } from "@/hooks/useProductCatalog"; import { @@ -96,6 +105,9 @@ function CreateInvoice() { // Holds inline validation error for client wallet address // Used instead of browser alerts for better, non blocking UX const [clientAddressError, setClientAddressError] = useState(""); + const [totalAmountError, setTotalAmountError] = useState(""); + const [itemErrors, setItemErrors] = useState([]); + const [fieldErrors, setFieldErrors] = useState({}); // const TESTNET_TOKEN = ["0xB5E9C6e57C9d312937A059089B547d0036c155C7"]; //sepolia based chainvoice test token (CIN) @@ -171,6 +183,40 @@ function CreateInvoice() { } }, []); + const resolveTokenDecimals = useCallback( + async (tokenAddress, fallbackDecimals) => { + if ( + fallbackDecimals !== undefined && + fallbackDecimals !== null && + !Number.isNaN(Number(fallbackDecimals)) + ) { + return Number(fallbackDecimals); + } + + try { + let provider; + const rpcUrl = + chainIdForTokens && CHAIN_ID_TO_PUBLIC_RPC[Number(chainIdForTokens)]; + + if (rpcUrl) { + provider = new JsonRpcProvider(rpcUrl); + } else if (typeof window !== "undefined" && window.ethereum) { + provider = new BrowserProvider(window.ethereum); + } else { + return null; + } + + const contract = new ethers.Contract(tokenAddress, ERC20_ABI, provider); + const decimals = await contract.decimals(); + return Number(decimals); + } catch (error) { + console.warn("Failed to resolve token decimals:", error); + return null; + } + }, + [chainIdForTokens] + ); + useEffect(() => { const urlClientAddress = searchParams.get("clientAddress"); const urlTokenAddress = searchParams.get("tokenAddress"); @@ -195,6 +241,7 @@ function CreateInvoice() { ...(urlDescription && { description: urlDescription }), ...(urlAmount && { qty: "1", unitPrice: urlAmount }), }; + updatedFirst.amount = getSafeLineAmountDisplay(updatedFirst); return [updatedFirst, ...prev.slice(1)]; }); } @@ -258,17 +305,12 @@ function CreateInvoice() { useEffect(() => { const total = itemData.reduce((sum, item) => { - const qty = parseUnits(item.qty || "0", 18); - const unitPrice = parseUnits(item.unitPrice || "0", 18); - const discount = parseUnits(item.discount || "0", 18); - const tax = parseUnits(item.tax || "0", 18); - const lineTotal = (qty * unitPrice) / parseUnits("1", 18); - const adjusted = lineTotal - discount + tax; - - return sum + adjusted; + const { valid, amountWei } = getLineAmountDetails(item); + if (!valid || amountWei < 0n) return sum; + return sum + amountWei; }, 0n); - setTotalAmountDue(formatUnits(total, 18)); + setTotalAmountDue(formatUnits(total, INVOICE_DECIMALS)); }, [itemData]); @@ -279,6 +321,20 @@ function CreateInvoice() { const handleItemData = (e, index) => { const { name, value } = e.target; + if (["qty", "unitPrice", "discount", "tax"].includes(name) && value !== "") { + if (/[^0-9.]/.test(value)) return; + const parts = value.split("."); + if (parts.length > 2) return; + + const numValue = parseFloat(value); + if ( + (name === "discount" && itemData[index]?.discountType === "percentage" && (numValue < 0 || numValue > 100)) || + (name === "tax" && itemData[index]?.taxType === "percentage" && (numValue < 0 || numValue > 100)) + ) { + return; + } + } + setItemData((prevItemData) => prevItemData.map((item, i) => { if (i === index) { @@ -289,15 +345,7 @@ function CreateInvoice() { name === "discount" || name === "tax" ) { - const qty = parseUnits(updatedItem.qty || "0", 18); - const unitPrice = parseUnits(updatedItem.unitPrice || "0", 18); - const discount = parseUnits(updatedItem.discount || "0", 18); - const tax = parseUnits(updatedItem.tax || "0", 18); - - const lineTotal = (qty * unitPrice) / parseUnits("1", 18); - const finalAmount = lineTotal - discount + tax; - - updatedItem.amount = formatUnits(finalAmount, 18); + updatedItem.amount = getSafeLineAmountDisplay(updatedItem); } return updatedItem; } @@ -310,36 +358,89 @@ function CreateInvoice() { setItemData((prev) => [...prev, createEmptyInvoiceItem()]); }; - + const handleFieldChange = (e) => { + const { name } = e.target; + if (fieldErrors[name]) { + setFieldErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors[name]; + return newErrors; + }); + } + }; -const validateClientAddress = useCallback((value) => { - // Empty input, no error - if (!value) { - setClientAddressError(""); - return; - } + const handleFieldBlur = (e) => { + const { name, value } = e.target; + let error = ""; + if (name === "userFname" || name === "clientFname") { + if (!value.trim()) error = "First name is required"; + } else if (name === "userEmail" || name === "clientEmail") { + if (!value.trim()) error = "Email is required"; + else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) error = "Invalid email address"; + } + + if (error) { + setFieldErrors((prev) => ({ ...prev, [name]: error })); + } + }; + const validateClientAddress = useCallback((value, options = {}) => { + const error = getClientAddressError(value, { + ...options, + ownerAddress: account.address, + }); + setClientAddressError(error); + return !error; + }, [account.address]); - // Do not validate until it looks like a full EVM address - if (!value.startsWith("0x") || value.length < 42) { - setClientAddressError(""); - return; - } + const validateInvoiceBeforeSubmit = useCallback((data, paymentToken) => { + const validation = validateSingleInvoiceData({ + clientAddress: data.clientAddress, + itemData, + totalAmountDue, + paymentToken, + ownerAddress: account.address, + }); - // Invalid EVM address - if (!ethers.isAddress(value)) { - setClientAddressError("Please enter a valid wallet address"); - return; - } + if (!validation.isValid) { + let hasFieldError = false; + if (validation.fieldErrors.clientAddress) { + setClientAddressError(validation.fieldErrors.clientAddress); + hasFieldError = true; + } else { + setClientAddressError(""); + } - // Self-invoicing check - if (value.toLowerCase() === account.address?.toLowerCase()) { - setClientAddressError("You cannot create an invoice for your own wallet"); - return; - } + if (validation.fieldErrors.totalAmountDue) { + setTotalAmountError(validation.fieldErrors.totalAmountDue); + hasFieldError = true; + } else { + setTotalAmountError(""); + } - // Valid other wallet - setClientAddressError(""); -}, [account.address]); + const newItemErrors = []; + const newFieldErrors = {}; + Object.keys(validation.fieldErrors).forEach(key => { + if (key.startsWith("item_")) { + const idx = parseInt(key.split("_")[1]); + newItemErrors[idx] = validation.fieldErrors[key]; + hasFieldError = true; + } else { + newFieldErrors[key] = validation.fieldErrors[key]; + } + }); + setItemErrors(newItemErrors); + setFieldErrors(newFieldErrors); + + toast.error(validation.errorMessage || "Please fix the required fields"); + return false; + } + + setTotalAmountError(""); + setItemErrors([]); + setFieldErrors({}); + + return true; + }, [account.address, itemData, totalAmountDue]); const createInvoiceRequest = async (data) => { if (!isConnected || !walletClient) { @@ -347,16 +448,24 @@ const validateClientAddress = useCallback((value) => { return; } - if (!data.clientAddress) { - setClientAddressError("Client address is required"); + const paymentToken = useCustomToken ? verifiedToken : selectedToken; + if (!paymentToken?.address) { + toast.error("Please select or verify a payment token."); return; } - if (!ethers.isAddress(data.clientAddress)) { - setClientAddressError("Please enter a valid wallet address"); + + const tokenDecimals = Number(paymentToken?.decimals); + if (!Number.isInteger(tokenDecimals) || tokenDecimals < 0) { + toast.error("Selected token has invalid decimals"); return; } - if (data.clientAddress.toLowerCase() === account.address?.toLowerCase()) { - setClientAddressError("You cannot create an invoice for your own wallet"); + + const normalizedData = { + ...data, + clientAddress: (data.clientAddress || "").trim(), + }; + + if (!validateInvoiceBeforeSubmit(normalizedData, paymentToken)) { return; } @@ -366,13 +475,6 @@ const validateClientAddress = useCallback((value) => { const provider = new BrowserProvider(walletClient); const signer = await provider.getSigner(); - const paymentToken = useCustomToken ? verifiedToken : selectedToken; - if (!paymentToken?.address) { - toast.error("Please select or verify a payment token."); - setLoading(false); - return; - } - const invoicePayload = { amountDue: totalAmountDue.toString(), dueDate, @@ -392,15 +494,18 @@ const validateClientAddress = useCallback((value) => { postalcode: data.userPostalcode, }, client: { - address: data.clientAddress, - fname: data.clientFname, - lname: data.clientLname, - email: data.clientEmail, - country: data.clientCountry, - city: data.clientCity, - postalcode: data.clientPostalcode, + address: normalizedData.clientAddress, + fname: normalizedData.clientFname, + lname: normalizedData.clientLname, + email: normalizedData.clientEmail, + country: normalizedData.clientCountry, + city: normalizedData.clientCity, + postalcode: normalizedData.clientPostalcode, }, - items: itemData, + items: itemData.map((item) => ({ + ...item, + amount: getSafeLineAmountDisplay(item), + })), }; const invoiceString = JSON.stringify(invoicePayload); @@ -424,8 +529,8 @@ const validateClientAddress = useCallback((value) => { const contract = new Contract(contractAddress, ChainvoiceABI, signer); const tx = await contract.createInvoice( - data.clientAddress, - ethers.parseUnits(totalAmountDue.toString(), paymentToken.decimals), + normalizedData.clientAddress, + ethers.parseUnits(totalAmountDue.toString(), tokenDecimals), paymentToken.address, encryptedStringBase64, dataToEncryptHash @@ -584,14 +689,19 @@ const validateClientAddress = useCallback((value) => {
+ {fieldErrors.userFname && ( +
{fieldErrors.userFname}
+ )}