Stablecoin Invoices
Issue invoices payable in a stablecoin and collect them onchain, with a paid/cancelled record per invoice. Pre-filled with QUSDC on QIE Mainnet.
StablecoinInvoices.sol
// SPDX-License-Identifier: MITpragma solidity ^0.8.20; interface IERC20 { function transferFrom(address from, address to, uint256 amount) external returns (bool); function transfer(address to, uint256 amount) external returns (bool); function balanceOf(address account) external view returns (uint256); function decimals() external view returns (uint8);} /// @title StablecoinInvoices/// @notice Issue invoices payable in a stablecoin (QUSDC on QIE) and collect/// them onchain, with a per-invoice paid/cancelled record./// @dev Amounts are in the TOKEN'S OWN smallest unit. QUSDC has 6 decimals,/// not 18: 1 QUSDC is 1_000_000. Never assume 1e18 here./// Uses transferFrom, so a payer must approve() this contract for the/// invoice amount first. Pull-based on withdraw, and state is written/// before the external call on every path.contract StablecoinInvoices { struct Invoice { address payer; // address(0) = payable by anyone uint256 amount; // in token smallest units uint64 dueBy; // unix seconds; 0 = no expiry bool paid; bool cancelled; string memo; } IERC20 public immutable token; address public owner; uint256 public invoiceCount; uint256 public totalCollected; mapping(uint256 => Invoice) private _invoices; event InvoiceIssued(uint256 indexed id, address indexed payer, uint256 amount, string memo); event InvoicePaid(uint256 indexed id, address indexed paidBy, uint256 amount); event InvoiceCancelled(uint256 indexed id); event Withdrawn(address indexed to, uint256 amount); event OwnerChanged(address indexed previousOwner, address indexed newOwner); error NotOwner(); error ZeroAddress(); error NotAContract(); error ZeroAmount(); error NoSuchInvoice(); error AlreadySettled(); error NotYourInvoice(); error PastDue(); error TransferFailed(); error NothingToWithdraw(); modifier onlyOwner() { if (msg.sender != owner) revert NotOwner(); _; } /// @param token_ Stablecoin accepted for payment (QUSDC on QIE Mainnet). /// @param initialOwner Receives collected funds and may issue invoices. constructor(address token_, address initialOwner) { if (token_ == address(0) || initialOwner == address(0)) revert ZeroAddress(); // token is immutable: a wrong address means no invoice can ever be // paid and nothing can be withdrawn, with no way to correct it. if (token_.code.length == 0) revert NotAContract(); token = IERC20(token_); owner = initialOwner; emit OwnerChanged(address(0), initialOwner); } /// @notice Issue an invoice. `payer` may be address(0) for "anyone can pay". /// @param amount In the token's smallest unit (QUSDC: 1 QUSDC = 1000000). /// @param dueBy Unix seconds after which payment is refused; 0 = no expiry. function issueInvoice(address payer, uint256 amount, uint64 dueBy, string calldata memo) external onlyOwner returns (uint256 id) { if (amount == 0) revert ZeroAmount(); id = ++invoiceCount; _invoices[id] = Invoice(payer, amount, dueBy, false, false, memo); emit InvoiceIssued(id, payer, amount, memo); } /// @notice Pay an invoice. Approve this contract for `amount` first. function payInvoice(uint256 id) external { Invoice storage inv = _invoices[id]; if (inv.amount == 0) revert NoSuchInvoice(); if (inv.paid || inv.cancelled) revert AlreadySettled(); if (inv.payer != address(0) && inv.payer != msg.sender) revert NotYourInvoice(); if (inv.dueBy != 0 && block.timestamp > inv.dueBy) revert PastDue(); inv.paid = true; // effects before interaction totalCollected += inv.amount; emit InvoicePaid(id, msg.sender, inv.amount); if (!token.transferFrom(msg.sender, address(this), inv.amount)) revert TransferFailed(); } function cancelInvoice(uint256 id) external onlyOwner { Invoice storage inv = _invoices[id]; if (inv.amount == 0) revert NoSuchInvoice(); if (inv.paid || inv.cancelled) revert AlreadySettled(); inv.cancelled = true; emit InvoiceCancelled(id); } /// @notice Send the contract's entire token balance to `to`. function withdraw(address to) external onlyOwner { if (to == address(0)) revert ZeroAddress(); uint256 balance = token.balanceOf(address(this)); if (balance == 0) revert NothingToWithdraw(); emit Withdrawn(to, balance); if (!token.transfer(to, balance)) revert TransferFailed(); } function transferOwnership(address newOwner) external onlyOwner { if (newOwner == address(0)) revert ZeroAddress(); emit OwnerChanged(owner, newOwner); owner = newOwner; } function getInvoice(uint256 id) external view returns (Invoice memory) { if (_invoices[id].amount == 0) revert NoSuchInvoice(); return _invoices[id]; } function isPayable(uint256 id) external view returns (bool) { Invoice storage inv = _invoices[id]; return inv.amount != 0 && !inv.paid && !inv.cancelled && (inv.dueBy == 0 || block.timestamp <= inv.dueBy); }}