Building a Decentralized Invoice Management System on Conflux eSpace

A comprehensive guide to building a blockchain-based invoice system using Solidity, Next.js, and ethers.js.

Video Tutorial: Watch the full tutorial here
GitHub Repository: View the source code


Project Overview

CfxInvoice is a decentralized invoice management system built on Conflux eSpace that enables freelancers to create, manage, and track invoices on the blockchain. The system ensures transparency, security, and automatic payment processing without intermediaries.

Key Benefits

  • Transparency: All invoices are stored on-chain and publicly verifiable
  • Security: Cryptographic security through smart contracts
  • Automation: Automatic payment processing and status updates
  • Cost-Effective: Low transaction fees on Conflux eSpace
  • Decentralized: No single point of failure

Architecture

The project follows a modern full-stack architecture:

┌─────────────────┐
│   Next.js UI    │  ← React Components & Pages
├─────────────────┤
│  Wallet Provider│  ← MetaMask Integration
├─────────────────┤
│  Contract Layer │  ← ethers.js Integration
├─────────────────┤
│  Smart Contract │  ← Solidity on Conflux eSpace
└─────────────────┘

Tech Stack

  • Frontend: Next.js 14 (App Router), React, TypeScript, Tailwind CSS
  • Blockchain: Solidity, Hardhat, ethers.js v6
  • Network: Conflux eSpace Testnet/Mainnet
  • Wallet: MetaMask (or compatible Web3 wallet)

Smart Contract Overview

The smart contract manages invoice lifecycle with the following core functions:

Invoice Status Flow

Pending → Paid (via payment)
Pending → Overdue (via time check)
Pending → Cancelled (by freelancer)

Key Contract Functions

  • createInvoice() - Create new invoice
  • payInvoice() - Process payment (with automatic fund transfer)
  • cancelInvoice() - Cancel invoice (freelancer only)
  • markAsOverdue() - Update status to overdue
  • getInvoice() - Retrieve invoice details
  • getFreelancerInvoices() - Get all invoices created by address
  • getClientInvoices() - Get all invoices received by address

Frontend Implementation

1. Contract Integration Layer (lib/contract.ts)

This is the core file that handles all blockchain interactions. It abstracts the complexity of ethers.js and provides clean functions for the UI.

Provider & Contract Setup

export async function getProvider() {
  if (typeof window !== 'undefined' && window.ethereum) {
    return new ethers.BrowserProvider(window.ethereum);
  }
  const rpcUrl = process.env.NEXT_PUBLIC_RPC_URL || 'https://evmtestnet.confluxrpc.com';
  return new ethers.JsonRpcProvider(rpcUrl);
}

export async function getContract(signer?: ethers.Signer) {
  const provider = await getProvider();
  const contractProvider = signer || provider;
  
  if (!INVOICE_SYSTEM_ADDRESS) {
    throw new Error('Contract address not set');
  }
  
  return new ethers.Contract(
    INVOICE_SYSTEM_ADDRESS,
    INVOICE_SYSTEM_ABI,
    contractProvider
  );
}

Wallet Connection & Network Switching

export async function connectWallet(): Promise<ethers.Signer | null> {
  if (typeof window === 'undefined' || !window.ethereum) {
    throw new Error('MetaMask or compatible wallet not found');
  }

  const provider = new ethers.BrowserProvider(window.ethereum);
  await provider.send('eth_requestAccounts', []);
  return provider.getSigner();
}

export async function switchNetwork() {
  const chainId = process.env.NEXT_PUBLIC_CHAIN_ID || '71';
  const hexChainId = `0x${parseInt(chainId).toString(16)}`;

  try {
    await window.ethereum.request({
      method: 'wallet_switchEthereumChain',
      params: [{ chainId: hexChainId }],
    });
  } catch (switchError: any) {
    if (switchError.code === 4902) {
      // Chain not added, add it automatically
      await window.ethereum.request({
        method: 'wallet_addEthereumChain',
        params: [{
          chainId: hexChainId,
          chainName: 'Conflux eSpace Testnet',
          nativeCurrency: { name: 'CFX', symbol: 'CFX', decimals: 18 },
          rpcUrls: ['https://evmtestnet.confluxrpc.com'],
          blockExplorerUrls: ['https://evmtestnet.confluxscan.net/'],
        }],
      });
    }
  }
}

Transaction Functions

