diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml new file mode 100644 index 00000000..a606d6f2 --- /dev/null +++ b/.github/workflows/frontend-tests.yml @@ -0,0 +1,43 @@ +name: Frontend Tests + +on: + pull_request: + branches: [main] + paths: + - frontend/** + - .github/workflows/frontend-tests.yml + push: + branches: [main] + paths: + - frontend/** + - .github/workflows/frontend-tests.yml + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: | + if [ -f "package-lock.json" ]; then + npm ci + else + npm install + fi + + - name: Run Jest tests with coverage + run: npm run test:ci diff --git a/README.md b/README.md index 32ff9a75..11ca7d58 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Chainvoice is a decentralized invoicing platform that enables secure, transparen - [Project Structure](#project-structure) - [Getting Started](#getting-started) - [Frontend Setup](#frontend-setup) +- [Frontend Testing (Jest)](#frontend-testing-jest) - [Smart Contract Testing](#smart-contract-testing) - [Deploy to Ethereum Classic](#deploy-to-ethereum-classic) - [Environment Variables](#environment-variables) @@ -89,6 +90,17 @@ npm run dev 4. **Open application** Navigate to `http://localhost:5173` in your browser +## Frontend Testing (Jest) + +1. **Navigate to frontend directory** +cd frontend + +2. **Run tests** +npm test + +3. **Run tests with coverage (CI mode)** +npm run test:ci + ## Smart Contract Testing > **Prerequisites:** [Foundry](https://getfoundry.sh/) must be installed diff --git a/frontend/README.md b/frontend/README.md index f768e33f..a520b582 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,8 +1,39 @@ -# React + Vite +# Chainvoice Frontend -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +This frontend is built with React and Vite. -Currently, two official plugins are available: +## Prerequisites -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh +- Node.js 20+ +- npm + +## Development + +1. Install dependencies: +npm install + +2. Start local dev server: +npm run dev + +3. Build production assets: +npm run build + +## Testing + +Jest is configured for utility-level unit testing. + +- Run tests: +npm test + +- Run tests with coverage (CI mode): +npm run test:ci + +- Watch mode: +npm run test:watch + +## Current Test Scope + +- Invoice amount calculations +- Single invoice validation rules +- Batch invoice validation rules +- Negative amount prevention and message consistency diff --git a/frontend/jest.config.cjs b/frontend/jest.config.cjs new file mode 100644 index 00000000..1f879153 --- /dev/null +++ b/frontend/jest.config.cjs @@ -0,0 +1,10 @@ +module.exports = { + rootDir: ".", + testEnvironment: "node", + testMatch: ["/tests/**/*.test.[jt]s?(x)"], + collectCoverageFrom: [ + "src/utils/invoiceCalculations.js", + "src/utils/invoiceValidation.js", + ], + coverageDirectory: "/coverage", +}; diff --git a/frontend/package.json b/frontend/package.json index 61a2806e..c0eef999 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,10 @@ "dev": "vite", "build": "vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --runInBand", + "test:watch": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --watch", + "test:ci": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --ci --coverage" }, "dependencies": { "@emotion/react": "^11.14.0", @@ -66,6 +69,7 @@ "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.16", "globals": "^15.14.0", + "jest": "^30.2.0", "postcss": "^8.5.0", "tailwindcss": "^3.4.17", "vite": "^6.0.5" 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 a5c853f4..a7d10f69 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"; @@ -49,6 +48,15 @@ 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"; /** Public RPC URLs by chain ID for token verification when visitor has no wallet (e.g. opening invoice request link in incognito). */ @@ -151,6 +159,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"); @@ -180,6 +222,7 @@ function CreateInvoice() { ...(urlDescription && { description: urlDescription }), ...(urlAmount && { qty: "1", unitPrice: urlAmount }), }; + updatedFirst.amount = getSafeLineAmountDisplay(updatedFirst); return [updatedFirst, ...prev.slice(1)]; }); } @@ -243,17 +286,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]); useEffect(() => { @@ -287,15 +325,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; } @@ -317,37 +347,34 @@ function CreateInvoice() { }, ]); }; + const validateClientAddress = useCallback((value, options = {}) => { + const error = getClientAddressError(value, { + ...options, + ownerAddress: account.address, + }); + setClientAddressError(error); + return !error; + }, [account.address]); + + const validateInvoiceBeforeSubmit = useCallback((data, paymentToken) => { + const validation = validateSingleInvoiceData({ + clientAddress: data.clientAddress, + itemData, + totalAmountDue, + paymentToken, + ownerAddress: account.address, + }); + + if (!validation.isValid) { + if (validation.fieldErrors.clientAddress) { + setClientAddressError(validation.fieldErrors.clientAddress); + } + toast.error(validation.errorMessage); + return false; + } - - -const validateClientAddress = useCallback((value) => { - // Empty input, no error - if (!value) { - setClientAddressError(""); - return; - } - - // Do not validate until it looks like a full EVM address - if (!value.startsWith("0x") || value.length < 42) { - setClientAddressError(""); - return; - } - - // Invalid EVM address - if (!ethers.isAddress(value)) { - setClientAddressError("Please enter a valid wallet address"); - return; - } - - // Self-invoicing check - if (value.toLowerCase() === account.address?.toLowerCase()) { - setClientAddressError("You cannot create an invoice for your own wallet"); - return; - } - - // Valid other wallet - setClientAddressError(""); -}, [account.address]); + return true; + }, [account.address, itemData, totalAmountDue]); const createInvoiceRequest = async (data) => { if (!isConnected || !walletClient) { @@ -355,8 +382,24 @@ const validateClientAddress = useCallback((value) => { return; } - validateClientAddress(data.clientAddress); - if (clientAddressError) { + const paymentToken = useCustomToken ? verifiedToken : selectedToken; + if (!paymentToken?.address) { + toast.error("Please select or verify a payment token."); + return; + } + + const tokenDecimals = Number(paymentToken?.decimals); + if (!Number.isInteger(tokenDecimals) || tokenDecimals < 0) { + toast.error("Selected token has invalid decimals"); + return; + } + + const normalizedData = { + ...data, + clientAddress: (data.clientAddress || "").trim(), + }; + + if (!validateInvoiceBeforeSubmit(normalizedData, paymentToken)) { return; } @@ -366,13 +409,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 +428,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); @@ -432,7 +471,7 @@ const validateClientAddress = useCallback((value) => { parameters: [":userAddress"], returnValueTest: { comparator: "=", - value: data.clientAddress.toLowerCase(), + value: normalizedData.clientAddress.toLowerCase(), }, }, ]; @@ -492,8 +531,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 @@ -890,14 +929,29 @@ const validateClientAddress = useCallback((value) => { )} { + onSelect={async (token) => { + const address = token.contract_address || token.address; + const decimals = await resolveTokenDecimals( + address, + token.decimals + ); + + if (decimals === null) { + toast.error( + "Failed to fetch token decimals for selected token" + ); + return false; + } + setSelectedToken({ - address: token.contract_address, + address, symbol: token.symbol, name: token.name, logo: token.image, - decimals: 18, + decimals, }); + + return true; }} chainId={chainIdForTokens} disabled={loading} @@ -1075,7 +1129,7 @@ const validateClientAddress = useCallback((value) => {
DESCRIPTION
QTY
UNIT PRICE
-
DISCOUNT
+
DISCOUNT (AMOUNT)
TAX(%)
AMOUNT
@@ -1115,6 +1169,8 @@ const validateClientAddress = useCallback((value) => { placeholder="0" className="w-full border-gray-300 text-black" name="qty" + min="0" + step="any" value={itemData[index]?.qty ?? ""} onChange={(e) => handleItemData(e, index)} /> @@ -1124,10 +1180,12 @@ const validateClientAddress = useCallback((value) => { Unit Price handleItemData(e, index)} /> @@ -1137,13 +1195,15 @@ const validateClientAddress = useCallback((value) => {
handleItemData(e, index)} /> @@ -1153,10 +1213,12 @@ const validateClientAddress = useCallback((value) => { Tax (%) handleItemData(e, index)} /> @@ -1173,12 +1235,7 @@ const validateClientAddress = useCallback((value) => { className="w-full bg-gray-100 border-gray-300 text-gray-700 font-semibold" name="amount" disabled - value={String( - (parseFloat(itemData[index]?.qty) || 0) * - (parseFloat(itemData[index]?.unitPrice) || 0) - - (parseFloat(itemData[index]?.discount) || 0) + - (parseFloat(itemData[index]?.tax) || 0) - )} + value={getSafeLineAmountDisplay(itemData[index]) || "0"} />
@@ -1229,36 +1286,44 @@ const validateClientAddress = useCallback((value) => { placeholder="0" className="w-full border-gray-300 text-black py-2" name="qty" + min="0" + step="any" value={itemData[index]?.qty ?? ""} onChange={(e) => handleItemData(e, index)} />
handleItemData(e, index)} />
handleItemData(e, index)} />
handleItemData(e, index)} /> @@ -1270,12 +1335,7 @@ const validateClientAddress = useCallback((value) => { className="w-full bg-gray-50 border-gray-300 text-gray-700 py-2" name="amount" disabled - value={String( - (parseFloat(itemData[index]?.qty) || 0) * - (parseFloat(itemData[index]?.unitPrice) || 0) - - (parseFloat(itemData[index]?.discount) || 0) + - (parseFloat(itemData[index]?.tax) || 0) - )} + value={getSafeLineAmountDisplay(itemData[index]) || "0"} />
diff --git a/frontend/src/page/CreateInvoicesBatch.jsx b/frontend/src/page/CreateInvoicesBatch.jsx index a4c5d531..ed632d80 100644 --- a/frontend/src/page/CreateInvoicesBatch.jsx +++ b/frontend/src/page/CreateInvoicesBatch.jsx @@ -7,7 +7,6 @@ import { Contract, ethers, formatUnits, - parseUnits, } from "ethers"; import { useAccount, useWalletClient } from "wagmi"; import { ChainvoiceABI } from "../contractsABI/ChainvoiceABI"; @@ -36,8 +35,7 @@ import { cn } from "@/lib/utils"; import { format } from "date-fns"; import { Label } from "@/components/ui/label"; import { useNavigate } from "react-router-dom"; -import { toast } from "react-toastify"; -import "react-toastify/dist/ReactToastify.css"; +import toast from "react-hot-toast"; import { LitNodeClient } from "@lit-protocol/lit-node-client"; import { encryptString } from "@lit-protocol/encryption/src/lib/encryption.js"; @@ -54,6 +52,15 @@ import WalletConnectionAlert from "../components/WalletConnectionAlert"; import TokenPicker, { ToggleSwitch } from "@/components/TokenPicker"; import { CopyButton } from "@/components/ui/copyButton"; import CountryPicker from "@/components/CountryPicker"; +import { + getLineAmountDetails, + getSafeLineAmountDisplay, + INVOICE_DECIMALS, +} from "@/utils/invoiceCalculations"; +import { + getClientAddressError, + validateBatchInvoiceData, +} from "@/utils/invoiceValidation"; function CreateInvoicesBatch() { const { data: walletClient } = useWalletClient(); @@ -72,6 +79,7 @@ function CreateInvoicesBatch() { const [tokenVerificationState, setTokenVerificationState] = useState("idle"); const [verifiedToken, setVerifiedToken] = useState(null); const [showWalletAlert, setShowWalletAlert] = useState(!isConnected); + const [clientAddressErrors, setClientAddressErrors] = useState({}); // UI state for collapsible invoices const [expandedInvoice, setExpandedInvoice] = useState(0); @@ -115,18 +123,16 @@ function CreateInvoicesBatch() { setInvoiceRows((prev) => prev.map((row) => { const total = row.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; + const { valid, amountWei } = getLineAmountDetails(item); + if (!valid) return sum; + let adjusted = amountWei; + if (adjusted < 0n) adjusted = 0n; return sum + adjusted; }, 0n); return { ...row, - totalAmountDue: formatUnits(total, 18), + totalAmountDue: formatUnits(total, INVOICE_DECIMALS), }; }) ); @@ -178,16 +184,23 @@ function CreateInvoicesBatch() { }, ]); setExpandedInvoice(newIndex); - toast.success("New invoice added to batch"); }; const removeInvoiceRow = (index) => { if (invoiceRows.length > 1) { setInvoiceRows((prev) => prev.filter((_, i) => i !== index)); + setClientAddressErrors((prev) => { + const next = {}; + Object.entries(prev).forEach(([key, value]) => { + const numericKey = Number(key); + if (numericKey < index) next[numericKey] = value; + if (numericKey > index) next[numericKey - 1] = value; + }); + return next; + }); if (expandedInvoice === index) { setExpandedInvoice(0); } - toast.success("Invoice removed from batch"); } }; @@ -213,15 +226,12 @@ function CreateInvoicesBatch() { 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); + const { valid, amountWei } = getLineAmountDetails(updatedItem); + if (!valid) { + updatedItem.amount = ""; + } else { + updatedItem.amount = getSafeLineAmountDisplay(updatedItem); + } } return updatedItem; } @@ -291,19 +301,62 @@ function CreateInvoicesBatch() { // Enhanced error handling for batch creation const getErrorMessage = (error) => { - if (error.code === "ACTION_REJECTED") { + if (error?.code === "ACTION_REJECTED") { return "Transaction was cancelled by user"; - } else if (error.message?.includes("insufficient")) { + } + + if (error?.message?.toLowerCase().includes("insufficient")) { return "Insufficient balance to complete transaction"; - } else if (error.message?.includes("network")) { + } + + if (error?.message?.toLowerCase().includes("network")) { return "Network error. Please check your connection and try again"; - } else if (error.reason) { + } + + if (error?.reason) { return `Transaction failed: ${error.reason}`; - } else if (error.message) { + } + + if (error?.message) { return error.message; - } else { - return "Failed to create invoice batch. Please try again."; } + + return "Failed to create invoice batch. Please try again."; + }; + + const validateClientAddress = (rowIndex, value, options = {}) => { + const error = getClientAddressError(value, { + ...options, + ownerAddress: account.address, + }); + setClientAddressErrors((prev) => { + if (!error && !prev[rowIndex]) return prev; + const next = { ...prev }; + if (error) { + next[rowIndex] = error; + } else { + delete next[rowIndex]; + } + return next; + }); + return !error; + }; + + const validateInvoicesBeforeSubmit = (rows, paymentToken) => { + const validation = validateBatchInvoiceData({ + rows, + paymentToken, + ownerAddress: account.address, + }); + + if (!validation.isValid) { + setClientAddressErrors(validation.addressErrors || {}); + toast.error(validation.errorMessage); + return null; + } + + setClientAddressErrors({}); + return { validInvoices: validation.validInvoices }; }; // Create batch invoices @@ -315,7 +368,6 @@ function CreateInvoicesBatch() { try { setLoading(true); - toast.info("Starting batch invoice creation..."); const provider = new BrowserProvider(walletClient); const signer = await provider.getSigner(); @@ -327,17 +379,20 @@ function CreateInvoicesBatch() { return; } - // Validate invoices - const validInvoices = invoiceRows.filter( - (row) => row.clientAddress && parseFloat(row.totalAmountDue) > 0 - ); + const tokenDecimals = Number(paymentToken?.decimals); + if (!Number.isInteger(tokenDecimals) || tokenDecimals < 0) { + toast.error("Selected token has invalid decimals"); + return; + } - if (validInvoices.length === 0) { - toast.error( - "Please add at least one valid invoice with client address and amount" - ); + const validationResult = validateInvoicesBeforeSubmit( + invoiceRows, + paymentToken + ); + if (!validationResult) { return; } + const { validInvoices } = validationResult; // Prepare batch arrays const tos = []; @@ -351,14 +406,8 @@ function CreateInvoicesBatch() { return; } - toast.info(`Processing ${validInvoices.length} invoices...`); - // Process each invoice for (const [index, row] of validInvoices.entries()) { - toast.info( - `Encrypting invoice ${index + 1} of ${validInvoices.length}...` - ); - const invoicePayload = { amountDue: row.totalAmountDue.toString(), dueDate, @@ -366,7 +415,7 @@ function CreateInvoicesBatch() { paymentToken: { address: paymentToken.address, symbol: paymentToken.symbol, - decimals: Number(paymentToken.decimals), + decimals: tokenDecimals, }, user: { address: account?.address.toString(), @@ -386,7 +435,10 @@ function CreateInvoicesBatch() { city: row.clientCity, postalcode: row.clientPostalcode, }, - items: row.itemData, + items: row.itemData.map((item) => ({ + ...item, + amount: getSafeLineAmountDisplay(item), + })), // Add batch metadata batchInfo: { batchId: `batch_${Date.now()}`, @@ -466,19 +518,24 @@ function CreateInvoicesBatch() { // Add to batch arrays tos.push(row.clientAddress); - amounts.push( - ethers.parseUnits( + let amountForContract; + try { + amountForContract = ethers.parseUnits( row.totalAmountDue.toString(), - paymentToken.decimals - ) - ); + tokenDecimals + ); + } catch { + toast.error( + `Invoice #${index + 1} total exceeds ${tokenDecimals} decimals for ${paymentToken.symbol || "selected token"}` + ); + return; + } + + amounts.push(amountForContract); encryptedPayloads.push(encryptedStringBase64); encryptedHashes.push(dataToEncryptHash); } - toast.success("All invoices encrypted successfully!"); - toast.info("Submitting batch transaction to blockchain..."); - // Send to contract const contractAddress = import.meta.env[ `VITE_CONTRACT_ADDRESS_${chainId}` @@ -498,17 +555,12 @@ function CreateInvoicesBatch() { encryptedHashes ); - toast.info("Transaction submitted! Waiting for confirmation..."); const receipt = await tx.wait(); toast.success( `Successfully created ${validInvoices.length} invoices in batch!` ); - toast.success( - `Gas saved: ~${ - (validInvoices.length - 1) * 75 - }% compared to individual transactions!` - ); + toast.success(`Estimated gas savings: ~${(validInvoices.length - 1) * 75}%`); setTimeout(() => navigate("/dashboard/sent"), 3000); } catch (err) { @@ -988,16 +1040,29 @@ function CreateInvoicesBatch() { - updateInvoiceRow( - rowIndex, - "clientAddress", - e.target.value - ) - } + onChange={(e) => { + const value = e.target.value; + updateInvoiceRow(rowIndex, "clientAddress", value); + validateClientAddress(rowIndex, value); + }} + onBlur={(e) => { + validateClientAddress(rowIndex, e.target.value, { + required: true, + }); + }} /> + {clientAddressErrors[rowIndex] && ( +
+ {clientAddressErrors[rowIndex]} +
+ )}
@@ -1088,7 +1153,7 @@ function CreateInvoicesBatch() {
DESCRIPTION
QTY
UNIT PRICE
-
DISCOUNT
+
DISCOUNT (AMOUNT)
TAX(%)
AMOUNT
ACTION
@@ -1149,11 +1214,11 @@ function CreateInvoicesBatch() {
- {( - (parseFloat(item.qty) || 0) * - (parseFloat(item.unitPrice) || 0) - - (parseFloat(item.discount) || 0) + - (parseFloat(item.tax) || 0) - ).toFixed(4)} + {item.amount === "" ? "-" : (Number(item.amount) || 0).toFixed(4)}
diff --git a/frontend/src/utils/invoiceCalculations.js b/frontend/src/utils/invoiceCalculations.js new file mode 100644 index 00000000..4e31bdb4 --- /dev/null +++ b/frontend/src/utils/invoiceCalculations.js @@ -0,0 +1,73 @@ +import { formatUnits, parseUnits } from "ethers"; + +export const INVOICE_DECIMALS = 18; +const ONE_INVOICE_UNIT = parseUnits("1", INVOICE_DECIMALS); +const HUNDRED_INVOICE_UNITS = parseUnits("100", INVOICE_DECIMALS); + +export const parseNumericInputToWei = (value) => { + const normalized = String(value ?? "").trim(); + + if (!normalized) return 0n; + if ( + normalized === "-" || + normalized === "." || + normalized === "-." || + !/^-?\d*(\.\d*)?$/.test(normalized) + ) { + return null; + } + + try { + return parseUnits(normalized, INVOICE_DECIMALS); + } catch { + return null; + } +}; + +export const getLineAmountDetails = (item) => { + const qtyWei = parseNumericInputToWei(item?.qty); + const unitPriceWei = parseNumericInputToWei(item?.unitPrice); + const discountWei = parseNumericInputToWei(item?.discount); + const taxRateWei = parseNumericInputToWei(item?.tax); + + if ( + qtyWei === null || + unitPriceWei === null || + discountWei === null || + taxRateWei === null + ) { + return { + valid: false, + amountWei: 0n, + lineTotalWei: 0n, + qtyWei: 0n, + unitPriceWei: 0n, + discountWei: 0n, + taxRateWei: 0n, + taxAmountWei: 0n, + }; + } + + const lineTotalWei = (qtyWei * unitPriceWei) / ONE_INVOICE_UNIT; + // Discount is a flat token amount; tax is a percentage of the line total. + const taxAmountWei = (lineTotalWei * taxRateWei) / HUNDRED_INVOICE_UNITS; + const amountWei = lineTotalWei - discountWei + taxAmountWei; + + return { + valid: true, + amountWei, + lineTotalWei, + qtyWei, + unitPriceWei, + discountWei, + taxRateWei, + taxAmountWei, + }; +}; + +export const getSafeLineAmountDisplay = (item) => { + const { valid, amountWei } = getLineAmountDetails(item); + if (!valid) return ""; + const safeAmount = amountWei < 0n ? 0n : amountWei; + return formatUnits(safeAmount, INVOICE_DECIMALS); +}; diff --git a/frontend/src/utils/invoiceValidation.js b/frontend/src/utils/invoiceValidation.js new file mode 100644 index 00000000..67d1eb5f --- /dev/null +++ b/frontend/src/utils/invoiceValidation.js @@ -0,0 +1,228 @@ +import { ethers } from "ethers"; +import { getLineAmountDetails, parseNumericInputToWei } from "./invoiceCalculations"; + +export const getClientAddressError = (value, options = {}) => { + const { required = false, ownerAddress } = options; + const trimmed = (value || "").trim(); + + if (!trimmed) { + return required ? "Please enter a client wallet address" : ""; + } + + if (!trimmed.startsWith("0x") || trimmed.length !== 42 || !ethers.isAddress(trimmed)) { + return "Please enter a valid wallet address"; + } + + if (ownerAddress && trimmed.toLowerCase() === ownerAddress.toLowerCase()) { + return "You cannot create an invoice for your own wallet"; + } + + return ""; +}; + +const getTokenDecimalsError = (amountAsString, paymentToken) => { + const tokenDecimals = Number(paymentToken?.decimals); + if (!Number.isInteger(tokenDecimals) || tokenDecimals < 0) { + return null; + } + + try { + ethers.parseUnits(amountAsString.toString(), tokenDecimals); + return null; + } catch { + return `Invoice total supports up to ${tokenDecimals} decimals for ${paymentToken?.symbol || "selected token"}`; + } +}; + +const getLineItemError = (lineLabel, item) => { + const { valid, amountWei, qtyWei, unitPriceWei, discountWei, taxRateWei } = getLineAmountDetails(item); + + if (!valid) { + return `${lineLabel} has invalid number format`; + } + + if (qtyWei < 0n) { + return `${lineLabel}: quantity cannot be negative`; + } + + if (unitPriceWei < 0n) { + return `${lineLabel}: unit price cannot be negative`; + } + + if (discountWei < 0n) { + return `${lineLabel}: discount cannot be negative`; + } + + if (taxRateWei < 0n) { + return `${lineLabel}: tax cannot be negative`; + } + + if (amountWei < 0n) { + return `${lineLabel} amount cannot be negative. Reduce discount or update values`; + } + + return null; +}; + +export const validateSingleInvoiceData = ({ + clientAddress, + itemData, + totalAmountDue, + paymentToken, + ownerAddress, +}) => { + const addressError = getClientAddressError(clientAddress, { + required: true, + ownerAddress, + }); + + if (addressError) { + return { + isValid: false, + errorMessage: addressError, + fieldErrors: { clientAddress: addressError }, + }; + } + + for (let i = 0; i < itemData.length; i += 1) { + const itemError = getLineItemError(`Line item ${i + 1}`, itemData[i]); + if (itemError) { + return { + isValid: false, + errorMessage: itemError, + fieldErrors: {}, + }; + } + } + + const totalWei = parseNumericInputToWei(totalAmountDue); + if (totalWei === null || totalWei <= 0n) { + return { + isValid: false, + errorMessage: "Invoice total must be greater than 0", + fieldErrors: {}, + }; + } + + const decimalsError = getTokenDecimalsError(totalAmountDue, paymentToken); + if (decimalsError) { + return { + isValid: false, + errorMessage: decimalsError, + fieldErrors: {}, + }; + } + + return { + isValid: true, + errorMessage: "", + fieldErrors: {}, + }; +}; + +export const validateBatchInvoiceData = ({ + rows, + paymentToken, + ownerAddress, +}) => { + const normalizedRows = rows.map((row) => ({ + ...row, + clientAddress: (row.clientAddress || "").trim(), + })); + + const duplicateTracker = new Map(); + const pendingAddressErrors = {}; + + for (let rowIndex = 0; rowIndex < normalizedRows.length; rowIndex += 1) { + const row = normalizedRows[rowIndex]; + const rowLabel = `Invoice #${rowIndex + 1}`; + const hasMeaningfulInput = row.clientAddress || parseFloat(row.totalAmountDue) > 0; + + if (!hasMeaningfulInput) { + continue; + } + + const addressError = getClientAddressError(row.clientAddress, { + required: true, + ownerAddress, + }); + + if (addressError) { + pendingAddressErrors[rowIndex] = addressError; + return { + isValid: false, + errorMessage: `${rowLabel}: ${addressError}`, + addressErrors: pendingAddressErrors, + validInvoices: [], + }; + } + + const normalizedAddress = row.clientAddress.toLowerCase(); + if (duplicateTracker.has(normalizedAddress)) { + const firstIndex = duplicateTracker.get(normalizedAddress); + pendingAddressErrors[firstIndex] = "Duplicate wallet address in batch"; + pendingAddressErrors[rowIndex] = "Duplicate wallet address in batch"; + + return { + isValid: false, + errorMessage: `Duplicate client wallet found in Invoice #${firstIndex + 1} and Invoice #${rowIndex + 1}`, + addressErrors: pendingAddressErrors, + validInvoices: [], + }; + } + duplicateTracker.set(normalizedAddress, rowIndex); + + for (let itemIndex = 0; itemIndex < row.itemData.length; itemIndex += 1) { + const itemError = getLineItemError(`${rowLabel}, line item ${itemIndex + 1}`, row.itemData[itemIndex]); + if (itemError) { + return { + isValid: false, + errorMessage: itemError, + addressErrors: pendingAddressErrors, + validInvoices: [], + }; + } + } + + const totalWei = parseNumericInputToWei(row.totalAmountDue); + if (totalWei === null || totalWei <= 0n) { + return { + isValid: false, + errorMessage: `${rowLabel}: invoice total must be greater than 0`, + addressErrors: pendingAddressErrors, + validInvoices: [], + }; + } + + const tokenDecimals = Number(paymentToken?.decimals); + const decimalsError = getTokenDecimalsError(row.totalAmountDue, paymentToken); + if (decimalsError) { + return { + isValid: false, + errorMessage: `${rowLabel}: total supports up to ${tokenDecimals} decimals for ${paymentToken?.symbol || "selected token"}`, + addressErrors: pendingAddressErrors, + validInvoices: [], + }; + } + } + + const validInvoices = normalizedRows.filter( + (row) => row.clientAddress && parseFloat(row.totalAmountDue) > 0 + ); + + if (validInvoices.length === 0) { + return { + isValid: false, + errorMessage: "Please add at least one valid invoice with client address and amount", + addressErrors: pendingAddressErrors, + validInvoices: [], + }; + } + + return { + isValid: true, + errorMessage: "", + addressErrors: {}, + validInvoices, + }; +}; \ No newline at end of file diff --git a/frontend/tests/utils/invoiceCalculations.test.js b/frontend/tests/utils/invoiceCalculations.test.js new file mode 100644 index 00000000..0c8a9290 --- /dev/null +++ b/frontend/tests/utils/invoiceCalculations.test.js @@ -0,0 +1,88 @@ +import { + getLineAmountDetails, + getSafeLineAmountDisplay, + parseNumericInputToWei, +} from "../../src/utils/invoiceCalculations.js"; + +describe("invoiceCalculations.parseNumericInputToWei", () => { + test("returns 0n for empty values", () => { + expect(parseNumericInputToWei("")).toBe(0n); + expect(parseNumericInputToWei(null)).toBe(0n); + expect(parseNumericInputToWei(undefined)).toBe(0n); + }); + + test("parses positive and negative decimals", () => { + expect(parseNumericInputToWei("1.5")).toBe(1500000000000000000n); + expect(parseNumericInputToWei("-2.25")).toBe(-2250000000000000000n); + }); + + test("returns null for invalid numeric formats", () => { + expect(parseNumericInputToWei("abc")).toBeNull(); + expect(parseNumericInputToWei("1.2.3")).toBeNull(); + expect(parseNumericInputToWei("-.")).toBeNull(); + }); +}); + +describe("invoiceCalculations.getLineAmountDetails", () => { + test("computes amount with discount and tax", () => { + const result = getLineAmountDetails({ + qty: "10", + unitPrice: "10", + discount: "5", + tax: "10", + }); + + expect(result.valid).toBe(true); + expect(result.lineTotalWei).toBe(100000000000000000000n); + expect(result.taxAmountWei).toBe(10000000000000000000n); + expect(result.amountWei).toBe(105000000000000000000n); + }); + + test("can produce negative line amount when discount is too high", () => { + const result = getLineAmountDetails({ + qty: "10", + unitPrice: "10", + discount: "1000", + tax: "0", + }); + + expect(result.valid).toBe(true); + expect(result.amountWei).toBe(-900000000000000000000n); + }); + + test("marks line invalid when any input format is invalid", () => { + const result = getLineAmountDetails({ + qty: "x", + unitPrice: "10", + discount: "0", + tax: "0", + }); + + expect(result.valid).toBe(false); + expect(result.amountWei).toBe(0n); + }); +}); + +describe("invoiceCalculations.getSafeLineAmountDisplay", () => { + test("clamps negative line amount to 0 for display", () => { + const display = getSafeLineAmountDisplay({ + qty: "10", + unitPrice: "10", + discount: "1000", + tax: "0", + }); + + expect(display).toBe("0.0"); + }); + + test("returns empty string for invalid line inputs", () => { + expect( + getSafeLineAmountDisplay({ + qty: "bad", + unitPrice: "10", + discount: "0", + tax: "0", + }) + ).toBe(""); + }); +}); diff --git a/frontend/tests/utils/invoiceValidation.test.js b/frontend/tests/utils/invoiceValidation.test.js new file mode 100644 index 00000000..73117a9a --- /dev/null +++ b/frontend/tests/utils/invoiceValidation.test.js @@ -0,0 +1,231 @@ +import { + getClientAddressError, + validateBatchInvoiceData, + validateSingleInvoiceData, +} from "../../src/utils/invoiceValidation.js"; + +const OWNER = "0x66f820a414680B5bcda5eECA5dea238543F42054"; +const CLIENT_1 = "0x742d35Cc6634C0532925a3b844Bc454e4438f44e"; +const CLIENT_2 = "0xFE3B557E8Fb62b89F4916B721be55cEb828dBd73"; + +const validItem = { + description: "Consulting", + qty: "2", + unitPrice: "50", + discount: "10", + tax: "10", +}; + +describe("invoiceValidation.getClientAddressError", () => { + test("requires address when required flag is true", () => { + expect(getClientAddressError("", { required: true, ownerAddress: OWNER })).toBe( + "Please enter a client wallet address" + ); + }); + + test("rejects malformed wallet addresses", () => { + expect(getClientAddressError("0x123", { ownerAddress: OWNER })).toBe( + "Please enter a valid wallet address" + ); + }); + + test("rejects owner self-invoicing", () => { + expect(getClientAddressError(OWNER, { ownerAddress: OWNER })).toBe( + "You cannot create an invoice for your own wallet" + ); + }); + + test("accepts a valid client address", () => { + expect(getClientAddressError(CLIENT_1, { ownerAddress: OWNER })).toBe(""); + }); +}); + +describe("invoiceValidation.validateSingleInvoiceData", () => { + test("accepts valid single invoice payload", () => { + const result = validateSingleInvoiceData({ + clientAddress: CLIENT_1, + itemData: [validItem], + totalAmountDue: "100", + paymentToken: { symbol: "USDT", decimals: 6 }, + ownerAddress: OWNER, + }); + + expect(result.isValid).toBe(true); + expect(result.errorMessage).toBe(""); + }); + + test("blocks negative line amount", () => { + const result = validateSingleInvoiceData({ + clientAddress: CLIENT_1, + itemData: [ + { + ...validItem, + qty: "10", + unitPrice: "10", + discount: "1000", + tax: "0", + }, + ], + totalAmountDue: "100", + paymentToken: { symbol: "USDT", decimals: 6 }, + ownerAddress: OWNER, + }); + + expect(result.isValid).toBe(false); + expect(result.errorMessage).toBe( + "Line item 1 amount cannot be negative. Reduce discount or update values" + ); + }); + + test("blocks negative quantity/unit price/discount/tax", () => { + const byField = [ + { field: "qty", expected: "Line item 1: quantity cannot be negative" }, + { field: "unitPrice", expected: "Line item 1: unit price cannot be negative" }, + { field: "discount", expected: "Line item 1: discount cannot be negative" }, + { field: "tax", expected: "Line item 1: tax cannot be negative" }, + ]; + + for (const { field, expected } of byField) { + const item = { ...validItem, [field]: "-1" }; + const result = validateSingleInvoiceData({ + clientAddress: CLIENT_1, + itemData: [item], + totalAmountDue: "100", + paymentToken: { symbol: "USDT", decimals: 6 }, + ownerAddress: OWNER, + }); + + expect(result.isValid).toBe(false); + expect(result.errorMessage).toBe(expected); + } + }); + + test("blocks zero or invalid totals", () => { + const zeroTotal = validateSingleInvoiceData({ + clientAddress: CLIENT_1, + itemData: [validItem], + totalAmountDue: "0", + paymentToken: { symbol: "USDT", decimals: 6 }, + ownerAddress: OWNER, + }); + expect(zeroTotal.isValid).toBe(false); + expect(zeroTotal.errorMessage).toBe("Invoice total must be greater than 0"); + + const invalidTotal = validateSingleInvoiceData({ + clientAddress: CLIENT_1, + itemData: [validItem], + totalAmountDue: "bad", + paymentToken: { symbol: "USDT", decimals: 6 }, + ownerAddress: OWNER, + }); + expect(invalidTotal.isValid).toBe(false); + expect(invalidTotal.errorMessage).toBe("Invoice total must be greater than 0"); + }); + + test("blocks token-decimal precision overflow", () => { + const result = validateSingleInvoiceData({ + clientAddress: CLIENT_1, + itemData: [validItem], + totalAmountDue: "1.1234567", + paymentToken: { symbol: "USDC", decimals: 6 }, + ownerAddress: OWNER, + }); + + expect(result.isValid).toBe(false); + expect(result.errorMessage).toBe( + "Invoice total supports up to 6 decimals for USDC" + ); + }); +}); + +describe("invoiceValidation.validateBatchInvoiceData", () => { + const makeRow = (overrides = {}) => ({ + clientAddress: CLIENT_1, + itemData: [validItem], + totalAmountDue: "100", + ...overrides, + }); + + test("returns valid invoices when batch is valid", () => { + const result = validateBatchInvoiceData({ + rows: [makeRow({ clientAddress: CLIENT_1 }), makeRow({ clientAddress: CLIENT_2 })], + paymentToken: { symbol: "USDT", decimals: 6 }, + ownerAddress: OWNER, + }); + + expect(result.isValid).toBe(true); + expect(result.validInvoices).toHaveLength(2); + }); + + test("blocks duplicate client wallet addresses", () => { + const result = validateBatchInvoiceData({ + rows: [makeRow({ clientAddress: CLIENT_1 }), makeRow({ clientAddress: CLIENT_1 })], + paymentToken: { symbol: "USDT", decimals: 6 }, + ownerAddress: OWNER, + }); + + expect(result.isValid).toBe(false); + expect(result.errorMessage).toContain("Duplicate client wallet found"); + expect(result.addressErrors[0]).toBe("Duplicate wallet address in batch"); + expect(result.addressErrors[1]).toBe("Duplicate wallet address in batch"); + }); + + test("blocks negative line amount in any invoice row", () => { + const result = validateBatchInvoiceData({ + rows: [ + makeRow({ clientAddress: CLIENT_1 }), + makeRow({ + clientAddress: CLIENT_2, + itemData: [ + { + ...validItem, + qty: "10", + unitPrice: "10", + discount: "1000", + tax: "0", + }, + ], + }), + ], + paymentToken: { symbol: "USDT", decimals: 6 }, + ownerAddress: OWNER, + }); + + expect(result.isValid).toBe(false); + expect(result.errorMessage).toBe( + "Invoice #2, line item 1 amount cannot be negative. Reduce discount or update values" + ); + }); + + test("blocks invoice rows whose totals exceed token precision", () => { + const result = validateBatchInvoiceData({ + rows: [makeRow({ totalAmountDue: "1.1234567" })], + paymentToken: { symbol: "USDC", decimals: 6 }, + ownerAddress: OWNER, + }); + + expect(result.isValid).toBe(false); + expect(result.errorMessage).toBe( + "Invoice #1: total supports up to 6 decimals for USDC" + ); + }); + + test("fails when no row contains meaningful invoice data", () => { + const result = validateBatchInvoiceData({ + rows: [ + { + clientAddress: "", + itemData: [validItem], + totalAmountDue: "0", + }, + ], + paymentToken: { symbol: "USDT", decimals: 6 }, + ownerAddress: OWNER, + }); + + expect(result.isValid).toBe(false); + expect(result.errorMessage).toBe( + "Please add at least one valid invoice with client address and amount" + ); + }); +});