diff --git a/.github/workflows/nextjs.yml b/.github/workflows/deploy.yml similarity index 51% rename from .github/workflows/nextjs.yml rename to .github/workflows/deploy.yml index ed747367..69ef6f2d 100644 --- a/.github/workflows/nextjs.yml +++ b/.github/workflows/deploy.yml @@ -1,14 +1,11 @@ -# Sample workflow for building and deploying a Next.js site to GitHub Pages -# -# To get started with Next.js see: https://nextjs.org/docs/getting-started -# -name: Deploy Next.js site to Pages +# Sample workflow for building and deploying a Vite React site to GitHub Pages +# This workflow handles a monorepo structure with frontend in a subfolder +name: Deploy Vite React site to Pages on: # Runs on pushes targeting the default branch push: branches: ["main"] - # Allows you to run this workflow manually from the Actions tab workflow_dispatch: @@ -25,62 +22,75 @@ concurrency: cancel-in-progress: false jobs: - # Build job + # Build job - compiles the React app using Vite build: runs-on: ubuntu-latest steps: + # Download repository code - name: Checkout uses: actions/checkout@v4 + + # Detect package manager with robust lockfile handling - name: Detect package manager id: detect-package-manager run: | - if [ -f "${{ github.workspace }}/yarn.lock" ]; then + FRONTEND="${{ github.workspace }}/frontend" + # Check for yarn.lock first in frontend directory + if [ -f "$FRONTEND/yarn.lock" ]; then echo "manager=yarn" >> $GITHUB_OUTPUT echo "command=install" >> $GITHUB_OUTPUT echo "runner=yarn" >> $GITHUB_OUTPUT + echo "lockfile_path=frontend/yarn.lock" >> $GITHUB_OUTPUT exit 0 - elif [ -f "${{ github.workspace }}/package.json" ]; then + # Check for package-lock.json (prefer npm ci when lockfile exists) + elif [ -f "$FRONTEND/package-lock.json" ]; then echo "manager=npm" >> $GITHUB_OUTPUT echo "command=ci" >> $GITHUB_OUTPUT - echo "runner=npx --no-install" >> $GITHUB_OUTPUT + echo "runner=npm" >> $GITHUB_OUTPUT + echo "lockfile_path=frontend/package-lock.json" >> $GITHUB_OUTPUT + exit 0 + # Fallback to npm install when only package.json exists + elif [ -f "$FRONTEND/package.json" ]; then + echo "manager=npm" >> $GITHUB_OUTPUT + echo "command=install" >> $GITHUB_OUTPUT + echo "runner=npm" >> $GITHUB_OUTPUT + echo "lockfile_path=" >> $GITHUB_OUTPUT exit 0 else - echo "Unable to determine package manager" + # Neither found - fail the build + echo "Unable to determine package manager (missing frontend/)" exit 1 fi + + # Setup Node.js environment with built-in caching - name: Setup Node uses: actions/setup-node@v4 with: node-version: "20" cache: ${{ steps.detect-package-manager.outputs.manager }} + cache-dependency-path: ${{ steps.detect-package-manager.outputs.lockfile_path }} + + # Configure GitHub Pages - name: Setup Pages uses: actions/configure-pages@v5 - with: - # Automatically inject basePath in your Next.js configuration file and disable - # server side image optimization (https://nextjs.org/docs/api-reference/next/image#unoptimized). - # - # You may remove this line if you want to manage the configuration yourself. - static_site_generator: next - - name: Restore cache - uses: actions/cache@v4 - with: - path: | - .next/cache - # Generate a new cache whenever packages or source files change. - key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }} - # If source files changed but packages didn't, rebuild from a prior cache. - restore-keys: | - ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}- + + # Install project dependencies using detected package manager - name: Install dependencies run: ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }} - - name: Build with Next.js - run: ${{ steps.detect-package-manager.outputs.runner }} next build + working-directory: ./frontend + + # Build the React app using Vite + - name: Build with Vite + run: ${{ steps.detect-package-manager.outputs.runner }} run build + working-directory: ./frontend + + # Upload the built files for deployment - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: - path: ./out + path: ./frontend/dist - # Deployment job + # Deployment job - deploys the built files to GitHub Pages deploy: environment: name: github-pages @@ -88,6 +98,7 @@ jobs: runs-on: ubuntu-latest needs: build steps: + # Deploy the uploaded artifact to GitHub Pages - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 diff --git a/contracts/src/Chainvoice.sol b/contracts/src/Chainvoice.sol index 662a07ad..38cb04cd 100644 --- a/contracts/src/Chainvoice.sol +++ b/contracts/src/Chainvoice.sol @@ -2,73 +2,85 @@ pragma solidity ^0.8.13; interface IERC20 { - function transferFrom( - address sender, - address recipient, - uint256 amount - ) external returns (bool); + function transferFrom(address sender, address recipient, uint256 amount) external returns (bool); function balanceOf(address account) external view returns (uint256); - function allowance( - address owner, - address spender - ) external view returns (uint256); + function allowance(address owner, address spender) external view returns (uint256); } contract Chainvoice { + // Errors + error MixedTokenBatch(); + error InvalidBatchSize(); + error AlreadySettled(); + error NotAuthorizedPayer(); + error IncorrectNativeValue(); + error InsufficientAllowance(); + + // Storage struct InvoiceDetails { uint256 id; address from; address to; uint256 amountDue; - address tokenAddress; + address tokenAddress; // address(0) == native bool isPaid; bool isCancelled; - string encryptedInvoiceData; // Base64-encoded ciphertext - string encryptedHash; + string encryptedInvoiceData; // Base64-encoded ciphertext + string encryptedHash; // Content hash or integrity ref } InvoiceDetails[] public invoices; - mapping(address => uint256[]) public sentInvoices; mapping(address => uint256[]) public receivedInvoices; address public owner; address public treasuryAddress; - uint256 public fee; - uint256 public accumulatedFees; - - event InvoiceCreated( - uint256 indexed id, - address indexed from, - address indexed to, - address tokenAddress - ); - - event InvoicePaid( - uint256 indexed id, - address indexed from, - address indexed to, - uint256 amount, - address tokenAddress - ); - - event InvoiceCancelled( - uint256 indexed id, - address indexed from, - address indexed to, - address tokenAddress - ); + uint256 public fee; // native fee per invoice + uint256 public accumulatedFees; // native fees accrued (for withdraw) + + // Events + event InvoiceCreated(uint256 indexed id, address indexed from, address indexed to, address tokenAddress); + event InvoicePaid(uint256 indexed id, address indexed from, address indexed to, uint256 amount, address tokenAddress); + event InvoiceCancelled(uint256 indexed id, address indexed from, address indexed to, address tokenAddress); + event InvoiceBatchCreated(address indexed creator, address indexed token, uint256 count, uint256[] ids); + event InvoiceBatchPaid(address indexed payer, address indexed token, uint256 count, uint256 totalAmount, uint256[] ids); + + // Constructor constructor() { owner = msg.sender; fee = 0.0005 ether; } + // Modifiers modifier onlyOwner() { require(msg.sender == owner, "Only owner can call"); _; } + // Simple non-reentrancy guard + bool private _entered; + modifier nonReentrant() { + require(!_entered, "Reentrancy"); + _entered = true; + _; + _entered = false; + } + + // Constants + uint256 public constant MAX_BATCH = 50; + + // Internal utils + function _isERC20(address token) internal view returns (bool) { + if (token == address(0)) return false; + if (token.code.length == 0) return false; + (bool success, ) = token.staticcall( + abi.encodeWithSignature("balanceOf(address)", address(this)) + ); + return success; + } + + // ========== Single-invoice create ========== function createInvoice( address to, uint256 amountDue, @@ -86,6 +98,7 @@ contract Chainvoice { ); require(success, "Not an ERC20 token"); } + uint256 invoiceId = invoices.length; invoices.push( @@ -108,20 +121,86 @@ contract Chainvoice { emit InvoiceCreated(invoiceId, msg.sender, to, tokenAddress); } + // ========== Batch create ========== + function createInvoicesBatch( + address[] calldata tos, + uint256[] calldata amountsDue, + address tokenAddress, + string[] calldata encryptedPayloads, + string[] calldata encryptedHashes + ) external { + uint256 n = tos.length; + if (n == 0 || n > MAX_BATCH) revert InvalidBatchSize(); + require( + n == amountsDue.length && + n == encryptedPayloads.length && + n == encryptedHashes.length, + "Array length mismatch" + ); + + if (tokenAddress != address(0)) { + require(tokenAddress.code.length > 0, "Not a contract address"); + (bool ok, ) = tokenAddress.staticcall( + abi.encodeWithSignature("balanceOf(address)", address(this)) + ); + require(ok, "Not an ERC20 token"); + } + + uint256[] memory ids = new uint256[](n); + + for (uint256 i = 0; i < n; i++) { + address to = tos[i]; + require(to != address(0), "Recipient zero"); + require(to != msg.sender, "Self-invoicing"); + uint256 amt = amountsDue[i]; + require(amt > 0, "Amount zero"); + + uint256 invoiceId = invoices.length; + + invoices.push( + InvoiceDetails({ + id: invoiceId, + from: msg.sender, + to: to, + amountDue: amt, + tokenAddress: tokenAddress, + isPaid: false, + isCancelled: false, + encryptedInvoiceData: encryptedPayloads[i], + encryptedHash: encryptedHashes[i] + }) + ); + + sentInvoices[msg.sender].push(invoiceId); + receivedInvoices[to].push(invoiceId); + + emit InvoiceCreated(invoiceId, msg.sender, to, tokenAddress); + ids[i] = invoiceId; + } + + emit InvoiceBatchCreated(msg.sender, tokenAddress, n, ids); + } + + // ========== Cancel single invoice ========== function cancelInvoice(uint256 invoiceId) external { require(invoiceId < invoices.length, "Invalid invoice ID"); InvoiceDetails storage invoice = invoices[invoiceId]; require(msg.sender == invoice.from, "Only invoice creator can cancel"); - require( - !invoice.isPaid && !invoice.isCancelled, - "Invoice not cancellable" - ); + require(!invoice.isPaid && !invoice.isCancelled, "Invoice not cancellable"); + invoice.isCancelled = true; - emit InvoiceCancelled(invoiceId, invoice.from, invoice.to, invoice.tokenAddress); + + emit InvoiceCancelled( + invoiceId, + invoice.from, + invoice.to, + invoice.tokenAddress + ); } - function payInvoice(uint256 invoiceId) external payable { + // ========== Pay single invoice ========== + function payInvoice(uint256 invoiceId) external payable nonReentrant { require(invoiceId < invoices.length, "Invalid invoice ID"); InvoiceDetails storage invoice = invoices[invoiceId]; @@ -129,31 +208,24 @@ contract Chainvoice { require(!invoice.isPaid, "Already paid"); require(!invoice.isCancelled, "Invoice is cancelled"); + // Effects first for CEI (mark paid, bump fees), then interactions + invoice.isPaid = true; + if (invoice.tokenAddress == address(0)) { - // Native token (ETH) payment - require( - msg.value == invoice.amountDue + fee, - "Incorrect payment amount" - ); + require(msg.value == invoice.amountDue + fee, "Incorrect payment amount"); accumulatedFees += fee; - uint256 amountToSender = msg.value - fee; - (bool sent, ) = payable(invoice.from).call{value: amountToSender}( - "" - ); + (bool sent, ) = payable(invoice.from).call{value: invoice.amountDue}(""); require(sent, "Transfer failed"); } else { - // ERC20 token payment require(msg.value == fee, "Must pay fee in native token"); require( - IERC20(invoice.tokenAddress).allowance( - msg.sender, - address(this) - ) >= invoice.amountDue, + IERC20(invoice.tokenAddress).allowance(msg.sender, address(this)) >= invoice.amountDue, "Insufficient allowance" ); accumulatedFees += fee; + bool transferSuccess = IERC20(invoice.tokenAddress).transferFrom( msg.sender, invoice.from, @@ -162,7 +234,6 @@ contract Chainvoice { require(transferSuccess, "Token transfer failed"); } - invoice.isPaid = true; emit InvoicePaid( invoiceId, invoice.from, @@ -172,6 +243,81 @@ contract Chainvoice { ); } + // ========== Batch pay (all-or-nothing) ========== + function payInvoicesBatch(uint256[] calldata invoiceIds) external payable nonReentrant { + uint256 n = invoiceIds.length; + if (n == 0 || n > MAX_BATCH) revert InvalidBatchSize(); + + // Establish token for batch & initial checks + uint256 firstId = invoiceIds[0]; // FIX: index into the array + require(firstId < invoices.length, "Invalid id"); + + InvoiceDetails storage inv0 = invoices[firstId]; + if (msg.sender != inv0.to) revert NotAuthorizedPayer(); + if (inv0.isPaid || inv0.isCancelled) revert AlreadySettled(); + + address token = inv0.tokenAddress; + + uint256 totalAmounts = 0; + uint256 totalNativeFee = fee * n; + + // Validate and sum + for (uint256 i = 0; i < n; i++) { + uint256 id = invoiceIds[i]; + require(id < invoices.length, "Invalid id"); + + InvoiceDetails storage inv = invoices[id]; + + if (msg.sender != inv.to) revert NotAuthorizedPayer(); + if (inv.isPaid || inv.isCancelled) revert AlreadySettled(); + if (inv.tokenAddress != token) revert MixedTokenBatch(); + + totalAmounts += inv.amountDue; + } + + // Effects: mark all paid & bump fee accumulator BEFORE interactions + for (uint256 i = 0; i < n; i++) { + invoices[invoiceIds[i]].isPaid = true; + } + accumulatedFees += totalNativeFee; + + // Interactions + if (token == address(0)) { + // Native: must include amounts + total fee + if (msg.value != (totalAmounts + totalNativeFee)) revert IncorrectNativeValue(); + + // Pay each issuer + for (uint256 i = 0; i < n; i++) { + InvoiceDetails storage inv = invoices[invoiceIds[i]]; + (bool sent, ) = payable(inv.from).call{value: inv.amountDue}(""); + require(sent, "Native transfer failed"); + emit InvoicePaid(inv.id, inv.from, inv.to, inv.amountDue, address(0)); + } + } else { + // ERC-20: fee in native, token from allowance + if (msg.value != totalNativeFee) revert IncorrectNativeValue(); + + IERC20 erc20 = IERC20(token); + if (erc20.allowance(msg.sender, address(this)) < totalAmounts) { + revert InsufficientAllowance(); + } + + for (uint256 i = 0; i < n; i++) { + InvoiceDetails storage inv = invoices[invoiceIds[i]]; + bool ok = erc20.transferFrom(msg.sender, inv.from, inv.amountDue); + require(ok, "Token transfer failed"); + emit InvoicePaid(inv.id, inv.from, inv.to, inv.amountDue, token); + } + } + + // Emit batch summary; dynamic array emitted from memory + uint256[] memory idsCopy = new uint256[](n); + for (uint256 i = 0; i < n; i++) idsCopy[i] = invoiceIds[i]; + + emit InvoiceBatchPaid(msg.sender, token, n, totalAmounts, idsCopy); + } + + // ========== Views ========== function getPaymentStatus( uint256 invoiceId, address payer @@ -182,46 +328,38 @@ contract Chainvoice { { require(invoiceId < invoices.length, "Invalid invoice ID"); InvoiceDetails memory invoice = invoices[invoiceId]; + if (invoice.isCancelled) { - return (false, (payer).balance, 0); + return (false, payer.balance, 0); } if (invoice.tokenAddress == address(0)) { + // Native return ( payer.balance >= invoice.amountDue + fee, payer.balance, - type(uint256).max // Native token has no allowance + type(uint256).max // Native has no allowance ); } else { + uint256 bal = IERC20(invoice.tokenAddress).balanceOf(payer); + uint256 allw = IERC20(invoice.tokenAddress).allowance(payer, address(this)); return ( - IERC20(invoice.tokenAddress).balanceOf(payer) >= - invoice.amountDue && - IERC20(invoice.tokenAddress).allowance( - payer, - address(this) - ) >= - invoice.amountDue, - IERC20(invoice.tokenAddress).balanceOf(payer), - IERC20(invoice.tokenAddress).allowance(payer, address(this)) + bal >= invoice.amountDue && allw >= invoice.amountDue, + bal, + allw ); } } - function getSentInvoices( - address user - ) external view returns (InvoiceDetails[] memory) { + function getSentInvoices(address user) external view returns (InvoiceDetails[] memory) { return _getInvoices(sentInvoices[user]); } - function getReceivedInvoices( - address user - ) external view returns (InvoiceDetails[] memory) { + function getReceivedInvoices(address user) external view returns (InvoiceDetails[] memory) { return _getInvoices(receivedInvoices[user]); } - function _getInvoices( - uint256[] storage ids - ) internal view returns (InvoiceDetails[] memory) { + function _getInvoices(uint256[] storage ids) internal view returns (InvoiceDetails[] memory) { InvoiceDetails[] memory result = new InvoiceDetails[](ids.length); for (uint256 i = 0; i < ids.length; i++) { result[i] = invoices[ids[i]]; @@ -229,13 +367,12 @@ contract Chainvoice { return result; } - function getInvoice( - uint256 invoiceId - ) external view returns (InvoiceDetails memory) { + function getInvoice(uint256 invoiceId) external view returns (InvoiceDetails memory) { require(invoiceId < invoices.length, "Invalid ID"); return invoices[invoiceId]; } + // ========== Admin ========== function setFeeAmount(uint256 _fee) external onlyOwner { fee = _fee; } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 67637337..3c0898db 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -33,6 +33,8 @@ export const config = getDefaultConfig({ const queryClient = new QueryClient(); import { Toaster } from "react-hot-toast"; import GenerateLink from "./page/GenerateLink"; +import CreateInvoicesBatch from "./page/CreateInvoicesBatch"; +import BatchPayment from "./page/BatchPayment"; // New import needed function App() { return ( @@ -83,7 +85,11 @@ function App() { } /> } /> } /> - }/> + } /> + } + /> } /> } /> diff --git a/frontend/src/components/TokenCrousel.jsx b/frontend/src/components/TokenCrousel.jsx index d2e5aa53..980e3360 100644 --- a/frontend/src/components/TokenCrousel.jsx +++ b/frontend/src/components/TokenCrousel.jsx @@ -1,12 +1,15 @@ import { useEffect, useRef } from "react"; import { motion } from "framer-motion"; import { SiEthereum } from "react-icons/si"; -// import { TOKEN_PRESETS } from "@/utils/erc20_token"; +import { useTokenList } from "../hooks/useTokenList"; const TokenCarousel = () => { const carouselRef = useRef(); - const duplicatedTokens = [...TOKEN_PRESETS, ...TOKEN_PRESETS]; // Double the tokens for seamless loop - +const { + tokens, + } = useTokenList('1'); + const duplicatedTokens = [...tokens]; + console.log(tokens); useEffect(() => { const carousel = carouselRef.current; let animationFrame; @@ -54,11 +57,15 @@ const TokenCarousel = () => {
{token.symbol} { - e.target.src = "/tokenImages/default.png"; + e.target.src = "/tokenImages/generic.png"; }} /> {token.address === diff --git a/frontend/src/page/BatchPayment.jsx b/frontend/src/page/BatchPayment.jsx new file mode 100644 index 00000000..0b2ff552 --- /dev/null +++ b/frontend/src/page/BatchPayment.jsx @@ -0,0 +1,1603 @@ +// pages/BatchPayment.jsx - Complete Enhanced Version +import React, { useEffect, useState, useRef } from "react"; +import { ChainvoiceABI } from "../contractsABI/ChainvoiceABI"; +import { BrowserProvider, Contract, ethers } from "ethers"; +import { useAccount, useWalletClient } from "wagmi"; +import SwipeableDrawer from "@mui/material/SwipeableDrawer"; +import html2canvas from "html2canvas"; +import { LitNodeClient } from "@lit-protocol/lit-node-client"; +import { decryptToString } from "@lit-protocol/encryption/src/lib/encryption.js"; +import { LIT_ABILITY, LIT_NETWORK } from "@lit-protocol/constants"; +import { + createSiweMessageWithRecaps, + generateAuthSig, + LitAccessControlConditionResource, +} from "@lit-protocol/auth-helpers"; +import { ERC20_ABI } from "../contractsABI/ERC20_ABI"; +import { toast } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; +import { + CheckCircle2, + Loader2, + X, + Eye, + Download, + CreditCard, + Users, + DollarSign, + Clock, + AlertTriangle, + Lightbulb, + Layers, +} from "lucide-react"; +import { useTokenList } from "../hooks/useTokenList"; +import WalletConnectionAlert from "../components/WalletConnectionAlert"; + +function BatchPayment() { + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(10); + const { data: walletClient } = useWalletClient(); + const { address, isConnected, chainId } = useAccount(); + const [loading, setLoading] = useState(true); + const [receivedInvoices, setReceivedInvoices] = useState([]); + const [selectedInvoices, setSelectedInvoices] = useState(new Set()); + const [batchLoading, setBatchLoading] = useState(false); + const [fee, setFee] = useState(0); + const [error, setError] = useState(null); + const [litReady, setLitReady] = useState(false); + const litClientRef = useRef(null); + const [paymentLoading, setPaymentLoading] = useState({}); + const [networkLoading, setNetworkLoading] = useState(false); + const [showWalletAlert, setShowWalletAlert] = useState(!isConnected); + const [balanceErrors, setBalanceErrors] = useState([]); + const [batchSuggestions, setBatchSuggestions] = useState([]); + + // Drawer state (exact same as ReceivedInvoice) + const [drawerState, setDrawerState] = useState({ + open: false, + selectedInvoice: null, + }); + + // Get tokens from the hook + const { tokens } = useTokenList(chainId || 1); + + const handleChangePage = (event, newPage) => { + setPage(newPage); + }; + + const handleChangeRowsPerPage = (event) => { + setRowsPerPage(+event.target.value); + setPage(0); + }; + + // Helper function to get token info + const getTokenInfo = (tokenAddress) => { + if (!tokens || tokens.length === 0) return null; + return tokens.find( + (token) => + token.contract_address?.toLowerCase() === tokenAddress?.toLowerCase() || + token.address?.toLowerCase() === tokenAddress?.toLowerCase() + ); + }; + + // Helper function to get token symbol + const getTokenSymbol = (tokenAddress, fallbackSymbol = "TOKEN") => { + const tokenInfo = getTokenInfo(tokenAddress); + return tokenInfo?.symbol || fallbackSymbol; + }; + + // Detect batch information from invoice metadata + const detectBatchFromMetadata = (invoice) => { + if (invoice.batchInfo) { + return { + batchId: invoice.batchInfo.batchId, + batchSize: invoice.batchInfo.batchSize, + index: invoice.batchInfo.index, + batchType: invoice.batchInfo.batchType, + }; + } + return null; + }; + + // Find smart batch suggestions + const findBatchSuggestions = (invoices) => { + const suggestions = []; + + // Group by sender + same day + same token for unpaid invoices + const groups = invoices + .filter((inv) => !inv.isPaid && !inv.isCancelled) + .reduce((acc, inv) => { + const issueDate = new Date(inv.issueDate).toDateString(); + const key = `${inv.user?.address}_${ + inv.paymentToken?.address || "ETH" + }_${issueDate}`; + if (!acc[key]) acc[key] = []; + acc[key].push(inv); + return acc; + }, {}); + + // Suggest batches for groups with 2+ invoices + Object.entries(groups).forEach(([key, invoices]) => { + if (invoices.length >= 2) { + const totalAmount = invoices.reduce( + (sum, inv) => sum + parseFloat(inv.amountDue), + 0 + ); + suggestions.push({ + id: key, + invoices, + sender: invoices[0].user, + token: invoices[0].paymentToken, + totalAmount, + reason: `${invoices.length} invoices from same sender on same day`, + type: "same_day_sender", + }); + } + }); + + // Also suggest batches by same token type (different approach) + const tokenGroups = invoices + .filter((inv) => !inv.isPaid && !inv.isCancelled) + .reduce((acc, inv) => { + const tokenAddress = inv.paymentToken?.address || "ETH"; + if (!acc[tokenAddress]) acc[tokenAddress] = []; + acc[tokenAddress].push(inv); + return acc; + }, {}); + + Object.entries(tokenGroups).forEach(([tokenAddress, invoices]) => { + if (invoices.length >= 3) { + const totalAmount = invoices.reduce( + (sum, inv) => sum + parseFloat(inv.amountDue), + 0 + ); + const symbol = invoices[0].paymentToken?.symbol || "ETH"; + suggestions.push({ + id: `token_${tokenAddress}`, + invoices, + token: invoices[0].paymentToken, + totalAmount, + reason: `${invoices.length} invoices payable in ${symbol}`, + type: "same_token", + }); + } + }); + + return suggestions; + }; + + // Balance check function + const checkPaymentCapability = async (group, signer) => { + const { tokenAddress, symbol, invoices, totalAmount } = group; + const userAddress = await signer.getAddress(); + + if (tokenAddress === ethers.ZeroAddress) { + // Check ETH balance + const balance = await signer.provider.getBalance(userAddress); + const totalFee = BigInt(fee) * BigInt(invoices.length); + const totalRequired = + ethers.parseUnits(totalAmount.toString(), 18) + totalFee; + + if (balance < totalRequired) { + throw new Error( + `Insufficient ETH balance. Required: ${ethers.formatEther( + totalRequired + )} ETH, Available: ${ethers.formatEther(balance)} ETH` + ); + } + } else { + // Check ERC20 balance + const tokenContract = new Contract(tokenAddress, ERC20_ABI, signer); + const balance = await tokenContract.balanceOf(userAddress); + const decimals = await tokenContract.decimals(); + const requiredAmount = ethers.parseUnits( + totalAmount.toString(), + decimals + ); + + if (balance < requiredAmount) { + const availableFormatted = ethers.formatUnits(balance, decimals); + throw new Error( + `Insufficient ${symbol} balance. Required: ${totalAmount} ${symbol}, Available: ${availableFormatted} ${symbol}` + ); + } + + // Check ETH balance for fees + const ethBalance = await signer.provider.getBalance(userAddress); + const totalFee = BigInt(fee) * BigInt(invoices.length); + + if (ethBalance < totalFee) { + throw new Error( + `Insufficient ETH for fees. Required: ${ethers.formatEther( + totalFee + )} ETH, Available: ${ethers.formatEther(ethBalance)} ETH` + ); + } + } + }; + + // Select batch suggestion + const selectBatchSuggestion = (suggestion) => { + const invoiceIds = suggestion.invoices.map((inv) => inv.id); + setSelectedInvoices(new Set(invoiceIds)); + toast.success(`Selected ${invoiceIds.length} invoices for batch payment`); + }; + + // Pay entire batch by batch ID + const payEntireBatch = async (batchId) => { + const batchInvoices = receivedInvoices.filter( + (inv) => + inv.batchInfo?.batchId === batchId && !inv.isPaid && !inv.isCancelled + ); + + if (batchInvoices.length === 0) { + toast.error("No unpaid invoices found in this batch"); + return; + } + + setSelectedInvoices(new Set(batchInvoices.map((inv) => inv.id))); + toast.info( + `Selected ${batchInvoices.length} invoices from batch #${batchId}` + ); + + // Auto-trigger batch payment after selection + setTimeout(() => { + handleBatchPayment(); + }, 1000); + }; + + // Batch selection handlers + const handleSelectInvoice = (invoiceId) => { + const invoice = receivedInvoices.find((inv) => inv.id === invoiceId); + if (invoice?.isPaid || invoice?.isCancelled) return; + + setSelectedInvoices((prev) => { + const newSet = new Set(prev); + if (newSet.has(invoiceId)) { + newSet.delete(invoiceId); + } else { + newSet.add(invoiceId); + } + return newSet; + }); + }; + + const handleSelectAll = () => { + const unpaidInvoices = receivedInvoices.filter( + (inv) => !inv.isPaid && !inv.isCancelled + ); + setSelectedInvoices(new Set(unpaidInvoices.map((inv) => inv.id))); + }; + + const handleClearAll = () => { + setSelectedInvoices(new Set()); + }; + + // Group selected invoices by token + const getGroupedInvoices = () => { + const grouped = new Map(); + + receivedInvoices.forEach((invoice) => { + if (!selectedInvoices.has(invoice.id)) return; + + const tokenAddress = invoice.paymentToken?.address || ethers.ZeroAddress; + const tokenKey = `${tokenAddress}_${ + invoice.paymentToken?.symbol || "ETH" + }`; + + if (!grouped.has(tokenKey)) { + grouped.set(tokenKey, { + tokenAddress, + symbol: invoice.paymentToken?.symbol || "ETH", + logo: invoice.paymentToken?.logo, + decimals: invoice.paymentToken?.decimals || 18, + invoices: [], + totalAmount: 0, + }); + } + + const group = grouped.get(tokenKey); + group.invoices.push(invoice); + group.totalAmount += parseFloat(invoice.amountDue); + }); + + return grouped; + }; + + // Initialize Lit Protocol + useEffect(() => { + const initLit = async () => { + try { + setLoading(true); + if (!litClientRef.current) { + const client = new LitNodeClient({ + litNetwork: LIT_NETWORK.DatilDev, + debug: false, + }); + await client.connect(); + litClientRef.current = client; + setLitReady(true); + } + } catch (error) { + console.error("Error initializing Lit client:", error); + } finally { + setLoading(false); + } + }; + initLit(); + }, []); + + useEffect(() => { + setShowWalletAlert(!isConnected); + }, [isConnected]); + + // Fetch invoices with batch awareness + useEffect(() => { + if (!walletClient || !address || !litReady) return; + + const fetchReceivedInvoices = async () => { + try { + setLoading(true); + setError(null); + const provider = new BrowserProvider(walletClient); + const signer = await provider.getSigner(); + const network = await provider.getNetwork(); + + if (network.chainId != 11155111) { + setError( + `You're connected to ${network.name}. Please switch to Sepolia network to view your invoices.` + ); + setLoading(false); + return; + } + + const litNodeClient = litClientRef.current; + if (!litNodeClient) { + alert("Lit client not initialized"); + return; + } + + const contract = new Contract( + import.meta.env.VITE_CONTRACT_ADDRESS, + ChainvoiceABI, + signer + ); + + const res = await contract.getReceivedInvoices(address); + + if (!res || !Array.isArray(res) || res.length === 0) { + setReceivedInvoices([]); + setLoading(false); + return; + } + + const decryptedInvoices = []; + + for (const invoice of res) { + try { + const id = invoice[0]; + const from = invoice[1].toLowerCase(); + const to = invoice[2].toLowerCase(); + const isPaid = invoice[5]; + const isCancelled = invoice[6]; + const encryptedStringBase64 = invoice[7]; + const dataToEncryptHash = invoice[8]; + + if (!encryptedStringBase64 || !dataToEncryptHash) continue; + + const currentUserAddress = address.toLowerCase(); + if (currentUserAddress !== from && currentUserAddress !== to) { + continue; + } + + const ciphertext = atob(encryptedStringBase64); + const accessControlConditions = [ + { + contractAddress: "", + standardContractType: "", + chain: "ethereum", + method: "", + parameters: [":userAddress"], + returnValueTest: { + comparator: "=", + value: from, + }, + }, + { operator: "or" }, + { + contractAddress: "", + standardContractType: "", + chain: "ethereum", + method: "", + parameters: [":userAddress"], + returnValueTest: { + comparator: "=", + value: to, + }, + }, + ]; + + const sessionSigs = await litNodeClient.getSessionSigs({ + chain: "ethereum", + resourceAbilityRequests: [ + { + resource: new LitAccessControlConditionResource("*"), + ability: LIT_ABILITY.AccessControlConditionDecryption, + }, + ], + authNeededCallback: async ({ + uri, + expiration, + resourceAbilityRequests, + }) => { + const nonce = await litNodeClient.getLatestBlockhash(); + const toSign = await createSiweMessageWithRecaps({ + uri, + expiration, + resources: resourceAbilityRequests, + walletAddress: address, + nonce, + litNodeClient, + }); + return await generateAuthSig({ signer, toSign }); + }, + }); + + const decryptedString = await decryptToString( + { + accessControlConditions, + chain: "ethereum", + ciphertext, + dataToEncryptHash, + sessionSigs, + }, + litNodeClient + ); + + const parsed = JSON.parse(decryptedString); + parsed["id"] = id; + parsed["isPaid"] = isPaid; + parsed["isCancelled"] = isCancelled; + + // Detect batch information + const batchInfo = detectBatchFromMetadata(parsed); + if (batchInfo) { + parsed.batchInfo = batchInfo; + } + + // Enhanced token info + if (parsed.paymentToken?.address) { + const tokenInfo = getTokenInfo(parsed.paymentToken.address); + if (tokenInfo) { + parsed.paymentToken = { + ...parsed.paymentToken, + logo: tokenInfo.image || tokenInfo.logo, + decimals: tokenInfo.decimals || parsed.paymentToken.decimals, + name: tokenInfo.name || parsed.paymentToken.name, + symbol: tokenInfo.symbol || parsed.paymentToken.symbol, + }; + } else { + try { + const tokenContract = new ethers.Contract( + parsed.paymentToken.address, + ERC20_ABI, + provider + ); + + const [symbol, name, decimals] = await Promise.all([ + tokenContract + .symbol() + .catch(() => parsed.paymentToken.symbol || "UNKNOWN"), + tokenContract + .name() + .catch(() => parsed.paymentToken.name || "Unknown Token"), + tokenContract + .decimals() + .catch(() => parsed.paymentToken.decimals || 18), + ]); + + parsed.paymentToken = { + ...parsed.paymentToken, + symbol, + name, + decimals: Number(decimals), + logo: "/tokenImages/generic.png", + }; + } catch (error) { + console.error("Failed to fetch token info:", error); + parsed.paymentToken.logo = + parsed.paymentToken.logo || "/tokenImages/generic.png"; + } + } + } + + decryptedInvoices.push(parsed); + } catch (err) { + console.error(`Error processing invoice ${invoice[0]}:`, err); + } + } + + setReceivedInvoices(decryptedInvoices); + + // Generate batch suggestions + const suggestions = findBatchSuggestions(decryptedInvoices); + setBatchSuggestions(suggestions); + + const fee = await contract.fee(); + setFee(fee); + } catch (error) { + console.error("Fetch error:", error); + setError("Failed to fetch invoices. Please try again."); + } finally { + setLoading(false); + } + }; + + fetchReceivedInvoices(); + }, [walletClient, litReady, address, tokens]); + + // ENHANCED Batch payment function with pre-checks + const handleBatchPayment = async () => { + if (!walletClient || selectedInvoices.size === 0) return; + + setBatchLoading(true); + setBalanceErrors([]); + + try { + const provider = new BrowserProvider(walletClient); + const signer = await provider.getSigner(); + const contract = new Contract( + import.meta.env.VITE_CONTRACT_ADDRESS, + ChainvoiceABI, + signer + ); + + const grouped = getGroupedInvoices(); + + // PRE-CHECK ALL BALANCES BEFORE ANY TRANSACTIONS + toast.info("Checking balances..."); + const errors = []; + + for (const [tokenKey, group] of grouped.entries()) { + try { + await checkPaymentCapability(group, signer); + } catch (error) { + errors.push(`${group.symbol}: ${error.message}`); + } + } + + if (errors.length > 0) { + setBalanceErrors(errors); + toast.error( + "Insufficient balance detected. Please check the errors above." + ); + setBatchLoading(false); + return; + } + + toast.success("Balance checks passed! Processing payments..."); + + // Process payments only after all checks pass + for (const [tokenKey, group] of grouped.entries()) { + const { tokenAddress, symbol, decimals, invoices } = group; + const invoiceIds = invoices.map((inv) => BigInt(inv.id)); + + if (invoiceIds.length > 50) { + throw new Error( + `Batch size limit exceeded for ${symbol}. Max 50 invoices per batch.` + ); + } + + // Calculate total amount for this batch + let totalAmount = BigInt(0); + for (const invoice of invoices) { + const amount = ethers.parseUnits( + invoice.amountDue.toString(), + decimals + ); + totalAmount += amount; + } + + // Get fee per invoice + const feePerInvoice = await contract.fee(); + const totalFee = feePerInvoice * BigInt(invoiceIds.length); + + const isNativeToken = tokenAddress === ethers.ZeroAddress; + + if (!isNativeToken) { + // For ERC20 tokens: Check and approve allowance + const tokenContract = new Contract(tokenAddress, ERC20_ABI, signer); + + const currentAllowance = await tokenContract.allowance( + await signer.getAddress(), + import.meta.env.VITE_CONTRACT_ADDRESS + ); + + if (currentAllowance < totalAmount) { + toast.info(`Approving ${symbol} for spending...`); + const approveTx = await tokenContract.approve( + import.meta.env.VITE_CONTRACT_ADDRESS, + totalAmount + ); + await approveTx.wait(); + toast.success(`${symbol} approved successfully!`); + } + + // Pay batch with ERC20 token (fee paid in ETH) + const tx = await contract.payInvoicesBatch(invoiceIds, { + value: totalFee, // Only fee in ETH + }); + + await tx.wait(); + toast.success( + `Successfully paid ${invoices.length} invoices with ${symbol}!` + ); + } else { + // Pay batch with native ETH (total amount + fees) + const tx = await contract.payInvoicesBatch(invoiceIds, { + value: totalAmount + totalFee, // Total amount + fees in ETH + }); + + await tx.wait(); + toast.success( + `Successfully paid ${invoices.length} invoices with ETH!` + ); + } + + // Update invoice statuses locally + const updatedInvoices = receivedInvoices.map((inv) => + invoiceIds.some((id) => id === BigInt(inv.id)) + ? { ...inv, isPaid: true } + : inv + ); + setReceivedInvoices(updatedInvoices); + } + + setSelectedInvoices(new Set()); + toast.success("All batch payments completed successfully!"); + } catch (error) { + console.error("Batch payment error details:", { + error: error.message, + code: error.code, + reason: error.reason, + data: error.data, + }); + + if (error.code === "ACTION_REJECTED") { + toast.error("Transaction was rejected by user"); + } else if (error.message.includes("insufficient")) { + toast.error("Insufficient balance for this transaction"); + } else if (error.message.includes("Batch size")) { + toast.error(error.message); + } else { + toast.error(`Batch payment failed: ${error.reason || error.message}`); + } + } finally { + setBatchLoading(false); + } + }; + + // Individual payment function (same as ReceivedInvoice) + const payInvoice = async (invoiceId, amountDue, tokenAddress) => { + if (!walletClient) { + console.error("Wallet not connected"); + return; + } + + setPaymentLoading((prev) => ({ ...prev, [invoiceId]: true })); + + try { + const provider = new BrowserProvider(walletClient); + const signer = await provider.getSigner(); + const contract = new Contract( + import.meta.env.VITE_CONTRACT_ADDRESS, + ChainvoiceABI, + signer + ); + const invoice = receivedInvoices.find((inv) => inv.id === invoiceId); + if (invoice?.isCancelled) { + throw new Error("Cannot pay a cancelled invoice"); + } + const fee = await contract.fee(); + const isNativeToken = tokenAddress === ethers.ZeroAddress; + + if (!ethers.isAddress(tokenAddress)) { + throw new Error(`Invalid token address: ${tokenAddress}`); + } + + const tokenSymbol = getTokenSymbol(tokenAddress, "Token"); + + if (!isNativeToken) { + const tokenContract = new Contract(tokenAddress, ERC20_ABI, signer); + + const currentAllowance = await tokenContract.allowance( + await signer.getAddress(), + import.meta.env.VITE_CONTRACT_ADDRESS + ); + + const decimals = await tokenContract.decimals(); + const amountDueInWei = ethers.parseUnits(String(amountDue), decimals); + + if (currentAllowance < amountDueInWei) { + const approveTx = await tokenContract.approve( + import.meta.env.VITE_CONTRACT_ADDRESS, + amountDueInWei + ); + + await approveTx.wait(); + alert( + `Approval for ${tokenSymbol} completed! Now processing payment...` + ); + } + + const tx = await contract.payInvoice(BigInt(invoiceId), { + value: fee, + }); + + await tx.wait(); + alert(`Payment successful in ${tokenSymbol}!`); + } else { + const amountDueInWei = ethers.parseUnits(String(amountDue), 18); + const total = amountDueInWei + BigInt(fee); + + const tx = await contract.payInvoice(BigInt(invoiceId), { + value: total, + }); + + await tx.wait(); + alert("Payment successful in ETH!"); + } + + // Refresh invoice status + const updatedInvoices = receivedInvoices.map((inv) => + inv.id === invoiceId ? { ...inv, isPaid: true } : inv + ); + setReceivedInvoices(updatedInvoices); + } catch (error) { + console.error("Payment failed:", error); + if (error.code === "ACTION_REJECTED") { + toast.error("Transaction was rejected by user"); + } else if (error.message.includes("insufficient balance")) { + toast.error("Insufficient balance for this transaction"); + } else if (error.message.includes("cancelled")) { + toast.error("Cannot pay a cancelled invoice"); + } else { + toast.error(`Payment failed: ${error.reason || error.message}`); + } + } finally { + setPaymentLoading((prev) => ({ ...prev, [invoiceId]: false })); + } + }; + + // Drawer functions (exact same as ReceivedInvoice) + const toggleDrawer = (invoice) => (event) => { + if ( + event && + event.type === "keydown" && + (event.key === "Tab" || event.key === "Shift") + ) { + return; + } + setDrawerState({ + open: !drawerState.open, + selectedInvoice: invoice || null, + }); + }; + + const handlePrint = async () => { + const element = document.getElementById("invoice-print"); + if (!element) return; + + const canvas = await html2canvas(element, { scale: 2 }); + const data = canvas.toDataURL("image/png"); + + const link = document.createElement("a"); + link.download = `invoice-${drawerState.selectedInvoice.id}.png`; + link.href = data; + link.click(); + }; + + const switchNetwork = async () => { + try { + setNetworkLoading(true); + await window.ethereum.request({ + method: "wallet_switchEthereumChain", + params: [{ chainId: "0xaa36a7" }], // Sepolia chain ID + }); + setError(null); + } catch (error) { + console.error("Network switch failed:", error); + alert("Failed to switch network. Please switch to Sepolia manually."); + } finally { + setNetworkLoading(false); + } + }; + + const formatAddress = (address) => { + return `${address.substring(0, 10)}...${address.substring( + address.length - 10 + )}`; + }; + + const formatDate = (issueDate) => { + const date = new Date(issueDate); + return date.toLocaleString(); + }; + + const unpaidInvoices = receivedInvoices.filter( + (inv) => !inv.isPaid && !inv.isCancelled + ); + const selectedCount = selectedInvoices.size; + const grouped = getGroupedInvoices(); + + return ( + <> +
+ setShowWalletAlert(false)} + /> +
+
+
+
+
+

