# easy-invoice/2.4.0/includes/Controllers/InvoiceController.php

Easy Invoice – Invoice Generator, PDF Quotes &amp; Payments, version 2.4.0. 1,262 lines.

- Page: https://pluginprobe.com/plugins/easy-invoice/2.4.0/code/includes/Controllers/InvoiceController.php
- Raw: https://pluginprobe.com/plugins/easy-invoice/2.4.0/raw/includes/Controllers/InvoiceController.php
- Modified: 2026-09-15T12:31:20+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/easy-invoice/2.4.0/code/includes/Controllers/InvoiceController.php#L10-L20`.

```php
<?php
/**
 * Invoice Controller Class
 *
 * @package Easy_Invoice
 * @subpackage Controllers
 */

namespace EasyInvoice\Controllers;

use EasyInvoice\Models\Invoice;
use EasyInvoice\Providers\InvoiceServiceProvider;
use EasyInvoice\Constants\PagesSlugs;
use WP_Query;

/**
 * InvoiceController handles all invoice-related functionality
 */
class InvoiceController extends BaseController {
    /**
     * Initialize the controller
     */
    public function init() {
        // Allow plugins to extend the controller initialization
        do_action('easy_invoice_invoice_controller_before_init', $this);

        // Register AJAX endpoints for invoice management
        add_action('wp_ajax_easy_invoice_trash_invoice', array($this, 'trashInvoice'));
        add_action('wp_ajax_easy_invoice_restore_invoice', array($this, 'restoreInvoice'));
        add_action('wp_ajax_easy_invoice_delete_invoice_permanently', array($this, 'deleteInvoicePermanently'));
        add_action('wp_ajax_easy_invoice_delete_invoice', array($this, 'deleteInvoice')); // Legacy support
        add_action('wp_ajax_easy_invoice_publish_invoice', array($this, 'publishInvoice'));
        add_action('wp_ajax_easy_invoice_draft_invoice', array($this, 'draftInvoice'));

        // Handle bulk actions
        add_action('admin_init', array($this, 'handleBulkActions'));

        // Register additional AJAX handlers
        $this->registerAjaxHandlers();

        // NOTE: `easy_invoice_create_new_invoice` is registered by
        // registerAjaxHandlers() (called above), not here. It used to be registered
        // in both places, so WordPress held two handlers for the same action and
        // ajax_create_new_invoice() was hooked twice on a single request — harmless
        // only because the handler exits on reply. Registered once now; keep this
        // tombstone so it doesn't get added back.

        // Allow plugins to extend the controller initialization
        do_action('easy_invoice_invoice_controller_after_init', $this);
    }

    /**
     * Display method implementation
     *
     * @param array $args Display arguments
     */
    public function display(array $args = []) {
        // Allow plugins to modify display arguments
        $args = apply_filters('easy_invoice_invoice_controller_display_args', $args);

        $page = isset($args['page']) ? $args['page'] : '';

        // Allow plugins to modify the page before processing
        $page = apply_filters('easy_invoice_invoice_controller_display_page', $page, $args);

        switch ($page) {
            case PagesSlugs::ALL_INVOICES:
                $this->displayInvoicesPage();
                break;

            case PagesSlugs::INVOICE_NEW:
                // For a new invoice, ensure no ID is passed
                $_GET['id'] = isset($_GET['id']) ? $_GET['id'] : 0;
                $this->displayInvoiceBuilderPage();
                break;

            case PagesSlugs::INVOICE_PREVIEW:
                $this->displayPreviewPage();
                break;

            default:
                $this->displayInvoicesPage();
                break;
        }

        // Allow plugins to perform actions after display
        do_action('easy_invoice_invoice_controller_after_display', $page, $args);
    }

    /**
     * Display all invoices page
     *
     * Uses WordPress's built-in WP_Query and paginate_links() for optimal performance
     * with large datasets (10,000+ invoices). The pagination is handled efficiently
     * by WordPress core functions which are optimized for scalability.
     */
    protected function displayInvoicesPage() {
        // Allow plugins to perform actions before displaying invoices page
        do_action('easy_invoice_invoice_controller_before_display_invoices_page');

        // Get filter parameters
        $status_filter = isset($_GET['status']) ? sanitize_text_field($_GET['status']) : '';
        $recurring_filter = isset($_GET['recurring']) ? sanitize_text_field($_GET['recurring']) : '';
        $subscription_filter = isset($_GET['subscription']) ? sanitize_text_field($_GET['subscription']) : '';
        $client_filter = isset($_GET['client_id']) ? absint($_GET['client_id']) : 0;
        $search_query = isset($_GET['search']) ? sanitize_text_field(wp_unslash($_GET['search'])) : '';
        $current_view = isset($_GET['view']) ? sanitize_text_field($_GET['view']) : 'all';
        $current_page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1;
        $per_page = 20;
        $offset = ($current_page - 1) * $per_page;

        // Build repository query arguments
        $args = [];

        // Set post status based on view
        if ($current_view === 'trash') {
            $args['post_status'] = 'trash';
        } elseif ($current_view === 'draft') {
            // A draft is an invoice whose *status* is draft (every invoice is a
            // published post); the tab used to look for draft posts and always
            // read 0 while the Draft filter pill listed several.
            $status_filter = 'draft';
        }

        // Build meta query array
        $meta_query = [];

        // Add status filter if provided. "Overdue" is a state an invoice is in
        // (owed and past its due date), not a status anything writes, so the
        // filter derives it: any invoice still awaiting money whose due date
        // has passed, plus the few that carry the literal status.
        $overdue_ids = null;
        if ('overdue' === $status_filter) {
            // One direct lookup. As a nested OR/AND meta_query (with a DATE cast)
            // WordPress joined postmeta three times and scanned it — 17 s at
            // 10,000 invoices. Due dates are stored Y-m-d, so a string compare
            // is a date compare.
            global $wpdb;
            $overdue_ids = array_map('intval', (array) $wpdb->get_col($wpdb->prepare(
                "SELECT s.post_id FROM {$wpdb->postmeta} s
                 LEFT JOIN {$wpdb->postmeta} d ON d.post_id = s.post_id AND d.meta_key = '_easy_invoice_due_date'
                 WHERE s.meta_key = '_easy_invoice_status'
                   AND (s.meta_value = 'overdue'
                        OR (s.meta_value IN ('available', 'unpaid', 'partial', 'sent', 'pending')
                            AND d.meta_value IS NOT NULL AND d.meta_value <> '' AND d.meta_value < %s))",
                gmdate('Y-m-d', current_time('timestamp'))
            )));
        } elseif (!empty($status_filter)) {
            $meta_query[] = [
                'key' => '_easy_invoice_status',
                'value' => $status_filter,
                'compare' => '='
            ];
        }

        // Add client filter if provided.
        //
        // Easy Invoice stores either:
        //   • `_easy_invoice_client_id`  — populated when the user picks a
        //      client from the dropdown in the Invoice Builder, OR
        //   • `_easy_invoice_customer_email` (+ customer_name) — populated when
        //      the biller types ad-hoc customer info inline.
        //
        // To make the filter useful for both flows we match on
        //   client_id == N  OR  customer_email == that client's email.
        if (!empty($client_filter)) {
            $client_email = '';
            try {
                $client_repo = new \EasyInvoice\Repositories\ClientRepository();
                $client_obj  = $client_repo->find($client_filter);
                if ($client_obj) {
                    // The Client model exposes the email via the magic __call → __get fallback.
                    $client_email = (string) $client_obj->getEmail();
                }
            } catch (\Throwable $e) {
                $client_email = '';
            }

            $client_clauses = [
                'relation' => 'OR',
                [
                    'key'     => '_easy_invoice_client_id',
                    'value'   => (string) $client_filter,
                    'compare' => '=',
                ],
            ];
            if ($client_email !== '') {
                $client_clauses[] = [
                    'key'     => '_easy_invoice_customer_email',
                    'value'   => $client_email,
                    'compare' => '=',
                ];
            }
            $meta_query[] = $client_clauses;
        }

        // Add recurring filter if provided
        if (!empty($recurring_filter)) {
            if ($recurring_filter === 'recurring') {
                // Show only recurring invoices
                $meta_query[] = [
                    'key' => '_easy_invoice_recurring_enabled',
                    'value' => '1',
                    'compare' => '='
                ];
            } elseif ($recurring_filter === 'non-recurring') {
                // Show only non-recurring invoices
                $meta_query[] = [
                    'relation' => 'OR',
                    [
                        'key' => '_easy_invoice_recurring_enabled',
                        'compare' => 'NOT EXISTS'
                    ],
                    [
                        'key' => '_easy_invoice_recurring_enabled',
                        'value' => '0',
                        'compare' => '='
                    ]
                ];
            }
        }

        // Subscription filter (Pro's Subscription Invoices addon renders the
        // pills; the value was read above but never applied to the query).
        if (!empty($subscription_filter)) {
            if ($subscription_filter === 'subscription') {
                $meta_query[] = [
                    'key' => '_easy_invoice_subscription_enabled',
                    'value' => '1',
                    'compare' => '='
                ];
            } elseif ($subscription_filter === 'non-subscription') {
                $meta_query[] = [
                    'relation' => 'OR',
                    [
                        'key' => '_easy_invoice_subscription_enabled',
                        'compare' => 'NOT EXISTS'
                    ],
                    [
                        'key' => '_easy_invoice_subscription_enabled',
                        'value' => '0',
                        'compare' => '='
                    ]
                ];
            }
        }

        // Add meta query to args if we have any filters
        if (!empty($meta_query)) {
            if (count($meta_query) === 1) {
                $args['meta_query'] = [ $meta_query[0] ]; // a bare clause is ignored by WP_Query; it must be a list of clauses
            } else {
                $args['meta_query'] = [
                    'relation' => 'AND',
                    ...$meta_query
                ];
            }
        }

        // Add pagination parameters to args
        $args['posts_per_page'] = $per_page;
        $args['offset'] = $offset;
        $args['orderby'] = 'date';
        $args['order'] = 'DESC';

        // Allow plugins to modify query arguments
        $args = apply_filters('easy_invoice_invoice_controller_query_args', $args, $current_view, $status_filter);

        // Get paginated invoices using WordPress query
        $repository = InvoiceServiceProvider::getInvoiceRepository();

        // Use WordPress WP_Query directly for better pagination handling
        $query_args = array_merge([
            'post_type' => \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE,
            'post_status' => $args['post_status'] ?? 'publish',
            'posts_per_page' => $per_page,
            'paged' => $current_page,
            'orderby' => 'date',
            'order' => 'DESC',
            'no_found_rows' => false, // We need this for pagination
            'update_post_term_cache' => false, // Disable term cache for better performance
            'update_post_meta_cache' => false, // Disable meta cache for better performance
        ], $args);

        // Add search functionality
        if (!empty($search_query)) {
            // For search, we'll use a simpler approach that works better with WordPress
            // First, get all invoices that match the search criteria
            $search_ids = [];

            // Search in post title and content
            $title_search = new WP_Query([
                'post_type' => \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE,
                'post_status' => $args['post_status'] ?? 'publish',
                'posts_per_page' => -1,
                's' => $search_query
            ]);

            if ($title_search->have_posts()) {
                $search_ids = array_merge($search_ids, wp_list_pluck($title_search->posts, 'ID'));
            }

            // Search in meta fields
            $meta_search = new WP_Query([
                'post_type' => \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE,
                'post_status' => $args['post_status'] ?? 'publish',
                'posts_per_page' => -1,
                'meta_query' => [
                    'relation' => 'OR',
                    [
                        'key' => '_easy_invoice_number',
                        'value' => $search_query,
                        'compare' => 'LIKE'
                    ],
                    [
                        'key' => '_easy_invoice_customer_name',
                        'value' => $search_query,
                        'compare' => 'LIKE'
                    ],
                    [
                        'key' => '_easy_invoice_customer_email',
                        'value' => $search_query,
                        'compare' => 'LIKE'
                    ]
                ]
            ]);

            if ($meta_search->have_posts()) {
                $search_ids = array_merge($search_ids, wp_list_pluck($meta_search->posts, 'ID'));
            }

            // Remove duplicates
            $search_ids = array_unique($search_ids);

            if (!empty($search_ids)) {
                // Use post__in to filter by the found IDs
                $query_args['post__in'] = $search_ids;
            } else {
                // If no results found, set post__in to empty array to show no results
                $query_args['post__in'] = [0];
            }
        }

        // Remove offset as we're using paged
        if (null !== $overdue_ids) {
            $query_args['post__in'] = isset($query_args['post__in'])
                ? (array_values(array_intersect($query_args['post__in'], $overdue_ids)) ?: [0])
                : ($overdue_ids ?: [0]);
        }

        unset($query_args['offset']);

        // Allow plugins to modify the final query arguments
        $query_args = apply_filters('easy_invoice_invoice_controller_final_query_args', $query_args);

        $wp_query = new WP_Query($query_args);
        $invoices = [];

        if ($wp_query->have_posts()) {
            foreach ($wp_query->posts as $post) {
                $invoice = $repository->find($post->ID);
                if ($invoice) {
                    $invoices[] = $invoice;
                }
            }
        }

        // Allow plugins to modify the invoices array
        $invoices = apply_filters('easy_invoice_invoice_controller_invoices_list', $invoices, $wp_query);

        // Get pagination info from WordPress query
        $total_invoices = $wp_query->found_posts;
        $total_pages = $wp_query->max_num_pages;

        // Get trash count for tab display (without pagination)
        // Tab counts are counts, not model loads.
        $trash_count = (int) $repository->count(['post_status' => 'trash']);
        $draft_count = (int) $repository->count(['meta_key' => '_easy_invoice_status', 'meta_value' => 'draft']); // phpcs:ignore WordPress.DB.SlowDBQuery

        // Build clients list for the listing filter dropdown
        $clients_list = [];
        try {
            $client_repository = new \EasyInvoice\Repositories\ClientRepository();
            foreach ($client_repository->all() as $client) {
                $name = $client->getBusinessClientName() ?: trim($client->getFirstName() . ' ' . $client->getLastName());
                if ($name === '') {
                    continue;
                }
                $clients_list[] = [
                    'id'   => $client->getId(),
                    'name' => $name,
                ];
            }
            usort($clients_list, function ($a, $b) {
                return strcasecmp($a['name'], $b['name']);
            });
        } catch (\Throwable $e) {
            $clients_list = [];
        }

        // Prepare template data
        $template_data = [
            'invoices' => $invoices,
            'current_view' => $current_view,
            'status_filter' => $status_filter,
            'recurring_filter' => $recurring_filter,
            'subscription_filter' => $subscription_filter,
            'client_filter' => $client_filter,
            'clients_list' => $clients_list,
            'search_query' => $search_query,
            'trash_count' => $trash_count,
            'draft_count' => $draft_count,
            'repository' => $repository,
            'current_page' => $current_page,
            'per_page' => $per_page,
            'total_invoices' => $total_invoices,
            'total_pages' => $total_pages,
            'wp_query' => $wp_query
        ];

        // Allow plugins to modify template data
        $template_data = apply_filters('easy_invoice_invoice_controller_template_data', $template_data);

        // Display the template
        $this->displayTemplate(
            EASY_INVOICE_PLUGIN_DIR . 'templates/invoices/listing.php',
            $template_data
        );

        // Allow plugins to perform actions after displaying invoices page
        do_action('easy_invoice_invoice_controller_after_display_invoices_page', $template_data);
    }

    /**
     * Display invoice builder page
     */
    protected function displayInvoiceBuilderPage() {
        $invoice_id = isset($_GET['id']) ? intval($_GET['id']) : 0;
        $repository = InvoiceServiceProvider::getInvoiceRepository();

        // Display the template
        $this->displayTemplate(
            EASY_INVOICE_PLUGIN_DIR . 'templates/invoices/builder.php',
            ['invoice_id' => $invoice_id, 'repository' => $repository]
        );
    }

    /**
     * Display invoice preview page
     */
    protected function displayPreviewPage() {
        $this->renderInvoicePreview();
    }

    /**
     * Common helper method to render an invoice preview
     * Used by both preview methods to ensure consistency
     */
    private function renderInvoicePreview() {
        $check = $this->checkCapability('ei_view_invoices');
        if (is_wp_error($check)) {
            wp_die(esc_html($check->get_error_message()));
        }

        // The quote preview takes ?id=; accept both spellings here too.
        $invoice_id = isset($_GET['invoice_id']) ? intval($_GET['invoice_id']) : (isset($_GET['id']) ? intval($_GET['id']) : 0);

        if ($invoice_id <= 0) {
            wp_die(esc_html__('Invalid invoice ID', 'easy-invoice'));
        }

        // Get invoice from repository
        $repository = InvoiceServiceProvider::getInvoiceRepository();
        $invoice = $repository->find($invoice_id);

        if (!$invoice) {
            wp_die(esc_html__('Invalid invoice ID', 'easy-invoice'));
        }

        // Get common template variables
        $template_vars = $this->getCommonTemplateVars();
        $currency_symbol = $template_vars['currency_symbol'];

        // Enqueue preview styles
        wp_enqueue_style(
            'easy-invoice-preview',
            EASY_INVOICE_PLUGIN_URL . 'assets/css/preview.css',
            array(),
            EASY_INVOICE_VERSION
        );

        // Display the template
        $this->displayTemplate(
            EASY_INVOICE_PLUGIN_DIR . 'templates/invoices/preview.php',
            [
                'invoice' => $invoice,
                'currency_symbol' => $currency_symbol
            ]
        );
    }

    /**
     * Trash an invoice (move to trash)
     */
    public function trashInvoice() {
        if (!$this->handleAjaxSecurity(($_POST['nonce'] ?? ''))) {
            return;
        }

        // Check invoice ID
        if (!isset($_POST['invoice_id']) || empty($_POST['invoice_id'])) {
            wp_send_json_error(array('message' => 'Invalid invoice ID'));
        }

        $invoice_id = intval($_POST['invoice_id']);

        // Get the invoice object to update status
        $invoice_repository = InvoiceServiceProvider::getInvoiceRepository();
        $invoice = $invoice_repository->find($invoice_id);
        if ($invoice) {
            self::cancelForTrash($invoice);
        }

        // Move to trash
        $result = wp_trash_post($invoice_id);

        if ($result) {
            wp_send_json_success(array('message' => 'Invoice moved to trash'));
        } else {
            wp_send_json_error(array('message' => 'Error moving invoice to trash'));
        }
    }

    /**
     * Restore an invoice from trash
     */
    /**
     * Trashing cancels the invoice (the document survives and says what
     * happened) but remembers what it was, so restoring puts it back —
     * a paid invoice taken out of the list must not come back as unpaid.
     *
     * @param object $invoice Invoice model.
     */
    public static function cancelForTrash($invoice): void {
        $status = strtolower((string) $invoice->getStatus());
        if ('cancelled' !== $status) {
            update_post_meta((int) $invoice->getId(), '_easy_invoice_status_before_trash', $status);
        }
        $invoice->setStatus('cancelled');
        $invoice->save();
    }

    /**
     * Republish a restored invoice (WordPress restores to draft) and give it
     * back the status it had before it was trashed.
     *
     * @param int $invoice_id Invoice.
     */
    public static function restoreAfterTrash(int $invoice_id): void {
        wp_update_post(array('ID' => $invoice_id, 'post_status' => 'publish'));
        $previous = (string) get_post_meta($invoice_id, '_easy_invoice_status_before_trash', true);
        delete_post_meta($invoice_id, '_easy_invoice_status_before_trash');
        $invoice = InvoiceServiceProvider::getInvoiceRepository()->find($invoice_id);
        if ($invoice) {
            $invoice->setStatus('' !== $previous ? $previous : 'available');
            $invoice->save();
        }
    }

    public function restoreInvoice() {
        if (!$this->handleAjaxSecurity(($_POST['nonce'] ?? ''))) {
            return;
        }

        // Check invoice ID
        if (!isset($_POST['invoice_id']) || empty($_POST['invoice_id'])) {
            wp_send_json_error(array('message' => 'Invalid invoice ID'));
        }

        $invoice_id = intval($_POST['invoice_id']);

        // Restore from trash
        $result = wp_untrash_post($invoice_id);

        if ($result) {
            self::restoreAfterTrash($invoice_id);

            wp_send_json_success(array('message' => __('Invoice restored from trash.', 'easy-invoice')));
        } else {
            wp_send_json_error(array('message' => 'Error restoring invoice from trash'));
        }
    }

    /**
     * Delete an invoice permanently
     */
    public function deleteInvoicePermanently() {
        if (!$this->handleAjaxSecurity(($_POST['nonce'] ?? ''))) {
            return;
        }

        // Check invoice ID
        if (!isset($_POST['invoice_id']) || empty($_POST['invoice_id'])) {
            wp_send_json_error(array('message' => 'Invalid invoice ID'));
        }

        $invoice_id = intval($_POST['invoice_id']);

        // Ask before deleting, so a refusal can say why and what to do instead.
        // InvoiceRetention also blocks this at the data layer, but a bare
        // "Error deleting invoice" would leave the user with no idea that the
        // refusal was deliberate.
        $may_delete = \EasyInvoice\Services\InvoiceRetention::mayDelete($invoice_id);
        if (is_wp_error($may_delete)) {
            wp_send_json_error(array(
                'message' => $may_delete->get_error_message(),
                'code'    => $may_delete->get_error_code(),
            ));
        }

        // Delete permanently
        $result = wp_delete_post($invoice_id, true);

        if ($result) {
            wp_send_json_success(array('message' => 'Invoice deleted permanently'));
        } else {
            wp_send_json_error(array('message' => 'Error deleting invoice'));
        }
    }

    /**
     * Legacy delete invoice handler (now redirects to trash)
     */
    public function deleteInvoice() {
        // Redirect to trash function for backward compatibility
        $this->trashInvoice();
    }

    /**
     * Publish an invoice (change status from draft to publish)
     */
    public function publishInvoice() {
        if (!$this->handleAjaxSecurity(($_POST['nonce'] ?? ''))) {
            return;
        }

        // Check invoice ID
        if (!isset($_POST['invoice_id']) || empty($_POST['invoice_id'])) {
            wp_send_json_error(array('message' => 'Invalid invoice ID'));
        }

        $invoice_id = intval($_POST['invoice_id']);

        // Update post status to published
        $result = wp_update_post(array(
            'ID' => $invoice_id,
            'post_status' => 'publish'
        ));

        if ($result) {
            wp_send_json_success(array('message' => 'Invoice published successfully'));
        } else {
            wp_send_json_error(array('message' => 'Error publishing invoice'));
        }
    }

    /**
     * Set an invoice to draft status
     */
    public function draftInvoice() {
        if (!$this->handleAjaxSecurity(($_POST['nonce'] ?? ''))) {
            return;
        }

        // Check invoice ID
        if (!isset($_POST['invoice_id']) || empty($_POST['invoice_id'])) {
            wp_send_json_error(array('message' => 'Invalid invoice ID'));
        }

        $invoice_id = intval($_POST['invoice_id']);
        $repository = InvoiceServiceProvider::getInvoiceRepository();
        $invoice    = $repository->find($invoice_id);
        if (!$invoice) {
            wp_send_json_error(array('message' => __('Invoice not found.', 'easy-invoice')));
        }
        // Back to draft means the *invoice* status: the post stays published so
        // the invoice stays in the list and keeps its number and link. (It used
        // to set the post to draft, which made the invoice vanish from every
        // list and page.) Money already received cannot be un-issued.
        $status = strtolower((string) $invoice->getStatus());
        if (in_array($status, ['paid', 'partial'], true)) {
            wp_send_json_error(array('message' => __('A paid or part-paid invoice cannot go back to draft.', 'easy-invoice')));
        }
        $invoice->setStatus('draft');
        if ($invoice->save()) {
            wp_send_json_success(array('message' => __('Invoice set back to draft.', 'easy-invoice')));
        } else {
            wp_send_json_error(array('message' => __('The invoice could not be updated.', 'easy-invoice')));
        }
    }

    /**
     * Handle bulk actions
     */
    public function handleBulkActions() {
        // Check if we're processing a bulk action
        if (!isset($_POST['action']) || $_POST['action'] !== 'easy_invoice_bulk_action') {
            return;
        }

        // Check nonce and capability
        $security_check = $this->securityCheck(($_POST['easy_invoice_bulk_nonce'] ?? ''), 'easy_invoice_bulk_action');
        if (is_wp_error($security_check)) {
            wp_die(esc_html($security_check->get_error_message()));
        }

        // Check if we have invoice IDs
        if (!isset($_POST['invoice_ids']) || !is_array($_POST['invoice_ids']) || empty($_POST['invoice_ids'])) {
            wp_safe_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_error=no_selection'));
            exit;
        }

        // Get bulk action and invoice IDs
        $bulk_action = isset($_POST['bulk_action']) ? sanitize_text_field($_POST['bulk_action']) : '';
        $invoice_ids = array_map('intval', $_POST['invoice_ids']);

        // Process based on action
        $processed = 0;

        switch ($bulk_action) {
            case 'trash':
                foreach ($invoice_ids as $id) {
                    $model = InvoiceServiceProvider::getInvoiceRepository()->find((int) $id);
                    if ($model) {
                        self::cancelForTrash($model);
                    }
                    if (wp_trash_post($id)) {
                        $processed++;
                    }
                }
                wp_safe_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_trashed=' . $processed));
                break;

            case 'restore':
                foreach ($invoice_ids as $id) {
                    if (wp_untrash_post($id)) {
                        self::restoreAfterTrash((int) $id);
                        $processed++;
                    }
                }
                wp_safe_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_restored=' . $processed));
                break;

            case 'delete':
                // Issued invoices are skipped rather than failing the whole
                // batch: selecting "all" and finding nothing happened would be
                // worse than deleting the drafts and reporting the rest.
                $protected = 0;
                foreach ($invoice_ids as $id) {
                    if (is_wp_error(\EasyInvoice\Services\InvoiceRetention::mayDelete((int) $id))) {
                        $protected++;
                        continue;
                    }
                    if (wp_delete_post($id, true)) {
                        $processed++;
                    }
                }
                wp_safe_redirect(add_query_arg(
                    array_filter([
                        'page'          => 'easy-invoice-all',
                        'view'          => 'trash',
                        'bulk_deleted'  => $processed,
                        'bulk_kept'     => $protected ?: null,
                    ]),
                    admin_url('admin.php')
                ));
                break;

            case 'draft':
                // Invoice status, not post status (see draftInvoice()); paid and
                // part-paid invoices are left alone.
                foreach ($invoice_ids as $id) {
                    $model = InvoiceServiceProvider::getInvoiceRepository()->find((int) $id);
                    if (!$model || in_array(strtolower((string) $model->getStatus()), ['paid', 'partial'], true)) {
                        continue;
                    }
                    $model->setStatus('draft');
                    if ($model->save()) {
                        $processed++;
                    }
                }
                wp_safe_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_drafted=' . $processed));
                break;

            case 'publish':
                foreach ($invoice_ids as $id) {
                    // Update post status to publish
                    if (wp_update_post(array(
                        'ID' => $id,
                        'post_status' => 'publish'
                    ))) {
                        $processed++;
                    }
                }
                wp_safe_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_published=' . $processed));
                break;

            default:
                // Includes the `export` action — that's a Pro-only feature handled
                // by the BulkExportSelected extension. When Pro is inactive, the
                // Free-side teaser JS intercepts the submit before the form ever
                // POSTs here. If somehow it does (curl, etc), we redirect cleanly.
                wp_safe_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_error=invalid_action'));
        }

        exit;
    }

    /**
     * Get stats for dashboard
     */
    public function getInvoiceStats() {
        // Everything here is answered from SQL over the persisted totals
        // (InvoiceTotalsCache). This used to load every open and every paid
        // invoice as a model on each list view — minutes and hundreds of
        // megabytes once a store had a few thousand invoices.
        $repository       = InvoiceServiceProvider::getInvoiceRepository();
        $total_invoices   = (int) $repository->count();
        $outstanding      = \EasyInvoice\Services\InvoiceTotalsCache::outstanding();
        $pending_invoices = (int) $outstanding['count'];
        $paid             = \EasyInvoice\Services\InvoiceTotalsCache::paidRevenue();
        $paid_invoices    = (int) $paid['paid_count'];

        $site_currency       = strtoupper((string) get_option('easy_invoice_currency_code', 'USD'));
        $revenue_by_currency = $paid['revenue'];
        if (!isset($revenue_by_currency[$site_currency])) {
            $revenue_by_currency = [$site_currency => ['amount' => 0, 'symbol' => \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($site_currency)]] + $revenue_by_currency;
        }

        // "Total value": what is still owed, by currency, with the number of open invoices.
        $total_value_by_currency = [];
        foreach ($outstanding['amount'] as $currency_code => $amount) {
            $total_value_by_currency[$currency_code] = [
                'amount'         => (float) $amount,
                'invoices'       => (int) ($outstanding['count_by_currency'][$currency_code] ?? 0),
                'invoice_object' => null,
                'currency'       => $currency_code,
            ];
        }

        return [
            'total_invoices'   => $total_invoices,
            'pending_invoices' => $pending_invoices,
            'paid_invoices'    => $paid_invoices,
            'total_revenue'    => $revenue_by_currency,
            'total_value'      => $total_value_by_currency,
        ];
    }

    /**
     * Register additional AJAX handlers
     */
    public function registerAjaxHandlers() {
        add_action('wp_ajax_easy_invoice_load_template', array($this, 'handleLoadTemplate'));
        add_action('wp_ajax_easy_invoice_create_new_invoice', array($this, 'ajax_create_new_invoice'));
        // The `easy_invoice_search_clients` AJAX is owned by EasyInvoiceAjax.
        // The duplicate registration that used to live here raced with
        // EasyInvoiceAjax::searchClients() — only the first-registered
        // handler ran, and which one won depended on bootstrap order. That
        // intermittently broke the client-search dropdown in the invoice
        // builder. Keep this comment as a tombstone so the registration
        // doesn't get added back.
    }

    /**
     * Handle AJAX request to load invoice template
     */
    public function handleLoadTemplate() {
        // Verify nonce
        if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'easy_invoice_nonce')) {
            wp_send_json_error(array('message' => __('Security check failed', 'easy-invoice')));
        }

        // The preview renders the invoice's full content: only people who
        // can work on invoices may ask for it.
        if (!current_user_can('manage_options') && !easy_invoice_user_can('ei_create_invoice') && !easy_invoice_user_can('ei_view_invoices')) {
            wp_send_json_error(array('message' => __('You do not have permission to preview invoices.', 'easy-invoice')));
        }

        // Get template name and validate it securely
        $template = isset($_POST['template']) ? sanitize_text_field($_POST['template']) : 'standard';
        $template = $this->validateTemplateName($template, 'invoice');
		$invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;

        // Get secure template file path
        $template_file = $this->getSecureTemplatePath($template, 'invoice');


        if (!$template_file) {
            wp_send_json_error(array('message' => __('Invalid template', 'easy-invoice')));
        }

        // For new invoices (no ID), just return the template without invoice data
        if ($invoice_id === 0) {
            // Start output buffering
            ob_start();

            // Set up empty variables for new invoices.
            //
            // $invoice used to be null here, but every invoice design template calls
            // $invoice->getTitle() / getNumber() / etc. unguarded — so previewing or
            // switching a template on an invoice that has not been saved yet was an
            // immediate fatal. The model's constructor accepts null and fills itself
            // from the field defaults, so an empty instance gives the templates the
            // getters they expect and renders a blank preview.
            $invoice = new \EasyInvoice\Models\Invoice();
            // What the builder currently holds, so the preview is live.
            $invoice = \EasyInvoice\Helpers\PreviewOverlay::apply($invoice, isset($_POST['form_data']) ? (string) wp_unslash($_POST['form_data']) : '', 'invoice');

            // $formatter was null here too, and the templates call
            // $formatter->format() for every currency value — so even with a valid
            // empty invoice the render still died. Build the same formatter the
            // saved-invoice path below uses, wrapping the empty invoice.
            $formatter = new \EasyInvoice\Helpers\InvoiceFormatter($invoice);

            include_once $template_file;
            $html = ob_get_clean();

            // Send response
            wp_send_json_success(array('html' => $html));
            return;
        }

        // Get invoice data for existing invoices
        $repository = InvoiceServiceProvider::getInvoiceRepository();
        $invoice = $repository->find($invoice_id);

        if (!$invoice) {
            wp_send_json_error(array('message' => __('Invoice not found', 'easy-invoice')));
        }
        // Unsaved edits from the builder take precedence over the stored values.
        $invoice = \EasyInvoice\Helpers\PreviewOverlay::apply($invoice, isset($_POST['form_data']) ? (string) wp_unslash($_POST['form_data']) : '', 'invoice');

        // Initialize formatter for currency formatting
        $formatter = new \EasyInvoice\Helpers\InvoiceFormatter($invoice);

        // Start output buffering
        ob_start();
        include_once $template_file;
        $html = ob_get_clean();

        // Send response
        wp_send_json_success(array('html' => $html));
    }

    /**
     * Validate and sanitize template name to prevent directory traversal attacks
     *
     * @param string $template The template name to validate
     * @param string $type Either 'invoice' or 'quote'
     * @return string Validated template name or 'standard' as fallback
     */
    private function validateTemplateName($template, $type = 'invoice') {
        // Whitelist of allowed template names
        $allowed_templates = array(
            'invoice' => array('classic', 'corporate', 'creative', 'elegant', 'legacy', 'minimal', 'modern', 'professional', 'standard', 'default'),
            'quote' => array('legacy', 'minimal', 'minimalist', 'modern', 'standard', 'default')
        );

        // Strip any directory components using basename
        $template = basename($template);

        // Remove any file extension
        $template = preg_replace('/\.(php|html|htm)$/i', '', $template);

        // Remove any non-alphanumeric characters except hyphens and underscores
        $template = preg_replace('/[^a-z0-9_-]/i', '', $template);

        // Check if template is in whitelist
        if (isset($allowed_templates[$type]) && in_array($template, $allowed_templates[$type], true)) {
            return $template;
        }

        // Return default template if not in whitelist
        return 'standard';
    }

    /**
     * Get secure template file path with directory traversal protection
     *
     * @param string $template The validated template name
     * @param string $type Either 'invoice' or 'quote'
     * @return string|false The secure template file path or false if invalid
     */
    private function getSecureTemplatePath($template, $type = 'invoice') {
        // Define template directories
        $template_dirs = array(
            'invoice' => EASY_INVOICE_PLUGIN_DIR . 'templates/invoice-templates/',
            'quote' => EASY_INVOICE_PLUGIN_DIR . 'templates/quote-templates/'
        );

        if (!isset($template_dirs[$type])) {
            return false;
        }

        $template_dir = $template_dirs[$type];

        // Ensure template directory exists and is a directory
        if (!is_dir($template_dir)) {
            return false;
        }

        // Get the real path of the template directory (resolves any symlinks)
        $real_template_dir = realpath($template_dir);
        if ($real_template_dir === false) {
            return false;
        }

        // Construct the template file path
        $template_file = $real_template_dir . DIRECTORY_SEPARATOR . $template . '.php';

        // Get the real path of the template file (resolves any .. or . components)
        $real_template_file = realpath($template_file);

        // Verify that the resolved path is within the template directory
        // This prevents directory traversal attacks
        if ($real_template_file === false || strpos($real_template_file, $real_template_dir) !== 0) {
            // If template doesn't exist or is outside the directory, use default
            $default_file = $real_template_dir . DIRECTORY_SEPARATOR . 'standard.php';
            $real_default_file = realpath($default_file);

            if ($real_default_file !== false && strpos($real_default_file, $real_template_dir) === 0) {
                return $real_default_file;
            }

            return false;
        }

        // Verify the file exists and is readable
        if (!is_file($real_template_file) || !is_readable($real_template_file)) {
            // Fallback to standard template
            $default_file = $real_template_dir . DIRECTORY_SEPARATOR . 'standard.php';
            $real_default_file = realpath($default_file);

            if ($real_default_file !== false && strpos($real_default_file, $real_template_dir) === 0 && is_file($real_default_file) && is_readable($real_default_file)) {
                return $real_default_file;
            }

            return false;
        }

        return $real_template_file;
    }


    /**
     * AJAX handler for creating a new invoice with title
     */
    public function ajax_create_new_invoice() {
        // Security check: verify nonce
        check_ajax_referer('easy_invoice_nonce', 'nonce');

        // Security check: verify user capabilities
        if (!easy_invoice_user_can('ei_create_invoice')) {
            wp_send_json_error([
                'message' => __('You do not have permission to create invoices.', 'easy-invoice')
            ], 403);
            return;
        }

        // Get the invoice title
        $title = isset($_POST['title']) ? sanitize_text_field($_POST['title']) : '';

        if (empty($title)) {
            wp_send_json_error([
                'message' => __('Invoice title is required.', 'easy-invoice')
            ], 400);
            return;
        }

        try {
            $invoice_repository = InvoiceServiceProvider::getInvoiceRepository();

            // Prepare invoice data for repository
            $invoice_data = [
                'title' => $title,
                'post_status' => 'draft',
                'issue_date' => current_time('Y-m-d'),
                'due_date' => wp_date('Y-m-d', strtotime('+30 days')),
                'status' => 'draft',
                'invoice_template' => get_option('easy_invoice_last_invoice_template', 'standard')
            ];

			// Create the invoice using repository (this will auto-generate invoice number)
            $invoice = $invoice_repository->create($invoice_data);

            if (!$invoice) {
                throw new \Exception('Failed to create invoice');
            }

            // Debug: Check if invoice number was set
            $invoice_number = $invoice->getNumber();
            if (empty($invoice_number)) {
                // Force set the invoice number if it's empty
                $invoice_number_service = easy_invoice_get_invoice_number_service();
                $generated_number = $invoice_number_service->generateUniqueNumber();
                $invoice->setNumber($generated_number);
                $invoice->save();
            }

            wp_send_json_success([
                'message' => __('Invoice created successfully!', 'easy-invoice'),
                'invoice_id' => $invoice->getId(),
                'invoice_number' => $invoice->getNumber(),
                'redirect_url' => admin_url('admin.php?page=easy-invoice-builder&invoice_id=' . $invoice->getId())
            ]);

        } catch (\Exception $e) {
            wp_send_json_error([
                'message' => __('Error creating invoice:', 'easy-invoice') . ' ' . $e->getMessage()
            ], 500);
        }
    }

    // ---------------------------------------------------------------------
    // Per-invoice access token + authorisation helpers.
    //
    // Used by submitManualPayment (and any future invoice-scoped public
    // action) to gate the request without relying on the global
    // `easy_invoice_payment` nonce, which is rendered on every public
    // invoice page and is therefore harvestable for cross-invoice abuse.
    //
    // Mirrors QuoteController::quoteAccessToken / canActOnQuote — see the
    // CVE-2026-9021 patch for the design rationale. The shape is intentionally
    // the same so future audits can verify both quote and invoice paths
    // against the same mental model.
    // ---------------------------------------------------------------------

    /**
     * Get (or lazily generate) the per-invoice access token. 32 hex chars =
     * 128 bits of entropy, well above what's brute-forceable inside the
     * lifetime of a published invoice. Stored in private post meta.
     */
    public static function invoiceAccessToken(int $invoice_id): string {
        if ($invoice_id <= 0) {
            return '';
        }
        $token = (string) get_post_meta($invoice_id, '_easy_invoice_invoice_access_token', true);
        if ($token === '' || strlen($token) < 32) {
            try {
                $token = bin2hex(random_bytes(16));
            } catch (\Throwable $e) {
                // Fallback for systems without CSPRNG. wp_generate_password
                // uses random_bytes internally on modern PHP — same entropy.
                $token = wp_generate_password(32, false, false);
            }
            update_post_meta($invoice_id, '_easy_invoice_invoice_access_token', $token);
        }
        return $token;
    }

    /**
     * Read-only sibling of invoiceAccessToken(). Returns the persisted
     * token if one already exists, or an empty string otherwise — never
     * mints. Use this from user-controlled rendering contexts (e.g. the
     * `[easy_invoice_url]` shortcode) where allowing an arbitrary caller
     * to MINT a payment-authorising token for an attacker-chosen invoice
     * would be a privilege-escalation vector.
     *
     * Trusted server contexts (the EmailManager invoice-send path) should
     * keep calling invoiceAccessToken() so first-send still works.
     */
    public static function invoiceAccessTokenIfExists(int $invoice_id): string {
        if ($invoice_id <= 0) {
            return '';
        }
        $token = (string) get_post_meta($invoice_id, '_easy_invoice_invoice_access_token', true);
        return strlen($token) >= 32 ? $token : '';
    }

    /**
     * Pull the presented access token off the current request. Accepts it on
     * either POST (when the JS form posts AJAX) or GET (when the invoice URL
     * is opened directly from an emailed link).
     */
    private static function invoiceTokenFromRequest(): string {
        $token = '';
        if (isset($_POST['access_token'])) {
            $token = sanitize_text_field(wp_unslash($_POST['access_token']));
        } elseif (isset($_GET['ik'])) {
            $token = sanitize_text_field(wp_unslash($_GET['ik']));
        }
        /**
         * Filter the access token presented for the current request.
         *
         * Lets another proof of access (a signed secure link, say) stand in
         * for the ?ik= token. Return the document's own token to grant.
         *
         * @param string $token Token from the request, may be ''.
         * @param string $type  'invoice'.
         */
        return (string) apply_filters('easy_invoice_presented_access_token', $token, 'invoice');
    }

    /**
     * Central authorisation check for invoice-scoped public actions
     * (currently only manual-payment submission). Returns true when ANY of:
     *
     *   1. The request carries a valid per-invoice access token (the
     *      legitimate email-recipient flow). Constant-time compared with
     *      hash_equals.
     *   2. The current user is logged in AND has admin-grade capability
     *      (manage_options) — admin-side payment recording.
     *   3. The current user is logged in AND is the invoice's bound client
     *      (case-insensitive email match against the invoice's client_id
     *      record).
     *
     * Returns false otherwise. Callers must reject the request when this
     * returns false.
     */
    public static function canSubmitPaymentForInvoice(int $invoice_id, $invoice = null): bool {
        if ($invoice_id <= 0) {
            return false;
        }

        // Path 1: legitimate access-token flow (email/shortcode link recipient).
        $presented = self::invoiceTokenFromRequest();
        if ($presented !== '') {
            $stored = (string) get_post_meta($invoice_id, '_easy_invoice_invoice_access_token', true);
            if ($stored !== '' && hash_equals($stored, $presented)) {
                return true;
            }
        }

        // Path 2: admin override.
        if (current_user_can('manage_options')) {
            return true;
        }

        // Path 3: authenticated owner. ONLY when the current user is the
        // invoice's bound client (email match against the client_id record).
        //
        // Note: Invoice model resolves `getClientId()` via __call magic,
        // so method_exists() returns FALSE for it (PHP's method_exists
        // does not recognise __call-resolved methods). Use is_callable
        // instead — it correctly returns TRUE when the receiver has a
        // __call that can field the message, so this guard actually
        // permits the bound-client path on real Invoice objects.
        if (is_user_logged_in() && $invoice && is_callable([$invoice, 'getClientId']) && $invoice->getClientId()) {
            $current_user      = wp_get_current_user();
            $client_repository = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository();
            $client            = $client_repository->find($invoice->getClientId());
            if ($client && strcasecmp((string) $client->getEmail(), (string) $current_user->user_email) === 0) {
                return true;
            }
        }

        return false;
    }

}

```