export async function createInvoice(
  client: string,
  amount: bigint,
  dueDate: bigint,
  description: string
) {
  const signer = await connectWallet();
  if (!signer) throw new Error('Wallet not connected');
  
  const contract = await getContract(signer);
  const tx = await contract.createInvoice(client, amount, dueDate, description);
  await tx.wait();
  return tx;
}

export async function payInvoice(invoiceId: bigint, amount: bigint) {
  const signer = await connectWallet();
  if (!signer) throw new Error('Wallet not connected');
  
  const contract = await getContract(signer);
  const tx = await contract.payInvoice(invoiceId, { value: amount });
  await tx.wait();
  return tx;
}

Data Fetching Functions

export async function fetchInvoice(invoiceId: bigint): Promise<Invoice> {
  const contract = await getContract();
  const data = await contract.getInvoice(invoiceId);
  return parseInvoice(data);
}

export async function fetchFreelancerInvoices(address: string): Promise<bigint[]> {
  const contract = await getContract();
  const invoiceIds = await contract.getFreelancerInvoices(address);
  return invoiceIds.map((id: any) => BigInt(id.toString()));
}

2. Wallet Provider Context (components/WalletProvider.tsx)

A React Context that manages wallet state globally across the application.

Context Setup

interface WalletContextType {
  account: string | null;
  connectWallet: () => Promise<void>;
  disconnectWallet: () => void;
  loading: boolean;
}

const WalletContext = createContext<WalletContextType>({
  account: null,
  connectWallet: async () => {},
  disconnectWallet: () => {},
  loading: false,
});

Auto-Connection & Event Listeners

useEffect(() => {
  checkWalletConnection();
  
  if (typeof window !== 'undefined' && window.ethereum) {
    window.ethereum.on('accountsChanged', handleAccountsChanged);
    window.ethereum.on('chainChanged', handleChainChanged);
    
    return () => {
      window.ethereum?.removeListener('accountsChanged', handleAccountsChanged);
      window.ethereum?.removeListener('chainChanged', handleChainChanged);
    };
  }
}, []);

const handleAccountsChanged = (accounts: string[]) => {
  if (accounts.length === 0) {
    setAccount(null);
  } else {
    setAccount(accounts[0]);
  }
};

Connection Handler

const handleConnectWallet = async () => {
  try {
    setLoading(true);
    await switchNetwork();
    const signer = await connectWallet();
    const address = await signer.getAddress();
    setAccount(address);
  } catch (error: any) {
    console.error('Error connecting wallet:', error);
    alert(error.message || 'Failed to connect wallet');
  } finally {
    setLoading(false);
  }
};

3. Create Invoice Page (app/create/page.tsx)

The form that allows freelancers to create new invoices.

Form State & Validation

const [formData, setFormData] = useState({
  client: '',
  amount: '',
  dueDate: '',
  description: '',
});

const handleSubmit = async (e: React.FormEvent) => {
  e.preventDefault();
  
  if (!account) {
    alert('Please connect your wallet first');
    return;
  }

  if (!isValidAddress(formData.client)) {
    alert('Invalid client address');
    return;
  }

  if (formData.client.toLowerCase() === account.toLowerCase()) {
    alert('Cannot create invoice for yourself');
    return;
  }

  try {
    setLoading(true);
    const amount = parseCFXToWei(formData.amount);
    const dueDate = Math.floor(new Date(formData.dueDate).getTime() / 1000);

    const tx = await createInvoiceTx(
      formData.client,
      amount,
      BigInt(dueDate),
      formData.description
    );

    alert('Invoice created successfully!');
    router.push('/invoices');
  } catch (error: any) {
    alert(error.message || 'Failed to create invoice');
  } finally {
    setLoading(false);
  }
};

4. Invoice Listing Page (app/invoices/page.tsx)

Displays all invoices with filtering capabilities.

Data Fetching Logic

const loadInvoices = async () => {
  if (!account) return;

  try {
    setLoading(true);
    const invoiceIds = new Set<bigint>();

    if (filter === 'all' || filter === 'sent') {
      const freelancerIds = await fetchFreelancerInvoices(account);
      freelancerIds.forEach((id) => invoiceIds.add(id));
    }

    if (filter === 'all' || filter === 'received') {
      const clientIds = await fetchClientInvoices(account);
      clientIds.forEach((id) => invoiceIds.add(id));
    }

    const invoicePromises = Array.from(invoiceIds).map((id) => fetchInvoice(id));
    const fetchedInvoices = await Promise.all(invoicePromises);
    
    // Sort by creation date, newest first
    fetchedInvoices.sort((a, b) => {
      const dateA = Number(a.createdAt);
      const dateB = Number(b.createdAt);
      return dateB - dateA;
    });

    setInvoices(fetchedInvoices);
  } catch (error) {
    console.error('Error loading invoices:', error);
  } finally {
    setLoading(false);
  }
};