Batch Payment

+

+ Select and pay multiple invoices in one transaction +

+
+ {error && ( + + )} +
+ + {/* Balance Error Alerts */} + {balanceErrors.length > 0 && ( +
+
+ + + Insufficient Balance Detected + +
+
+ {balanceErrors.map((error, index) => ( +
+ • {error} +
+ ))} +
+
+ )} + + {/* Smart Batch Suggestions */} + {batchSuggestions.length > 0 && ( +
+
+ + + 💡 Smart Batch Suggestions + +
+
+ {batchSuggestions.map((suggestion) => ( +
+
+ + {suggestion.invoices.length} invoices + {suggestion.sender + ? ` from ${suggestion.sender.fname}` + : ""} + + + ({suggestion.reason}) + +
+
+ + {suggestion.totalAmount.toFixed(4)}{" "} + {suggestion.token?.symbol || "ETH"} + + +
+
+ ))} +
+
+ )} + + {/* Batch Actions Panel */} + {unpaidInvoices.length > 0 && ( +
+
+
+ + + Batch Payment + +
+ {selectedCount} selected +
+
+
+ + +
+
+ + {/* Payment Summary */} + {selectedCount > 0 && ( + <> +
+

+ Payment Summary: +

+
+ {Array.from(grouped.entries()).map( + ([tokenKey, group]) => ( +
+
+ {group.logo ? ( + {group.symbol} { + e.target.src = "/tokenImages/generic.png"; + }} + /> + ) : ( + + )} + + {group.symbol} + + + ({group.invoices.length} invoices) + +
+ + {group.totalAmount.toFixed(6)} {group.symbol} + +
+ ) + )} +
+
+ + + )} + + {selectedCount === 0 && ( +
+
+ + + Select one or more unpaid invoices to enable batch payment + +
+
+ )} +
+ )} + + {/* Invoices Table */} +
+ {loading ? ( +
+ +

Loading invoices...

+
+ ) : error ? ( +
+
+

{error}

+
+
+ ) : receivedInvoices.length === 0 ? ( +
+
+ +

+ No Invoices Found +

+

+ You don't have any received invoices yet. +

+
+
+ ) : ( +
+ + + + + + + + + + + + + + {receivedInvoices + .slice( + page * rowsPerPage, + page * rowsPerPage + rowsPerPage + ) + .map((invoice) => ( + + + + + + + + + + ))} + +
+ 0 + } + onChange={(e) => { + if (e.target.checked) { + handleSelectAll(); + } else { + handleClearAll(); + } + }} + /> + + Client + + Sender + + Amount + + Status + + Date + + Actions +
+ handleSelectInvoice(invoice.id)} + disabled={invoice.isPaid || invoice.isCancelled} + /> + +
+
+ + {invoice.user?.fname?.charAt(0) || "C"} + +
+
+
+ {invoice.user?.fname} {invoice.user?.lname} +
+
+ {invoice.user?.email} +
+ {/* Batch indicator */} + {invoice.batchInfo && ( +
+
+ + Batch # + {invoice.batchInfo.batchId.slice(-4)}( + {invoice.batchInfo.index + 1}/ + {invoice.batchInfo.batchSize}) +
+
+ )} +
+
+
+ + {formatAddress(invoice.user?.address)} + + +
+ {invoice.paymentToken?.logo ? ( + {invoice.paymentToken.symbol} { + e.target.src = "/tokenImages/generic.png"; + }} + /> + ) : ( + + )} + + {invoice.amountDue}{" "} + {invoice.paymentToken?.symbol} + +
+
+ {invoice.isCancelled ? ( + + + Cancelled + + ) : invoice.isPaid ? ( + + + Paid + + ) : ( + + + Unpaid + + )} + + {formatDate(invoice.issueDate)} + +
+ + + {/* Pay Entire Batch Button */} + {invoice.batchInfo && + !invoice.isPaid && + !invoice.isCancelled && ( + + )} + + {!invoice.isPaid && !invoice.isCancelled && ( + + )} + {invoice.isCancelled && ( + + )} +
+
+
+ )} + + {/* Pagination */} + {receivedInvoices.length > rowsPerPage && ( +
+
+ + Showing {page * rowsPerPage + 1} to{" "} + {Math.min( + (page + 1) * rowsPerPage, + receivedInvoices.length + )}{" "} + of {receivedInvoices.length} results + +
+
+ + +
+
+ )} +
+
+ + {/* Invoice Detail Drawer - EXACT SAME AS RECEIVED INVOICE */} + + {drawerState.selectedInvoice && ( + <> +
+
+
+
+ Chainvoice +

+ Cha + + in + + voice +

+
+

+ Powered by Chainvoice +

+
+
+

+ INVOICE +

+

+ # + {drawerState.selectedInvoice.id + .toString() + .padStart(6, "0")} +

+
+ {drawerState.selectedInvoice.isCancelled ? ( + + + CANCELLED + + ) : drawerState.selectedInvoice.isPaid ? ( + + + PAID + + ) : ( + + + UNPAID + + )} +
+
+
+ + {drawerState.selectedInvoice.isCancelled && ( +
+
+
+
+ Invoice Cancelled by{" "} + {drawerState.selectedInvoice.user?.fname || + "The sender"}{" "} + {drawerState.selectedInvoice.user?.lname || ""}{" "} +
+
+ You no longer need to make payment for this invoice. +
+
+
+ + {!drawerState.selectedInvoice.isPaid && ( +
+
+ Note: This invoice was cancelled before payment was + completed +
+
+ )} +
+ )} + +
+
+

+ From +

+

+ {drawerState.selectedInvoice.user.fname}{" "} + {drawerState.selectedInvoice.user.lname} +

+

+ {drawerState.selectedInvoice.user.address} +

+

+ {drawerState.selectedInvoice.user.city},{" "} + {drawerState.selectedInvoice.user.country},{" "} + {drawerState.selectedInvoice.user.postalcode} +

+

+ {drawerState.selectedInvoice.user.email} +

+
+ +
+

+ Bill To +

+

+ {drawerState.selectedInvoice.client.fname}{" "} + {drawerState.selectedInvoice.client.lname} +

+

+ {drawerState.selectedInvoice.client.address} +

+

+ {drawerState.selectedInvoice.client.city},{" "} + {drawerState.selectedInvoice.client.country},{" "} + {drawerState.selectedInvoice.client.postalcode} +

+

+ {drawerState.selectedInvoice.client.email} +

+
+
+ +
+

+ Payment Currency +

+
+ {drawerState.selectedInvoice.paymentToken?.logo ? ( + {drawerState.selectedInvoice.paymentToken.symbol} { + e.target.src = "/tokenImages/generic.png"; + }} + /> + ) : ( +
+ +
+ )} +
+

+ {drawerState.selectedInvoice.paymentToken?.name || + "Ether "} + {"("} + {drawerState.selectedInvoice.paymentToken?.symbol || + "ETH"} + {")"} +

+

+ {drawerState.selectedInvoice.paymentToken?.address + ? `${drawerState.selectedInvoice.paymentToken.address.substring( + 0, + 10 + )}......${drawerState.selectedInvoice.paymentToken.address.substring( + 33 + )}` + : "Native Currency"} +

+
+
+ {drawerState.selectedInvoice.paymentToken?.address && ( +
+

+ Decimals:{" "} + {drawerState.selectedInvoice.paymentToken.decimals || + 18} +

+

Chain: Sepolia Testnet

+
+ )} +
+ +
+
+ + Issued:{" "} + {new Date( + drawerState.selectedInvoice.issueDate + ).toLocaleDateString()} + + + Due:{" "} + {new Date( + drawerState.selectedInvoice.dueDate + ).toLocaleDateString()} + +
+
+ +
+ + + + + + + + + + + + + {drawerState.selectedInvoice.items?.map((item, index) => ( + + + + + + + + + ))} + +
DescriptionQtyPriceDiscountTaxAmount
{item.description}{item.qty} + {item.unitPrice}{" "} + {drawerState.selectedInvoice.paymentToken?.symbol} + + {item.discount || "0"} + + {item.tax || "0%"} + + {item.amount}{" "} + {drawerState.selectedInvoice.paymentToken?.symbol} +
+
+ +
+
+ Subtotal: + + {drawerState.selectedInvoice.amountDue}{" "} + {drawerState.selectedInvoice.paymentToken?.symbol} + +
+
+ Network Fee: + + {ethers.formatUnits(fee)} ETH + +
+
+ Total Amount: + + {drawerState.selectedInvoice.paymentToken?.symbol === + "ETH" + ? `${( + parseFloat(drawerState.selectedInvoice.amountDue) + + parseFloat(ethers.formatUnits(fee)) + ).toFixed(6)} ETH` + : `${drawerState.selectedInvoice.amountDue} ${ + drawerState.selectedInvoice.paymentToken?.symbol + } + ${ethers.formatUnits(fee)} ETH`} + +
+
+ +
+ + +
+
+ + )} +
+
+ + ); +} + +export default BatchPayment; diff --git a/frontend/src/page/CreateInvoicesBatch.jsx b/frontend/src/page/CreateInvoicesBatch.jsx new file mode 100644 index 00000000..5fe3a31b --- /dev/null +++ b/frontend/src/page/CreateInvoicesBatch.jsx @@ -0,0 +1,1249 @@ +// pages/CreateInvoicesBatch.jsx - Clean & Professional +import React, { useEffect, useRef, useState } from "react"; +import { Input } from "../components/ui/input"; +import { Button } from "../components/ui/button"; +import { + BrowserProvider, + Contract, + ethers, + formatUnits, + parseUnits, +} from "ethers"; +import { useAccount, useWalletClient } from "wagmi"; +import { ChainvoiceABI } from "../contractsABI/ChainvoiceABI"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Calendar } from "@/components/ui/calendar"; +import { + Badge, + CalendarIcon, + CheckCircle2, + Coins, + Loader2, + PlusIcon, + XCircle, + X, + ChevronDown, + ChevronRight, + AlertTriangle, + Users, + Receipt, +} from "lucide-react"; +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 { LitNodeClient } from "@lit-protocol/lit-node-client"; +import { encryptString } from "@lit-protocol/encryption/src/lib/encryption.js"; +import { LIT_ABILITY, LIT_NETWORK } from "@lit-protocol/constants"; +import { + createSiweMessageWithRecaps, + generateAuthSig, + LitAccessControlConditionResource, +} from "@lit-protocol/auth-helpers"; + +import TokenIntegrationRequest from "@/components/TokenIntegrationRequest"; +import { ERC20_ABI } from "@/contractsABI/ERC20_ABI"; +import WalletConnectionAlert from "../components/WalletConnectionAlert"; +import TokenPicker, { ToggleSwitch } from "@/components/TokenPicker"; +import { CopyButton } from "@/components/ui/copyButton"; + +function CreateInvoicesBatch() { + const { data: walletClient } = useWalletClient(); + const { isConnected } = useAccount(); + const account = useAccount(); + const [dueDate, setDueDate] = useState(new Date()); + const [issueDate, setIssueDate] = useState(new Date()); + const [loading, setLoading] = useState(false); + const navigate = useNavigate(); + const litClientRef = useRef(null); + + // Token selection state + const [selectedToken, setSelectedToken] = useState(null); + const [customTokenAddress, setCustomTokenAddress] = useState(""); + const [useCustomToken, setUseCustomToken] = useState(false); + const [tokenVerificationState, setTokenVerificationState] = useState("idle"); + const [verifiedToken, setVerifiedToken] = useState(null); + const [showWalletAlert, setShowWalletAlert] = useState(!isConnected); + + // UI state for collapsible invoices + const [expandedInvoice, setExpandedInvoice] = useState(0); + + // Batch invoice data + const [invoiceRows, setInvoiceRows] = useState([ + { + clientAddress: "", + clientFname: "", + clientLname: "", + clientEmail: "", + clientCountry: "", + clientCity: "", + clientPostalcode: "", + itemData: [ + { + description: "", + qty: "", + unitPrice: "", + discount: "", + tax: "", + amount: "", + }, + ], + totalAmountDue: 0, + }, + ]); + + // User info (shared across all invoices) + const [userInfo, setUserInfo] = useState({ + userFname: "", + userLname: "", + userEmail: "", + userCountry: "", + userCity: "", + userPostalcode: "", + }); + + // Calculate totals for each invoice + useEffect(() => { + 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; + return sum + adjusted; + }, 0n); + + return { + ...row, + totalAmountDue: formatUnits(total, 18), + }; + }) + ); + }, [invoiceRows.map((r) => JSON.stringify(r.itemData)).join(",")]); + + // Initialize Lit + useEffect(() => { + const initLit = async () => { + if (!litClientRef.current) { + const client = new LitNodeClient({ + litNetwork: LIT_NETWORK.DatilDev, + debug: false, + }); + await client.connect(); + litClientRef.current = client; + } + }; + initLit(); + }, []); + + useEffect(() => { + setShowWalletAlert(!isConnected); + }, [isConnected]); + + // Invoice management + const addInvoiceRow = () => { + const newIndex = invoiceRows.length; + setInvoiceRows((prev) => [ + ...prev, + { + clientAddress: "", + clientFname: "", + clientLname: "", + clientEmail: "", + clientCountry: "", + clientCity: "", + clientPostalcode: "", + itemData: [ + { + description: "", + qty: "", + unitPrice: "", + discount: "", + tax: "", + amount: "", + }, + ], + totalAmountDue: 0, + }, + ]); + setExpandedInvoice(newIndex); + toast.success("New invoice added to batch"); + }; + + const removeInvoiceRow = (index) => { + if (invoiceRows.length > 1) { + setInvoiceRows((prev) => prev.filter((_, i) => i !== index)); + if (expandedInvoice === index) { + setExpandedInvoice(0); + } + toast.success("Invoice removed from batch"); + } + }; + + const updateInvoiceRow = (rowIndex, field, value) => { + setInvoiceRows((prev) => + prev.map((row, i) => (i === rowIndex ? { ...row, [field]: value } : row)) + ); + }; + + // Item management + const handleItemData = (e, rowIndex, itemIndex) => { + const { name, value } = e.target; + + setInvoiceRows((prevRows) => + prevRows.map((row, rIndex) => { + if (rIndex === rowIndex) { + const updatedItemData = row.itemData.map((item, iIndex) => { + if (iIndex === itemIndex) { + const updatedItem = { ...item, [name]: value }; + if ( + name === "qty" || + name === "unitPrice" || + 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); + } + return updatedItem; + } + return item; + }); + + return { ...row, itemData: updatedItemData }; + } + return row; + }) + ); + }; + + const addItem = (rowIndex) => { + setInvoiceRows((prev) => + prev.map((row, i) => { + if (i === rowIndex) { + return { + ...row, + itemData: [ + ...row.itemData, + { + description: "", + qty: "", + unitPrice: "", + discount: "", + tax: "", + amount: "", + }, + ], + }; + } + return row; + }) + ); + }; + + // Token verification + const verifyToken = async (address) => { + setTokenVerificationState("verifying"); + + try { + if (typeof window !== "undefined" && window.ethereum) { + const provider = new BrowserProvider(window.ethereum); + const contract = new ethers.Contract(address, ERC20_ABI, provider); + + const [symbol, name, decimals] = await Promise.all([ + contract.symbol().catch(() => "UNKNOWN"), + contract.name().catch(() => "Unknown Token"), + contract.decimals().catch(() => 18), + ]); + + setVerifiedToken({ address, symbol, name, decimals }); + setTokenVerificationState("success"); + toast.success(`Token verified: ${name} (${symbol})`); + } else { + console.error("No Ethereum provider found"); + setTokenVerificationState("error"); + toast.error("No Ethereum provider found"); + } + } catch (error) { + console.error("Verification failed:", error); + setTokenVerificationState("error"); + toast.error("Token verification failed. Please check the address."); + } + }; + + // Enhanced error handling for batch creation + const getErrorMessage = (error) => { + if (error.code === "ACTION_REJECTED") { + return "Transaction was cancelled by user"; + } else if (error.message?.includes("insufficient")) { + return "Insufficient balance to complete transaction"; + } else if (error.message?.includes("network")) { + return "Network error. Please check your connection and try again"; + } else if (error.reason) { + return `Transaction failed: ${error.reason}`; + } else if (error.message) { + return error.message; + } else { + return "Failed to create invoice batch. Please try again."; + } + }; + + // Create batch invoices + const createInvoicesRequest = async () => { + if (!isConnected || !walletClient) { + toast.error("Please connect your wallet to continue"); + return; + } + + try { + setLoading(true); + toast.info("Starting batch invoice creation..."); + + const provider = new BrowserProvider(walletClient); + const signer = await provider.getSigner(); + + const paymentToken = useCustomToken ? verifiedToken : selectedToken; + + if (!paymentToken) { + toast.error("Please select a payment token"); + return; + } + + // Validate invoices + const validInvoices = invoiceRows.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; + } + + // Prepare batch arrays + const tos = []; + const amounts = []; + const encryptedPayloads = []; + const encryptedHashes = []; + + const litNodeClient = litClientRef.current; + if (!litNodeClient) { + toast.error("Encryption service not ready. Please try again."); + 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, + issueDate, + paymentToken: { + address: paymentToken.address, + symbol: paymentToken.symbol, + decimals: Number(paymentToken.decimals), + }, + user: { + address: account?.address.toString(), + fname: userInfo.userFname, + lname: userInfo.userLname, + email: userInfo.userEmail, + country: userInfo.userCountry, + city: userInfo.userCity, + postalcode: userInfo.userPostalcode, + }, + client: { + address: row.clientAddress, + fname: row.clientFname, + lname: row.clientLname, + email: row.clientEmail, + country: row.clientCountry, + city: row.clientCity, + postalcode: row.clientPostalcode, + }, + items: row.itemData, + // Add batch metadata + batchInfo: { + batchId: `batch_${Date.now()}`, + batchSize: validInvoices.length, + index: index, + batchType: "user_created", + }, + }; + + const invoiceString = JSON.stringify(invoicePayload); + + const accessControlConditions = [ + { + contractAddress: "", + standardContractType: "", + chain: "ethereum", + method: "", + parameters: [":userAddress"], + returnValueTest: { + comparator: "=", + value: account.address.toLowerCase(), + }, + }, + { operator: "or" }, + { + contractAddress: "", + standardContractType: "", + chain: "ethereum", + method: "", + parameters: [":userAddress"], + returnValueTest: { + comparator: "=", + value: row.clientAddress.toLowerCase(), + }, + }, + ]; + + const { ciphertext, dataToEncryptHash } = await encryptString( + { + accessControlConditions, + dataToEncrypt: invoiceString, + }, + litNodeClient + ); + + const sessionSigs = await litNodeClient.getSessionSigs({ + chain: "ethereum", + resourceAbilityRequests: [ + { + resource: new LitAccessControlConditionResource("*"), + ability: LIT_ABILITY.AccessControlConditionDecryption, + }, + ], + authNeededCallback: async ({ + uri, + expiration, + resourceAbilityRequests, + }) => { + const nonce = await litNodeClient.getLatestBlockhash(); + const toSign = await createSiweMessageWithRecaps({ + uri, + expiration, + resources: resourceAbilityRequests, + walletAddress: account.address, + nonce, + litNodeClient, + }); + + return await generateAuthSig({ + signer, + toSign, + }); + }, + }); + + const encryptedStringBase64 = btoa(ciphertext); + + // Add to batch arrays + tos.push(row.clientAddress); + amounts.push( + ethers.parseUnits( + row.totalAmountDue.toString(), + paymentToken.decimals + ) + ); + encryptedPayloads.push(encryptedStringBase64); + encryptedHashes.push(dataToEncryptHash); + } + + toast.success("All invoices encrypted successfully!"); + toast.info("Submitting batch transaction to blockchain..."); + + // Send to contract + const contract = new Contract( + import.meta.env.VITE_CONTRACT_ADDRESS, + ChainvoiceABI, + signer + ); + + const tx = await contract.createInvoicesBatch( + tos, + amounts, + paymentToken.address, + encryptedPayloads, + 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!` + ); + + setTimeout(() => navigate("/dashboard/sent"), 3000); + } catch (err) { + console.error("Batch creation failed:", err); + const errorMsg = getErrorMessage(err); + toast.error(errorMsg); + } finally { + setLoading(false); + } + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + await createInvoicesRequest(); + }; + + const totalBatchAmount = invoiceRows.reduce((sum, row) => { + return sum + (parseFloat(row.totalAmountDue) || 0); + }, 0); + + const validInvoices = invoiceRows.filter( + (row) => row.clientAddress && parseFloat(row.totalAmountDue) > 0 + ).length; + + const gasSavingsPercent = validInvoices > 1 ? (validInvoices - 1) * 75 : 0; + + return ( + <> +
+ setShowWalletAlert(false)} + /> +
+ +
+ {/* Simple Header */} +
+

