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; } }