5. Invoice Card Component (components/InvoiceCard.tsx)

Displays individual invoice with role-based actions.

Role Detection & Action Permissions

const isFreelancer = account?.toLowerCase() === invoice.freelancer.toLowerCase();
const isClient = account?.toLowerCase() === invoice.client.toLowerCase();
const canPay = isClient && (invoice.status === InvoiceStatus.Pending || invoice.status === InvoiceStatus.Overdue);
const canCancel = isFreelancer && (invoice.status === InvoiceStatus.Pending || invoice.status === InvoiceStatus.Overdue);

Payment Handler

const handlePay = async () => {
  if (!account) return;
  try {
    setLoading(true);
    await payInvoice(invoice.invoiceId, invoice.amount);
    alert('Invoice paid successfully!');
    onUpdate();
  } catch (error: any) {
    console.error('Error paying invoice:', error);
    alert(error.message || 'Failed to pay invoice');
  } finally {
    setLoading(false);
  }
};

Status Banner Display

const getStatusBanner = () => {
  switch (invoice.status) {
    case InvoiceStatus.Paid:
      return {
        bgColor: 'bg-green-50',
        borderColor: 'border-green-300',
        icon: '✓',
        title: 'PAID',
        message: `Payment received on ${formatDateTime(invoice.paidAt)}.`,
      };
    case InvoiceStatus.Overdue:
      return {
        bgColor: 'bg-red-50',
        borderColor: 'border-red-300',
        icon: '⚠️',
        title: 'OVERDUE',
        message: `Due date was ${formatDateTime(invoice.dueDate)}.`,
      };
    // ... other statuses
  }
};

6. Utility Functions (lib/utils.ts)

Helper functions for formatting and validation.

Currency & Address Formatting

export function formatCFXFromWei(wei: bigint): string {
  const cfx = Number(wei) / 1e18;
  return cfx.toFixed(4);
}

export function formatAddress(address: string): string {
  if (!address) return '';
  return `${address.slice(0, 6)}...${address.slice(-4)}`;
}

export function parseCFXToWei(cfx: string): bigint {
  const amount = parseFloat(cfx);
  if (isNaN(amount)) return BigInt(0);
  return BigInt(Math.floor(amount * 1e18));
}

Date Formatting

export function formatDateTime(timestamp: bigint | number): string {
  const date = new Date(Number(timestamp) * 1000);
  return date.toLocaleString('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
    hour: '2-digit',
    minute: '2-digit',
  });
}

Address Validation

export function isValidAddress(address: string): boolean {
  return /^0x[a-fA-F0-9]{40}$/.test(address);
}

Key Features

1. Automatic Payment Processing

When a client pays an invoice, funds are automatically transferred to the freelancer’s wallet via the smart contract’s payInvoice() function.

2. Role-Based UI

The interface adapts based on whether the user is the freelancer (invoice creator) or client (payer), showing relevant actions and information.

3. Real-Time Status Updates

Invoice status updates are reflected immediately after blockchain transactions are confirmed.

4. Filtering & Organization

Users can filter invoices by:

  • All invoices
  • Sent invoices (created by me)
  • Received invoices (sent to me)

5. Network Auto-Switching

The app automatically switches or adds the Conflux eSpace network to MetaMask if not already configured.

6. Responsive Design

Modern, mobile-friendly UI built with Tailwind CSS.


Setup & Deployment

Prerequisites

  • Node.js (v16 or higher)
  • MetaMask or compatible Web3 wallet
  • Conflux eSpace testnet CFX (from faucet)

Installation Steps

  1. Clone the repository
git clone https://github.com/Vikash-8090-Yadav/CfxInvoice
cd CfxInvoice
  1. Install dependencies
npm install
  1. Configure environment variables

Create .env.local:

NEXT_PUBLIC_CONTRACT_ADDRESS=your_deployed_contract_address
NEXT_PUBLIC_RPC_URL=https://evmtestnet.confluxrpc.com
NEXT_PUBLIC_CHAIN_ID=71
  1. Deploy smart contract
npm run compile
npm run deploy:testnet
  1. Run frontend
npm run dev

Network Configuration

Conflux eSpace Testnet:


Resources:

Built with :heart: using Conflux eSpace