+ Create Invoices (Batch) +

+

+ Create multiple invoices in a single transaction and save on gas + fees +

+
+ + {/* Clean Date Selection */} +
+
+
+
+ +
+ {invoiceRows.length} +
+
+
+ +
+ + +
+ +
+ + + + + + + { + if (date) { + setDueDate(date); + } + }} + initialFocus + disabled={(date) => date < new Date()} + /> + + +
+
+
+ + {/* Clean Summary Stats */} +
+
+
+
{invoiceRows.length}
+
Total Invoices
+
+
+
{validInvoices}
+
Valid Invoices
+
+
+
+ {totalBatchAmount.toFixed(4)} +
+
+ Total Amount ( + {useCustomToken + ? verifiedToken?.symbol || "TOKEN" + : selectedToken?.symbol || "TOKEN"} + ) +
+
+
+
~{gasSavingsPercent}%
+
Gas Savings
+
+
+
+ +
+ {/* Clean User Information */} +
+
+

+ From (Your Information) +

+
+ + +
+ +
+
+ + + setUserInfo((prev) => ({ + ...prev, + userFname: e.target.value, + })) + } + /> +
+
+ + + setUserInfo((prev) => ({ + ...prev, + userLname: e.target.value, + })) + } + /> +
+
+ + + setUserInfo((prev) => ({ + ...prev, + userEmail: e.target.value, + })) + } + /> +
+
+ + + setUserInfo((prev) => ({ + ...prev, + userCountry: e.target.value, + })) + } + /> +
+
+
+
+ + {/* Clean Token Selection */} +
+

