quote_repository = new QuoteRepository(); $this->client_repository = new ClientRepository(); $this->form_processor = new FormProcessor(); $this->quote_log_service = new QuoteLogService(); } /** * Initialize the controller * * @since 1.0.0 */ public function init(): void { // Allow plugins to extend the controller initialization do_action('easy_invoice_quote_controller_before_init', $this); // Add AJAX handlers add_action('wp_ajax_easy_invoice_delete_quote', [$this, 'handleDeleteQuote']); add_action('wp_ajax_easy_invoice_get_quote', [$this, 'handleGetQuote']); add_action('wp_ajax_easy_invoice_load_quote_template', [$this, 'handleLoadQuoteTemplate']); add_action('wp_ajax_easy_invoice_convert_quote', [$this, 'handleConvertQuote']); add_filter('easy_invoice_quote_row_actions', [$this, 'addConvertRowAction'], 5, 2); add_action('wp_ajax_easy_invoice_create_new_quote', [$this, 'handleCreateNewQuote']); // 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 quote // builder. Keep this comment as a tombstone so it doesn't get // added back. add_action('wp_ajax_easy_invoice_load_quote_form', [$this, 'handleLoadQuoteForm']); add_action('wp_ajax_easy_invoice_accept_quote', [$this, 'handleAcceptQuote']); add_action('wp_ajax_easy_invoice_decline_quote', [$this, 'handleDeclineQuote']); add_action('wp_ajax_nopriv_easy_invoice_accept_quote', [$this, 'handleAcceptQuote']); add_action('wp_ajax_nopriv_easy_invoice_decline_quote', [$this, 'handleDeclineQuote']); // Add missing AJAX handlers for quote listing actions add_action('wp_ajax_easy_invoice_bulk_quote_action', [$this, 'handleBulkQuoteAction']); add_action('wp_ajax_easy_invoice_trash_quote', [$this, 'handleTrashQuote']); add_action('wp_ajax_easy_invoice_draft_quote', [$this, 'handleDraftQuote']); // Add regular POST form handlers for quote actions add_action('init', [$this, 'handleQuoteFormActions']); // Add new AJAX handler for restoring a trashed quote add_action('wp_ajax_easy_invoice_restore_quote', [ $this, 'handleRestoreQuote' ]); // Add new AJAX handler for emptying trash add_action('wp_ajax_easy_invoice_empty_trash', [ $this, 'handleEmptyTrash' ]); // Add new AJAX handler for getting quote logs add_action('wp_ajax_easy_invoice_get_quote_logs', [ $this, 'handleGetQuoteLogs' ]); // Allow plugins to extend the controller initialization do_action('easy_invoice_quote_controller_after_init', $this); } /** * Display quote pages * * @since 1.0.0 * @param array $args Display arguments */ public function display(array $args = []): void { // Allow plugins to modify display arguments $args = apply_filters('easy_invoice_quote_controller_display_args', $args); $page = $args['page'] ?? ''; // Allow plugins to modify the page before processing $page = apply_filters('easy_invoice_quote_controller_display_page', $page, $args); switch ($page) { case PagesSlugs::ALL_QUOTES: $this->displayListing(); break; case PagesSlugs::QUOTE_NEW: $this->displayBuilder(); break; case PagesSlugs::QUOTE_PREVIEW: $this->displayPreview($args); break; default: $this->displayListing(); break; } // Allow plugins to perform actions after display do_action('easy_invoice_quote_controller_after_display', $page, $args); } /** * Display quote listing page * * @since 1.0.0 */ private function displayListing(): void { // First, get all counts independently of any filtering global $wpdb; // Get trash count first (based on post_status) $trash_count = (int)$wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'trash'", PostTypes::EASY_INVOICE_QUOTE_POST_TYPE )); // Get counts for each meta status (excluding trashed posts) $status_counts = $wpdb->get_results($wpdb->prepare( "SELECT COALESCE(pm.meta_value, 'draft') as status, COUNT(*) as count FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id AND pm.meta_key = '_easy_invoice_quote_status' WHERE p.post_type = %s AND p.post_status != 'trash' GROUP BY COALESCE(pm.meta_value, 'draft')", PostTypes::EASY_INVOICE_QUOTE_POST_TYPE )); // Initialize counts $draft_count = 0; $available_count = 0; $sent_count = 0; $accepted_count = 0; $declined_count = 0; $expired_count = 0; $cancelled_count = 0; $all_count = 0; // Process status counts foreach ($status_counts as $status) { $count = (int)$status->count; $all_count += $count; // Add to total (excluding trash) switch ($status->status) { case 'draft': $draft_count = $count; break; case 'available': $available_count = $count; break; case 'sent': $sent_count = $count; break; case 'accepted': $accepted_count = $count; break; case 'declined': $declined_count = $count; break; case 'expired': $expired_count = $count; break; case 'cancelled': $cancelled_count = $count; break; } } // Now handle the display filtering // Allow plugins to perform actions before displaying listing do_action('easy_invoice_quote_controller_before_display_listing'); // Get filter parameters $status_filter = isset($_GET['status']) ? sanitize_text_field($_GET['status']) : ''; $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; // Build query args for display $query_args = [ 'post_type' => PostTypes::EASY_INVOICE_QUOTE_POST_TYPE, 'posts_per_page' => $per_page, 'paged' => $current_page, 'orderby' => 'date', 'order' => 'DESC', 'no_found_rows' => false, 'update_post_term_cache' => false, 'update_post_meta_cache' => false ]; // Handle view filtering if ($current_view === 'trash' || $current_view === 'cancelled') { // For trash and cancelled views, look at post_status = 'trash' $query_args['post_status'] = 'trash'; // For cancelled view, also filter by meta status if ($current_view === 'cancelled') { $query_args['meta_query'] = [ [ 'key' => '_easy_invoice_quote_status', 'value' => 'cancelled', 'compare' => '=' ] ]; } } else { // For all other views, exclude trashed posts $query_args['post_status'] = ['publish', 'draft', 'private', 'pending']; if ($current_view !== 'all') { // For specific status views, add meta query $query_args['meta_query'] = [ [ 'key' => '_easy_invoice_quote_status', 'value' => $current_view, 'compare' => '=' ] ]; } } // Add client filter if provided (merges with any existing meta_query). // // Quote model uses the `_easy_invoice_quote_*` meta-key namespace // (see Models/Quote.php :: saveMetaData → meta_key = `_easy_invoice_quote_` . $field_name). // We match on either: // • `_easy_invoice_quote_client_id` (when picked from the client dropdown), OR // • `_easy_invoice_quote_customer_email` (when entered ad-hoc inline). if (!empty($client_filter)) { $client_email = ''; try { $client_repo = new \EasyInvoice\Repositories\ClientRepository(); $client_obj = $client_repo->find($client_filter); if ($client_obj) { $client_email = (string) $client_obj->getEmail(); } } catch (\Throwable $e) { $client_email = ''; } $client_clauses = [ 'relation' => 'OR', [ 'key' => '_easy_invoice_quote_client_id', 'value' => (string) $client_filter, 'compare' => '=', ], ]; if ($client_email !== '') { $client_clauses[] = [ 'key' => '_easy_invoice_quote_customer_email', 'value' => $client_email, 'compare' => '=', ]; } if (!empty($query_args['meta_query'])) { $existing = $query_args['meta_query']; if (!isset($existing['relation'])) { $existing = ['relation' => 'AND'] + $existing; } $existing[] = $client_clauses; $query_args['meta_query'] = $existing; } else { $query_args['meta_query'] = [$client_clauses]; } } // Add search if provided if (!empty($search_query)) { $search_ids = []; // Build base query args for search $search_query_args = [ 'post_type' => PostTypes::EASY_INVOICE_QUOTE_POST_TYPE, 'post_status' => $query_args['post_status'], 'posts_per_page' => -1, 'fields' => 'ids' // Only get IDs for better performance ]; // Search in title and content $title_search_args = array_merge($search_query_args, [ 's' => $search_query ]); $title_search = new \WP_Query($title_search_args); $search_ids = $title_search->posts; // Search in meta $meta_search_args = array_merge($search_query_args, [ 'meta_query' => [ 'relation' => 'OR', [ 'key' => '_easy_invoice_quote_number', 'value' => $search_query, 'compare' => 'LIKE' ], [ 'key' => '_easy_invoice_quote_client_name', 'value' => $search_query, 'compare' => 'LIKE' ], [ 'key' => '_easy_invoice_quote_client_email', 'value' => $search_query, 'compare' => 'LIKE' ] ] ]); $meta_search = new \WP_Query($meta_search_args); // 'fields' => 'ids' above: $posts already holds ids. Plucking 'ID' off // integers produced nulls, so a search by quote number, client name or // email matched nothing. $search_ids = array_map('intval', array_merge($search_ids, (array) $meta_search->posts)); $search_ids = array_unique($search_ids); if (!empty($search_ids)) { $query_args['post__in'] = $search_ids; } else { $query_args['post__in'] = [0]; } } // Allow plugins to modify query args $query_args = apply_filters('easy_invoice_quote_controller_final_query_args', $query_args); // Get filtered quotes for display $wp_query = new \WP_Query($query_args); $quotes = []; if ($wp_query->have_posts()) { foreach ($wp_query->posts as $post) { $quote = $this->quote_repository->find($post->ID); if ($quote) { $quotes[] = $quote; } } } // Allow plugins to modify the quotes array $quotes = apply_filters('easy_invoice_quote_controller_quotes_list', $quotes, $wp_query); // Get pagination info from WordPress query $total_quotes = $wp_query->found_posts; $total_pages = $wp_query->max_num_pages; // Initialize counts $draft_count = 0; $available_count = 0; $sent_count = 0; $accepted_count = 0; $declined_count = 0; $expired_count = 0; $cancelled_count = 0; // Process status counts foreach ($status_counts as $status) { switch ($status->status) { case 'draft': $draft_count = $status->count; break; case 'available': $available_count = $status->count; break; case 'sent': $sent_count = $status->count; break; case 'accepted': $accepted_count = $status->count; break; case 'declined': $declined_count = $status->count; break; case 'expired': $expired_count = $status->count; break; case 'cancelled': $cancelled_count = $status->count; break; } } // 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 = [ 'quotes' => $quotes, 'current_view' => $current_view, 'status_filter' => $status_filter, 'client_filter' => $client_filter, 'clients_list' => $clients_list, 'search_query' => $search_query, 'all_count' => (int)$all_count, 'trash_count' => (int)$trash_count, 'draft_count' => (int)$draft_count, 'available_count' => (int)$available_count, 'sent_count' => (int)$sent_count, 'accepted_count' => (int)$accepted_count, 'declined_count' => (int)$declined_count, 'expired_count' => (int)$expired_count, 'cancelled_count' => (int)$cancelled_count, 'repository' => $this->quote_repository, 'current_page' => $current_page, 'per_page' => $per_page, 'total_quotes' => $total_quotes, 'total_pages' => $total_pages, 'wp_query' => $wp_query ]; // Allow plugins to modify template data $template_data = apply_filters('easy_invoice_quote_controller_template_data', $template_data); // Display the template include EASY_INVOICE_PLUGIN_DIR . 'templates/quotes/listing.php'; // Allow plugins to perform actions after displaying listing do_action('easy_invoice_quote_controller_after_display_listing', $template_data); } /** * Display quote builder page * * @since 1.0.0 */ private function displayBuilder(): void { // Allow plugins to perform actions before displaying builder do_action('easy_invoice_quote_controller_before_display_builder'); $quote_id = isset($_GET['id']) ? (int) $_GET['id'] : 0; $quote = null; if ($quote_id > 0) { $quote = $this->quote_repository->find($quote_id); } // The builder's picker searches over AJAX; the hidden mirror select only needs // the quote's own client (rendered by the form). Loading every client here // built a model per user on each open. $clients = []; // Allow plugins to modify the data $quote = apply_filters('easy_invoice_quote_controller_builder_quote', $quote, $quote_id); $clients = apply_filters('easy_invoice_quote_controller_builder_clients', $clients); // Include the builder template include EASY_INVOICE_PLUGIN_DIR . 'templates/quotes/builder.php'; // Allow plugins to perform actions after displaying builder do_action('easy_invoice_quote_controller_after_display_builder', $quote, $clients); } /** * Display quote preview page * * @since 1.0.0 * @param array $args Display arguments */ private function displayPreview(array $args): void { // Allow plugins to perform actions before displaying preview do_action('easy_invoice_quote_controller_before_display_preview', $args); $quote_id = isset($_GET['id']) ? (int) $_GET['id'] : 0; if ($quote_id <= 0) { wp_die(esc_html__('Quote not found.', 'easy-invoice')); } $quote = $this->quote_repository->find($quote_id); if (!$quote) { wp_die(esc_html__('Quote not found.', 'easy-invoice')); } // Allow plugins to modify the quote $quote = apply_filters('easy_invoice_quote_controller_preview_quote', $quote, $quote_id); // Include the preview template include EASY_INVOICE_PLUGIN_DIR . 'templates/quotes/preview.php'; // Allow plugins to perform actions after displaying preview do_action('easy_invoice_quote_controller_after_display_preview', $quote, $args); } /** * Handle delete quote AJAX request * * @since 1.0.0 */ public function handleDeleteQuote(): void { // Verify nonce if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) { wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]); } // Check permissions if (!easy_invoice_user_can('ei_delete_quote')) { wp_send_json_error(['message' => __('Insufficient permissions.', 'easy-invoice')]); } $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0; if ($quote_id <= 0) { wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]); } if ($this->quote_repository->delete($quote_id)) { // Log the quote deletion $this->quote_log_service->logDeletion($quote_id); wp_send_json_success([ 'message' => __('Quote deleted successfully.', 'easy-invoice'), 'toast' => [ 'type' => 'success', 'message' => __('Quote deleted successfully.', 'easy-invoice') ] ]); } else { wp_send_json_error(['message' => __('Failed to delete quote.', 'easy-invoice')]); } } /** * Handle get quote AJAX request * * @since 1.0.0 */ public function handleGetQuote(): void { // Verify nonce if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_get_quote')) { wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]); } // Check permissions if (!easy_invoice_user_can('ei_view_quotes')) { wp_send_json_error(['message' => __('Insufficient permissions.', 'easy-invoice')]); } $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0; if ($quote_id <= 0) { wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]); } $quote = $this->quote_repository->find($quote_id); if (!$quote) { wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]); } wp_send_json_success(['quote' => $quote->toArray()]); } /** * Handle AJAX request to load quote template * * @since 1.0.0 */ public function handleLoadQuoteTemplate(): void { // Verify nonce if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_nonce')) { wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]); } // Check permissions if (!easy_invoice_user_can('ei_create_quote')) { wp_send_json_error(['message' => __('Insufficient permissions.', 'easy-invoice')]); } $template_id = sanitize_text_field($_POST['template'] ?? ''); $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0; if (empty($template_id)) { wp_send_json_error(['message' => __('Template ID is required.', 'easy-invoice')]); } // Validate template name securely $template_id = $this->validateTemplateName($template_id, 'quote'); // Get secure template file path $template_file = $this->getSecureTemplatePath($template_id, 'quote'); if (!$template_file) { wp_send_json_error(['message' => __('Template not found.', 'easy-invoice')]); } // Load quote if provided. // // For an unsaved quote there is no id, and the quote design templates call // $quote->getTitle() / getNumber() / etc. unguarded — passing null made // previewing or switching a template on a new quote fatal, the same way it // did on the invoice side (see InvoiceController::handleLoadTemplate). The // model's constructor accepts null and fills itself from the field defaults, // so an empty instance renders a blank preview instead. $quote = new \EasyInvoice\Models\Quote(); if ($quote_id > 0) { $loaded = $this->quote_repository->find($quote_id); if ($loaded) { $quote = $loaded; } } // Unsaved edits from the builder take precedence over the stored values. $quote = \EasyInvoice\Helpers\PreviewOverlay::apply($quote, isset($_POST['form_data']) ? (string) wp_unslash($_POST['form_data']) : '', 'quote'); // Start output buffering to capture template HTML ob_start(); // Include the template file include $template_file; // Get the captured HTML $html = ob_get_clean(); wp_send_json_success(['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 = 'quote') { // Whitelist of allowed template names $allowed_templates = array( 'invoice' => array('classic', 'corporate', 'creative', 'elegant', 'legacy', 'minimal', 'modern', 'professional', 'standard'), 'quote' => array('legacy', 'minimal', 'minimalist', 'modern', 'standard') ); // 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 = 'quote') { // 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; } /** * Handle AJAX request to create a new quote with just the title * * @since 1.0.0 */ public function handleCreateNewQuote(): void { // Verify nonce if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) { wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]); } // Check permissions if (!easy_invoice_user_can('ei_create_quote')) { wp_send_json_error(['message' => __('Insufficient permissions.', 'easy-invoice')]); } $title = isset($_POST['title']) ? sanitize_text_field($_POST['title']) : ''; if (empty($title)) { wp_send_json_error(['message' => __('Quote title is required.', 'easy-invoice')]); } // Generate a unique quote number $quote_number = ''; if (class_exists('\\EasyInvoice\\Services\\QuoteNumberService')) { $quote_number_service = new \EasyInvoice\Services\QuoteNumberService(); $quote_number = $quote_number_service->generateUniqueNumber(); } else { // Fallback if service doesn't exist $quote_number = 'QT-' . str_pad(time(), 6, '0', STR_PAD_LEFT); } // Get global quote settings $settings_controller = new \EasyInvoice\Controllers\SettingsController(); $quote_terms = $settings_controller::getQuoteTermsConditions(); $quote_footer = $settings_controller::getQuoteFooterText(); $quote_accept_button = get_option('easy_invoice_quote_accept_button', 'yes'); $quote_accept_action = get_option('easy_invoice_quote_accept_action', 'email'); $quote_accept_text = get_option('easy_invoice_quote_accept_text', __('Accept Quote', 'easy-invoice')); $quote_accepted_message = get_option('easy_invoice_quote_accepted_message', __('Thank you for accepting our quote!', 'easy-invoice')); $quote_declined_message = get_option('easy_invoice_quote_declined_message', __('Thank you for your consideration.', 'easy-invoice')); // Create the quote with just the title and default values $data = [ 'title' => $title, 'status' => 'draft', 'number' => $quote_number, // Use the generated unique number 'issue_date' => current_time('Y-m-d'), 'expiry_date' => wp_date('Y-m-d', strtotime('+30 days')), 'items' => [], 'notes' => '', // Ensure notes is never null 'terms' => $quote_terms, // Use global terms setting 'footer_text' => $quote_footer, // Use global footer setting 'accept_button' => $quote_accept_button, // Use global accept button setting 'accept_action' => $quote_accept_action, // Use global accept action setting 'accept_text' => $quote_accept_text, // Use global accept text setting 'accepted_message' => $quote_accepted_message, // Use global accepted message setting 'declined_message' => $quote_declined_message, // Use global declined message setting 'template' => get_option('easy_invoice_last_quote_template', 'standard') ]; $quote = $this->quote_repository->create($data); if (!$quote) { wp_send_json_error(['message' => __('Failed to create quote.', 'easy-invoice')]); } wp_send_json_success(['quote_id' => $quote->getId()]); } /** * Handle AJAX request to load quote form for modal * * @since 1.0.0 */ public function handleLoadQuoteForm(): void { // Verify nonce if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) { wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]); } // Check permissions if (!easy_invoice_user_can('ei_create_quote')) { wp_send_json_error(['message' => __('Insufficient permissions.', 'easy-invoice')]); } // Get global quote settings $settings_controller = new \EasyInvoice\Controllers\SettingsController(); $quote_terms = $settings_controller::getQuoteTermsConditions(); $quote_footer = $settings_controller::getQuoteFooterText(); $quote_accept_button = get_option('easy_invoice_quote_accept_button', 'yes'); $quote_accept_action = get_option('easy_invoice_quote_accept_action', 'email'); $quote_accept_text = get_option('easy_invoice_quote_accept_text', __('Accept Quote', 'easy-invoice')); $quote_accepted_message = get_option('easy_invoice_quote_accepted_message', __('Thank you for accepting our quote!', 'easy-invoice')); $quote_declined_message = get_option('easy_invoice_quote_declined_message', __('Thank you for your consideration.', 'easy-invoice')); // Create a new quote object for the form $quote_number_service = function_exists('easy_invoice_get_quote_number_service') ? easy_invoice_get_quote_number_service() : null; $quote_data = array( 'number' => $quote_number_service ? $quote_number_service->getNextNumber() : 'QT-1', 'date' => current_time('Y-m-d'), 'expiry_date' => wp_date('Y-m-d', strtotime('+30 days')), 'client_id' => 0, 'client_name' => '', 'client_email' => '', 'client_phone' => '', 'client_address' => '', 'items' => array(), 'notes' => '', 'internal_notes' => '', 'discount' => 0, 'discount_type' => 'percentage', 'calculation_method' => 'before_tax', 'tax_rate' => 10, 'prices_include_tax' => 'no', 'status' => 'draft', 'currency' => 'USD', 'currency_symbol' => '$', 'title' => '', 'description' => '', 'terms' => $quote_terms, // Use global terms setting 'footer_text' => $quote_footer, // Use global footer setting 'accept_button' => $quote_accept_button, // Use global accept button setting 'accept_action' => $quote_accept_action, // Use global accept action setting 'accept_text' => $quote_accept_text, // Use global accept text setting 'accepted_message' => $quote_accepted_message, // Use global accepted message setting 'declined_message' => $quote_declined_message, // Use global declined message setting ); // Create a temporary WP_Post object for new quote $empty_post = new \WP_Post((object) array( 'ID' => 0, 'post_author' => get_current_user_id(), 'post_date' => current_time('mysql'), 'post_date_gmt' => current_time('mysql', 1), 'post_title' => $quote_data['number'], 'post_status' => 'auto-draft', 'comment_status' => 'closed', 'ping_status' => 'closed', 'post_name' => '', 'post_modified' => current_time('mysql'), 'post_modified_gmt' => current_time('mysql', 1), 'post_parent' => 0, 'guid' => '', 'menu_order' => 0, 'post_type' => \EasyInvoice\Constants\PostTypes::EASY_INVOICE_QUOTE_POST_TYPE, 'post_mime_type' => '', 'comment_count' => 0, 'filter' => 'raw', )); $quote = new \EasyInvoice\Models\Quote($empty_post); // Set default values on the quote object foreach ($quote_data as $key => $value) { $setter = 'set' . easy_invoice_str_replace('_', '', ucwords($key, '_')); if (method_exists($quote, $setter)) { switch ($setter) { case 'setClientId': $quote->setClientId((int) $value); break; case 'setItems': $quote->setItems((array) $value); break; case 'setSubtotal': case 'setTaxAmount': case 'setDiscountAmount': case 'setTotal': case 'setDiscountValue': case 'setTaxRate': $quote->$setter((float) $value); break; case 'setPricesIncludeTax': $quote->$setter((bool) $value); break; default: $quote->$setter((string) $value); break; } } } // Initialize empty items array $quote->setItems([]); // Set variables needed by the form template $quote_id = 0; $clients = []; $quote_form_manager = new \EasyInvoice\Forms\Quote\QuoteFormManager(); $quote_items_json = json_encode([]); $admin_nonce = wp_create_nonce('easy_invoice_admin_nonce'); $quote_field_config = $quote_form_manager->getFieldConfigForJavaScript(); // Start output buffering to capture form HTML ob_start(); // Include the quote form template include EASY_INVOICE_PLUGIN_DIR . 'templates/quotes/form.php'; // Get the captured HTML $html = ob_get_clean(); wp_send_json_success(['html' => $html]); } /** * Nonce action for quote accept/decline (includes quote ID to prevent cross-quote reuse). */ private function quoteAcceptDeclineNonceAction(int $quote_id): string { return 'easy_invoice_quote_action_' . $quote_id; } /** * Get the per-quote access token. Lazily generated on first read. * * Previously the public quote page embedded an `easy_invoice_quote_action_{id}` * nonce that, combined with the off-by-default `easy_invoice_pro_restrict_quote_to_client` * option, let any visitor accept or decline any published quote * (CVE-2026-9021). The token replaces that public-nonce-as-authorisation * model: it's a cryptographically random per-quote secret that's only * leaked to the legitimate quote recipient via the emailed link's * `?qk=...` parameter, and is required server-side by the accept / * decline handlers (alongside an unconditional ownership check on * authenticated callers). * * The token is single-purpose (just accept/decline gating) and lives * in private post meta. We generate 32 hex chars (128 bits of entropy) * which is well above what's brute-forceable inside the lifetime of a * published quote. */ public static function quoteAccessToken(int $quote_id): string { if ($quote_id <= 0) { return ''; } $token = (string) get_post_meta($quote_id, '_easy_invoice_quote_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 source. $token = wp_generate_password(32, false, false); } update_post_meta($quote_id, '_easy_invoice_quote_access_token', $token); } return $token; } /** * Read-only sibling of quoteAccessToken(). 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_quote_url]` shortcode) where allowing an arbitrary caller * to MINT an Accept/Decline-authorising token for an attacker-chosen * quote would be a privilege-escalation vector. * * Trusted server contexts (the EmailManager quote-send path) should * keep calling quoteAccessToken() so first-send still works. */ public static function quoteAccessTokenIfExists(int $quote_id): string { if ($quote_id <= 0) { return ''; } $token = (string) get_post_meta($quote_id, '_easy_invoice_quote_access_token', true); return strlen($token) >= 32 ? $token : ''; } /** * Constant-time comparison helper for the access token. */ private static function quoteTokenFromRequest(): string { $token = ''; if (isset($_POST['access_token'])) { $token = sanitize_text_field(wp_unslash($_POST['access_token'])); } elseif (isset($_GET['qk'])) { $token = sanitize_text_field(wp_unslash($_GET['qk'])); } /** This filter is documented in includes/Controllers/InvoiceController.php */ return (string) apply_filters('easy_invoice_presented_access_token', $token, 'quote'); } /** * Central authorisation check for quote accept/decline. Returns true * when ANY of these is true: * * 1. The request carries a valid per-quote 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 accept/decline. * 3. The current user is logged in AND is the quote's bound client * (email match against the quote's client_id record). This was * previously gated behind the off-by-default * `easy_invoice_pro_restrict_quote_to_client` option — that gate * is removed in 2.3.4 so the ownership check runs unconditionally. * * Returns false otherwise. Callers must reject the request when this * returns false; we don't reject from in here so the caller can choose * wp_send_json_error vs wp_die based on its transport. */ /** * Whether a quote can still be accepted or declined: it must be open * (draft, available or sent) and not past its expiry date. * * @param object $quote Quote model. * @return true|\WP_Error Error carrying the reason to show the client. */ public static function openForDecision($quote) { $status = is_callable([$quote, 'getStatus']) ? strtolower((string) $quote->getStatus()) : ''; if ('accepted' === $status) { return new \WP_Error('easy_invoice_quote_closed', __('This quote has already been accepted.', 'easy-invoice')); } if ('declined' === $status) { return new \WP_Error('easy_invoice_quote_closed', __('This quote has already been declined.', 'easy-invoice')); } if (!in_array($status, ['draft', 'available', 'sent', 'expired'], true)) { return new \WP_Error('easy_invoice_quote_closed', __('This quote is no longer open.', 'easy-invoice')); } $expiry = is_callable([$quote, 'getExpiryDate']) ? (string) $quote->getExpiryDate() : ''; $expired = 'expired' === $status || ('' !== $expiry && strtotime($expiry) && gmdate('Y-m-d', strtotime($expiry)) < gmdate('Y-m-d', current_time('timestamp'))); if ($expired) { return new \WP_Error( 'easy_invoice_quote_expired', '' !== $expiry /* translators: %s: expiry date. */ ? sprintf(__('This quote expired on %s. Please ask for a new one.', 'easy-invoice'), date_i18n(get_option('date_format'), strtotime($expiry))) : __('This quote has expired. Please ask for a new one.', 'easy-invoice') ); } return true; } public static function canActOnQuote(int $quote_id, $quote = null): bool { if ($quote_id <= 0) { return false; } // Path 1: legitimate access-token flow (email link recipient). $presented = self::quoteTokenFromRequest(); if ($presented !== '') { $stored = (string) get_post_meta($quote_id, '_easy_invoice_quote_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 // quote's bound client (email match). Previously this was // skipped entirely when the Pro option was 'no' (the default) — // which is what made the CVE exploitable. Now it always runs. // // Note: Quote 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 Quote objects. if (is_user_logged_in() && $quote && is_callable([$quote, 'getClientId']) && $quote->getClientId()) { $current_user = wp_get_current_user(); $client_repository = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository(); $client = $client_repository->find($quote->getClientId()); if ($client && strcasecmp((string) $client->getEmail(), (string) $current_user->user_email) === 0) { return true; } } return false; } /** * Handle AJAX request to accept a quote * * @since 1.0.0 */ public function handleAcceptQuote(): void { $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0; if ($quote_id <= 0) { wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]); } // Quote-scoped nonce prevents cross-quote IDOR with a leaked global nonce. if (!wp_verify_nonce($_POST['nonce'] ?? '', $this->quoteAcceptDeclineNonceAction($quote_id))) { wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]); } $is_admin = current_user_can('manage_options'); if ($is_admin) { $quote = $this->quote_repository->find($quote_id); } else { $quote = $this->quote_repository->findPublished($quote_id); } if (!$quote) { wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]); } // SECURITY (CVE-2026-9021): authorise unconditionally — admin, valid // access token (email-link path), or authenticated client whose // email matches the quote's bound client. The previous gating // behind easy_invoice_pro_restrict_quote_to_client was OFF by // default, letting any anonymous visitor who could read the public // single-quote page harvest the nonce and accept arbitrary quotes. if (!self::canActOnQuote($quote_id, $quote)) { wp_send_json_error(['message' => __('You do not have permission to accept this quote.', 'easy-invoice')]); } $ei_open = self::openForDecision($quote); if (is_wp_error($ei_open)) { wp_send_json_error(['message' => $ei_open->get_error_message()]); } $current_user = wp_get_current_user(); // Get global accept action setting $settings_controller = new \EasyInvoice\Controllers\SettingsController(); $accept_action = $settings_controller::getQuoteAcceptAction(); // Update quote status to accepted $quote->setStatus('accepted'); $quote->setAcceptedDate(gmdate('Y-m-d H:i:s')); $quote->setAcceptedBy($current_user->ID); // Save the quote $saved = $quote->save(); if (!$saved) { wp_send_json_error(['message' => __('Failed to accept quote.', 'easy-invoice')]); } // Log the quote acceptance $this->quote_log_service->logAcceptance($quote_id, [ 'accept_action' => $accept_action, 'user_type' => $is_admin ? 'admin' : 'client' ]); // What the acceptance was made with. The signature is a data-URL PNG // from the page's signature pad (only present when an addon asked for // it); it is validated here and stored by whoever listens. $signature = isset($_POST['signature']) ? (string) wp_unslash($_POST['signature']) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- validated below. if ('' !== $signature && !preg_match('#^data:image/png;base64,[A-Za-z0-9+/=]+$#', $signature)) { $signature = ''; } /** * Fires once a quote has been accepted and saved. * * @param int $quote_id Quote id. * @param object $quote Quote model. * @param array $context accept_action, user_type, signature (data URL or ''), * signer_name, ip, user_agent, accepted_at. */ do_action('easy_invoice_quote_accepted', $quote_id, $quote, [ 'accept_action' => $accept_action, 'user_type' => $is_admin ? 'admin' : 'client', 'signature' => $signature, 'signer_name' => isset($_POST['signer_name']) ? sanitize_text_field(wp_unslash($_POST['signer_name'])) : '', 'ip' => isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '', 'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '', 'accepted_at' => current_time('mysql'), ]); // Perform the configured accept action $invoice_id = null; $action_message = ''; switch ($accept_action) { case 'convert': // Convert quote to invoice (Draft status) $invoice_id = $this->convertQuoteToInvoice($quote, 'draft'); if ($invoice_id) { $this->quote_log_service->logConversionToInvoice($quote_id, $invoice_id); } $action_message = __('Quote converted to invoice successfully.', 'easy-invoice'); break; case 'convert_available': // Convert quote to invoice (Available status) $invoice_id = $this->convertQuoteToInvoice($quote, 'available'); if ($invoice_id) { $this->quote_log_service->logConversionToInvoice($quote_id, $invoice_id); } $action_message = __('Quote converted to invoice successfully.', 'easy-invoice'); break; case 'convert_send': // Convert quote to invoice and send to client (Available status) $invoice_id = $this->convertQuoteToInvoice($quote, 'available'); if ($invoice_id) { $this->sendInvoiceToClient($invoice_id); } $action_message = __('Quote converted to invoice and sent to client successfully.', 'easy-invoice'); break; case 'duplicate': // Create new invoice, keep quote as-is (Draft status) $invoice_id = $this->createInvoiceFromQuote($quote, 'draft'); if ($invoice_id) { $this->quote_log_service->logDuplicationToInvoice($quote_id, $invoice_id); } $action_message = __('New invoice created from quote successfully.', 'easy-invoice'); break; case 'duplicate_send': // Create new invoice and send to client, keep quote as-is (Available status) $invoice_id = $this->createInvoiceFromQuote($quote, 'available'); if ($invoice_id) { $this->sendInvoiceToClient($invoice_id); } $action_message = __('New invoice created and sent to client successfully.', 'easy-invoice'); break; case 'do_nothing': default: // Do nothing additional $action_message = __('Quote accepted successfully.', 'easy-invoice'); break; } // Send notification email to admin if (!$is_admin) { $this->sendQuoteAcceptanceNotification($quote); } // Get URLs for the new invoice $invoice_url = null; $secure_url = null; if ($invoice_id) { // Always use WordPress permalink $invoice_url = get_permalink($invoice_id); // If Pro and secure link available, use secure link if (class_exists('\EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController')) { $secure_url = \EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController::getInvoiceSecureLinkUrl($invoice_id); if ($secure_url) { $invoice_url = $secure_url; } } } wp_send_json_success([ 'message' => $action_message, 'invoice_id' => $invoice_id, 'invoice_url' => $invoice_url, 'secure_url' => $secure_url, 'toast' => [ 'type' => 'success', 'message' => $action_message ] ]); } /** * Convert quote to invoice * * @param \EasyInvoice\Models\Quote $quote The quote to convert * @param string $status The status for the new invoice ('draft' or 'available') * @return int|null The invoice ID if successful, null otherwise */ /** * "Convert to invoice" on the quote row — for the quote the client accepted * by phone or in person, which the public Accept button never sees. * * @param array $actions Row actions. * @param object $quote Quote model. * @return array */ public function addConvertRowAction($actions, $quote): array { $actions = is_array($actions) ? $actions : []; if (!easy_invoice_user_can('ei_create_invoice') || !is_callable([$quote, 'getId'])) { return $actions; } $converted = (int) get_post_meta((int) $quote->getId(), '_easy_invoice_quote_converted_invoice_id', true); if ($converted > 0 && get_post($converted)) { $actions['convert'] = sprintf( '%s', esc_url(admin_url('admin.php?page=easy-invoice-builder&invoice_id=' . $converted)), esc_attr__('Open the invoice made from this quote', 'easy-invoice'), esc_html__('Invoice', 'easy-invoice') ); return $actions; } $actions['convert'] = sprintf( '%s', (int) $quote->getId(), esc_attr((string) $quote->getNumber()), esc_html__('Convert to invoice', 'easy-invoice') ); return $actions; } /** * AJAX: make a draft invoice from a quote and mark the quote accepted. */ public function handleConvertQuote(): void { if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'easy_invoice_admin_nonce')) { wp_send_json_error(['message' => __('Security check failed. Please reload the page and try again.', 'easy-invoice')]); } if (!easy_invoice_user_can('ei_create_invoice')) { wp_send_json_error(['message' => __('You do not have permission to create invoices.', 'easy-invoice')]); } $quote_id = isset($_POST['quote_id']) ? absint($_POST['quote_id']) : 0; $quote = $quote_id > 0 ? $this->quote_repository->find($quote_id) : null; if (!$quote) { wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]); } $existing = (int) get_post_meta($quote_id, '_easy_invoice_quote_converted_invoice_id', true); if ($existing > 0 && get_post($existing)) { wp_send_json_success(['invoice_id' => $existing, 'already' => true, 'message' => __('This quote already has an invoice.', 'easy-invoice')]); } $invoice_id = $this->convertQuoteToInvoice($quote, 'draft'); if (!$invoice_id) { wp_send_json_error(['message' => __('The invoice could not be created.', 'easy-invoice')]); } update_post_meta($quote_id, '_easy_invoice_quote_converted_invoice_id', $invoice_id); update_post_meta($invoice_id, '_easy_invoice_converted_from_quote', $quote_id); if (!in_array((string) $quote->getStatus(), ['accepted', 'declined', 'cancelled'], true)) { update_post_meta($quote_id, '_easy_invoice_quote_status', 'accepted'); } /** * Fires after an administrator converts a quote into an invoice by hand. * * @param int $quote_id Quote. * @param int $invoice_id New draft invoice. */ do_action('easy_invoice_quote_converted_manually', $quote_id, $invoice_id); wp_send_json_success([ 'invoice_id' => $invoice_id, 'message' => __('Draft invoice created from the quote.', 'easy-invoice'), 'redirect' => admin_url('admin.php?page=easy-invoice-builder&invoice_id=' . $invoice_id), ]); } private function convertQuoteToInvoice($quote, $status = 'draft'): ?int { try { // Get invoice repository $invoice_repository = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository(); // Create invoice data from quote - convert ALL fields $invoice_data = [ 'title' => $quote->getTitle() ?: 'Invoice from Quote ' . $quote->getNumber(), 'number' => $this->generateInvoiceNumber(), 'status' => $status, 'issue_date' => current_time('Y-m-d'), 'due_date' => wp_date('Y-m-d', strtotime('+30 days')), 'client_id' => $quote->getClientId(), 'customer_name' => $quote->getCustomerName(), 'customer_email' => $quote->getCustomerEmail(), 'customer_address' => $quote->getCustomerAddress(), 'shipping_name' => $quote->getCustomerName(), // Use customer name as shipping name 'shipping_address' => $quote->getCustomerAddress(), // Use customer address as shipping address 'items' => $this->convertQuoteItemsToInvoiceItems($quote->getItems()), 'notes' => $quote->getNotes(), 'description' => $quote->getDescription(), 'terms' => $quote->getTerms(), 'internal_notes' => $quote->getInternalNotes(), 'payment_instructions' => '', // Invoice-specific field, leave empty 'payment_gateways' => [], // Invoice-specific field, leave empty 'template' => $quote->getTemplate(), 'subtotal' => $quote->getSubtotal(), 'tax_rate' => $quote->getTaxRate(), 'tax_enabled' => $quote->getTaxEnabled() ?: (get_option('easy_invoice_tax_enabled', 'no') === 'yes' ? 'yes' : 'no'), 'tax_amount' => $quote->getTaxAmount(), 'discount_type' => $quote->getDiscountType(), 'discount_value' => $quote->getDiscountValue(), 'discount_amount' => $quote->getDiscountAmount(), 'total' => $quote->getTotal(), 'currency_code' => $quote->getCurrencyCode() ?: 'USD', 'currency_position' => $quote->getCurrencyPosition() ?: 'left', 'footer_text' => $quote->getFooterText(), 'calculation_method' => 'standard', // Default calculation method for invoices 'prices_include_tax' => $quote->getPricesIncludeTax(), 'custom_fields' => $quote->getCustomFields(), // Transfer custom fields ]; /** * Filter the data an invoice is created from when a quote is * converted, so addons can carry their own quote fields across. * * @param array $invoice_data * @param Quote $quote */ $invoice_data = apply_filters('easy_invoice_quote_to_invoice_data', $invoice_data, $quote); // Create the invoice $invoice = $invoice_repository->create($invoice_data); if ($invoice) { // Store the quote ID in the invoice's meta for tracking update_post_meta($invoice->getId(), '_converted_from_quote', $quote->getId()); update_post_meta($invoice->getId(), '_easy_invoice_converted_from_quote', $quote->getId()); // Update quote to reference the created invoice — the same key // the quote list and "convert" guard read, whichever path // (manual convert, accept-and-convert) produced the invoice. update_post_meta($quote->getId(), '_easy_invoice_quote_converted_invoice_id', (int) $invoice->getId()); $quote->setCustomField('converted_invoice_id', $invoice->getId()); $quote->save(); // Ensure secure link is generated for the new invoice (Pro version) if (class_exists('\EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController')) { // Trigger the save_post hook to generate secure link. // // Core's save_post_{post_type} passes three arguments — $post_id, // $post and $update — and callbacks are written against that // signature. Firing it with two put a client-facing fatal on the // quote-acceptance path: Team Roles' audit logger declares all three // as required, so accepting a quote raised ArgumentCountError and // the customer got "There has been a critical error on this website" // after the invoice had already been created. Passing `true` for // $update because the invoice row exists by this point. do_action('save_post_easy_invoice', $invoice->getId(), get_post($invoice->getId()), true); } return $invoice->getId(); } return null; } catch (\Exception $e) { // Error converting quote to invoice return null; } } /** * Create new invoice from quote (duplicate) * * @param \EasyInvoice\Models\Quote $quote The quote to duplicate * @param string $status The status for the new invoice ('draft' or 'available') * @return int|null The invoice ID if successful, null otherwise */ private function createInvoiceFromQuote($quote, $status = 'draft'): ?int { try { // Get invoice repository $invoice_repository = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository(); // Create invoice data from quote - convert ALL fields $invoice_data = [ 'title' => 'Invoice from Quote ' . $quote->getNumber(), 'number' => $this->generateInvoiceNumber(), 'status' => $status, 'issue_date' => current_time('Y-m-d'), 'due_date' => wp_date('Y-m-d', strtotime('+30 days')), 'client_id' => $quote->getClientId(), 'customer_name' => $quote->getCustomerName(), 'customer_email' => $quote->getCustomerEmail(), 'customer_address' => $quote->getCustomerAddress(), 'shipping_name' => $quote->getCustomerName(), // Use customer name as shipping name 'shipping_address' => $quote->getCustomerAddress(), // Use customer address as shipping address 'items' => $this->convertQuoteItemsToInvoiceItems($quote->getItems()), 'notes' => $quote->getNotes(), 'description' => $quote->getDescription(), 'terms' => $quote->getTerms(), 'internal_notes' => $quote->getInternalNotes(), 'payment_instructions' => '', // Invoice-specific field, leave empty 'payment_gateways' => [], // Invoice-specific field, leave empty 'template' => $quote->getTemplate(), 'subtotal' => $quote->getSubtotal(), 'tax_rate' => $quote->getTaxRate(), 'tax_enabled' => $quote->getTaxEnabled() ?: (get_option('easy_invoice_tax_enabled', 'no') === 'yes' ? 'yes' : 'no'), 'tax_amount' => $quote->getTaxAmount(), 'discount_type' => $quote->getDiscountType(), 'discount_value' => $quote->getDiscountValue(), 'discount_amount' => $quote->getDiscountAmount(), 'total' => $quote->getTotal(), 'currency_code' => $quote->getCurrencyCode() ?: 'USD', 'currency_position' => $quote->getCurrencyPosition() ?: 'left', 'footer_text' => $quote->getFooterText(), 'calculation_method' => 'standard', // Default calculation method for invoices 'prices_include_tax' => $quote->getPricesIncludeTax(), 'custom_fields' => $quote->getCustomFields(), // Transfer custom fields ]; /** * Filter the data an invoice is created from when a quote is * converted, so addons can carry their own quote fields across. * * @param array $invoice_data * @param Quote $quote */ $invoice_data = apply_filters('easy_invoice_quote_to_invoice_data', $invoice_data, $quote); // Create the invoice $invoice = $invoice_repository->create($invoice_data); if ($invoice) { // Link the invoice to the quote $quote->setCustomField('related_invoice_id', $invoice->getId()); $quote->save(); // Ensure secure link is generated for the new invoice (Pro version) if (class_exists('\EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController')) { // Trigger the save_post hook to generate secure link. // // Core's save_post_{post_type} passes three arguments — $post_id, // $post and $update — and callbacks are written against that // signature. Firing it with two put a client-facing fatal on the // quote-acceptance path: Team Roles' audit logger declares all three // as required, so accepting a quote raised ArgumentCountError and // the customer got "There has been a critical error on this website" // after the invoice had already been created. Passing `true` for // $update because the invoice row exists by this point. do_action('save_post_easy_invoice', $invoice->getId(), get_post($invoice->getId()), true); } return $invoice->getId(); } return null; } catch (\Exception $e) { // Error creating invoice from quote return null; } } /** * Send invoice to client * * @param int $invoice_id The invoice ID * @return bool True if sent successfully */ private function sendInvoiceToClient(int $invoice_id): bool { try { // Get invoice $invoice_repository = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository(); $invoice = $invoice_repository->find($invoice_id); if (!$invoice) { return false; } // Get email manager $email_manager = \EasyInvoice\Services\EmailManager::getInstance(); // Send invoice email $result = $email_manager->sendInvoiceEmail($invoice, 'new'); return $result['success']; } catch (\Exception $e) { // Error sending invoice to client return false; } } /** * Convert quote items to invoice items * * @param array $quote_items Array of quote items * @return array Array of invoice items */ private function convertQuoteItemsToInvoiceItems(array $quote_items): array { $invoice_items = []; foreach ($quote_items as $quote_item) { if (is_object($quote_item) && method_exists($quote_item, 'toArray')) { // A saved quote stores its lines as title/total, an invoice as // name/amount; read through the model, which knows both, or // the converted invoice has nameless lines that add up to 0. $item_data = $quote_item->toArray(); $name = (string) (is_callable([$quote_item, 'getName']) ? $quote_item->getName() : ''); if ('' === $name) { $name = (string) ($item_data['name'] ?? $item_data['title'] ?? ''); } $amount = $item_data['amount'] ?? $item_data['total'] ?? null; if (null === $amount || '' === $amount) { $amount = is_callable([$quote_item, 'getAmount']) ? $quote_item->getAmount() : (float) ($item_data['quantity'] ?? 0) * (float) ($item_data['price'] ?? 0); } $invoice_items[] = [ 'name' => $name, 'description' => $item_data['description'] ?? '', 'quantity' => $item_data['quantity'] ?? 0, 'price' => $item_data['price'] ?? 0, 'amount' => $amount, 'taxable' => $item_data['taxable'] ?? true, // Map adjust_percentage to a similar field if needed 'adjust_percentage' => $item_data['adjust_percentage'] ?? 0, ]; } elseif (is_array($quote_item)) { // Convert array item directly $invoice_items[] = [ 'name' => $quote_item['name'] ?? $quote_item['title'] ?? '', 'description' => $quote_item['description'] ?? '', 'quantity' => $quote_item['quantity'] ?? 0, 'price' => $quote_item['price'] ?? 0, 'amount' => $quote_item['amount'] ?? $quote_item['total'] ?? 0, 'taxable' => $quote_item['taxable'] ?? true, 'adjust_percentage' => $quote_item['adjust_percentage'] ?? 0, ]; } } return $invoice_items; } /** * Generate unique invoice number * * @return string The invoice number */ private function generateInvoiceNumber(): string { // Try to use invoice number service if available if (class_exists('\\EasyInvoice\\Services\\InvoiceNumberService')) { $invoice_number_service = new \EasyInvoice\Services\InvoiceNumberService(); return $invoice_number_service->generateUniqueNumber(); } // Fallback to timestamp-based number return 'INV-' . str_pad(time(), 6, '0', STR_PAD_LEFT); } /** * Get changes between two quote versions * * @param \EasyInvoice\Models\Quote $old_quote Old quote * @param \EasyInvoice\Models\Quote $new_quote New quote * @return array Array of changes */ private function getQuoteChanges($old_quote, $new_quote): array { $changes = []; // Compare key fields $fields_to_compare = [ 'title' => 'Title', 'status' => 'Status', 'customer_name' => 'Customer Name', 'customer_email' => 'Customer Email', 'customer_address' => 'Customer Address', 'issue_date' => 'Issue Date', 'expiry_date' => 'Expiry Date', 'total' => 'Total Amount', 'notes' => 'Notes', 'terms' => 'Terms', ]; foreach ($fields_to_compare as $field => $label) { $method_name = 'get' . easy_invoice_str_replace('_', '', ucwords($field, '_')); if (method_exists($old_quote, $method_name) && method_exists($new_quote, $method_name)) { $old_value = $old_quote->$method_name(); $new_value = $new_quote->$method_name(); if ($old_value !== $new_value) { $changes[$field] = $new_value; } } } return $changes; } /** * Handle AJAX request to decline a quote * * @since 1.0.0 */ public function handleDeclineQuote(): void { $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0; $decline_reason = isset($_POST['decline_reason']) ? sanitize_textarea_field($_POST['decline_reason']) : ''; if ($quote_id <= 0) { wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]); } if (!wp_verify_nonce($_POST['nonce'] ?? '', $this->quoteAcceptDeclineNonceAction($quote_id))) { wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]); } $is_admin = current_user_can('manage_options'); if ($is_admin) { $quote = $this->quote_repository->find($quote_id); } else { $quote = $this->quote_repository->findPublished($quote_id); } if (!$quote) { wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]); } // Check if decline reason is required by global settings $settings_controller = new \EasyInvoice\Controllers\SettingsController(); if ($settings_controller::isDeclineReasonRequired() && empty(trim($decline_reason))) { wp_send_json_error(['message' => __('Reason for declining is required.', 'easy-invoice')]); } // SECURITY (CVE-2026-9021): unconditional authorisation — see // handleAcceptQuote for the full rationale. Same three paths: // admin / valid access token / authenticated bound client. if (!self::canActOnQuote($quote_id, $quote)) { wp_send_json_error(['message' => __('You do not have permission to decline this quote.', 'easy-invoice')]); } $ei_open = self::openForDecision($quote); if (is_wp_error($ei_open)) { wp_send_json_error(['message' => $ei_open->get_error_message()]); } $current_user = wp_get_current_user(); // Update quote status to declined $quote->setStatus('declined'); $quote->setDeclinedDate(gmdate('Y-m-d H:i:s')); $quote->setDeclinedBy($current_user->ID); // Save decline reason if provided if (!empty($decline_reason)) { $quote->setDeclineReason($decline_reason); } // Save the quote $saved = $quote->save(); if (!$saved) { wp_send_json_error(['message' => __('Failed to decline quote.', 'easy-invoice')]); } // Log the quote decline $this->quote_log_service->logDecline($quote_id, $decline_reason, [ 'user_type' => $is_admin ? 'admin' : 'client' ]); // Send notification email to admin if (!$is_admin) { $this->sendQuoteDeclineNotification($quote); } wp_send_json_success([ 'message' => __('Quote declined successfully.', 'easy-invoice'), 'toast' => [ 'type' => 'success', 'message' => __('Quote declined successfully.', 'easy-invoice') ] ]); } /** * Send quote acceptance notification to admin * * @param \EasyInvoice\Models\Quote $quote The quote that was accepted */ private function sendQuoteAcceptanceNotification($quote): void { // Use EmailManager to send admin notification $email_manager = \EasyInvoice\Services\EmailManager::getInstance(); $email_manager->sendAdminQuoteNotification($quote, 'accepted'); } /** * Send quote decline notification to admin * * @param \EasyInvoice\Models\Quote $quote The quote that was declined */ private function sendQuoteDeclineNotification($quote): void { // Use EmailManager to send admin notification $email_manager = \EasyInvoice\Services\EmailManager::getInstance(); $email_manager->sendAdminQuoteNotification($quote, 'declined'); } /** * Handle AJAX request to duplicate a quote * * @since 1.0.0 */ public function handleDuplicateQuote(): void { // Verify nonce if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) { wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]); } // Check permissions if (!easy_invoice_user_can('ei_create_quote')) { wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]); } $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0; if ($quote_id <= 0) { wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]); } $quote = $this->quote_repository->find($quote_id); if (!$quote) { wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]); } // Get global quote settings $settings_controller = new \EasyInvoice\Controllers\SettingsController(); $quote_terms = $settings_controller::getQuoteTermsConditions(); $quote_footer = $settings_controller::getQuoteFooterText(); $quote_accept_button = get_option('easy_invoice_quote_accept_button', 'yes'); $quote_accept_action = get_option('easy_invoice_quote_accept_action', 'email'); $quote_accept_text = get_option('easy_invoice_quote_accept_text', __('Accept Quote', 'easy-invoice')); $quote_accepted_message = get_option('easy_invoice_quote_accepted_message', __('Thank you for accepting our quote!', 'easy-invoice')); $quote_declined_message = get_option('easy_invoice_quote_declined_message', __('Thank you for your consideration.', 'easy-invoice')); // Create the duplicate quote $duplicate_data = [ 'title' => $quote->getTitle() . ' (Copy)', 'status' => 'draft', 'number' => $this->generateInvoiceNumber(), // Use invoice number service for consistency 'issue_date' => current_time('Y-m-d'), 'expiry_date' => wp_date('Y-m-d', strtotime('+30 days')), 'items' => $this->convertQuoteItemsToInvoiceItems($quote->getItems()), // Use invoice item conversion 'notes' => $quote->getNotes(), 'description' => $quote->getDescription(), 'terms' => $quote_terms, 'internal_notes' => $quote->getInternalNotes(), 'accept_button' => $quote_accept_button, 'accept_action' => $quote_accept_action, 'accept_text' => $quote_accept_text, 'accepted_message' => $quote_accepted_message, 'declined_message' => $quote_declined_message, ]; // Set client ID to 0 for a new quote $duplicate_data['client_id'] = 0; $duplicate_quote = $this->quote_repository->create($duplicate_data); if ($duplicate_quote) { $this->quote_log_service->logActivity($quote_id, 'duplicate', 'Quote duplicated', ['duplicate_id' => $duplicate_quote->getId()]); wp_send_json_success([ 'message' => __('Quote duplicated successfully.', 'easy-invoice'), 'quote_id' => $duplicate_quote->getId(), 'toast' => [ 'type' => 'success', 'message' => __('Quote duplicated successfully.', 'easy-invoice') ] ]); } else { wp_send_json_error(['message' => __('Failed to duplicate quote.', 'easy-invoice')]); } } /** * Handle regular POST form actions for quote accept/decline * * @since 1.0.0 */ public function handleQuoteFormActions(): void { // Only process on POST requests if ($_SERVER['REQUEST_METHOD'] !== 'POST') { return; } // Handle accept quote if (isset($_POST['accept_quote']) && isset($_POST['quote_id'])) { $this->handleAcceptQuoteForm(); } // Handle decline quote if (isset($_POST['decline_quote']) && isset($_POST['quote_id'])) { $this->handleDeclineQuoteForm(); } } /** * Handle accept quote form submission * * @since 1.0.0 */ private function handleAcceptQuoteForm(): void { $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0; if ($quote_id <= 0) { wp_die(esc_html__('Invalid quote ID.', 'easy-invoice')); } if (!wp_verify_nonce($_POST['quote_nonce'] ?? '', $this->quoteAcceptDeclineNonceAction($quote_id))) { wp_die(esc_html__('Security check failed.', 'easy-invoice')); } $current_user = wp_get_current_user(); $is_admin = current_user_can('manage_options'); if ($is_admin) { $quote = $this->quote_repository->find($quote_id); } else { $quote = $this->quote_repository->findPublished($quote_id); } if (!$quote) { wp_die(esc_html__('Quote not found.', 'easy-invoice')); } // SECURITY (CVE-2026-9021): unconditional authorisation. See // handleAcceptQuote (AJAX path) for full rationale. if (!self::canActOnQuote($quote_id, $quote)) { wp_die(esc_html__('You do not have permission to accept this quote.', 'easy-invoice')); } $ei_open = self::openForDecision($quote); if (is_wp_error($ei_open)) { wp_die(esc_html($ei_open->get_error_message())); } // Update quote status to accepted $quote->setStatus('accepted'); $quote->setAcceptedDate(gmdate('Y-m-d H:i:s')); $quote->setAcceptedBy($current_user->ID); // Save the quote $saved = $quote->save(); if (!$saved) { wp_die(esc_html__('Failed to accept quote.', 'easy-invoice')); } // Send notification email to admin if (!$is_admin) { $this->sendQuoteAcceptanceNotification($quote); } // Redirect back to the quote page with success message $redirect_url = add_query_arg('action', 'accepted', get_permalink($quote_id)); wp_safe_redirect($redirect_url); exit; } /** * Handle decline quote form submission * * @since 1.0.0 */ private function handleDeclineQuoteForm(): void { $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0; if ($quote_id <= 0) { wp_die(esc_html__('Invalid quote ID.', 'easy-invoice')); } if (!wp_verify_nonce($_POST['quote_nonce'] ?? '', $this->quoteAcceptDeclineNonceAction($quote_id))) { wp_die(esc_html__('Security check failed.', 'easy-invoice')); } $current_user = wp_get_current_user(); $is_admin = current_user_can('manage_options'); if ($is_admin) { $quote = $this->quote_repository->find($quote_id); } else { $quote = $this->quote_repository->findPublished($quote_id); } if (!$quote) { wp_die(esc_html__('Quote not found.', 'easy-invoice')); } // SECURITY (CVE-2026-9021): unconditional authorisation. See // handleAcceptQuote (AJAX path) for full rationale. if (!self::canActOnQuote($quote_id, $quote)) { wp_die(esc_html__('You do not have permission to decline this quote.', 'easy-invoice')); } $ei_open = self::openForDecision($quote); if (is_wp_error($ei_open)) { wp_die(esc_html($ei_open->get_error_message())); } // Update quote status to declined $quote->setStatus('declined'); $quote->setDeclinedDate(gmdate('Y-m-d H:i:s')); $quote->setDeclinedBy($current_user->ID); // Save the quote $saved = $quote->save(); if (!$saved) { wp_die(esc_html__('Failed to decline quote.', 'easy-invoice')); } // Send notification email to admin if (!$is_admin) { $this->sendQuoteDeclineNotification($quote); } // Redirect back to the quote page with success message $redirect_url = add_query_arg('action', 'declined', get_permalink($quote_id)); wp_safe_redirect($redirect_url); exit; } /** * Handle AJAX request for bulk quote actions * * @since 1.0.0 */ public function handleBulkQuoteAction(): void { // Verify nonce if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) { wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]); } // Check permissions — gate at ei_create_quote (state transitions like // trash/draft/restore). Permanent-delete actions are additionally // gated below by ei_delete_quote per action. if (!easy_invoice_user_can('ei_create_quote')) { wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]); } $quote_ids = isset($_POST['quote_ids']) ? array_map('intval', $_POST['quote_ids']) : []; $bulk_action = sanitize_text_field($_POST['bulk_action'] ?? ''); // Per-action gate: permanent delete requires the stricter delete cap. if (in_array($bulk_action, ['delete', 'permanent-delete', 'empty-trash'], true) && !easy_invoice_user_can('ei_delete_quote')) { wp_send_json_error(['message' => __('You do not have permission to delete quotes.', 'easy-invoice')]); } if (empty($quote_ids)) { wp_send_json_error(['message' => __('No quotes selected.', 'easy-invoice')]); } if (empty($bulk_action)) { wp_send_json_error(['message' => __('No action selected.', 'easy-invoice')]); } $success_count = 0; $error_count = 0; foreach ($quote_ids as $quote_id) { $quote = $this->quote_repository->find($quote_id); if (!$quote) { $error_count++; continue; } try { switch ($bulk_action) { case 'delete': if ($this->quote_repository->delete($quote_id)) { $this->quote_log_service->logDeletion($quote_id); $success_count++; } else { $error_count++; } break; case 'trash': $old_status = $quote->getStatus(); $quote->setStatus('cancelled'); // Using cancelled as trash status if ($quote->save()) { $this->quote_log_service->logStatusChange($quote_id, $old_status, 'cancelled'); $success_count++; } else { $error_count++; } break; case 'draft': $old_status = $quote->getStatus(); $quote->setStatus('draft'); if ($quote->save()) { $this->quote_log_service->logStatusChange($quote_id, $old_status, 'draft'); $success_count++; } else { $error_count++; } break; case 'restore': $old_status = $quote->getStatus(); $quote->setStatus('draft'); if ($quote->save()) { $this->quote_log_service->logRestoration($quote_id); $success_count++; } else { $error_count++; } break; default: $error_count++; break; } } catch (\Exception $e) { $error_count++; // Error in bulk action } } if ($error_count > 0) { wp_send_json_success([ /* translators: %1$d: number processed; %2$d: number failed. */ 'message' => sprintf(__('Processed %1$d quotes successfully. %2$d failed.', 'easy-invoice'), $success_count, $error_count), 'toast' => [ 'type' => 'warning', /* translators: %1$d: number processed; %2$d: number failed. */ 'message' => sprintf(__('Processed %1$d quotes successfully. %2$d failed.', 'easy-invoice'), $success_count, $error_count) ] ]); } else { wp_send_json_success([ /* translators: %d: number processed. */ 'message' => sprintf(__('Successfully processed %d quotes.', 'easy-invoice'), $success_count), 'toast' => [ 'type' => 'success', /* translators: %d: number processed. */ 'message' => sprintf(__('Successfully processed %d quotes.', 'easy-invoice'), $success_count) ] ]); } } /** * Handle AJAX request to trash a quote * * @since 1.0.0 */ public function handleTrashQuote(): void { // Verify nonce if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) { wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]); } // Check permissions — trash is reversible, gated at the create-quote cap. if (!easy_invoice_user_can('ei_create_quote')) { wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]); } $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0; if ($quote_id <= 0) { wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]); } $quote = $this->quote_repository->find($quote_id); if (!$quote) { wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]); } // Set status to cancelled before moving to trash $old_status = $quote->getStatus(); $quote->setStatus('cancelled'); $quote->save(); // Move the post to trash status $result = wp_trash_post($quote_id); if ($result) { $this->quote_log_service->logStatusChange($quote_id, $old_status, 'cancelled'); wp_send_json_success([ 'message' => __('Quote moved to trash successfully.', 'easy-invoice'), 'toast' => [ 'type' => 'success', 'message' => __('Quote moved to trash successfully.', 'easy-invoice') ] ]); } else { wp_send_json_error(['message' => __('Failed to move quote to trash.', 'easy-invoice')]); } } /** * Handle AJAX request to move a quote to draft * * @since 1.0.0 */ public function handleDraftQuote(): void { // Verify nonce if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) { wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]); } // Check permissions — moving to draft is an edit, not a delete. if (!easy_invoice_user_can('ei_create_quote')) { wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]); } $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0; if ($quote_id <= 0) { wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]); } $quote = $this->quote_repository->find($quote_id); if (!$quote) { wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]); } // Set status to draft $old_status = $quote->getStatus(); $quote->setStatus('draft'); if ($quote->save()) { $this->quote_log_service->logStatusChange($quote_id, $old_status, 'draft'); wp_send_json_success([ 'message' => __('Quote moved to draft successfully.', 'easy-invoice'), 'toast' => [ 'type' => 'success', 'message' => __('Quote moved to draft successfully.', 'easy-invoice') ] ]); } else { wp_send_json_error(['message' => __('Failed to move quote to draft.', 'easy-invoice')]); } } /** * Handle AJAX request to restore a trashed quote * * @since 1.0.0 */ public function handleRestoreQuote(): void { // Verify nonce if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) { wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]); } // Check permissions — restoring from trash is an edit operation. if (!easy_invoice_user_can('ei_create_quote')) { wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]); } $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0; if ($quote_id <= 0) { wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]); } $quote = $this->quote_repository->find($quote_id); if (!$quote) { wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]); } // Restore the post from trash $result = wp_untrash_post($quote_id); if ($result) { // After restoring from trash, set the meta status to available $quote->setStatus('available'); $quote->save(); $this->quote_log_service->logRestoration($quote_id); wp_send_json_success([ 'message' => __('Quote restored successfully.', 'easy-invoice'), 'toast' => [ 'type' => 'success', 'message' => __('Quote restored successfully.', 'easy-invoice') ] ]); } else { wp_send_json_error(['message' => __('Failed to restore quote.', 'easy-invoice')]); } } /** * Handle AJAX request to empty trash * * @since 1.0.0 */ public function handleEmptyTrash(): void { try { // Verify nonce if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_nonce')) { wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]); } // Check permissions — emptying trash permanently deletes quotes. if (!easy_invoice_user_can('ei_delete_quote')) { wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]); } // Get all quotes in trash (post_status = 'trash') global $wpdb; $quote_ids = $wpdb->get_col($wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'trash'", PostTypes::EASY_INVOICE_QUOTE_POST_TYPE )); if (empty($quote_ids)) { wp_send_json_error(['message' => __('No quotes found in trash.', 'easy-invoice')]); } $success_count = 0; $error_count = 0; foreach ($quote_ids as $quote_id) { if (wp_delete_post($quote_id, true)) { $this->quote_log_service->logDeletion($quote_id); $success_count++; } else { $error_count++; } } if ($error_count > 0) { wp_send_json_success([ /* translators: %1$d: number processed; %2$d: number failed. */ 'message' => sprintf(__('Emptied trash: %1$d quotes deleted successfully, %2$d failed.', 'easy-invoice'), $success_count, $error_count), 'success_count' => $success_count, 'error_count' => $error_count, 'toast' => [ 'type' => 'warning', /* translators: %1$d: number processed; %2$d: number failed. */ 'message' => sprintf(__('Emptied trash: %1$d quotes deleted successfully, %2$d failed.', 'easy-invoice'), $success_count, $error_count) ] ]); } else { wp_send_json_success([ /* translators: %d: number processed. */ 'message' => sprintf(__('Successfully emptied trash: %d quotes deleted.', 'easy-invoice'), $success_count), 'success_count' => $success_count, 'error_count' => 0, 'toast' => [ 'type' => 'success', /* translators: %d: number processed. */ 'message' => sprintf(__('Successfully emptied trash: %d quotes deleted.', 'easy-invoice'), $success_count) ] ]); } } catch (\Exception $e) { error_log('Error emptying quote trash: ' . $e->getMessage()); wp_send_json_error([ 'message' => __('Failed to empty trash.', 'easy-invoice'), 'debug' => $e->getMessage() ]); } } /** * Handle AJAX request to get quote logs * * @since 1.0.0 */ public function handleGetQuoteLogs(): void { // Verify nonce if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) { wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]); } // Check permissions — viewing quote activity log. if (!easy_invoice_user_can('ei_view_quotes')) { wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]); } $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0; if ($quote_id <= 0) { wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]); } try { $logs = $this->quote_log_service->getLogs($quote_id); // Convert QuoteLog objects to arrays for JSON response $logs_data = []; foreach ($logs as $log) { $logs_data[] = [ 'action' => $log->getAction(), 'description' => $log->getDescription(), 'user_id' => $log->getUserId(), 'user_name' => $log->getUserName(), 'ip_address' => $log->getIpAddress(), 'user_agent' => $log->getUserAgent(), 'additional_data' => $log->getAdditionalData(), 'created_date' => $log->getCreatedDate(), ]; } wp_send_json_success([ 'logs' => $logs_data, 'count' => count($logs_data) ]); } catch (\Exception $e) { wp_send_json_error([ 'message' => __('Error retrieving quote logs.', 'easy-invoice'), 'debug' => $e->getMessage() ]); } } /** * Format currency amount using QuoteFormatter * * @param float $amount The amount to format * @param \EasyInvoice\Models\Quote|null $quote The quote object for currency settings * @return string Formatted currency string */ private function formatCurrency(float $amount, $quote = null): string { $formatter = new \EasyInvoice\Helpers\QuoteFormatter($quote); return $formatter->format($amount); } }