PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.3.7
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.3.7
2.4.0 2.4.1 2.3.8 2.3.7 2.3.6 2.3.5 2.3.4 2.3.3 2.3.2 2.3.1 2.2.0 2.1.21 2.1.20 2.1.19 2.1.18 2.1.0 2.1.1 2.1.10 2.1.11 2.1.12 2.1.13 2.1.14 2.1.15 2.1.16 2.1.2 All 57 releases
easy-invoice / includes / Controllers / QuoteController.php

QuoteController.php in Easy Invoice – Invoice Generator, PDF Quotes & Payments 2.3.7, at includes/Controllers/QuoteController.php

2,301 lines 88.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Quote Controller
4 *
5 * @package EasyInvoice
6 * @author Your Name
7 * @copyright Copyright (c) 2023, Your Company
8 * @license http://opensource.org/licenses/gpl-2.0.php GNU Public License
9 * @since 1.0.0
10 */
11
12 namespace EasyInvoice\Controllers;
13
14 use EasyInvoice\Repositories\QuoteRepository;
15 use EasyInvoice\Repositories\ClientRepository;
16 use EasyInvoice\Forms\FormProcessor;
17 use EasyInvoice\Constants\PagesSlugs;
18 use EasyInvoice\Constants\PostTypes;
19 use EasyInvoice\Services\QuoteLogService;
20
21 /**
22 * Quote Controller
23 *
24 * Handles quote-related operations and displays.
25 *
26 * @since 1.0.0
27 */
28 class QuoteController {
29
30 /**
31 * Quote repository
32 *
33 * @var QuoteRepository
34 */
35 private $quote_repository;
36
37 /**
38 * Client repository
39 *
40 * @var ClientRepository
41 */
42 private $client_repository;
43
44 /**
45 * Form processor
46 *
47 * @var FormProcessor
48 */
49 private $form_processor;
50
51 /**
52 * Quote log service
53 *
54 * @var QuoteLogService
55 */
56 private $quote_log_service;
57
58 /**
59 * Constructor
60 *
61 * @since 1.0.0
62 */
63 public function __construct() {
64 $this->quote_repository = new QuoteRepository();
65 $this->client_repository = new ClientRepository();
66 $this->form_processor = new FormProcessor();
67 $this->quote_log_service = new QuoteLogService();
68 }
69
70 /**
71 * Initialize the controller
72 *
73 * @since 1.0.0
74 */
75 public function init(): void {
76 // Allow plugins to extend the controller initialization
77 do_action('easy_invoice_quote_controller_before_init', $this);
78
79 // Add AJAX handlers
80 add_action('wp_ajax_easy_invoice_delete_quote', [$this, 'handleDeleteQuote']);
81 add_action('wp_ajax_easy_invoice_get_quote', [$this, 'handleGetQuote']);
82 add_action('wp_ajax_easy_invoice_load_quote_template', [$this, 'handleLoadQuoteTemplate']);
83 add_action('wp_ajax_easy_invoice_create_new_quote', [$this, 'handleCreateNewQuote']);
84 // The `easy_invoice_search_clients` AJAX is owned by EasyInvoiceAjax.
85 // The duplicate registration that used to live here raced with
86 // EasyInvoiceAjax::searchClients() — only the first-registered
87 // handler ran, and which one won depended on bootstrap order. That
88 // intermittently broke the client-search dropdown in the quote
89 // builder. Keep this comment as a tombstone so it doesn't get
90 // added back.
91 add_action('wp_ajax_easy_invoice_load_quote_form', [$this, 'handleLoadQuoteForm']);
92 add_action('wp_ajax_easy_invoice_accept_quote', [$this, 'handleAcceptQuote']);
93 add_action('wp_ajax_easy_invoice_decline_quote', [$this, 'handleDeclineQuote']);
94 add_action('wp_ajax_nopriv_easy_invoice_accept_quote', [$this, 'handleAcceptQuote']);
95 add_action('wp_ajax_nopriv_easy_invoice_decline_quote', [$this, 'handleDeclineQuote']);
96 add_action('wp_ajax_easy_invoice_update_existing_quotes', [$this, 'handleUpdateExistingQuotes']);
97
98 // Add missing AJAX handlers for quote listing actions
99 add_action('wp_ajax_easy_invoice_bulk_quote_action', [$this, 'handleBulkQuoteAction']);
100 add_action('wp_ajax_easy_invoice_trash_quote', [$this, 'handleTrashQuote']);
101 add_action('wp_ajax_easy_invoice_draft_quote', [$this, 'handleDraftQuote']);
102
103 // Add regular POST form handlers for quote actions
104 add_action('init', [$this, 'handleQuoteFormActions']);
105
106 // Add new AJAX handler for restoring a trashed quote
107 add_action('wp_ajax_easy_invoice_restore_quote', [ $this, 'handleRestoreQuote' ]);
108
109 // Add new AJAX handler for emptying trash
110 add_action('wp_ajax_easy_invoice_empty_trash', [ $this, 'handleEmptyTrash' ]);
111
112 // Add new AJAX handler for getting quote logs
113 add_action('wp_ajax_easy_invoice_get_quote_logs', [ $this, 'handleGetQuoteLogs' ]);
114
115 // Allow plugins to extend the controller initialization
116 do_action('easy_invoice_quote_controller_after_init', $this);
117 }
118
119 /**
120 * Display quote pages
121 *
122 * @since 1.0.0
123 * @param array $args Display arguments
124 */
125 public function display(array $args = []): void {
126 // Allow plugins to modify display arguments
127 $args = apply_filters('easy_invoice_quote_controller_display_args', $args);
128
129 $page = $args['page'] ?? '';
130
131 // Allow plugins to modify the page before processing
132 $page = apply_filters('easy_invoice_quote_controller_display_page', $page, $args);
133
134 switch ($page) {
135 case PagesSlugs::ALL_QUOTES:
136 $this->displayListing();
137 break;
138
139 case PagesSlugs::QUOTE_NEW:
140 $this->displayBuilder();
141 break;
142
143 case PagesSlugs::QUOTE_PREVIEW:
144 $this->displayPreview($args);
145 break;
146
147 default:
148 $this->displayListing();
149 break;
150 }
151
152 // Allow plugins to perform actions after display
153 do_action('easy_invoice_quote_controller_after_display', $page, $args);
154 }
155
156 /**
157 * Display quote listing page
158 *
159 * @since 1.0.0
160 */
161 private function displayListing(): void {
162 // First, get all counts independently of any filtering
163 global $wpdb;
164
165 // Get trash count first (based on post_status)
166 $trash_count = (int)$wpdb->get_var($wpdb->prepare(
167 "SELECT COUNT(*) FROM {$wpdb->posts}
168 WHERE post_type = %s AND post_status = 'trash'",
169 PostTypes::EASY_INVOICE_QUOTE_POST_TYPE
170 ));
171
172 // Get counts for each meta status (excluding trashed posts)
173 $status_counts = $wpdb->get_results($wpdb->prepare(
174 "SELECT COALESCE(pm.meta_value, 'draft') as status, COUNT(*) as count
175 FROM {$wpdb->posts} p
176 LEFT JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id AND pm.meta_key = '_easy_invoice_quote_status'
177 WHERE p.post_type = %s
178 AND p.post_status != 'trash'
179 GROUP BY COALESCE(pm.meta_value, 'draft')",
180 PostTypes::EASY_INVOICE_QUOTE_POST_TYPE
181 ));
182
183 // Initialize counts
184 $draft_count = 0;
185 $available_count = 0;
186 $sent_count = 0;
187 $accepted_count = 0;
188 $declined_count = 0;
189 $expired_count = 0;
190 $cancelled_count = 0;
191 $all_count = 0;
192
193 // Process status counts
194 foreach ($status_counts as $status) {
195 $count = (int)$status->count;
196 $all_count += $count; // Add to total (excluding trash)
197
198 switch ($status->status) {
199 case 'draft':
200 $draft_count = $count;
201 break;
202 case 'available':
203 $available_count = $count;
204 break;
205 case 'sent':
206 $sent_count = $count;
207 break;
208 case 'accepted':
209 $accepted_count = $count;
210 break;
211 case 'declined':
212 $declined_count = $count;
213 break;
214 case 'expired':
215 $expired_count = $count;
216 break;
217 case 'cancelled':
218 $cancelled_count = $count;
219 break;
220 }
221 }
222
223 // Now handle the display filtering
224 // Allow plugins to perform actions before displaying listing
225 do_action('easy_invoice_quote_controller_before_display_listing');
226
227 // Get filter parameters
228 $status_filter = isset($_GET['status']) ? sanitize_text_field($_GET['status']) : '';
229 $client_filter = isset($_GET['client_id']) ? absint($_GET['client_id']) : 0;
230 $search_query = isset($_GET['search']) ? sanitize_text_field(wp_unslash($_GET['search'])) : '';
231 $current_view = isset($_GET['view']) ? sanitize_text_field($_GET['view']) : 'all';
232 $current_page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1;
233 $per_page = 20;
234
235 // Build query args for display
236 $query_args = [
237 'post_type' => PostTypes::EASY_INVOICE_QUOTE_POST_TYPE,
238 'posts_per_page' => $per_page,
239 'paged' => $current_page,
240 'orderby' => 'date',
241 'order' => 'DESC',
242 'no_found_rows' => false,
243 'update_post_term_cache' => false,
244 'update_post_meta_cache' => false
245 ];
246
247 // Handle view filtering
248 if ($current_view === 'trash' || $current_view === 'cancelled') {
249 // For trash and cancelled views, look at post_status = 'trash'
250 $query_args['post_status'] = 'trash';
251
252 // For cancelled view, also filter by meta status
253 if ($current_view === 'cancelled') {
254 $query_args['meta_query'] = [
255 [
256 'key' => '_easy_invoice_quote_status',
257 'value' => 'cancelled',
258 'compare' => '='
259 ]
260 ];
261 }
262 } else {
263 // For all other views, exclude trashed posts
264 $query_args['post_status'] = ['publish', 'draft', 'private', 'pending'];
265
266 if ($current_view !== 'all') {
267 // For specific status views, add meta query
268 $query_args['meta_query'] = [
269 [
270 'key' => '_easy_invoice_quote_status',
271 'value' => $current_view,
272 'compare' => '='
273 ]
274 ];
275 }
276 }
277
278 // Add client filter if provided (merges with any existing meta_query).
279 //
280 // Quote model uses the `_easy_invoice_quote_*` meta-key namespace
281 // (see Models/Quote.php :: saveMetaData → meta_key = `_easy_invoice_quote_` . $field_name).
282 // We match on either:
283 // • `_easy_invoice_quote_client_id` (when picked from the client dropdown), OR
284 // • `_easy_invoice_quote_customer_email` (when entered ad-hoc inline).
285 if (!empty($client_filter)) {
286 $client_email = '';
287 try {
288 $client_repo = new \EasyInvoice\Repositories\ClientRepository();
289 $client_obj = $client_repo->find($client_filter);
290 if ($client_obj) {
291 $client_email = (string) $client_obj->getEmail();
292 }
293 } catch (\Throwable $e) {
294 $client_email = '';
295 }
296
297 $client_clauses = [
298 'relation' => 'OR',
299 [
300 'key' => '_easy_invoice_quote_client_id',
301 'value' => (string) $client_filter,
302 'compare' => '=',
303 ],
304 ];
305 if ($client_email !== '') {
306 $client_clauses[] = [
307 'key' => '_easy_invoice_quote_customer_email',
308 'value' => $client_email,
309 'compare' => '=',
310 ];
311 }
312
313 if (!empty($query_args['meta_query'])) {
314 $existing = $query_args['meta_query'];
315 if (!isset($existing['relation'])) {
316 $existing = ['relation' => 'AND'] + $existing;
317 }
318 $existing[] = $client_clauses;
319 $query_args['meta_query'] = $existing;
320 } else {
321 $query_args['meta_query'] = [$client_clauses];
322 }
323 }
324
325 // Add search if provided
326 if (!empty($search_query)) {
327 $search_ids = [];
328
329 // Build base query args for search
330 $search_query_args = [
331 'post_type' => PostTypes::EASY_INVOICE_QUOTE_POST_TYPE,
332 'post_status' => $query_args['post_status'],
333 'posts_per_page' => -1,
334 'fields' => 'ids' // Only get IDs for better performance
335 ];
336
337 // Search in title and content
338 $title_search_args = array_merge($search_query_args, [
339 's' => $search_query
340 ]);
341 $title_search = new \WP_Query($title_search_args);
342 $search_ids = $title_search->posts;
343
344 // Search in meta
345 $meta_search_args = array_merge($search_query_args, [
346 'meta_query' => [
347 'relation' => 'OR',
348 [
349 'key' => '_easy_invoice_quote_number',
350 'value' => $search_query,
351 'compare' => 'LIKE'
352 ],
353 [
354 'key' => '_easy_invoice_quote_client_name',
355 'value' => $search_query,
356 'compare' => 'LIKE'
357 ],
358 [
359 'key' => '_easy_invoice_quote_client_email',
360 'value' => $search_query,
361 'compare' => 'LIKE'
362 ]
363 ]
364 ]);
365 $meta_search = new \WP_Query($meta_search_args);
366
367 if ($meta_search->have_posts()) {
368 $search_ids = array_merge($search_ids, wp_list_pluck($meta_search->posts, 'ID'));
369 }
370
371 $search_ids = array_unique($search_ids);
372
373 if (!empty($search_ids)) {
374 $query_args['post__in'] = $search_ids;
375 } else {
376 $query_args['post__in'] = [0];
377 }
378 }
379
380 // Allow plugins to modify query args
381 $query_args = apply_filters('easy_invoice_quote_controller_final_query_args', $query_args);
382 // Get filtered quotes for display
383 $wp_query = new \WP_Query($query_args);
384 $quotes = [];
385
386 if ($wp_query->have_posts()) {
387 foreach ($wp_query->posts as $post) {
388 $quote = $this->quote_repository->find($post->ID);
389 if ($quote) {
390 $quotes[] = $quote;
391 }
392 }
393 }
394
395 // Allow plugins to modify the quotes array
396 $quotes = apply_filters('easy_invoice_quote_controller_quotes_list', $quotes, $wp_query);
397
398 // Get pagination info from WordPress query
399 $total_quotes = $wp_query->found_posts;
400 $total_pages = $wp_query->max_num_pages;
401
402 // Initialize counts
403 $draft_count = 0;
404 $available_count = 0;
405 $sent_count = 0;
406 $accepted_count = 0;
407 $declined_count = 0;
408 $expired_count = 0;
409 $cancelled_count = 0;
410
411 // Process status counts
412 foreach ($status_counts as $status) {
413 switch ($status->status) {
414 case 'draft':
415 $draft_count = $status->count;
416 break;
417 case 'available':
418 $available_count = $status->count;
419 break;
420 case 'sent':
421 $sent_count = $status->count;
422 break;
423 case 'accepted':
424 $accepted_count = $status->count;
425 break;
426 case 'declined':
427 $declined_count = $status->count;
428 break;
429 case 'expired':
430 $expired_count = $status->count;
431 break;
432 case 'cancelled':
433 $cancelled_count = $status->count;
434 break;
435 }
436 }
437
438 // Build clients list for the listing filter dropdown
439 $clients_list = [];
440 try {
441 $client_repository = new \EasyInvoice\Repositories\ClientRepository();
442 foreach ($client_repository->all() as $client) {
443 $name = $client->getBusinessClientName() ?: trim($client->getFirstName() . ' ' . $client->getLastName());
444 if ($name === '') {
445 continue;
446 }
447 $clients_list[] = [
448 'id' => $client->getId(),
449 'name' => $name,
450 ];
451 }
452 usort($clients_list, function ($a, $b) {
453 return strcasecmp($a['name'], $b['name']);
454 });
455 } catch (\Throwable $e) {
456 $clients_list = [];
457 }
458
459 // Prepare template data
460 $template_data = [
461 'quotes' => $quotes,
462 'current_view' => $current_view,
463 'status_filter' => $status_filter,
464 'client_filter' => $client_filter,
465 'clients_list' => $clients_list,
466 'search_query' => $search_query,
467 'all_count' => (int)$all_count,
468 'trash_count' => (int)$trash_count,
469 'draft_count' => (int)$draft_count,
470 'available_count' => (int)$available_count,
471 'sent_count' => (int)$sent_count,
472 'accepted_count' => (int)$accepted_count,
473 'declined_count' => (int)$declined_count,
474 'expired_count' => (int)$expired_count,
475 'cancelled_count' => (int)$cancelled_count,
476 'repository' => $this->quote_repository,
477 'current_page' => $current_page,
478 'per_page' => $per_page,
479 'total_quotes' => $total_quotes,
480 'total_pages' => $total_pages,
481 'wp_query' => $wp_query
482 ];
483
484 // Allow plugins to modify template data
485 $template_data = apply_filters('easy_invoice_quote_controller_template_data', $template_data);
486
487 // Display the template
488 include EASY_INVOICE_PLUGIN_DIR . 'templates/quotes/listing.php';
489
490 // Allow plugins to perform actions after displaying listing
491 do_action('easy_invoice_quote_controller_after_display_listing', $template_data);
492 }
493
494 /**
495 * Display quote builder page
496 *
497 * @since 1.0.0
498 */
499 private function displayBuilder(): void {
500 // Allow plugins to perform actions before displaying builder
501 do_action('easy_invoice_quote_controller_before_display_builder');
502
503 $quote_id = isset($_GET['id']) ? (int) $_GET['id'] : 0;
504 $quote = null;
505
506 if ($quote_id > 0) {
507 $quote = $this->quote_repository->find($quote_id);
508 }
509
510 $clients = $this->client_repository->all();
511
512 // Allow plugins to modify the data
513 $quote = apply_filters('easy_invoice_quote_controller_builder_quote', $quote, $quote_id);
514 $clients = apply_filters('easy_invoice_quote_controller_builder_clients', $clients);
515
516 // Include the builder template
517 include EASY_INVOICE_PLUGIN_DIR . 'templates/quotes/builder.php';
518
519 // Allow plugins to perform actions after displaying builder
520 do_action('easy_invoice_quote_controller_after_display_builder', $quote, $clients);
521 }
522
523 /**
524 * Display quote preview page
525 *
526 * @since 1.0.0
527 * @param array $args Display arguments
528 */
529 private function displayPreview(array $args): void {
530 // Allow plugins to perform actions before displaying preview
531 do_action('easy_invoice_quote_controller_before_display_preview', $args);
532
533 $quote_id = isset($_GET['id']) ? (int) $_GET['id'] : 0;
534
535 if ($quote_id <= 0) {
536 wp_die(__('Quote not found.', 'easy-invoice'));
537 }
538
539 $quote = $this->quote_repository->find($quote_id);
540 if (!$quote) {
541 wp_die(__('Quote not found.', 'easy-invoice'));
542 }
543
544 // Allow plugins to modify the quote
545 $quote = apply_filters('easy_invoice_quote_controller_preview_quote', $quote, $quote_id);
546
547 // Include the preview template
548 include EASY_INVOICE_PLUGIN_DIR . 'templates/quotes/preview.php';
549
550 // Allow plugins to perform actions after displaying preview
551 do_action('easy_invoice_quote_controller_after_display_preview', $quote, $args);
552 }
553
554 /**
555 * Handle delete quote AJAX request
556 *
557 * @since 1.0.0
558 */
559 public function handleDeleteQuote(): void {
560 // Verify nonce
561 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
562 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
563 }
564
565 // Check permissions
566 if (!easy_invoice_user_can('ei_delete_quote')) {
567 wp_send_json_error(['message' => __('Insufficient permissions.', 'easy-invoice')]);
568 }
569
570 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
571
572 if ($quote_id <= 0) {
573 wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]);
574 }
575
576 if ($this->quote_repository->delete($quote_id)) {
577 // Log the quote deletion
578 $this->quote_log_service->logDeletion($quote_id);
579
580 wp_send_json_success([
581 'message' => __('Quote deleted successfully.', 'easy-invoice'),
582 'toast' => [
583 'type' => 'success',
584 'message' => __('Quote deleted successfully.', 'easy-invoice')
585 ]
586 ]);
587 } else {
588 wp_send_json_error(['message' => __('Failed to delete quote.', 'easy-invoice')]);
589 }
590 }
591
592 /**
593 * Handle get quote AJAX request
594 *
595 * @since 1.0.0
596 */
597 public function handleGetQuote(): void {
598 // Verify nonce
599 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_get_quote')) {
600 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
601 }
602
603 // Check permissions
604 if (!easy_invoice_user_can('ei_view_quotes')) {
605 wp_send_json_error(['message' => __('Insufficient permissions.', 'easy-invoice')]);
606 }
607
608 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
609
610 if ($quote_id <= 0) {
611 wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]);
612 }
613
614 $quote = $this->quote_repository->find($quote_id);
615
616 if (!$quote) {
617 wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]);
618 }
619
620 wp_send_json_success(['quote' => $quote->toArray()]);
621 }
622
623 /**
624 * Handle AJAX request to load quote template
625 *
626 * @since 1.0.0
627 */
628 public function handleLoadQuoteTemplate(): void {
629 // Verify nonce
630 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_nonce')) {
631 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
632 }
633
634 // Check permissions
635 if (!easy_invoice_user_can('ei_create_quote')) {
636 wp_send_json_error(['message' => __('Insufficient permissions.', 'easy-invoice')]);
637 }
638
639 $template_id = sanitize_text_field($_POST['template'] ?? '');
640 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
641
642 if (empty($template_id)) {
643 wp_send_json_error(['message' => __('Template ID is required.', 'easy-invoice')]);
644 }
645
646 // Validate template name securely
647 $template_id = $this->validateTemplateName($template_id, 'quote');
648
649 // Get secure template file path
650 $template_file = $this->getSecureTemplatePath($template_id, 'quote');
651
652 if (!$template_file) {
653 wp_send_json_error(['message' => __('Template not found.', 'easy-invoice')]);
654 }
655
656 // Load quote if provided
657 $quote = null;
658 if ($quote_id > 0) {
659 $quote = $this->quote_repository->find($quote_id);
660 }
661
662 // Start output buffering to capture template HTML
663 ob_start();
664
665 // Include the template file
666 include $template_file;
667
668 // Get the captured HTML
669 $html = ob_get_clean();
670
671 wp_send_json_success(['html' => $html]);
672 }
673
674 /**
675 * Validate and sanitize template name to prevent directory traversal attacks
676 *
677 * @param string $template The template name to validate
678 * @param string $type Either 'invoice' or 'quote'
679 * @return string Validated template name or 'standard' as fallback
680 */
681 private function validateTemplateName($template, $type = 'quote') {
682 // Whitelist of allowed template names
683 $allowed_templates = array(
684 'invoice' => array('classic', 'corporate', 'creative', 'elegant', 'legacy', 'minimal', 'modern', 'professional', 'standard'),
685 'quote' => array('legacy', 'minimal', 'minimalist', 'modern', 'standard')
686 );
687
688 // Strip any directory components using basename
689 $template = basename($template);
690
691 // Remove any file extension
692 $template = preg_replace('/\.(php|html|htm)$/i', '', $template);
693
694 // Remove any non-alphanumeric characters except hyphens and underscores
695 $template = preg_replace('/[^a-z0-9_-]/i', '', $template);
696
697 // Check if template is in whitelist
698 if (isset($allowed_templates[$type]) && in_array($template, $allowed_templates[$type], true)) {
699 return $template;
700 }
701
702 // Return default template if not in whitelist
703 return 'standard';
704 }
705
706 /**
707 * Get secure template file path with directory traversal protection
708 *
709 * @param string $template The validated template name
710 * @param string $type Either 'invoice' or 'quote'
711 * @return string|false The secure template file path or false if invalid
712 */
713 private function getSecureTemplatePath($template, $type = 'quote') {
714 // Define template directories
715 $template_dirs = array(
716 'invoice' => EASY_INVOICE_PLUGIN_DIR . 'templates/invoice-templates/',
717 'quote' => EASY_INVOICE_PLUGIN_DIR . 'templates/quote-templates/'
718 );
719
720 if (!isset($template_dirs[$type])) {
721 return false;
722 }
723
724 $template_dir = $template_dirs[$type];
725
726 // Ensure template directory exists and is a directory
727 if (!is_dir($template_dir)) {
728 return false;
729 }
730
731 // Get the real path of the template directory (resolves any symlinks)
732 $real_template_dir = realpath($template_dir);
733 if ($real_template_dir === false) {
734 return false;
735 }
736
737 // Construct the template file path
738 $template_file = $real_template_dir . DIRECTORY_SEPARATOR . $template . '.php';
739
740 // Get the real path of the template file (resolves any .. or . components)
741 $real_template_file = realpath($template_file);
742
743 // Verify that the resolved path is within the template directory
744 // This prevents directory traversal attacks
745 if ($real_template_file === false || strpos($real_template_file, $real_template_dir) !== 0) {
746 // If template doesn't exist or is outside the directory, use default
747 $default_file = $real_template_dir . DIRECTORY_SEPARATOR . 'standard.php';
748 $real_default_file = realpath($default_file);
749
750 if ($real_default_file !== false && strpos($real_default_file, $real_template_dir) === 0) {
751 return $real_default_file;
752 }
753
754 return false;
755 }
756
757 // Verify the file exists and is readable
758 if (!is_file($real_template_file) || !is_readable($real_template_file)) {
759 // Fallback to standard template
760 $default_file = $real_template_dir . DIRECTORY_SEPARATOR . 'standard.php';
761 $real_default_file = realpath($default_file);
762
763 if ($real_default_file !== false && strpos($real_default_file, $real_template_dir) === 0 && is_file($real_default_file) && is_readable($real_default_file)) {
764 return $real_default_file;
765 }
766
767 return false;
768 }
769
770 return $real_template_file;
771 }
772
773 /**
774 * Handle AJAX request to create a new quote with just the title
775 *
776 * @since 1.0.0
777 */
778 public function handleCreateNewQuote(): void {
779 // Verify nonce
780 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
781 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
782 }
783 // Check permissions
784 if (!easy_invoice_user_can('ei_create_quote')) {
785 wp_send_json_error(['message' => __('Insufficient permissions.', 'easy-invoice')]);
786 }
787 $title = isset($_POST['title']) ? sanitize_text_field($_POST['title']) : '';
788 if (empty($title)) {
789 wp_send_json_error(['message' => __('Quote title is required.', 'easy-invoice')]);
790 }
791
792 // Generate a unique quote number
793 $quote_number = '';
794 if (class_exists('\\EasyInvoice\\Services\\QuoteNumberService')) {
795 $quote_number_service = new \EasyInvoice\Services\QuoteNumberService();
796 $quote_number = $quote_number_service->generateUniqueNumber();
797 } else {
798 // Fallback if service doesn't exist
799 $quote_number = 'QT-' . str_pad(time(), 6, '0', STR_PAD_LEFT);
800 }
801
802 // Get global quote settings
803 $settings_controller = new \EasyInvoice\Controllers\SettingsController();
804 $quote_terms = $settings_controller::getQuoteTermsConditions();
805 $quote_footer = $settings_controller::getQuoteFooterText();
806 $quote_accept_button = get_option('easy_invoice_quote_accept_button', 'yes');
807 $quote_accept_action = get_option('easy_invoice_quote_accept_action', 'email');
808 $quote_accept_text = get_option('easy_invoice_quote_accept_text', __('Accept Quote', 'easy-invoice'));
809 $quote_accepted_message = get_option('easy_invoice_quote_accepted_message', __('Thank you for accepting our quote!', 'easy-invoice'));
810 $quote_declined_message = get_option('easy_invoice_quote_declined_message', __('Thank you for your consideration.', 'easy-invoice'));
811
812 // Create the quote with just the title and default values
813 $data = [
814 'title' => $title,
815 'status' => 'draft',
816 'number' => $quote_number, // Use the generated unique number
817 'issue_date' => date('Y-m-d'),
818 'expiry_date' => date('Y-m-d', strtotime('+30 days')),
819 'items' => [],
820 'notes' => '', // Ensure notes is never null
821 'terms' => $quote_terms, // Use global terms setting
822 'footer_text' => $quote_footer, // Use global footer setting
823 'accept_button' => $quote_accept_button, // Use global accept button setting
824 'accept_action' => $quote_accept_action, // Use global accept action setting
825 'accept_text' => $quote_accept_text, // Use global accept text setting
826 'accepted_message' => $quote_accepted_message, // Use global accepted message setting
827 'declined_message' => $quote_declined_message, // Use global declined message setting
828 'template' => get_option('easy_invoice_last_quote_template', 'standard')
829 ];
830
831
832 $quote = $this->quote_repository->create($data);
833 if (!$quote) {
834 wp_send_json_error(['message' => __('Failed to create quote.', 'easy-invoice')]);
835 }
836 wp_send_json_success(['quote_id' => $quote->getId()]);
837 }
838
839 /**
840 * Handle AJAX request to load quote form for modal
841 *
842 * @since 1.0.0
843 */
844 public function handleLoadQuoteForm(): void {
845 // Verify nonce
846 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
847 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
848 }
849
850 // Check permissions
851 if (!easy_invoice_user_can('ei_create_quote')) {
852 wp_send_json_error(['message' => __('Insufficient permissions.', 'easy-invoice')]);
853 }
854
855 // Get global quote settings
856 $settings_controller = new \EasyInvoice\Controllers\SettingsController();
857 $quote_terms = $settings_controller::getQuoteTermsConditions();
858 $quote_footer = $settings_controller::getQuoteFooterText();
859 $quote_accept_button = get_option('easy_invoice_quote_accept_button', 'yes');
860 $quote_accept_action = get_option('easy_invoice_quote_accept_action', 'email');
861 $quote_accept_text = get_option('easy_invoice_quote_accept_text', __('Accept Quote', 'easy-invoice'));
862 $quote_accepted_message = get_option('easy_invoice_quote_accepted_message', __('Thank you for accepting our quote!', 'easy-invoice'));
863 $quote_declined_message = get_option('easy_invoice_quote_declined_message', __('Thank you for your consideration.', 'easy-invoice'));
864
865 // Create a new quote object for the form
866 $quote_number_service = function_exists('easy_invoice_get_quote_number_service') ? easy_invoice_get_quote_number_service() : null;
867 $quote_data = array(
868 'number' => $quote_number_service ? $quote_number_service->getNextNumber() : 'QT-1',
869 'date' => date('Y-m-d'),
870 'expiry_date' => date('Y-m-d', strtotime('+30 days')),
871 'client_id' => 0,
872 'client_name' => '',
873 'client_email' => '',
874 'client_phone' => '',
875 'client_address' => '',
876 'items' => array(),
877 'notes' => '',
878 'internal_notes' => '',
879 'discount' => 0,
880 'discount_type' => 'percentage',
881 'calculation_method' => 'before_tax',
882 'tax_rate' => 10,
883 'prices_include_tax' => 'no',
884 'status' => 'draft',
885 'currency' => 'USD',
886 'currency_symbol' => '$',
887 'title' => '',
888 'description' => '',
889 'terms' => $quote_terms, // Use global terms setting
890 'footer_text' => $quote_footer, // Use global footer setting
891 'accept_button' => $quote_accept_button, // Use global accept button setting
892 'accept_action' => $quote_accept_action, // Use global accept action setting
893 'accept_text' => $quote_accept_text, // Use global accept text setting
894 'accepted_message' => $quote_accepted_message, // Use global accepted message setting
895 'declined_message' => $quote_declined_message, // Use global declined message setting
896 );
897
898 // Create a temporary WP_Post object for new quote
899 $empty_post = new \WP_Post((object) array(
900 'ID' => 0,
901 'post_author' => get_current_user_id(),
902 'post_date' => current_time('mysql'),
903 'post_date_gmt' => current_time('mysql', 1),
904 'post_title' => $quote_data['number'],
905 'post_status' => 'auto-draft',
906 'comment_status' => 'closed',
907 'ping_status' => 'closed',
908 'post_name' => '',
909 'post_modified' => current_time('mysql'),
910 'post_modified_gmt' => current_time('mysql', 1),
911 'post_parent' => 0,
912 'guid' => '',
913 'menu_order' => 0,
914 'post_type' => \EasyInvoice\Constants\PostTypes::EASY_INVOICE_QUOTE_POST_TYPE,
915 'post_mime_type' => '',
916 'comment_count' => 0,
917 'filter' => 'raw',
918 ));
919
920 $quote = new \EasyInvoice\Models\Quote($empty_post);
921
922 // Set default values on the quote object
923 foreach ($quote_data as $key => $value) {
924 $setter = 'set' . easy_invoice_str_replace('_', '', ucwords($key, '_'));
925 if (method_exists($quote, $setter)) {
926 switch ($setter) {
927 case 'setClientId':
928 $quote->setClientId((int) $value);
929 break;
930 case 'setItems':
931 $quote->setItems((array) $value);
932 break;
933 case 'setSubtotal':
934 case 'setTaxAmount':
935 case 'setDiscountAmount':
936 case 'setTotal':
937 case 'setDiscountValue':
938 case 'setTaxRate':
939 $quote->$setter((float) $value);
940 break;
941 case 'setPricesIncludeTax':
942 $quote->$setter((bool) $value);
943 break;
944 default:
945 $quote->$setter((string) $value);
946 break;
947 }
948 }
949 }
950
951 // Initialize empty items array
952 $quote->setItems([]);
953
954 // Set variables needed by the form template
955 $quote_id = 0;
956 $clients = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository()->all();
957 $quote_form_manager = new \EasyInvoice\Forms\Quote\QuoteFormManager();
958 $quote_items_json = json_encode([]);
959 $admin_nonce = wp_create_nonce('easy_invoice_admin_nonce');
960 $quote_field_config = $quote_form_manager->getFieldConfigForJavaScript();
961
962 // Start output buffering to capture form HTML
963 ob_start();
964
965 // Include the quote form template
966 include EASY_INVOICE_PLUGIN_DIR . 'templates/quotes/form.php';
967
968 // Get the captured HTML
969 $html = ob_get_clean();
970
971 wp_send_json_success(['html' => $html]);
972 }
973
974
975 /**
976 * Nonce action for quote accept/decline (includes quote ID to prevent cross-quote reuse).
977 */
978 private function quoteAcceptDeclineNonceAction(int $quote_id): string {
979 return 'easy_invoice_quote_action_' . $quote_id;
980 }
981
982 /**
983 * Get the per-quote access token. Lazily generated on first read.
984 *
985 * Previously the public quote page embedded an `easy_invoice_quote_action_{id}`
986 * nonce that, combined with the off-by-default `easy_invoice_pro_restrict_quote_to_client`
987 * option, let any visitor accept or decline any published quote
988 * (CVE-2026-9021). The token replaces that public-nonce-as-authorisation
989 * model: it's a cryptographically random per-quote secret that's only
990 * leaked to the legitimate quote recipient via the emailed link's
991 * `?qk=...` parameter, and is required server-side by the accept /
992 * decline handlers (alongside an unconditional ownership check on
993 * authenticated callers).
994 *
995 * The token is single-purpose (just accept/decline gating) and lives
996 * in private post meta. We generate 32 hex chars (128 bits of entropy)
997 * which is well above what's brute-forceable inside the lifetime of a
998 * published quote.
999 */
1000 public static function quoteAccessToken(int $quote_id): string {
1001 if ($quote_id <= 0) {
1002 return '';
1003 }
1004 $token = (string) get_post_meta($quote_id, '_easy_invoice_quote_access_token', true);
1005 if ($token === '' || strlen($token) < 32) {
1006 try {
1007 $token = bin2hex(random_bytes(16));
1008 } catch (\Throwable $e) {
1009 // Fallback for systems without CSPRNG. wp_generate_password uses
1010 // random_bytes internally on modern PHP — same entropy source.
1011 $token = wp_generate_password(32, false, false);
1012 }
1013 update_post_meta($quote_id, '_easy_invoice_quote_access_token', $token);
1014 }
1015 return $token;
1016 }
1017
1018 /**
1019 * Read-only sibling of quoteAccessToken(). Returns the persisted
1020 * token if one already exists, or an empty string otherwise — never
1021 * mints. Use this from user-controlled rendering contexts (e.g. the
1022 * `[easy_quote_url]` shortcode) where allowing an arbitrary caller
1023 * to MINT an Accept/Decline-authorising token for an attacker-chosen
1024 * quote would be a privilege-escalation vector.
1025 *
1026 * Trusted server contexts (the EmailManager quote-send path) should
1027 * keep calling quoteAccessToken() so first-send still works.
1028 */
1029 public static function quoteAccessTokenIfExists(int $quote_id): string {
1030 if ($quote_id <= 0) {
1031 return '';
1032 }
1033 $token = (string) get_post_meta($quote_id, '_easy_invoice_quote_access_token', true);
1034 return strlen($token) >= 32 ? $token : '';
1035 }
1036
1037 /**
1038 * Constant-time comparison helper for the access token.
1039 */
1040 private static function quoteTokenFromRequest(): string {
1041 $token = '';
1042 if (isset($_POST['access_token'])) {
1043 $token = sanitize_text_field(wp_unslash($_POST['access_token']));
1044 } elseif (isset($_GET['qk'])) {
1045 $token = sanitize_text_field(wp_unslash($_GET['qk']));
1046 }
1047 return $token;
1048 }
1049
1050 /**
1051 * Central authorisation check for quote accept/decline. Returns true
1052 * when ANY of these is true:
1053 *
1054 * 1. The request carries a valid per-quote access token (the legitimate
1055 * email-recipient flow). Constant-time compared with hash_equals.
1056 * 2. The current user is logged in AND has admin-grade capability
1057 * (manage_options) — admin-side accept/decline.
1058 * 3. The current user is logged in AND is the quote's bound client
1059 * (email match against the quote's client_id record). This was
1060 * previously gated behind the off-by-default
1061 * `easy_invoice_pro_restrict_quote_to_client` option — that gate
1062 * is removed in 2.3.4 so the ownership check runs unconditionally.
1063 *
1064 * Returns false otherwise. Callers must reject the request when this
1065 * returns false; we don't reject from in here so the caller can choose
1066 * wp_send_json_error vs wp_die based on its transport.
1067 */
1068 public static function canActOnQuote(int $quote_id, $quote = null): bool {
1069 if ($quote_id <= 0) {
1070 return false;
1071 }
1072
1073 // Path 1: legitimate access-token flow (email link recipient).
1074 $presented = self::quoteTokenFromRequest();
1075 if ($presented !== '') {
1076 $stored = (string) get_post_meta($quote_id, '_easy_invoice_quote_access_token', true);
1077 if ($stored !== '' && hash_equals($stored, $presented)) {
1078 return true;
1079 }
1080 }
1081
1082 // Path 2: admin override.
1083 if (current_user_can('manage_options')) {
1084 return true;
1085 }
1086
1087 // Path 3: authenticated owner. ONLY when the current user is the
1088 // quote's bound client (email match). Previously this was
1089 // skipped entirely when the Pro option was 'no' (the default) —
1090 // which is what made the CVE exploitable. Now it always runs.
1091 //
1092 // Note: Quote model resolves `getClientId()` via __call magic,
1093 // so method_exists() returns FALSE for it (PHP's method_exists
1094 // does not recognise __call-resolved methods). Use is_callable
1095 // instead — it correctly returns TRUE when the receiver has a
1096 // __call that can field the message, so this guard actually
1097 // permits the bound-client path on real Quote objects.
1098 if (is_user_logged_in() && $quote && is_callable([$quote, 'getClientId']) && $quote->getClientId()) {
1099 $current_user = wp_get_current_user();
1100 $client_repository = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository();
1101 $client = $client_repository->find($quote->getClientId());
1102 if ($client && strcasecmp((string) $client->getEmail(), (string) $current_user->user_email) === 0) {
1103 return true;
1104 }
1105 }
1106
1107 return false;
1108 }
1109
1110 /**
1111 * Handle AJAX request to accept a quote
1112 *
1113 * @since 1.0.0
1114 */
1115 public function handleAcceptQuote(): void {
1116 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
1117
1118 if ($quote_id <= 0) {
1119 wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]);
1120 }
1121
1122 // Quote-scoped nonce prevents cross-quote IDOR with a leaked global nonce.
1123 if (!wp_verify_nonce($_POST['nonce'] ?? '', $this->quoteAcceptDeclineNonceAction($quote_id))) {
1124 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
1125 }
1126
1127 $is_admin = current_user_can('manage_options');
1128 if ($is_admin) {
1129 $quote = $this->quote_repository->find($quote_id);
1130 } else {
1131 $quote = $this->quote_repository->findPublished($quote_id);
1132 }
1133
1134 if (!$quote) {
1135 wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]);
1136 }
1137
1138 // SECURITY (CVE-2026-9021): authorise unconditionally — admin, valid
1139 // access token (email-link path), or authenticated client whose
1140 // email matches the quote's bound client. The previous gating
1141 // behind easy_invoice_pro_restrict_quote_to_client was OFF by
1142 // default, letting any anonymous visitor who could read the public
1143 // single-quote page harvest the nonce and accept arbitrary quotes.
1144 if (!self::canActOnQuote($quote_id, $quote)) {
1145 wp_send_json_error(['message' => __('You do not have permission to accept this quote.', 'easy-invoice')]);
1146 }
1147
1148 $current_user = wp_get_current_user();
1149
1150 // Get global accept action setting
1151 $settings_controller = new \EasyInvoice\Controllers\SettingsController();
1152 $accept_action = $settings_controller::getQuoteAcceptAction();
1153
1154 // Update quote status to accepted
1155 $quote->setStatus('accepted');
1156 $quote->setAcceptedDate(date('Y-m-d H:i:s'));
1157 $quote->setAcceptedBy($current_user->ID);
1158
1159 // Save the quote
1160 $saved = $quote->save();
1161
1162 if (!$saved) {
1163 wp_send_json_error(['message' => __('Failed to accept quote.', 'easy-invoice')]);
1164 }
1165
1166 // Log the quote acceptance
1167 $this->quote_log_service->logAcceptance($quote_id, [
1168 'accept_action' => $accept_action,
1169 'user_type' => $is_admin ? 'admin' : 'client'
1170 ]);
1171
1172 // Perform the configured accept action
1173 $invoice_id = null;
1174 $action_message = '';
1175
1176 switch ($accept_action) {
1177 case 'convert':
1178 // Convert quote to invoice (Draft status)
1179 $invoice_id = $this->convertQuoteToInvoice($quote, 'draft');
1180 if ($invoice_id) {
1181 $this->quote_log_service->logConversionToInvoice($quote_id, $invoice_id);
1182 }
1183 $action_message = __('Quote converted to invoice successfully.', 'easy-invoice');
1184 break;
1185
1186 case 'convert_available':
1187 // Convert quote to invoice (Available status)
1188 $invoice_id = $this->convertQuoteToInvoice($quote, 'available');
1189 if ($invoice_id) {
1190 $this->quote_log_service->logConversionToInvoice($quote_id, $invoice_id);
1191 }
1192 $action_message = __('Quote converted to invoice successfully.', 'easy-invoice');
1193 break;
1194
1195 case 'convert_send':
1196 // Convert quote to invoice and send to client (Available status)
1197 $invoice_id = $this->convertQuoteToInvoice($quote, 'available');
1198 if ($invoice_id) {
1199 $this->sendInvoiceToClient($invoice_id);
1200 }
1201 $action_message = __('Quote converted to invoice and sent to client successfully.', 'easy-invoice');
1202 break;
1203
1204 case 'duplicate':
1205 // Create new invoice, keep quote as-is (Draft status)
1206 $invoice_id = $this->createInvoiceFromQuote($quote, 'draft');
1207 if ($invoice_id) {
1208 $this->quote_log_service->logDuplicationToInvoice($quote_id, $invoice_id);
1209 }
1210 $action_message = __('New invoice created from quote successfully.', 'easy-invoice');
1211 break;
1212
1213 case 'duplicate_send':
1214 // Create new invoice and send to client, keep quote as-is (Available status)
1215 $invoice_id = $this->createInvoiceFromQuote($quote, 'available');
1216 if ($invoice_id) {
1217 $this->sendInvoiceToClient($invoice_id);
1218 }
1219 $action_message = __('New invoice created and sent to client successfully.', 'easy-invoice');
1220 break;
1221
1222 case 'do_nothing':
1223 default:
1224 // Do nothing additional
1225 $action_message = __('Quote accepted successfully.', 'easy-invoice');
1226 break;
1227 }
1228
1229 // Send notification email to admin
1230 if (!$is_admin) {
1231 $this->sendQuoteAcceptanceNotification($quote);
1232 }
1233
1234 // Get URLs for the new invoice
1235 $invoice_url = null;
1236 $secure_url = null;
1237
1238 if ($invoice_id) {
1239 // Always use WordPress permalink
1240 $invoice_url = get_permalink($invoice_id);
1241 // If Pro and secure link available, use secure link
1242 if (class_exists('\EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController')) {
1243 $secure_url = \EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController::getInvoiceSecureLinkUrl($invoice_id);
1244 if ($secure_url) {
1245 $invoice_url = $secure_url;
1246 }
1247 }
1248 }
1249
1250 wp_send_json_success([
1251 'message' => $action_message,
1252 'invoice_id' => $invoice_id,
1253 'invoice_url' => $invoice_url,
1254 'secure_url' => $secure_url,
1255 'toast' => [
1256 'type' => 'success',
1257 'message' => $action_message
1258 ]
1259 ]);
1260 }
1261
1262 /**
1263 * Convert quote to invoice
1264 *
1265 * @param \EasyInvoice\Models\Quote $quote The quote to convert
1266 * @param string $status The status for the new invoice ('draft' or 'available')
1267 * @return int|null The invoice ID if successful, null otherwise
1268 */
1269 private function convertQuoteToInvoice($quote, $status = 'draft'): ?int {
1270 try {
1271 // Get invoice repository
1272 $invoice_repository = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository();
1273
1274 // Create invoice data from quote - convert ALL fields
1275 $invoice_data = [
1276 'title' => $quote->getTitle() ?: 'Invoice from Quote ' . $quote->getNumber(),
1277 'number' => $this->generateInvoiceNumber(),
1278 'status' => $status,
1279 'issue_date' => date('Y-m-d'),
1280 'due_date' => date('Y-m-d', strtotime('+30 days')),
1281 'client_id' => $quote->getClientId(),
1282 'customer_name' => $quote->getCustomerName(),
1283 'customer_email' => $quote->getCustomerEmail(),
1284 'customer_address' => $quote->getCustomerAddress(),
1285 'shipping_name' => $quote->getCustomerName(), // Use customer name as shipping name
1286 'shipping_address' => $quote->getCustomerAddress(), // Use customer address as shipping address
1287 'items' => $this->convertQuoteItemsToInvoiceItems($quote->getItems()),
1288 'notes' => $quote->getNotes(),
1289 'description' => $quote->getDescription(),
1290 'terms' => $quote->getTerms(),
1291 'internal_notes' => $quote->getInternalNotes(),
1292 'payment_instructions' => '', // Invoice-specific field, leave empty
1293 'payment_gateways' => [], // Invoice-specific field, leave empty
1294 'template' => $quote->getTemplate(),
1295 'subtotal' => $quote->getSubtotal(),
1296 'tax_rate' => $quote->getTaxRate(),
1297 'tax_amount' => $quote->getTaxAmount(),
1298 'discount_type' => $quote->getDiscountType(),
1299 'discount_value' => $quote->getDiscountValue(),
1300 'discount_amount' => $quote->getDiscountAmount(),
1301 'total' => $quote->getTotal(),
1302 'currency_code' => $quote->getCurrencyCode() ?: 'USD',
1303 'currency_position' => $quote->getCurrencyPosition() ?: 'left',
1304 'footer_text' => $quote->getFooterText(),
1305 'calculation_method' => 'standard', // Default calculation method for invoices
1306 'prices_include_tax' => $quote->getPricesIncludeTax(),
1307 'custom_fields' => $quote->getCustomFields(), // Transfer custom fields
1308 ];
1309
1310 // Create the invoice
1311 $invoice = $invoice_repository->create($invoice_data);
1312
1313 if ($invoice) {
1314 // Store the quote ID in the invoice's meta for tracking
1315 update_post_meta($invoice->getId(), '_converted_from_quote', $quote->getId());
1316
1317 // Update quote to reference the created invoice
1318 $quote->setCustomField('converted_invoice_id', $invoice->getId());
1319 $quote->save();
1320
1321 // Ensure secure link is generated for the new invoice (Pro version)
1322 if (class_exists('\EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController')) {
1323 // Trigger the save_post hook to generate secure link
1324 do_action('save_post_easy_invoice', $invoice->getId(), get_post($invoice->getId()));
1325 }
1326
1327 return $invoice->getId();
1328 }
1329
1330 return null;
1331 } catch (\Exception $e) {
1332 // Error converting quote to invoice
1333 return null;
1334 }
1335 }
1336
1337 /**
1338 * Create new invoice from quote (duplicate)
1339 *
1340 * @param \EasyInvoice\Models\Quote $quote The quote to duplicate
1341 * @param string $status The status for the new invoice ('draft' or 'available')
1342 * @return int|null The invoice ID if successful, null otherwise
1343 */
1344 private function createInvoiceFromQuote($quote, $status = 'draft'): ?int {
1345 try {
1346 // Get invoice repository
1347 $invoice_repository = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository();
1348
1349 // Create invoice data from quote - convert ALL fields
1350 $invoice_data = [
1351 'title' => 'Invoice from Quote ' . $quote->getNumber(),
1352 'number' => $this->generateInvoiceNumber(),
1353 'status' => $status,
1354 'issue_date' => date('Y-m-d'),
1355 'due_date' => date('Y-m-d', strtotime('+30 days')),
1356 'client_id' => $quote->getClientId(),
1357 'customer_name' => $quote->getCustomerName(),
1358 'customer_email' => $quote->getCustomerEmail(),
1359 'customer_address' => $quote->getCustomerAddress(),
1360 'shipping_name' => $quote->getCustomerName(), // Use customer name as shipping name
1361 'shipping_address' => $quote->getCustomerAddress(), // Use customer address as shipping address
1362 'items' => $this->convertQuoteItemsToInvoiceItems($quote->getItems()),
1363 'notes' => $quote->getNotes(),
1364 'description' => $quote->getDescription(),
1365 'terms' => $quote->getTerms(),
1366 'internal_notes' => $quote->getInternalNotes(),
1367 'payment_instructions' => '', // Invoice-specific field, leave empty
1368 'payment_gateways' => [], // Invoice-specific field, leave empty
1369 'template' => $quote->getTemplate(),
1370 'subtotal' => $quote->getSubtotal(),
1371 'tax_rate' => $quote->getTaxRate(),
1372 'tax_amount' => $quote->getTaxAmount(),
1373 'discount_type' => $quote->getDiscountType(),
1374 'discount_value' => $quote->getDiscountValue(),
1375 'discount_amount' => $quote->getDiscountAmount(),
1376 'total' => $quote->getTotal(),
1377 'currency_code' => $quote->getCurrencyCode() ?: 'USD',
1378 'currency_position' => $quote->getCurrencyPosition() ?: 'left',
1379 'footer_text' => $quote->getFooterText(),
1380 'calculation_method' => 'standard', // Default calculation method for invoices
1381 'prices_include_tax' => $quote->getPricesIncludeTax(),
1382 'custom_fields' => $quote->getCustomFields(), // Transfer custom fields
1383 ];
1384
1385 // Create the invoice
1386 $invoice = $invoice_repository->create($invoice_data);
1387
1388 if ($invoice) {
1389 // Link the invoice to the quote
1390 $quote->setCustomField('related_invoice_id', $invoice->getId());
1391 $quote->save();
1392
1393 // Ensure secure link is generated for the new invoice (Pro version)
1394 if (class_exists('\EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController')) {
1395 // Trigger the save_post hook to generate secure link
1396 do_action('save_post_easy_invoice', $invoice->getId(), get_post($invoice->getId()));
1397 }
1398
1399 return $invoice->getId();
1400 }
1401
1402 return null;
1403 } catch (\Exception $e) {
1404 // Error creating invoice from quote
1405 return null;
1406 }
1407 }
1408
1409 /**
1410 * Send invoice to client
1411 *
1412 * @param int $invoice_id The invoice ID
1413 * @return bool True if sent successfully
1414 */
1415 private function sendInvoiceToClient(int $invoice_id): bool {
1416 try {
1417 // Get invoice
1418 $invoice_repository = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository();
1419 $invoice = $invoice_repository->find($invoice_id);
1420
1421 if (!$invoice) {
1422 return false;
1423 }
1424
1425 // Get email manager
1426 $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
1427
1428 // Send invoice email
1429 $result = $email_manager->sendInvoiceEmail($invoice, 'new');
1430
1431 return $result['success'];
1432 } catch (\Exception $e) {
1433 // Error sending invoice to client
1434 return false;
1435 }
1436 }
1437
1438 /**
1439 * Convert quote items to invoice items
1440 *
1441 * @param array $quote_items Array of quote items
1442 * @return array Array of invoice items
1443 */
1444 private function convertQuoteItemsToInvoiceItems(array $quote_items): array {
1445 $invoice_items = [];
1446
1447 foreach ($quote_items as $quote_item) {
1448 if (is_object($quote_item) && method_exists($quote_item, 'toArray')) {
1449 // Convert QuoteItem object to InvoiceItem array
1450 $item_data = $quote_item->toArray();
1451 $invoice_items[] = [
1452 'name' => $item_data['name'] ?? '',
1453 'description' => $item_data['description'] ?? '',
1454 'quantity' => $item_data['quantity'] ?? 0,
1455 'price' => $item_data['price'] ?? 0,
1456 'amount' => $item_data['amount'] ?? 0,
1457 'taxable' => $item_data['taxable'] ?? true,
1458 // Map adjust_percentage to a similar field if needed
1459 'adjust_percentage' => $item_data['adjust_percentage'] ?? 0,
1460 ];
1461 } elseif (is_array($quote_item)) {
1462 // Convert array item directly
1463 $invoice_items[] = [
1464 'name' => $quote_item['name'] ?? $quote_item['title'] ?? '',
1465 'description' => $quote_item['description'] ?? '',
1466 'quantity' => $quote_item['quantity'] ?? 0,
1467 'price' => $quote_item['price'] ?? 0,
1468 'amount' => $quote_item['amount'] ?? $quote_item['total'] ?? 0,
1469 'taxable' => $quote_item['taxable'] ?? true,
1470 'adjust_percentage' => $quote_item['adjust_percentage'] ?? 0,
1471 ];
1472 }
1473 }
1474
1475 return $invoice_items;
1476 }
1477
1478 /**
1479 * Generate unique invoice number
1480 *
1481 * @return string The invoice number
1482 */
1483 private function generateInvoiceNumber(): string {
1484 // Try to use invoice number service if available
1485 if (class_exists('\\EasyInvoice\\Services\\InvoiceNumberService')) {
1486 $invoice_number_service = new \EasyInvoice\Services\InvoiceNumberService();
1487 return $invoice_number_service->generateUniqueNumber();
1488 }
1489
1490 // Fallback to timestamp-based number
1491 return 'INV-' . str_pad(time(), 6, '0', STR_PAD_LEFT);
1492 }
1493
1494 /**
1495 * Get changes between two quote versions
1496 *
1497 * @param \EasyInvoice\Models\Quote $old_quote Old quote
1498 * @param \EasyInvoice\Models\Quote $new_quote New quote
1499 * @return array Array of changes
1500 */
1501 private function getQuoteChanges($old_quote, $new_quote): array {
1502 $changes = [];
1503
1504 // Compare key fields
1505 $fields_to_compare = [
1506 'title' => 'Title',
1507 'status' => 'Status',
1508 'customer_name' => 'Customer Name',
1509 'customer_email' => 'Customer Email',
1510 'customer_address' => 'Customer Address',
1511 'issue_date' => 'Issue Date',
1512 'expiry_date' => 'Expiry Date',
1513 'total' => 'Total Amount',
1514 'notes' => 'Notes',
1515 'terms' => 'Terms',
1516 ];
1517
1518 foreach ($fields_to_compare as $field => $label) {
1519 $method_name = 'get' . easy_invoice_str_replace('_', '', ucwords($field, '_'));
1520
1521 if (method_exists($old_quote, $method_name) && method_exists($new_quote, $method_name)) {
1522 $old_value = $old_quote->$method_name();
1523 $new_value = $new_quote->$method_name();
1524
1525 if ($old_value !== $new_value) {
1526 $changes[$field] = $new_value;
1527 }
1528 }
1529 }
1530
1531 return $changes;
1532 }
1533
1534 /**
1535 * Handle AJAX request to decline a quote
1536 *
1537 * @since 1.0.0
1538 */
1539 public function handleDeclineQuote(): void {
1540 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
1541 $decline_reason = isset($_POST['decline_reason']) ? sanitize_textarea_field($_POST['decline_reason']) : '';
1542
1543 if ($quote_id <= 0) {
1544 wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]);
1545 }
1546
1547 if (!wp_verify_nonce($_POST['nonce'] ?? '', $this->quoteAcceptDeclineNonceAction($quote_id))) {
1548 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
1549 }
1550
1551 $is_admin = current_user_can('manage_options');
1552 if ($is_admin) {
1553 $quote = $this->quote_repository->find($quote_id);
1554 } else {
1555 $quote = $this->quote_repository->findPublished($quote_id);
1556 }
1557
1558 if (!$quote) {
1559 wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]);
1560 }
1561
1562 // Check if decline reason is required by global settings
1563 $settings_controller = new \EasyInvoice\Controllers\SettingsController();
1564 if ($settings_controller::isDeclineReasonRequired() && empty(trim($decline_reason))) {
1565 wp_send_json_error(['message' => __('Reason for declining is required.', 'easy-invoice')]);
1566 }
1567
1568 // SECURITY (CVE-2026-9021): unconditional authorisation — see
1569 // handleAcceptQuote for the full rationale. Same three paths:
1570 // admin / valid access token / authenticated bound client.
1571 if (!self::canActOnQuote($quote_id, $quote)) {
1572 wp_send_json_error(['message' => __('You do not have permission to decline this quote.', 'easy-invoice')]);
1573 }
1574
1575 $current_user = wp_get_current_user();
1576
1577 // Update quote status to declined
1578 $quote->setStatus('declined');
1579 $quote->setDeclinedDate(date('Y-m-d H:i:s'));
1580 $quote->setDeclinedBy($current_user->ID);
1581
1582 // Save decline reason if provided
1583 if (!empty($decline_reason)) {
1584 $quote->setDeclineReason($decline_reason);
1585 }
1586
1587 // Save the quote
1588 $saved = $quote->save();
1589
1590 if (!$saved) {
1591 wp_send_json_error(['message' => __('Failed to decline quote.', 'easy-invoice')]);
1592 }
1593
1594 // Log the quote decline
1595 $this->quote_log_service->logDecline($quote_id, $decline_reason, [
1596 'user_type' => $is_admin ? 'admin' : 'client'
1597 ]);
1598
1599 // Send notification email to admin
1600 if (!$is_admin) {
1601 $this->sendQuoteDeclineNotification($quote);
1602 }
1603
1604 wp_send_json_success([
1605 'message' => __('Quote declined successfully.', 'easy-invoice'),
1606 'toast' => [
1607 'type' => 'success',
1608 'message' => __('Quote declined successfully.', 'easy-invoice')
1609 ]
1610 ]);
1611 }
1612
1613 /**
1614 * Send quote acceptance notification to admin
1615 *
1616 * @param \EasyInvoice\Models\Quote $quote The quote that was accepted
1617 */
1618 private function sendQuoteAcceptanceNotification($quote): void {
1619 // Use EmailManager to send admin notification
1620 $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
1621 $email_manager->sendAdminQuoteNotification($quote, 'accepted');
1622 }
1623
1624 /**
1625 * Send quote decline notification to admin
1626 *
1627 * @param \EasyInvoice\Models\Quote $quote The quote that was declined
1628 */
1629 private function sendQuoteDeclineNotification($quote): void {
1630 // Use EmailManager to send admin notification
1631 $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
1632 $email_manager->sendAdminQuoteNotification($quote, 'declined');
1633 }
1634
1635 /**
1636 * Handle AJAX request to update existing quotes with missing data
1637 *
1638 * @since 1.0.0
1639 */
1640 public function handleUpdateExistingQuotes(): void {
1641 // Verify nonce - match the nonce being sent from JavaScript
1642 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
1643 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
1644 }
1645
1646 // Check permissions — bulk migration / repair: admin-only.
1647 if (!current_user_can('manage_options')) {
1648 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
1649 }
1650
1651 $updated_count = 0;
1652 $quotes = $this->quote_repository->findAll();
1653
1654 foreach ($quotes as $quote) {
1655 $post = get_post($quote->getId());
1656 if ($post && empty($post->post_name)) {
1657 // Generate a proper slug for this quote
1658 $post_title = $quote->getTitle() ?: $quote->getNumber() ?: 'Untitled Quote';
1659 $post_name = sanitize_title($post_title);
1660
1661 // Ensure uniqueness
1662 $original_slug = $post_name;
1663 $counter = 1;
1664 while (get_page_by_path($post_name, OBJECT, \EasyInvoice\Constants\PostTypes::EASY_INVOICE_QUOTE_POST_TYPE)) {
1665 $post_name = $original_slug . '-' . $counter;
1666 $counter++;
1667 }
1668
1669 // Update the post with the new slug
1670 wp_update_post([
1671 'ID' => $quote->getId(),
1672 'post_name' => $post_name
1673 ]);
1674
1675 $updated_count++;
1676 }
1677 }
1678
1679 wp_send_json_success([
1680 'message' => sprintf(__('Updated %d quotes with proper URLs.', 'easy-invoice'), $updated_count)
1681 ]);
1682 }
1683
1684 /**
1685 * Handle AJAX request to duplicate a quote
1686 *
1687 * @since 1.0.0
1688 */
1689 public function handleDuplicateQuote(): void {
1690 // Verify nonce
1691 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
1692 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
1693 }
1694
1695 // Check permissions
1696 if (!easy_invoice_user_can('ei_create_quote')) {
1697 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
1698 }
1699
1700 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
1701
1702 if ($quote_id <= 0) {
1703 wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]);
1704 }
1705
1706 $quote = $this->quote_repository->find($quote_id);
1707
1708 if (!$quote) {
1709 wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]);
1710 }
1711
1712 // Get global quote settings
1713 $settings_controller = new \EasyInvoice\Controllers\SettingsController();
1714 $quote_terms = $settings_controller::getQuoteTermsConditions();
1715 $quote_footer = $settings_controller::getQuoteFooterText();
1716 $quote_accept_button = get_option('easy_invoice_quote_accept_button', 'yes');
1717 $quote_accept_action = get_option('easy_invoice_quote_accept_action', 'email');
1718 $quote_accept_text = get_option('easy_invoice_quote_accept_text', __('Accept Quote', 'easy-invoice'));
1719 $quote_accepted_message = get_option('easy_invoice_quote_accepted_message', __('Thank you for accepting our quote!', 'easy-invoice'));
1720 $quote_declined_message = get_option('easy_invoice_quote_declined_message', __('Thank you for your consideration.', 'easy-invoice'));
1721
1722 // Create the duplicate quote
1723 $duplicate_data = [
1724 'title' => $quote->getTitle() . ' (Copy)',
1725 'status' => 'draft',
1726 'number' => $this->generateInvoiceNumber(), // Use invoice number service for consistency
1727 'issue_date' => date('Y-m-d'),
1728 'expiry_date' => date('Y-m-d', strtotime('+30 days')),
1729 'items' => $this->convertQuoteItemsToInvoiceItems($quote->getItems()), // Use invoice item conversion
1730 'notes' => $quote->getNotes(),
1731 'description' => $quote->getDescription(),
1732 'terms' => $quote_terms,
1733 'internal_notes' => $quote->getInternalNotes(),
1734 'accept_button' => $quote_accept_button,
1735 'accept_action' => $quote_accept_action,
1736 'accept_text' => $quote_accept_text,
1737 'accepted_message' => $quote_accepted_message,
1738 'declined_message' => $quote_declined_message,
1739 ];
1740
1741 // Set client ID to 0 for a new quote
1742 $duplicate_data['client_id'] = 0;
1743
1744 $duplicate_quote = $this->quote_repository->create($duplicate_data);
1745
1746 if ($duplicate_quote) {
1747 $this->quote_log_service->logActivity($quote_id, 'duplicate', 'Quote duplicated', ['duplicate_id' => $duplicate_quote->getId()]);
1748 wp_send_json_success([
1749 'message' => __('Quote duplicated successfully.', 'easy-invoice'),
1750 'quote_id' => $duplicate_quote->getId(),
1751 'toast' => [
1752 'type' => 'success',
1753 'message' => __('Quote duplicated successfully.', 'easy-invoice')
1754 ]
1755 ]);
1756 } else {
1757 wp_send_json_error(['message' => __('Failed to duplicate quote.', 'easy-invoice')]);
1758 }
1759 }
1760
1761 /**
1762 * Handle regular POST form actions for quote accept/decline
1763 *
1764 * @since 1.0.0
1765 */
1766 public function handleQuoteFormActions(): void {
1767 // Only process on POST requests
1768 if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
1769 return;
1770 }
1771
1772 // Handle accept quote
1773 if (isset($_POST['accept_quote']) && isset($_POST['quote_id'])) {
1774 $this->handleAcceptQuoteForm();
1775 }
1776
1777 // Handle decline quote
1778 if (isset($_POST['decline_quote']) && isset($_POST['quote_id'])) {
1779 $this->handleDeclineQuoteForm();
1780 }
1781 }
1782
1783 /**
1784 * Handle accept quote form submission
1785 *
1786 * @since 1.0.0
1787 */
1788 private function handleAcceptQuoteForm(): void {
1789 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
1790
1791 if ($quote_id <= 0) {
1792 wp_die(__('Invalid quote ID.', 'easy-invoice'));
1793 }
1794
1795 if (!wp_verify_nonce($_POST['quote_nonce'] ?? '', $this->quoteAcceptDeclineNonceAction($quote_id))) {
1796 wp_die(__('Security check failed.', 'easy-invoice'));
1797 }
1798
1799 $current_user = wp_get_current_user();
1800 $is_admin = current_user_can('manage_options');
1801
1802 if ($is_admin) {
1803 $quote = $this->quote_repository->find($quote_id);
1804 } else {
1805 $quote = $this->quote_repository->findPublished($quote_id);
1806 }
1807
1808 if (!$quote) {
1809 wp_die(__('Quote not found.', 'easy-invoice'));
1810 }
1811
1812 // SECURITY (CVE-2026-9021): unconditional authorisation. See
1813 // handleAcceptQuote (AJAX path) for full rationale.
1814 if (!self::canActOnQuote($quote_id, $quote)) {
1815 wp_die(__('You do not have permission to accept this quote.', 'easy-invoice'));
1816 }
1817
1818 // Update quote status to accepted
1819 $quote->setStatus('accepted');
1820 $quote->setAcceptedDate(date('Y-m-d H:i:s'));
1821 $quote->setAcceptedBy($current_user->ID);
1822
1823 // Save the quote
1824 $saved = $quote->save();
1825
1826 if (!$saved) {
1827 wp_die(__('Failed to accept quote.', 'easy-invoice'));
1828 }
1829
1830 // Send notification email to admin
1831 if (!$is_admin) {
1832 $this->sendQuoteAcceptanceNotification($quote);
1833 }
1834
1835 // Redirect back to the quote page with success message
1836 $redirect_url = add_query_arg('action', 'accepted', get_permalink($quote_id));
1837 wp_redirect($redirect_url);
1838 exit;
1839 }
1840
1841 /**
1842 * Handle decline quote form submission
1843 *
1844 * @since 1.0.0
1845 */
1846 private function handleDeclineQuoteForm(): void {
1847 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
1848
1849 if ($quote_id <= 0) {
1850 wp_die(__('Invalid quote ID.', 'easy-invoice'));
1851 }
1852
1853 if (!wp_verify_nonce($_POST['quote_nonce'] ?? '', $this->quoteAcceptDeclineNonceAction($quote_id))) {
1854 wp_die(__('Security check failed.', 'easy-invoice'));
1855 }
1856
1857 $current_user = wp_get_current_user();
1858 $is_admin = current_user_can('manage_options');
1859
1860 if ($is_admin) {
1861 $quote = $this->quote_repository->find($quote_id);
1862 } else {
1863 $quote = $this->quote_repository->findPublished($quote_id);
1864 }
1865
1866 if (!$quote) {
1867 wp_die(__('Quote not found.', 'easy-invoice'));
1868 }
1869
1870 // SECURITY (CVE-2026-9021): unconditional authorisation. See
1871 // handleAcceptQuote (AJAX path) for full rationale.
1872 if (!self::canActOnQuote($quote_id, $quote)) {
1873 wp_die(__('You do not have permission to decline this quote.', 'easy-invoice'));
1874 }
1875
1876 // Update quote status to declined
1877 $quote->setStatus('declined');
1878 $quote->setDeclinedDate(date('Y-m-d H:i:s'));
1879 $quote->setDeclinedBy($current_user->ID);
1880
1881 // Save the quote
1882 $saved = $quote->save();
1883
1884 if (!$saved) {
1885 wp_die(__('Failed to decline quote.', 'easy-invoice'));
1886 }
1887
1888 // Send notification email to admin
1889 if (!$is_admin) {
1890 $this->sendQuoteDeclineNotification($quote);
1891 }
1892
1893 // Redirect back to the quote page with success message
1894 $redirect_url = add_query_arg('action', 'declined', get_permalink($quote_id));
1895 wp_redirect($redirect_url);
1896 exit;
1897 }
1898
1899 /**
1900 * Handle AJAX request for bulk quote actions
1901 *
1902 * @since 1.0.0
1903 */
1904 public function handleBulkQuoteAction(): void {
1905 // Verify nonce
1906 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
1907 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
1908 }
1909
1910 // Check permissions — gate at ei_create_quote (state transitions like
1911 // trash/draft/restore). Permanent-delete actions are additionally
1912 // gated below by ei_delete_quote per action.
1913 if (!easy_invoice_user_can('ei_create_quote')) {
1914 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
1915 }
1916
1917 $quote_ids = isset($_POST['quote_ids']) ? array_map('intval', $_POST['quote_ids']) : [];
1918 $bulk_action = sanitize_text_field($_POST['bulk_action'] ?? '');
1919
1920 // Per-action gate: permanent delete requires the stricter delete cap.
1921 if (in_array($bulk_action, ['delete', 'permanent-delete', 'empty-trash'], true)
1922 && !easy_invoice_user_can('ei_delete_quote')) {
1923 wp_send_json_error(['message' => __('You do not have permission to delete quotes.', 'easy-invoice')]);
1924 }
1925
1926 if (empty($quote_ids)) {
1927 wp_send_json_error(['message' => __('No quotes selected.', 'easy-invoice')]);
1928 }
1929
1930 if (empty($bulk_action)) {
1931 wp_send_json_error(['message' => __('No action selected.', 'easy-invoice')]);
1932 }
1933
1934 $success_count = 0;
1935 $error_count = 0;
1936
1937 foreach ($quote_ids as $quote_id) {
1938 $quote = $this->quote_repository->find($quote_id);
1939
1940 if (!$quote) {
1941 $error_count++;
1942 continue;
1943 }
1944
1945 try {
1946 switch ($bulk_action) {
1947 case 'delete':
1948 if ($this->quote_repository->delete($quote_id)) {
1949 $this->quote_log_service->logDeletion($quote_id);
1950 $success_count++;
1951 } else {
1952 $error_count++;
1953 }
1954 break;
1955
1956 case 'trash':
1957 $old_status = $quote->getStatus();
1958 $quote->setStatus('cancelled'); // Using cancelled as trash status
1959 if ($quote->save()) {
1960 $this->quote_log_service->logStatusChange($quote_id, $old_status, 'cancelled');
1961 $success_count++;
1962 } else {
1963 $error_count++;
1964 }
1965 break;
1966
1967 case 'draft':
1968 $old_status = $quote->getStatus();
1969 $quote->setStatus('draft');
1970 if ($quote->save()) {
1971 $this->quote_log_service->logStatusChange($quote_id, $old_status, 'draft');
1972 $success_count++;
1973 } else {
1974 $error_count++;
1975 }
1976 break;
1977
1978 case 'restore':
1979 $old_status = $quote->getStatus();
1980 $quote->setStatus('draft');
1981 if ($quote->save()) {
1982 $this->quote_log_service->logRestoration($quote_id);
1983 $success_count++;
1984 } else {
1985 $error_count++;
1986 }
1987 break;
1988
1989 default:
1990 $error_count++;
1991 break;
1992 }
1993 } catch (\Exception $e) {
1994 $error_count++;
1995 // Error in bulk action
1996 }
1997 }
1998
1999 if ($error_count > 0) {
2000 wp_send_json_success([
2001 'message' => sprintf(__('Processed %d quotes successfully. %d failed.', 'easy-invoice'), $success_count, $error_count),
2002 'toast' => [
2003 'type' => 'warning',
2004 'message' => sprintf(__('Processed %d quotes successfully. %d failed.', 'easy-invoice'), $success_count, $error_count)
2005 ]
2006 ]);
2007 } else {
2008 wp_send_json_success([
2009 'message' => sprintf(__('Successfully processed %d quotes.', 'easy-invoice'), $success_count),
2010 'toast' => [
2011 'type' => 'success',
2012 'message' => sprintf(__('Successfully processed %d quotes.', 'easy-invoice'), $success_count)
2013 ]
2014 ]);
2015 }
2016 }
2017
2018 /**
2019 * Handle AJAX request to trash a quote
2020 *
2021 * @since 1.0.0
2022 */
2023 public function handleTrashQuote(): void {
2024 // Verify nonce
2025 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
2026 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
2027 }
2028
2029 // Check permissions — trash is reversible, gated at the create-quote cap.
2030 if (!easy_invoice_user_can('ei_create_quote')) {
2031 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
2032 }
2033
2034 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
2035
2036 if ($quote_id <= 0) {
2037 wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]);
2038 }
2039
2040 $quote = $this->quote_repository->find($quote_id);
2041
2042 if (!$quote) {
2043 wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]);
2044 }
2045
2046 // Set status to cancelled before moving to trash
2047 $old_status = $quote->getStatus();
2048 $quote->setStatus('cancelled');
2049 $quote->save();
2050
2051 // Move the post to trash status
2052 $result = wp_trash_post($quote_id);
2053
2054 if ($result) {
2055 $this->quote_log_service->logStatusChange($quote_id, $old_status, 'cancelled');
2056 wp_send_json_success([
2057 'message' => __('Quote moved to trash successfully.', 'easy-invoice'),
2058 'toast' => [
2059 'type' => 'success',
2060 'message' => __('Quote moved to trash successfully.', 'easy-invoice')
2061 ]
2062 ]);
2063 } else {
2064 wp_send_json_error(['message' => __('Failed to move quote to trash.', 'easy-invoice')]);
2065 }
2066 }
2067
2068 /**
2069 * Handle AJAX request to move a quote to draft
2070 *
2071 * @since 1.0.0
2072 */
2073 public function handleDraftQuote(): void {
2074 // Verify nonce
2075 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
2076 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
2077 }
2078
2079 // Check permissions — moving to draft is an edit, not a delete.
2080 if (!easy_invoice_user_can('ei_create_quote')) {
2081 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
2082 }
2083
2084 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
2085
2086 if ($quote_id <= 0) {
2087 wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]);
2088 }
2089
2090 $quote = $this->quote_repository->find($quote_id);
2091
2092 if (!$quote) {
2093 wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]);
2094 }
2095
2096 // Set status to draft
2097 $old_status = $quote->getStatus();
2098 $quote->setStatus('draft');
2099
2100 if ($quote->save()) {
2101 $this->quote_log_service->logStatusChange($quote_id, $old_status, 'draft');
2102 wp_send_json_success([
2103 'message' => __('Quote moved to draft successfully.', 'easy-invoice'),
2104 'toast' => [
2105 'type' => 'success',
2106 'message' => __('Quote moved to draft successfully.', 'easy-invoice')
2107 ]
2108 ]);
2109 } else {
2110 wp_send_json_error(['message' => __('Failed to move quote to draft.', 'easy-invoice')]);
2111 }
2112 }
2113
2114 /**
2115 * Handle AJAX request to restore a trashed quote
2116 *
2117 * @since 1.0.0
2118 */
2119 public function handleRestoreQuote(): void {
2120 // Verify nonce
2121 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
2122 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
2123 }
2124
2125 // Check permissions — restoring from trash is an edit operation.
2126 if (!easy_invoice_user_can('ei_create_quote')) {
2127 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
2128 }
2129
2130 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
2131
2132 if ($quote_id <= 0) {
2133 wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]);
2134 }
2135
2136 $quote = $this->quote_repository->find($quote_id);
2137
2138 if (!$quote) {
2139 wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]);
2140 }
2141
2142 // Restore the post from trash
2143 $result = wp_untrash_post($quote_id);
2144
2145 if ($result) {
2146 // After restoring from trash, set the meta status to available
2147 $quote->setStatus('available');
2148 $quote->save();
2149
2150 $this->quote_log_service->logRestoration($quote_id);
2151 wp_send_json_success([
2152 'message' => __('Quote restored successfully.', 'easy-invoice'),
2153 'toast' => [
2154 'type' => 'success',
2155 'message' => __('Quote restored successfully.', 'easy-invoice')
2156 ]
2157 ]);
2158 } else {
2159 wp_send_json_error(['message' => __('Failed to restore quote.', 'easy-invoice')]);
2160 }
2161 }
2162
2163 /**
2164 * Handle AJAX request to empty trash
2165 *
2166 * @since 1.0.0
2167 */
2168 public function handleEmptyTrash(): void {
2169 try {
2170 // Verify nonce
2171 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_nonce')) {
2172 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
2173 }
2174
2175 // Check permissions — emptying trash permanently deletes quotes.
2176 if (!easy_invoice_user_can('ei_delete_quote')) {
2177 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
2178 }
2179
2180 // Get all quotes in trash (post_status = 'trash')
2181 global $wpdb;
2182 $quote_ids = $wpdb->get_col($wpdb->prepare(
2183 "SELECT ID FROM {$wpdb->posts}
2184 WHERE post_type = %s
2185 AND post_status = 'trash'",
2186 PostTypes::EASY_INVOICE_QUOTE_POST_TYPE
2187 ));
2188
2189 if (empty($quote_ids)) {
2190 wp_send_json_error(['message' => __('No quotes found in trash.', 'easy-invoice')]);
2191 }
2192
2193 $success_count = 0;
2194 $error_count = 0;
2195
2196 foreach ($quote_ids as $quote_id) {
2197 if (wp_delete_post($quote_id, true)) {
2198 $this->quote_log_service->logDeletion($quote_id);
2199 $success_count++;
2200 } else {
2201 $error_count++;
2202 }
2203 }
2204
2205 if ($error_count > 0) {
2206 wp_send_json_success([
2207 'message' => sprintf(__('Emptied trash: %d quotes deleted successfully, %d failed.', 'easy-invoice'), $success_count, $error_count),
2208 'success_count' => $success_count,
2209 'error_count' => $error_count,
2210 'toast' => [
2211 'type' => 'warning',
2212 'message' => sprintf(__('Emptied trash: %d quotes deleted successfully, %d failed.', 'easy-invoice'), $success_count, $error_count)
2213 ]
2214 ]);
2215 } else {
2216 wp_send_json_success([
2217 'message' => sprintf(__('Successfully emptied trash: %d quotes deleted.', 'easy-invoice'), $success_count),
2218 'success_count' => $success_count,
2219 'error_count' => 0,
2220 'toast' => [
2221 'type' => 'success',
2222 'message' => sprintf(__('Successfully emptied trash: %d quotes deleted.', 'easy-invoice'), $success_count)
2223 ]
2224 ]);
2225 }
2226
2227 } catch (\Exception $e) {
2228 error_log('Error emptying quote trash: ' . $e->getMessage());
2229 wp_send_json_error([
2230 'message' => __('Failed to empty trash.', 'easy-invoice'),
2231 'debug' => $e->getMessage()
2232 ]);
2233 }
2234 }
2235
2236 /**
2237 * Handle AJAX request to get quote logs
2238 *
2239 * @since 1.0.0
2240 */
2241 public function handleGetQuoteLogs(): void {
2242 // Verify nonce
2243 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
2244 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
2245 }
2246
2247 // Check permissions — viewing quote activity log.
2248 if (!easy_invoice_user_can('ei_view_quotes')) {
2249 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
2250 }
2251
2252 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
2253
2254 if ($quote_id <= 0) {
2255 wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]);
2256 }
2257
2258 try {
2259 $logs = $this->quote_log_service->getLogs($quote_id);
2260
2261 // Convert QuoteLog objects to arrays for JSON response
2262 $logs_data = [];
2263 foreach ($logs as $log) {
2264 $logs_data[] = [
2265 'action' => $log->getAction(),
2266 'description' => $log->getDescription(),
2267 'user_id' => $log->getUserId(),
2268 'user_name' => $log->getUserName(),
2269 'ip_address' => $log->getIpAddress(),
2270 'user_agent' => $log->getUserAgent(),
2271 'additional_data' => $log->getAdditionalData(),
2272 'created_date' => $log->getCreatedDate(),
2273 ];
2274 }
2275
2276 wp_send_json_success([
2277 'logs' => $logs_data,
2278 'count' => count($logs_data)
2279 ]);
2280
2281 } catch (\Exception $e) {
2282 wp_send_json_error([
2283 'message' => __('Error retrieving quote logs.', 'easy-invoice'),
2284 'debug' => $e->getMessage()
2285 ]);
2286 }
2287 }
2288
2289 /**
2290 * Format currency amount using QuoteFormatter
2291 *
2292 * @param float $amount The amount to format
2293 * @param \EasyInvoice\Models\Quote|null $quote The quote object for currency settings
2294 * @return string Formatted currency string
2295 */
2296 private function formatCurrency(float $amount, $quote = null): string {
2297 $formatter = new \EasyInvoice\Helpers\QuoteFormatter($quote);
2298 return $formatter->format($amount);
2299 }
2300 }
2301