+ Payment Currency +

+ +
+ + +
+ {!useCustomToken ? ( + <> + + { + setSelectedToken({ + address: token.contract_address, + symbol: token.symbol, + name: token.name, + logo: token.image, + decimals: 18, + }); + toast.success(`Selected ${token.symbol}`); + }} + chainId={account?.chainId || 1} + disabled={loading} + className="w-full" + allowCustom={false} + /> + + ) : ( + <> + + +
+
+ +
+

+ Custom Token Setup +

+

+ Enter the contract address of the ERC-20 token you + want to use. +

+
+
+
+ + { + const address = e.target.value; + setCustomTokenAddress(address); + if (!address || !ethers.isAddress(address)) { + setTokenVerificationState("idle"); + setVerifiedToken(null); + } else if (ethers.isAddress(address)) { + verifyToken(address); + } + }} + className="h-12 bg-gray-50 text-gray-700 border-gray-200" + disabled={loading} + /> + + {tokenVerificationState === "verifying" && ( +
+ + + Verifying token contract... + +
+ )} + + {tokenVerificationState === "success" && verifiedToken && ( +
+
+
+ +
+
+

+ {verifiedToken.name} ({verifiedToken.symbol}) +

+ + Verified + +
+
+ + {verifiedToken.address} + + +
+

+ Decimals: {String(verifiedToken.decimals)} +

+
+
+
+ +
+ )} + + {tokenVerificationState === "error" && ( +
+
+ +
+

+ Token verification failed +

+

+ Please check the contract address and try again. +

+
+
+
+ )} + + )} +
+
+
+ + {/* Clean Invoice Rows */} +
+
+

