From 7f56d927bee5c3b97dc097de8aae01b8e6e36d13 Mon Sep 17 00:00:00 2001 From: Atharva Naik Date: Wed, 25 Mar 2026 00:12:57 +0530 Subject: [PATCH 1/5] fix(invoice): resolve token decimals on picker selection and harden validation Issue: picker-selected tokens were being stored with hardcoded 18 decimals, causing submit-time precision validation to use incorrect decimals for tokens like USDC. Fix: fetch token decimals during token selection (with fallback to provided list decimals), block selection when decimals cannot be resolved, and expose lineTotalWei in shared calculation helper for consistency/debugging. --- frontend/src/components/TokenPicker.jsx | 59 ++++- frontend/src/page/CreateInvoice.jsx | 296 +++++++++++++++------- frontend/src/page/CreateInvoicesBatch.jsx | 147 ++++++++--- frontend/src/utils/invoiceCalculations.js | 73 ++++++ 4 files changed, 443 insertions(+), 132 deletions(-) create mode 100644 frontend/src/utils/invoiceCalculations.js 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/CreateInvoicesBatch.jsx b/frontend/src/page/CreateInvoicesBatch.jsx index 2d29237b..4bac7bd6 100644 --- a/frontend/src/page/CreateInvoicesBatch.jsx +++ b/frontend/src/page/CreateInvoicesBatch.jsx @@ -35,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"; @@ -57,6 +56,7 @@ import { getLineAmountDetails, getSafeLineAmountDisplay, INVOICE_DECIMALS, + parseNumericInputToWei, } from "@/utils/invoiceCalculations"; function CreateInvoicesBatch() { @@ -76,6 +76,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); @@ -180,16 +181,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"); } }; @@ -290,19 +298,27 @@ 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 getClientAddressError = (value, options = {}) => { @@ -324,6 +340,139 @@ function CreateInvoicesBatch() { return ""; }; + const validateClientAddress = (rowIndex, value, options = {}) => { + const error = getClientAddressError(value, options); + 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 normalizedRows = rows.map((row) => ({ + ...row, + clientAddress: (row.clientAddress || "").trim(), + })); + + const tokenDecimals = Number(paymentToken?.decimals); + const pendingAddressErrors = {}; + const duplicateTracker = new Map(); + + 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, + }); + if (addressError) { + pendingAddressErrors[rowIndex] = addressError; + setClientAddressErrors((prev) => ({ ...prev, ...pendingAddressErrors })); + toast.error(`${rowLabel}: ${addressError}`); + return null; + } + + 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"; + setClientAddressErrors((prev) => ({ ...prev, ...pendingAddressErrors })); + toast.error( + `Duplicate client wallet found in Invoice #${firstIndex + 1} and Invoice #${rowIndex + 1}` + ); + return null; + } + + duplicateTracker.set(normalizedAddress, rowIndex); + + for (let itemIndex = 0; itemIndex < row.itemData.length; itemIndex += 1) { + const item = row.itemData[itemIndex]; + const lineLabel = `${rowLabel}, line item ${itemIndex + 1}`; + const { + valid, + amountWei, + qtyWei, + unitPriceWei, + discountWei, + taxRateWei, + } = getLineAmountDetails(item); + + if (!valid) { + toast.error(`${lineLabel} has invalid number format`); + return null; + } + if (qtyWei < 0n) { + toast.error(`${lineLabel}: quantity cannot be negative`); + return null; + } + if (unitPriceWei < 0n) { + toast.error(`${lineLabel}: unit price cannot be negative`); + return null; + } + if (discountWei < 0n) { + toast.error(`${lineLabel}: discount cannot be negative`); + return null; + } + if (taxRateWei < 0n) { + toast.error(`${lineLabel}: tax cannot be negative`); + return null; + } + if (amountWei < 0n) { + toast.error( + `${lineLabel} amount cannot be negative. Reduce discount or update values` + ); + return null; + } + } + + const totalWei = parseNumericInputToWei(row.totalAmountDue); + if (totalWei === null || totalWei <= 0n) { + toast.error(`${rowLabel}: invoice total must be greater than 0`); + return null; + } + + if (Number.isInteger(tokenDecimals) && tokenDecimals >= 0) { + try { + ethers.parseUnits(row.totalAmountDue.toString(), tokenDecimals); + } catch { + toast.error( + `${rowLabel}: total supports up to ${tokenDecimals} decimals for ${paymentToken.symbol || "selected token"}` + ); + return null; + } + } + } + + setClientAddressErrors({}); + + const validInvoices = normalizedRows.filter( + (row) => row.clientAddress && parseFloat(row.totalAmountDue) > 0 + ); + + if (validInvoices.length === 0) { + toast.error( + "Please add at least one valid invoice with client address and amount" + ); + return null; + } + + return { validInvoices }; + }; + // Create batch invoices const createInvoicesRequest = async () => { if (!isConnected || !walletClient) { @@ -333,7 +482,6 @@ function CreateInvoicesBatch() { try { setLoading(true); - toast.info("Starting batch invoice creation..."); const provider = new BrowserProvider(walletClient); const signer = await provider.getSigner(); @@ -351,66 +499,14 @@ function CreateInvoicesBatch() { return; } - const normalizedRows = invoiceRows.map((row) => ({ - ...row, - clientAddress: (row.clientAddress || "").trim(), - })); - - const firstInvalidAddressRowIndex = normalizedRows.findIndex((row) => { - const hasMeaningfulInput = - row.clientAddress || parseFloat(row.totalAmountDue) > 0; - if (!hasMeaningfulInput) return false; - return Boolean(getClientAddressError(row.clientAddress, { required: true })); - }); - - if (firstInvalidAddressRowIndex !== -1) { - const addressError = getClientAddressError( - normalizedRows[firstInvalidAddressRowIndex].clientAddress, - { required: true } - ); - toast.error(`Invoice #${firstInvalidAddressRowIndex + 1}: ${addressError}`); - return; - } - - const firstInvalidRowIndex = normalizedRows.findIndex((row) => - row.itemData.some((item) => { - const { - valid, - amountWei, - qtyWei, - unitPriceWei, - discountWei, - taxRateWei, - } = getLineAmountDetails(item); - return ( - !valid || - qtyWei < 0n || - unitPriceWei < 0n || - discountWei < 0n || - taxRateWei < 0n || - amountWei < 0n - ); - }) - ); - - if (firstInvalidRowIndex !== -1) { - toast.error( - `Invoice #${firstInvalidRowIndex + 1} has invalid or negative line items` - ); - return; - } - - // Validate invoices - const validInvoices = normalizedRows.filter( - (row) => row.clientAddress && parseFloat(row.totalAmountDue) > 0 + const validationResult = validateInvoicesBeforeSubmit( + invoiceRows, + paymentToken ); - - if (validInvoices.length === 0) { - toast.error( - "Please add at least one valid invoice with client address and amount" - ); + if (!validationResult) { return; } + const { validInvoices } = validationResult; // Prepare batch arrays const tos = []; @@ -424,14 +520,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, @@ -560,9 +650,6 @@ function CreateInvoicesBatch() { 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}` @@ -582,17 +669,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) { @@ -1072,16 +1154,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]} +
+ )}
From d20f9f2ff77941ca4ba862b0801e1fb4ef1d855b Mon Sep 17 00:00:00 2001 From: Atharva Naik Date: Fri, 27 Mar 2026 15:36:17 +0530 Subject: [PATCH 3/5] test(frontend): add jest coverage for negative invoice validation --- .github/workflows/frontend-tests.yml | 45 ++++ README.md | 47 ++++ frontend/README.md | 41 +++- frontend/jest.config.cjs | 10 + frontend/package.json | 6 +- frontend/src/page/CreateInvoice.jsx | 102 ++------ frontend/src/page/CreateInvoicesBatch.jsx | 150 ++---------- frontend/src/utils/invoiceValidation.js | 228 +++++++++++++++++ .../tests/utils/invoiceCalculations.test.js | 88 +++++++ .../tests/utils/invoiceValidation.test.js | 231 ++++++++++++++++++ 10 files changed, 730 insertions(+), 218 deletions(-) create mode 100644 .github/workflows/frontend-tests.yml create mode 100644 frontend/jest.config.cjs create mode 100644 frontend/src/utils/invoiceValidation.js create mode 100644 frontend/tests/utils/invoiceCalculations.test.js create mode 100644 frontend/tests/utils/invoiceValidation.test.js diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml new file mode 100644 index 00000000..c4299f8f --- /dev/null +++ b/.github/workflows/frontend-tests.yml @@ -0,0 +1,45 @@ +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 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - 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..77f9c4ac 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,52 @@ 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 + +### Negative Invoice Amount Feature: Covered Test Cases + +The Jest suite currently covers all validation and calculation paths for issue #148: + +- Parsing behavior for empty numeric input values +- Parsing behavior for positive and negative decimal values +- Invalid numeric format rejection +- Line amount calculation with qty, unit price, discount, and tax +- Negative line amount generation case (discount greater than line total) +- Safe display clamping of negative amount to zero +- Invalid line input display behavior +- Required client address validation +- Invalid wallet address format validation +- Self-invoicing prevention validation +- Valid client address acceptance +- Single-invoice valid payload acceptance +- Single-invoice negative line amount rejection +- Single-invoice negative quantity rejection +- Single-invoice negative unit price rejection +- Single-invoice negative discount rejection +- Single-invoice negative tax rejection +- Single-invoice zero total rejection +- Single-invoice invalid total format rejection +- Single-invoice token decimal precision overflow rejection +- Batch-invoice valid rows acceptance +- Batch-invoice duplicate wallet detection +- Batch-invoice negative line amount rejection +- Batch-invoice token decimal precision overflow rejection +- Batch-invoice empty or non-meaningful input rejection + +### Current Coverage Snapshot + +- invoiceCalculations.js: 96.55% statements, 95% branches, 100% functions, 96.29% lines +- invoiceValidation.js: 93.25% statements, 86.95% branches, 100% functions, 92.94% lines + ## 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/page/CreateInvoice.jsx b/frontend/src/page/CreateInvoice.jsx index ce4ec04d..a7d10f69 100644 --- a/frontend/src/page/CreateInvoice.jsx +++ b/frontend/src/page/CreateInvoice.jsx @@ -52,8 +52,11 @@ import { getLineAmountDetails, getSafeLineAmountDisplay, INVOICE_DECIMALS, - parseNumericInputToWei, } 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). */ @@ -344,95 +347,34 @@ function CreateInvoice() { }, ]); }; - const getClientAddressError = useCallback((value, options = {}) => { - const { required = false } = 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 (trimmed.toLowerCase() === account.address?.toLowerCase()) { - return "You cannot create an invoice for your own wallet"; - } - - return ""; - }, [account.address]); - const validateClientAddress = useCallback((value, options = {}) => { - const error = getClientAddressError(value, options); + const error = getClientAddressError(value, { + ...options, + ownerAddress: account.address, + }); setClientAddressError(error); return !error; - }, [getClientAddressError]); + }, [account.address]); const validateInvoiceBeforeSubmit = useCallback((data, paymentToken) => { - const addressError = getClientAddressError(data.clientAddress, { required: true }); - if (addressError) { - setClientAddressError(addressError); - toast.error(addressError); - return false; - } - - for (let i = 0; i < itemData.length; i += 1) { - const item = itemData[i]; - const { valid, amountWei, qtyWei, unitPriceWei, discountWei, taxRateWei } = getLineAmountDetails(item); - const lineLabel = `Line item ${i + 1}`; - - if (!valid) { - toast.error(`${lineLabel} has invalid number format`); - return false; - } - - if (qtyWei < 0n) { - toast.error(`${lineLabel}: quantity cannot be negative`); - return false; - } - - if (unitPriceWei < 0n) { - toast.error(`${lineLabel}: unit price cannot be negative`); - return false; - } - - if (discountWei < 0n) { - toast.error(`${lineLabel}: discount cannot be negative`); - return false; - } - - if (taxRateWei < 0n) { - toast.error(`${lineLabel}: tax cannot be negative`); - return false; - } - - if (amountWei < 0n) { - toast.error(`${lineLabel} amount cannot be negative. Reduce discount or update values`); - return false; + const validation = validateSingleInvoiceData({ + clientAddress: data.clientAddress, + itemData, + totalAmountDue, + paymentToken, + ownerAddress: account.address, + }); + + if (!validation.isValid) { + if (validation.fieldErrors.clientAddress) { + setClientAddressError(validation.fieldErrors.clientAddress); } - } - - const totalWei = parseNumericInputToWei(totalAmountDue); - if (totalWei === null || totalWei <= 0n) { - toast.error("Invoice total must be greater than 0"); + toast.error(validation.errorMessage); return false; } - const tokenDecimals = Number(paymentToken?.decimals); - if (paymentToken && Number.isInteger(tokenDecimals) && tokenDecimals >= 0) { - try { - ethers.parseUnits(totalAmountDue.toString(), tokenDecimals); - } catch { - toast.error( - `Invoice total supports up to ${tokenDecimals} decimals for ${paymentToken.symbol || "selected token"}` - ); - return false; - } - } - return true; - }, [getClientAddressError, itemData, totalAmountDue]); + }, [account.address, itemData, totalAmountDue]); const createInvoiceRequest = async (data) => { if (!isConnected || !walletClient) { diff --git a/frontend/src/page/CreateInvoicesBatch.jsx b/frontend/src/page/CreateInvoicesBatch.jsx index 4bac7bd6..ed632d80 100644 --- a/frontend/src/page/CreateInvoicesBatch.jsx +++ b/frontend/src/page/CreateInvoicesBatch.jsx @@ -56,8 +56,11 @@ import { getLineAmountDetails, getSafeLineAmountDisplay, INVOICE_DECIMALS, - parseNumericInputToWei, } from "@/utils/invoiceCalculations"; +import { + getClientAddressError, + validateBatchInvoiceData, +} from "@/utils/invoiceValidation"; function CreateInvoicesBatch() { const { data: walletClient } = useWalletClient(); @@ -321,27 +324,11 @@ function CreateInvoicesBatch() { return "Failed to create invoice batch. Please try again."; }; - const getClientAddressError = (value, options = {}) => { - const { required = false } = 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 (trimmed.toLowerCase() === account.address?.toLowerCase()) { - return "You cannot create an invoice for your own wallet"; - } - - return ""; - }; - const validateClientAddress = (rowIndex, value, options = {}) => { - const error = getClientAddressError(value, options); + const error = getClientAddressError(value, { + ...options, + ownerAddress: account.address, + }); setClientAddressErrors((prev) => { if (!error && !prev[rowIndex]) return prev; const next = { ...prev }; @@ -356,121 +343,20 @@ function CreateInvoicesBatch() { }; const validateInvoicesBeforeSubmit = (rows, paymentToken) => { - const normalizedRows = rows.map((row) => ({ - ...row, - clientAddress: (row.clientAddress || "").trim(), - })); - - const tokenDecimals = Number(paymentToken?.decimals); - const pendingAddressErrors = {}; - const duplicateTracker = new Map(); - - 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, - }); - if (addressError) { - pendingAddressErrors[rowIndex] = addressError; - setClientAddressErrors((prev) => ({ ...prev, ...pendingAddressErrors })); - toast.error(`${rowLabel}: ${addressError}`); - return null; - } - - 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"; - setClientAddressErrors((prev) => ({ ...prev, ...pendingAddressErrors })); - toast.error( - `Duplicate client wallet found in Invoice #${firstIndex + 1} and Invoice #${rowIndex + 1}` - ); - return null; - } - - duplicateTracker.set(normalizedAddress, rowIndex); - - for (let itemIndex = 0; itemIndex < row.itemData.length; itemIndex += 1) { - const item = row.itemData[itemIndex]; - const lineLabel = `${rowLabel}, line item ${itemIndex + 1}`; - const { - valid, - amountWei, - qtyWei, - unitPriceWei, - discountWei, - taxRateWei, - } = getLineAmountDetails(item); - - if (!valid) { - toast.error(`${lineLabel} has invalid number format`); - return null; - } - if (qtyWei < 0n) { - toast.error(`${lineLabel}: quantity cannot be negative`); - return null; - } - if (unitPriceWei < 0n) { - toast.error(`${lineLabel}: unit price cannot be negative`); - return null; - } - if (discountWei < 0n) { - toast.error(`${lineLabel}: discount cannot be negative`); - return null; - } - if (taxRateWei < 0n) { - toast.error(`${lineLabel}: tax cannot be negative`); - return null; - } - if (amountWei < 0n) { - toast.error( - `${lineLabel} amount cannot be negative. Reduce discount or update values` - ); - return null; - } - } - - const totalWei = parseNumericInputToWei(row.totalAmountDue); - if (totalWei === null || totalWei <= 0n) { - toast.error(`${rowLabel}: invoice total must be greater than 0`); - return null; - } - - if (Number.isInteger(tokenDecimals) && tokenDecimals >= 0) { - try { - ethers.parseUnits(row.totalAmountDue.toString(), tokenDecimals); - } catch { - toast.error( - `${rowLabel}: total supports up to ${tokenDecimals} decimals for ${paymentToken.symbol || "selected token"}` - ); - return null; - } - } - } - - setClientAddressErrors({}); - - const validInvoices = normalizedRows.filter( - (row) => row.clientAddress && parseFloat(row.totalAmountDue) > 0 - ); + const validation = validateBatchInvoiceData({ + rows, + paymentToken, + ownerAddress: account.address, + }); - if (validInvoices.length === 0) { - toast.error( - "Please add at least one valid invoice with client address and amount" - ); + if (!validation.isValid) { + setClientAddressErrors(validation.addressErrors || {}); + toast.error(validation.errorMessage); return null; } - return { validInvoices }; + setClientAddressErrors({}); + return { validInvoices: validation.validInvoices }; }; // Create batch invoices 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" + ); + }); +}); From b29b4d77d3d7fb7ade456538a352f270bb435831 Mon Sep 17 00:00:00 2001 From: Atharva Naik Date: Fri, 27 Mar 2026 15:40:23 +0530 Subject: [PATCH 4/5] ci: remove setup-node cache dependency path for frontend tests --- .github/workflows/frontend-tests.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index c4299f8f..a606d6f2 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -30,8 +30,6 @@ jobs: uses: actions/setup-node@v4 with: node-version: 20 - cache: npm - cache-dependency-path: frontend/package-lock.json - name: Install dependencies run: | From ec3f65ea4ead1a562e148e90162cce030a685907 Mon Sep 17 00:00:00 2001 From: Atharva Naik Date: Fri, 27 Mar 2026 15:47:39 +0530 Subject: [PATCH 5/5] docs: remove detailed negative-invoice test matrix from root readme --- README.md | 35 ----------------------------------- 1 file changed, 35 deletions(-) diff --git a/README.md b/README.md index 77f9c4ac..11ca7d58 100644 --- a/README.md +++ b/README.md @@ -101,41 +101,6 @@ npm test 3. **Run tests with coverage (CI mode)** npm run test:ci -### Negative Invoice Amount Feature: Covered Test Cases - -The Jest suite currently covers all validation and calculation paths for issue #148: - -- Parsing behavior for empty numeric input values -- Parsing behavior for positive and negative decimal values -- Invalid numeric format rejection -- Line amount calculation with qty, unit price, discount, and tax -- Negative line amount generation case (discount greater than line total) -- Safe display clamping of negative amount to zero -- Invalid line input display behavior -- Required client address validation -- Invalid wallet address format validation -- Self-invoicing prevention validation -- Valid client address acceptance -- Single-invoice valid payload acceptance -- Single-invoice negative line amount rejection -- Single-invoice negative quantity rejection -- Single-invoice negative unit price rejection -- Single-invoice negative discount rejection -- Single-invoice negative tax rejection -- Single-invoice zero total rejection -- Single-invoice invalid total format rejection -- Single-invoice token decimal precision overflow rejection -- Batch-invoice valid rows acceptance -- Batch-invoice duplicate wallet detection -- Batch-invoice negative line amount rejection -- Batch-invoice token decimal precision overflow rejection -- Batch-invoice empty or non-meaningful input rejection - -### Current Coverage Snapshot - -- invoiceCalculations.js: 96.55% statements, 95% branches, 100% functions, 96.29% lines -- invoiceValidation.js: 93.25% statements, 86.95% branches, 100% functions, 92.94% lines - ## Smart Contract Testing > **Prerequisites:** [Foundry](https://getfoundry.sh/) must be installed