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