Invoices

+ +
+ + {invoiceRows.map((row, rowIndex) => ( +
+ {/* Clean Invoice Header */} +
+ setExpandedInvoice( + expandedInvoice === rowIndex ? -1 : rowIndex + ) + } + > +
+ {expandedInvoice === rowIndex ? ( + + ) : ( + + )} + + Invoice #{rowIndex + 1} + + {row.clientAddress && parseFloat(row.totalAmountDue) > 0 ? ( + + ) : ( + + )} +
+ +
+
+
+ {row.clientAddress + ? `${row.clientAddress.slice( + 0, + 8 + )}...${row.clientAddress.slice(-6)}` + : "No client"} +
+
+ {parseFloat(row.totalAmountDue).toFixed(4)}{" "} + {useCustomToken + ? verifiedToken?.symbol || "TOKEN" + : selectedToken?.symbol || "TOKEN"} +
+
+ {invoiceRows.length > 1 && ( + + )} +
+
+ + {/* Clean Invoice Content */} + {expandedInvoice === rowIndex && ( +
+ {/* Clean Client Information */} +
+

+ Client Information +

+
+ + + updateInvoiceRow( + rowIndex, + "clientAddress", + e.target.value + ) + } + /> +
+ +
+
+ + + updateInvoiceRow( + rowIndex, + "clientFname", + e.target.value + ) + } + /> +
+
+ + + updateInvoiceRow( + rowIndex, + "clientLname", + e.target.value + ) + } + /> +
+
+ + + updateInvoiceRow( + rowIndex, + "clientEmail", + e.target.value + ) + } + /> +
+
+ + + updateInvoiceRow( + rowIndex, + "clientCountry", + e.target.value + ) + } + /> +
+
+
+ + {/* Clean Invoice Items */} +
+
+

