registerAjaxHandlers(); // Add meta box for manual payment verification add_action('add_meta_boxes_easy-invoice', array($this, 'add_manual_payment_meta_box')); // AJAX handler for creating a sample invoice add_action('wp_ajax_easy_invoice_create_sample_invoice', array($this, 'ajax_create_sample_invoice')); // AJAX handler for creating a new invoice with title add_action('wp_ajax_easy_invoice_create_new_invoice', array($this, 'ajax_create_new_invoice')); // 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') { $args['post_status'] = 'draft'; } // Build meta query array $meta_query = []; // Add status filter if provided if (!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' => '=' ] ]; } } // 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]; } 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 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) $trash_args = ['post_status' => 'trash']; $trash_invoices = $repository->all($trash_args); $trash_count = count($trash_invoices); // Get draft count for tab display (without pagination) $draft_args = ['post_status' => 'draft']; $draft_invoices = $repository->all($draft_args); $draft_count = count($draft_invoices); // 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, '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(); if (is_wp_error($check)) { wp_die($check->get_error_message()); } $invoice_id = isset($_GET['invoice_id']) ? intval($_GET['invoice_id']) : 0; if ($invoice_id <= 0) { wp_die(__('Invalid invoice ID', 'easy-invoice')); } // Get invoice from repository $repository = InvoiceServiceProvider::getInvoiceRepository(); $invoice = $repository->find($invoice_id); if (!$invoice) { wp_die(__('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) { // Set status to cancelled before moving to trash $invoice->setStatus('cancelled'); $invoice->save(); } // 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 */ 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) { // WordPress defaults restored posts to 'draft', so we need to explicitly set it to 'publish' wp_update_post(array( 'ID' => $invoice_id, 'post_status' => 'publish' )); // Get the invoice object and set status to available $invoice_repository = InvoiceServiceProvider::getInvoiceRepository(); $invoice = $invoice_repository->find($invoice_id); if ($invoice) { $invoice->setStatus('available'); $invoice->save(); } wp_send_json_success(array('message' => 'Invoice restored from trash')); } 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']); // 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']); // Update post status to draft $result = wp_update_post(array( 'ID' => $invoice_id, 'post_status' => 'draft' )); if ($result) { wp_send_json_success(array('message' => 'Invoice set to draft successfully')); } else { wp_send_json_error(array('message' => 'Error setting invoice to draft')); } } /** * 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($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_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) { if (wp_trash_post($id)) { $processed++; } } wp_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)) { // Also set status to publish (since WordPress sets it to draft by default) wp_update_post(array( 'ID' => $id, 'post_status' => 'publish' )); $processed++; } } wp_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_restored=' . $processed)); break; case 'delete': foreach ($invoice_ids as $id) { if (wp_delete_post($id, true)) { $processed++; } } wp_redirect(admin_url('admin.php?page=easy-invoice-all&view=trash&bulk_deleted=' . $processed)); break; case 'draft': foreach ($invoice_ids as $id) { // Update post status to draft if (wp_update_post(array( 'ID' => $id, 'post_status' => 'draft' ))) { $processed++; } } wp_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_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_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_error=invalid_action')); } exit; } /** * Get stats for dashboard */ public function getInvoiceStats() { $repository = InvoiceServiceProvider::getInvoiceRepository(); $total_invoices = count($repository->all()); $pending_invoices = count($repository->findByStatus('pending')); $paid_invoices = count($repository->findByStatus('paid')); // Get total revenue by currency from paid invoices $revenue_by_currency = []; $paid_invoices_list = $repository->findByStatus('paid'); // First, get all currencies that exist in the system $all_invoices = $repository->all(); $all_currencies = []; foreach ($all_invoices as $invoice) { $currency_code = $invoice->getCurrencyCode(); // If currency is empty or "global", get the actual currency that was used if (empty($currency_code) || $currency_code === 'global') { // Get the actual currency from invoice meta $actual_currency = get_post_meta($invoice->getId(), '_easy_invoice_currency_code', true); $currency_code = !empty($actual_currency) ? $actual_currency : get_option('easy_invoice_currency_code', 'USD'); } // If currency is still "global", use the global setting if ($currency_code === 'global') { $currency_code = get_option('easy_invoice_currency_code', 'USD'); } // Normalize currency code to uppercase for consistent grouping $currency_code = strtoupper($currency_code); if (!empty($currency_code)) { $all_currencies[$currency_code] = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code); } } // Initialize revenue for all currencies found foreach ($all_currencies as $currency_code => $currency_symbol) { $revenue_by_currency[$currency_code] = [ 'amount' => 0, 'symbol' => $currency_symbol ]; } // Now calculate revenue for paid invoices foreach ($paid_invoices_list as $invoice) { $invoice_total = $invoice->getTotal(); if (!is_numeric($invoice_total)) { continue; } // Get the actual currency from the invoice $currency_code = $invoice->getCurrencyCode(); // If currency is empty or "global", get the actual currency that was used if (empty($currency_code) || $currency_code === 'global') { // Get the actual currency from invoice meta $actual_currency = get_post_meta($invoice->getId(), '_easy_invoice_currency_code', true); $currency_code = !empty($actual_currency) ? $actual_currency : get_option('easy_invoice_currency_code', 'USD'); } // If currency is still "global", use the global setting if ($currency_code === 'global') { $currency_code = get_option('easy_invoice_currency_code', 'USD'); } // Normalize currency code to uppercase for consistent grouping $currency_code = strtoupper($currency_code); if (isset($revenue_by_currency[$currency_code])) { $revenue_by_currency[$currency_code]['amount'] += $invoice_total; } } // Calculate total value from ALL invoices (not just paid ones) $total_value_by_currency = []; // Initialize total value for all currencies found foreach ($all_currencies as $currency_code => $currency_symbol) { $total_value_by_currency[$currency_code] = [ 'amount' => 0, 'invoices' => 0, 'invoice_object' => null // Keep reference for formatting ]; } // Calculate total value from all invoices foreach ($all_invoices as $invoice) { $invoice_total = $invoice->getTotal(); if (!is_numeric($invoice_total)) { continue; } // Get the actual currency from the invoice $currency_code = $invoice->getCurrencyCode(); // If currency is empty or "global", get the actual currency that was used if (empty($currency_code) || $currency_code === 'global') { // Get the actual currency from invoice meta $actual_currency = get_post_meta($invoice->getId(), '_easy_invoice_currency_code', true); $currency_code = !empty($actual_currency) ? $actual_currency : get_option('easy_invoice_currency_code', 'USD'); } // If currency is still "global", use the global setting if ($currency_code === 'global') { $currency_code = get_option('easy_invoice_currency_code', 'USD'); } // Normalize currency code to uppercase for consistent grouping $currency_code = strtoupper($currency_code); if (isset($total_value_by_currency[$currency_code])) { $total_value_by_currency[$currency_code]['amount'] += $invoice_total; $total_value_by_currency[$currency_code]['invoices']++; // Keep reference to first invoice for formatting if ($total_value_by_currency[$currency_code]['invoice_object'] === null) { $total_value_by_currency[$currency_code]['invoice_object'] = $invoice; } } } 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'))); } // 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 = null; $formatter = null; 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'))); } // 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; } /** * Add meta box for manual payment verification to the invoice edit screen. */ public function add_manual_payment_meta_box() { add_meta_box( 'easy_invoice_manual_payment_verification', __('Manual Payment Verification', 'easy-invoice'), array($this, 'render_manual_payment_meta_box'), 'easy-invoice', // Post type 'side', // Context 'high' // Priority ); } /** * Render the manual payment verification meta box. * * @param \WP_Post $post The current post object. */ public function render_manual_payment_meta_box(\WP_Post $post) { $payment_status = get_post_meta($post->ID, '_payment_status', true); $payment_method = get_post_meta($post->ID, '_payment_method', true); if (!in_array($payment_status, ['pending-bank', 'pending-cheque'])) { echo '
' . __('This invoice is not pending manual payment verification.', 'easy-invoice') . '
'; return; } wp_nonce_field('easy_invoice_mark_paid_' . $post->ID, 'easy_invoice_mark_paid_nonce'); echo '' . __('Transaction ID:', 'easy-invoice') . ' ' . esc_html($transaction_id) . '
'; if ($notes) { echo '' . __('Notes:', 'easy-invoice') . '
'; echo '' . __('Proof Document:', 'easy-invoice') . ' ' . __('View Proof', 'easy-invoice') . '
'; } } elseif ($payment_method === 'cheque') { $cheque_number = get_post_meta($post->ID, '_cheque_number', true); $bank_name = get_post_meta($post->ID, '_cheque_bank_name', true); $cheque_date = get_post_meta($post->ID, '_cheque_date', true); $notes = get_post_meta($post->ID, '_cheque_notes', true); $image_url = get_post_meta($post->ID, '_cheque_image', true); echo '' . __('Cheque Number:', 'easy-invoice') . ' ' . esc_html($cheque_number) . '
'; if ($bank_name) echo '' . __('Bank Name:', 'easy-invoice') . ' ' . esc_html($bank_name) . '
'; if ($cheque_date) echo '' . __('Cheque Date:', 'easy-invoice') . ' ' . esc_html($cheque_date) . '
'; if ($notes) { echo '' . __('Notes:', 'easy-invoice') . '
'; echo '' . __('Cheque Image:', 'easy-invoice') . ' ' . __('View Image', 'easy-invoice') . '
'; } } echo ''; echo ''; echo '
'; echo ''; // Add a script for the AJAX call ?> __('You do not have permission to create invoices.', 'easy-invoice') ], 403); return; } try { $invoice_repository = InvoiceServiceProvider::getInvoiceRepository(); // Sample Invoice Data $sample_invoice_data = [ 'post_title' => 'Sample Invoice - ' . date('Y-m-d H:i'), 'post_status' => 'draft', // Or 'publish' if you want it live immediately // Add other WP_Post fields as needed (e.g., post_author) ]; // Sample Meta Data $sample_meta_data = [ '_easy_invoice_number' => 'SAMPLE-' . time(), '_easy_invoice_issue_date' => date('Y-m-d'), '_easy_invoice_due_date' => date('Y-m-d', strtotime('+15 days')), '_easy_invoice_status' => 'draft', '_easy_invoice_customer_name' => 'John Doe (Sample Client)', '_easy_invoice_customer_email' => 'customer@example.com', '_easy_invoice_customer_address' => "123 Sample Street\nSampleville, ST 12345", 'currency_code' => 'USD', 'currency_position' => 'before', // Add other meta keys as needed ]; // Sample Line Items $sample_items = []; for ($i = 1; $i <= 3; $i++) { $sample_items[] = [ 'name' => 'Sample Service ' . $i, 'description' => 'Detailed description of sample service ' . $i . '.', 'quantity' => rand(1, 5), 'price' => rand(50, 200) * 1.00, // 'taxable' => true/false (optional) ]; } $sample_meta_data['_easy_invoice_items'] = $sample_items; // Create the invoice post $invoice_id = wp_insert_post($sample_invoice_data, true); // true for WP_Error on failure if (is_wp_error($invoice_id)) { throw new \Exception('Failed to create invoice post: ' . $invoice_id->get_error_message()); } // Set invoice meta data foreach ($sample_meta_data as $key => $value) { update_post_meta($invoice_id, $key, $value); } // Recalculate totals if your Invoice model or repository has a method for it // For example, if you have $invoice->calculateTotals()->save(); or similar. wp_send_json_success([ 'message' => __('Sample invoice created successfully!', 'easy-invoice'), 'invoice_id' => $invoice_id, 'edit_link' => admin_url('admin.php?page=easy-invoice-builder&id=' . $invoice_id) ]); } catch (\Exception $e) { wp_send_json_error([ 'message' => __('Error creating sample invoice:', 'easy-invoice') . ' ' . $e->getMessage() ], 500); } } /** * 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' => date('Y-m-d'), 'due_date' => 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'])); } return $token; } /** * 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; } }