+ Invoice Items +

+
+ +
+
DESCRIPTION
+
QTY
+
UNIT PRICE
+
DISCOUNT
+
TAX(%)
+
AMOUNT
+
ACTION
+
+ +
+ {row.itemData.map((item, itemIndex) => ( +
+
+ + handleItemData(e, rowIndex, itemIndex) + } + /> +
+
+ + handleItemData(e, rowIndex, itemIndex) + } + /> +
+
+ + handleItemData(e, rowIndex, itemIndex) + } + /> +
+
+ + handleItemData(e, rowIndex, itemIndex) + } + /> +
+
+ + handleItemData(e, rowIndex, itemIndex) + } + /> +
+
+
+ {( + (parseFloat(item.qty) || 0) * + (parseFloat(item.unitPrice) || 0) - + (parseFloat(item.discount) || 0) + + (parseFloat(item.tax) || 0) + ).toFixed(4)} +
+
+
+ {row.itemData.length > 1 && ( + + )} +
+
+ ))} +
+ +
+ + +
+
+ + Total: + + + {parseFloat(row.totalAmountDue).toFixed(4)}{" "} + {useCustomToken + ? verifiedToken?.symbol || "TOKEN" + : selectedToken?.symbol || "TOKEN"} + +
+
+
+
+
+ )} +
+ ))} +
+ + {/* Clean Form Actions */} +
+ +
+ + {validInvoices === 0 && ( +
+
+ + + Please add at least one valid invoice with client address and + items + +
+
+ )} +
+
+ + ); +} + +export default CreateInvoicesBatch; diff --git a/frontend/src/page/Home.jsx b/frontend/src/page/Home.jsx index 67188092..ae8e6c89 100644 --- a/frontend/src/page/Home.jsx +++ b/frontend/src/page/Home.jsx @@ -1,4 +1,4 @@ -// Home.js - Complete Updated Version with Generate Prefilled Link +// Home.js - Updated with Batch Creation AND Batch Payment sections import * as React from "react"; import Box from "@mui/material/Box"; import Drawer from "@mui/material/Drawer"; @@ -12,6 +12,7 @@ import DraftsIcon from "@mui/icons-material/Drafts"; import AddCircleOutlineIcon from "@mui/icons-material/AddCircleOutline"; import LinkIcon from "@mui/icons-material/Link"; import { Outlet, useNavigate, useLocation } from "react-router-dom"; +import { FileStack, CreditCard, Layers3, PlusCircle, FileStackIcon } from "lucide-react"; export default function Home() { const navigate = useNavigate(); @@ -42,6 +43,12 @@ export default function Home() { route: "generate-link", color: "#a78bfa", }, + { + text: "Batch Create", + icon: , + route: "batch-invoice", + color: "#22c55e", + }, ]; return ( @@ -101,7 +108,7 @@ export default function Home() { transform: "translateX(4px)", }, "&.Mui-selected": { - borderLeft: `4px solid ${item.color}`, + borderLeft: "4px solid " + item.color, }, padding: "12px 16px", }} diff --git a/frontend/src/page/ReceivedInvoice.jsx b/frontend/src/page/ReceivedInvoice.jsx index 1d61b217..351224d1 100644 --- a/frontend/src/page/ReceivedInvoice.jsx +++ b/frontend/src/page/ReceivedInvoice.jsx @@ -35,15 +35,31 @@ import { Tooltip, IconButton, Typography, + Checkbox, + Button, + Box, + Divider, + Alert, + FormControlLabel, + Snackbar, } from "@mui/material"; import PaidIcon from "@mui/icons-material/CheckCircle"; import UnpaidIcon from "@mui/icons-material/Pending"; import DownloadIcon from "@mui/icons-material/Download"; import CurrencyExchangeIcon from "@mui/icons-material/CurrencyExchange"; +import SelectAllIcon from "@mui/icons-material/SelectAll"; +import ClearAllIcon from "@mui/icons-material/ClearAll"; +import PaymentIcon from "@mui/icons-material/Payment"; +import WarningIcon from "@mui/icons-material/Warning"; +import LightbulbIcon from "@mui/icons-material/Lightbulb"; +import LayersIcon from "@mui/icons-material/Layers"; +import CloseIcon from "@mui/icons-material/Close"; +import ErrorIcon from "@mui/icons-material/Error"; import { useTokenList } from "@/hooks/useTokenList"; import WalletConnectionAlert from "@/components/WalletConnectionAlert"; const columns = [ + { id: "select", label: "", minWidth: 50 }, { id: "fname", label: "Client", minWidth: 120 }, { id: "to", label: "Sender", minWidth: 150 }, { id: "amountDue", label: "Amount", minWidth: 100, align: "right" }, @@ -67,7 +83,21 @@ function ReceivedInvoice() { const [networkLoading, setNetworkLoading] = useState(false); const [showWalletAlert, setShowWalletAlert] = useState(!isConnected); - // Get tokens from the hook + // Error handling states + const [paymentError, setPaymentError] = useState(""); + const [showPaymentError, setShowPaymentError] = useState(false); + + // Batch payment states + const [selectedInvoices, setSelectedInvoices] = useState(new Set()); + const [batchLoading, setBatchLoading] = useState(false); + const [batchSuggestions, setBatchSuggestions] = useState([]); + + // Drawer state + const [drawerState, setDrawerState] = useState({ + open: false, + selectedInvoice: null, + }); + const { tokens } = useTokenList(chainId || 1); const handleChangePage = (event, newPage) => { @@ -79,10 +109,71 @@ function ReceivedInvoice() { setPage(0); }; - // Helper function to get token info + // UNIFORM ERROR HANDLER + const getDetailedErrorMessage = (error) => { + if (error.code === "CALL_EXCEPTION") { + if (error.reason === "missing revert data" || !error.reason) { + return "Transaction failed. This may be due to insufficient balance, contract issues, or network problems. Please check your balance and try again."; + } + return `Transaction failed: ${error.reason}`; + } + + if (error.code === "ACTION_REJECTED" || error.code === 4001) { + return "Transaction was cancelled by user"; + } + + if ( + error.message?.includes("insufficient balance") || + error.message?.includes("insufficient funds") + ) { + return "Insufficient balance to complete this transaction"; + } + + if ( + error.message?.includes("User rejected") || + error.message?.includes("User denied") + ) { + return "Transaction was rejected in wallet"; + } + + if (error.message?.includes("network") || error.code === "NETWORK_ERROR") { + return "Network error. Please check your connection and try again"; + } + + if (error.message?.includes("gas")) { + return "Transaction failed due to gas estimation error. Please try again"; + } + + if ( + error.message?.includes("cancelled") || + error.message?.includes("cancel") + ) { + return "Cannot pay a cancelled invoice"; + } + + if (error.reason && error.reason !== "missing revert data") { + return `Transaction failed: ${error.reason}`; + } + + if (error.message) { + const cleanMessage = error.message + .replace( + /\(action="[^"]*", data=[^,]*, reason=[^,]*, transaction=\{[^}]*\}, invocation=[^,]*, revert=[^,]*, code=[^,]*, version=[^)]*\)/g, + "" + ) + .replace(/missing revert data/g, "transaction execution failed") + .trim(); + return ( + cleanMessage || "Payment failed. Please try again or contact support" + ); + } + + return "Payment failed. Please try again or contact support"; + }; + + // Helper functions const getTokenInfo = (tokenAddress) => { if (!tokens || tokens.length === 0) return null; - return tokens.find( (token) => token.contract_address?.toLowerCase() === tokenAddress?.toLowerCase() || @@ -90,29 +181,415 @@ function ReceivedInvoice() { ); }; - // Helper function to get token logo - const getTokenLogo = (tokenAddress, fallbackLogo) => { + const getTokenSymbol = (tokenAddress, fallbackSymbol = "TOKEN") => { const tokenInfo = getTokenInfo(tokenAddress); - return ( - tokenInfo?.image || - tokenInfo?.logo || - fallbackLogo || - "/tokenImages/generic.png" + return tokenInfo?.symbol || fallbackSymbol; + }; + + const detectBatchFromMetadata = (invoice) => { + if (invoice.batchInfo) { + return { + batchId: invoice.batchInfo.batchId, + batchSize: invoice.batchInfo.batchSize, + index: invoice.batchInfo.index, + batchType: invoice.batchInfo.batchType, + }; + } + return null; + }; + + const findBatchSuggestions = (invoices) => { + const suggestions = []; + const groups = invoices + .filter((inv) => !inv.isPaid && !inv.isCancelled) + .reduce((acc, inv) => { + const issueDate = new Date(inv.issueDate).toDateString(); + const key = `${inv.user?.address}_${ + inv.paymentToken?.address || "ETH" + }_${issueDate}`; + if (!acc[key]) acc[key] = []; + acc[key].push(inv); + return acc; + }, {}); + + Object.entries(groups).forEach(([key, invoices]) => { + if (invoices.length >= 2) { + const totalAmount = invoices.reduce( + (sum, inv) => sum + parseFloat(inv.amountDue), + 0 + ); + suggestions.push({ + id: key, + invoices, + sender: invoices[0].user, + token: invoices[0].paymentToken, + totalAmount, + reason: `${invoices.length} invoices from same sender on same day`, + type: "same_day_sender", + }); + } + }); + + return suggestions; + }; + + // UNIFORM BALANCE CHECK + const checkBalance = async (tokenAddress, amount, symbol, signer) => { + const userAddress = await signer.getAddress(); + + if (tokenAddress === ethers.ZeroAddress) { + const balance = await signer.provider.getBalance(userAddress); + const totalRequired = + ethers.parseUnits(amount.toString(), 18) + BigInt(fee); + + if (balance < totalRequired) { + const requiredEth = ethers.formatEther(totalRequired); + const availableEth = ethers.formatEther(balance); + throw new Error( + `Insufficient ETH balance. Required: ${requiredEth} ETH, Available: ${availableEth} ETH` + ); + } + } else { + const tokenContract = new Contract(tokenAddress, ERC20_ABI, signer); + const balance = await tokenContract.balanceOf(userAddress); + const decimals = await tokenContract.decimals(); + const requiredAmount = ethers.parseUnits(amount.toString(), decimals); + + if (balance < requiredAmount) { + const availableFormatted = ethers.formatUnits(balance, decimals); + throw new Error( + `Insufficient ${symbol} balance. Required: ${amount} ${symbol}, Available: ${availableFormatted} ${symbol}` + ); + } + + const ethBalance = await signer.provider.getBalance(userAddress); + if (ethBalance < BigInt(fee)) { + const requiredEthFee = ethers.formatEther(fee); + const availableEth = ethers.formatEther(ethBalance); + throw new Error( + `Insufficient ETH for fees. Required: ${requiredEthFee} ETH, Available: ${availableEth} ETH` + ); + } + } + }; + + const getGroupedInvoices = () => { + const grouped = new Map(); + receivedInvoices.forEach((invoice) => { + if (!selectedInvoices.has(invoice.id)) return; + + const tokenAddress = invoice.paymentToken?.address || ethers.ZeroAddress; + const tokenKey = `${tokenAddress}_${ + invoice.paymentToken?.symbol || "ETH" + }`; + + if (!grouped.has(tokenKey)) { + grouped.set(tokenKey, { + tokenAddress, + symbol: invoice.paymentToken?.symbol || "ETH", + logo: invoice.paymentToken?.logo, + decimals: invoice.paymentToken?.decimals || 18, + invoices: [], + totalAmount: 0, + }); + } + + const group = grouped.get(tokenKey); + group.invoices.push(invoice); + group.totalAmount += parseFloat(invoice.amountDue); + }); + return grouped; + }; + + // Auto-dismiss error + useEffect(() => { + if (!showPaymentError) return; + const timer = setTimeout(() => { + setShowPaymentError(false); + }, 8000); + return () => clearTimeout(timer); + }, [showPaymentError]); + + const handleSelectInvoice = (invoiceId) => { + const invoice = receivedInvoices.find((inv) => inv.id === invoiceId); + if (invoice?.isPaid || invoice?.isCancelled) return; + + setSelectedInvoices((prev) => { + const newSet = new Set(prev); + if (newSet.has(invoiceId)) { + newSet.delete(invoiceId); + } else { + newSet.add(invoiceId); + } + return newSet; + }); + }; + + const handleSelectAll = () => { + const unpaidInvoices = receivedInvoices.filter( + (inv) => !inv.isPaid && !inv.isCancelled ); + setSelectedInvoices(new Set(unpaidInvoices.map((inv) => inv.id))); }; - // Helper function to get token decimals - const getTokenDecimals = (tokenAddress, fallbackDecimals = 18) => { - const tokenInfo = getTokenInfo(tokenAddress); - return tokenInfo?.decimals || fallbackDecimals; + const handleClearAll = () => { + setSelectedInvoices(new Set()); }; - // Helper function to get token symbol - const getTokenSymbol = (tokenAddress, fallbackSymbol = "TOKEN") => { - const tokenInfo = getTokenInfo(tokenAddress); - return tokenInfo?.symbol || fallbackSymbol; + const selectBatchSuggestion = (suggestion) => { + const invoiceIds = suggestion.invoices.map((inv) => inv.id); + setSelectedInvoices(new Set(invoiceIds)); + toast.success(`Selected ${invoiceIds.length} invoices for batch payment`); + }; + + const payEntireBatch = async (batchId) => { + const batchInvoices = receivedInvoices.filter( + (inv) => + inv.batchInfo?.batchId === batchId && !inv.isPaid && !inv.isCancelled + ); + + if (batchInvoices.length === 0) { + toast.error("No unpaid invoices found in this batch"); + return; + } + + setSelectedInvoices(new Set(batchInvoices.map((inv) => inv.id))); + toast.info( + `Selected ${batchInvoices.length} invoices from batch #${batchId}` + ); + + setTimeout(() => { + handleBatchPayment(); + }, 1000); + }; + + // UNIFORM INDIVIDUAL PAYMENT + const payInvoice = async (invoiceId, amountDue, tokenAddress) => { + if (!walletClient) { + setPaymentError( + "Wallet not connected. Please connect your wallet and try again." + ); + setShowPaymentError(true); + return; + } + + setPaymentLoading((prev) => ({ ...prev, [invoiceId]: true })); + setPaymentError(""); + setShowPaymentError(false); + + try { + const provider = new BrowserProvider(walletClient); + const signer = await provider.getSigner(); + const contract = new Contract( + import.meta.env.VITE_CONTRACT_ADDRESS, + ChainvoiceABI, + signer + ); + + const invoice = receivedInvoices.find((inv) => inv.id === invoiceId); + if (invoice?.isCancelled) { + throw new Error("Cannot pay a cancelled invoice"); + } + + const fee = await contract.fee(); + const isNativeToken = tokenAddress === ethers.ZeroAddress; + const tokenSymbol = getTokenSymbol(tokenAddress, "Token"); + + // BALANCE CHECK (same as batch) + try { + await checkBalance(tokenAddress, amountDue, tokenSymbol, signer); + toast.success("Balance check passed! Processing payment..."); + } catch (balanceError) { + setPaymentError(balanceError.message); + setShowPaymentError(true); + return; + } + + if (!isNativeToken) { + const tokenContract = new Contract(tokenAddress, ERC20_ABI, signer); + const currentAllowance = await tokenContract.allowance( + await signer.getAddress(), + import.meta.env.VITE_CONTRACT_ADDRESS + ); + + const decimals = await tokenContract.decimals(); + const amountDueInWei = ethers.parseUnits(String(amountDue), decimals); + + if (currentAllowance < amountDueInWei) { + toast.info(`Requesting approval for ${tokenSymbol}...`); + const approveTx = await tokenContract.approve( + import.meta.env.VITE_CONTRACT_ADDRESS, + amountDueInWei + ); + toast.info("Approval transaction submitted. Please wait..."); + await approveTx.wait(); + toast.success(`${tokenSymbol} approval completed successfully!`); + } + + toast.info("Submitting payment transaction..."); + const tx = await contract.payInvoice(BigInt(invoiceId), { + value: fee, + }); + toast.info( + "Payment transaction submitted. Please wait for confirmation..." + ); + await tx.wait(); + toast.success(`Payment successful! Paid with ${tokenSymbol}`); + } else { + const amountDueInWei = ethers.parseUnits(String(amountDue), 18); + const total = amountDueInWei + BigInt(fee); + + toast.info("Submitting payment transaction..."); + const tx = await contract.payInvoice(BigInt(invoiceId), { + value: total, + }); + toast.info( + "Payment transaction submitted. Please wait for confirmation..." + ); + await tx.wait(); + toast.success("Payment successful! Paid with ETH"); + } + + const updatedInvoices = receivedInvoices.map((inv) => + inv.id === invoiceId ? { ...inv, isPaid: true } : inv + ); + setReceivedInvoice(updatedInvoices); + } catch (error) { + console.error("Payment failed:", error); + const errorMsg = getDetailedErrorMessage(error); + setPaymentError(errorMsg); + setShowPaymentError(true); + toast.error("Payment failed. Check error details for more information."); + } finally { + setPaymentLoading((prev) => ({ ...prev, [invoiceId]: false })); + } + }; + + // UNIFORM BATCH PAYMENT + const handleBatchPayment = async () => { + if (!walletClient || selectedInvoices.size === 0) return; + + setBatchLoading(true); + setPaymentError(""); + setShowPaymentError(false); + + try { + const provider = new BrowserProvider(walletClient); + const signer = await provider.getSigner(); + const contract = new Contract( + import.meta.env.VITE_CONTRACT_ADDRESS, + ChainvoiceABI, + signer + ); + + const grouped = getGroupedInvoices(); + + // BALANCE CHECK (same as individual) + toast.info("Checking balances..."); + + for (const [tokenKey, group] of grouped.entries()) { + try { + await checkBalance( + group.tokenAddress, + group.totalAmount, + group.symbol, + signer + ); + } catch (error) { + setPaymentError(error.message); + setShowPaymentError(true); + toast.error( + "Balance check failed. Check error details for more information." + ); + setBatchLoading(false); + return; + } + } + + toast.success("Balance checks passed! Processing payments..."); + + // Process payments + for (const [tokenKey, group] of grouped.entries()) { + const { tokenAddress, symbol, decimals, invoices } = group; + const invoiceIds = invoices.map((inv) => BigInt(inv.id)); + + if (invoiceIds.length > 50) { + throw new Error( + `Batch size limit exceeded for ${symbol}. Max 50 invoices per batch.` + ); + } + + let totalAmount = BigInt(0); + for (const invoice of invoices) { + const amount = ethers.parseUnits( + invoice.amountDue.toString(), + decimals + ); + totalAmount += amount; + } + + const feePerInvoice = await contract.fee(); + const totalFee = feePerInvoice * BigInt(invoiceIds.length); + const isNativeToken = tokenAddress === ethers.ZeroAddress; + + if (!isNativeToken) { + const tokenContract = new Contract(tokenAddress, ERC20_ABI, signer); + const currentAllowance = await tokenContract.allowance( + await signer.getAddress(), + import.meta.env.VITE_CONTRACT_ADDRESS + ); + + if (currentAllowance < totalAmount) { + toast.info(`Approving ${symbol} for spending...`); + const approveTx = await tokenContract.approve( + import.meta.env.VITE_CONTRACT_ADDRESS, + totalAmount + ); + await approveTx.wait(); + toast.success(`${symbol} approved successfully!`); + } + + const tx = await contract.payInvoicesBatch(invoiceIds, { + value: totalFee, + }); + await tx.wait(); + toast.success( + `Successfully paid ${invoices.length} invoices with ${symbol}!` + ); + } else { + const tx = await contract.payInvoicesBatch(invoiceIds, { + value: totalAmount + totalFee, + }); + await tx.wait(); + toast.success( + `Successfully paid ${invoices.length} invoices with ETH!` + ); + } + + const updatedInvoices = receivedInvoices.map((inv) => + invoiceIds.some((id) => id === BigInt(inv.id)) + ? { ...inv, isPaid: true } + : inv + ); + setReceivedInvoice(updatedInvoices); + } + + setSelectedInvoices(new Set()); + toast.success("All batch payments completed successfully!"); + } catch (error) { + console.error("Batch payment error:", error); + const errorMsg = getDetailedErrorMessage(error); + setPaymentError(errorMsg); + setShowPaymentError(true); + toast.error( + "Batch payment failed. Check error details for more information." + ); + } finally { + setBatchLoading(false); + } }; + // Initialize Lit Protocol useEffect(() => { const initLit = async () => { try { @@ -139,6 +616,7 @@ function ReceivedInvoice() { setShowWalletAlert(!isConnected); }, [isConnected]); + // Fetch invoices useEffect(() => { if (!walletClient || !address || !litReady) return; @@ -160,7 +638,8 @@ function ReceivedInvoice() { const litNodeClient = litClientRef.current; if (!litNodeClient) { - alert("Lit client not initialized"); + setError("Lit client not initialized. Please refresh the page."); + setLoading(false); return; } @@ -171,10 +650,8 @@ function ReceivedInvoice() { ); const res = await contract.getReceivedInvoices(address); - console.log("Raw invoices data:", res); if (!res || !Array.isArray(res) || res.length === 0) { - console.warn("No invoices found."); setReceivedInvoice([]); setLoading(false); return; @@ -196,7 +673,6 @@ function ReceivedInvoice() { const currentUserAddress = address.toLowerCase(); if (currentUserAddress !== from && currentUserAddress !== to) { - console.warn(`Unauthorized access attempt for invoice ${id}`); continue; } @@ -269,7 +745,10 @@ function ReceivedInvoice() { parsed["isPaid"] = isPaid; parsed["isCancelled"] = isCancelled; - // Enhance with token details using the new token fetching system + const batchInfo = detectBatchFromMetadata(parsed); + if (batchInfo) { + parsed.batchInfo = batchInfo; + } if (parsed.paymentToken?.address) { const tokenInfo = getTokenInfo(parsed.paymentToken.address); if (tokenInfo) { @@ -306,14 +785,9 @@ function ReceivedInvoice() { symbol, name, decimals: Number(decimals), - logo: "/tokenImages/generic.png", // Generic fallback + logo: "/tokenImages/generic.png", }; } catch (error) { - console.error( - "Failed to fetch token info from blockchain:", - error - ); - // Keep existing data or set defaults parsed.paymentToken.logo = parsed.paymentToken.logo || "/tokenImages/generic.png"; } @@ -327,6 +801,8 @@ function ReceivedInvoice() { } setReceivedInvoice(decryptedInvoices); + const suggestions = findBatchSuggestions(decryptedInvoices); + setBatchSuggestions(suggestions); const fee = await contract.fee(); setFee(fee); } catch (error) { @@ -340,102 +816,6 @@ function ReceivedInvoice() { fetchReceivedInvoices(); }, [walletClient, litReady, address, tokens]); - const payInvoice = async (invoiceId, amountDue, tokenAddress) => { - if (!walletClient) { - console.error("Wallet not connected"); - return; - } - - setPaymentLoading((prev) => ({ ...prev, [invoiceId]: true })); - - try { - const provider = new BrowserProvider(walletClient); - const signer = await provider.getSigner(); - const contract = new Contract( - import.meta.env.VITE_CONTRACT_ADDRESS, - ChainvoiceABI, - signer - ); - const invoice = receivedInvoices.find((inv) => inv.id === invoiceId); - if (invoice?.isCancelled) { - throw new Error("Cannot pay a cancelled invoice"); - } - const fee = await contract.fee(); - const isNativeToken = tokenAddress === ethers.ZeroAddress; - - if (!ethers.isAddress(tokenAddress)) { - throw new Error(`Invalid token address: ${tokenAddress}`); - } - - const tokenSymbol = getTokenSymbol(tokenAddress, "Token"); - - if (!isNativeToken) { - const tokenContract = new Contract(tokenAddress, ERC20_ABI, signer); - - const currentAllowance = await tokenContract.allowance( - await signer.getAddress(), - import.meta.env.VITE_CONTRACT_ADDRESS - ); - - const decimals = await tokenContract.decimals(); - const amountDueInWei = ethers.parseUnits(String(amountDue), decimals); - - if (currentAllowance < amountDueInWei) { - const approveTx = await tokenContract.approve( - import.meta.env.VITE_CONTRACT_ADDRESS, - amountDueInWei - ); - - await approveTx.wait(); - alert( - `Approval for ${tokenSymbol} completed! Now processing payment...` - ); - } - - const tx = await contract.payInvoice(BigInt(invoiceId), { - value: fee, - }); - - await tx.wait(); - alert(`Payment successful in ${tokenSymbol}!`); - } else { - const amountDueInWei = ethers.parseUnits(String(amountDue), 18); - const total = amountDueInWei + BigInt(fee); - - const tx = await contract.payInvoice(BigInt(invoiceId), { - value: total, - }); - - await tx.wait(); - alert("Payment successful in ETH!"); - } - - // Refresh invoice status - const updatedInvoices = receivedInvoices.map((inv) => - inv.id === invoiceId ? { ...inv, isPaid: true } : inv - ); - setReceivedInvoice(updatedInvoices); - } catch (error) { - console.error("Payment failed:", error); - if (error.code === "ACTION_REJECTED") { - toast.error("Transaction was rejected by user"); - } else if (error.message.includes("insufficient balance")) { - toast.error("Insufficient balance for this transaction"); - } else if (error.message.includes("cancelled")) { - toast.error("Cannot pay a cancelled invoice"); - } else { - toast.error(`Payment failed: ${error.reason || error.message}`); - } - } finally { - setPaymentLoading((prev) => ({ ...prev, [invoiceId]: false })); - } - }; - - const [drawerState, setDrawerState] = useState({ - open: false, - selectedInvoice: null, - }); - const toggleDrawer = (invoice) => (event) => { if ( event && @@ -454,13 +834,19 @@ function ReceivedInvoice() { const element = document.getElementById("invoice-print"); if (!element) return; - const canvas = await html2canvas(element, { scale: 2 }); - const data = canvas.toDataURL("image/png"); + try { + const canvas = await html2canvas(element, { scale: 2 }); + const data = canvas.toDataURL("image/png"); - const link = document.createElement("a"); - link.download = `invoice-${drawerState.selectedInvoice.id}.png`; - link.href = data; - link.click(); + const link = document.createElement("a"); + link.download = `invoice-${drawerState.selectedInvoice.id}.png`; + link.href = data; + link.click(); + + toast.success("Invoice downloaded successfully!"); + } catch (error) { + toast.error("Failed to download invoice. Please try again."); + } }; const switchNetwork = async () => { @@ -468,12 +854,15 @@ function ReceivedInvoice() { setNetworkLoading(true); await window.ethereum.request({ method: "wallet_switchEthereumChain", - params: [{ chainId: "0xaa36a7" }], // Sepolia chain ID + params: [{ chainId: "0xaa36a7" }], }); setError(null); + toast.success("Successfully switched to Sepolia network!"); } catch (error) { console.error("Network switch failed:", error); - alert("Failed to switch network. Please switch to Sepolia manually."); + toast.error( + "Failed to switch network. Please switch to Sepolia manually in your wallet." + ); } finally { setNetworkLoading(false); } @@ -490,12 +879,18 @@ function ReceivedInvoice() { return date.toLocaleString(); }; + const unpaidInvoices = receivedInvoices.filter( + (inv) => !inv.isPaid && !inv.isCancelled + ); + const selectedCount = selectedInvoices.size; + const grouped = getGroupedInvoices(); + return ( <>
setShowWalletAlert(false)} />
@@ -514,7 +909,7 @@ function ReceivedInvoice() {
+ {/* UNIFORM ERROR DISPLAY */} + setShowPaymentError(false)} + autoHideDuration={8000} + sx={{ mt: 8 }} + > + setShowPaymentError(false)} + > + + + } + > + + + + {paymentError} + + + + + + {/* Smart Batch Suggestions */} + {batchSuggestions.length > 0 && ( + + + + 💡 Smart Batch Suggestions + + {batchSuggestions.map((suggestion) => ( + + + + {suggestion.invoices.length} invoices + {suggestion.sender && ` from ${suggestion.sender.fname}`} + + + ({suggestion.reason}) + + + + + {suggestion.totalAmount.toFixed(4)}{" "} + {suggestion.token?.symbol || "ETH"} + + + + + ))} + + )} + + {/* Batch Actions Panel */} + {unpaidInvoices.length > 0 && ( + + + + + + Batch Payment + + 0 ? "success" : "default"} + size="small" + /> + + + + + + + + {selectedCount > 0 && ( + <> + + + Payment Summary: + + + {Array.from(grouped.entries()).map(([tokenKey, group]) => ( + + + {group.logo ? ( + {group.symbol} { + e.target.src = "/tokenImages/generic.png"; + }} + /> + ) : ( + + )} + + {group.symbol} + + + ({group.invoices.length} invoices) + + + + {group.totalAmount.toFixed(6)} {group.symbol} + + + ))} + + + + )} + + {selectedCount === 0 && ( + + Select one or more unpaid invoices to enable batch payment + + )} + + )} + ) : error ? (
-
-

{error}

+
+ +

{error}

) : receivedInvoices.length === 0 ? ( @@ -594,7 +1246,32 @@ function ReceivedInvoice() { borderBottom: "1px solid #f1f5f9", }} > - {column.label} + {column.id === "select" ? ( + 0 && + selectedCount < unpaidInvoices.length + } + checked={ + selectedCount === unpaidInvoices.length && + unpaidInvoices.length > 0 + } + onChange={(e) => { + if (e.target.checked) { + handleSelectAll(); + } else { + handleClearAll(); + } + }} + /> + } + label="" + /> + ) : ( + column.label + )} ))} @@ -612,9 +1289,20 @@ function ReceivedInvoice() { sx={{ "&:last-child td": { borderBottom: 0 }, "&:hover": { backgroundColor: "#f8fafc" }, + backgroundColor: selectedInvoices.has(invoice.id) + ? "rgba(34, 197, 94, 0.05)" + : "transparent", }} > - {/* Client Column */} + + handleSelectInvoice(invoice.id)} + disabled={invoice.isPaid || invoice.isCancelled} + color="success" + /> + +
{invoice.user?.email}
+ {invoice.batchInfo && ( +
+ } + label={`Batch #${invoice.batchInfo.batchId.slice( + -4 + )} (${invoice.batchInfo.index + 1}/${ + invoice.batchInfo.batchSize + })`} + size="small" + variant="outlined" + color="secondary" + sx={{ + fontSize: "0.7rem", + height: "20px", + }} + /> +
+ )}
- {/* Sender Column */} @@ -649,7 +1355,6 @@ function ReceivedInvoice() { - {/* Amount Column */}
{invoice.paymentToken?.logo ? ( @@ -674,7 +1379,6 @@ function ReceivedInvoice() {
- {/* Status Column */} {invoice.isCancelled ? ( )} - {/* Date Column */} {formatDate(invoice.issueDate)} @@ -726,8 +1429,36 @@ function ReceivedInvoice() { + {invoice.batchInfo && + !invoice.isPaid && + !invoice.isCancelled && ( + + + payEntireBatch( + invoice.batchInfo.batchId + ) + } + sx={{ + backgroundColor: "#f3e8ff", + "&:hover": { + backgroundColor: "#e9d5ff", + }, + }} + > + + + + )} + {!invoice.isPaid && !invoice.isCancelled && ( - + ) : null + } + > + {paymentLoading[invoice.id] + ? "Processing..." + : "Pay Now"} + )} {invoice.isCancelled && ( - + )} @@ -823,7 +1572,6 @@ function ReceivedInvoice() { voice

-

Powered by Chainvoice

@@ -1075,19 +1823,21 @@ function ReceivedInvoice() {
- - +