PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.3.4
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.3.4
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.4, at includes/Controllers/QuoteController.php

2,275 lines 87.7 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 * Constant-time comparison helper for the access token.
1020 */
1021 private static function quoteTokenFromRequest(): string {
1022 $token = '';
1023 if (isset($_POST['access_token'])) {
1024 $token = sanitize_text_field(wp_unslash($_POST['access_token']));
1025 } elseif (isset($_GET['qk'])) {
1026 $token = sanitize_text_field(wp_unslash($_GET['qk']));
1027 }
1028 return $token;
1029 }
1030
1031 /**
1032 * Central authorisation check for quote accept/decline. Returns true
1033 * when ANY of these is true:
1034 *
1035 * 1. The request carries a valid per-quote access token (the legitimate
1036 * email-recipient flow). Constant-time compared with hash_equals.
1037 * 2. The current user is logged in AND has admin-grade capability
1038 * (manage_options) — admin-side accept/decline.
1039 * 3. The current user is logged in AND is the quote's bound client
1040 * (email match against the quote's client_id record). This was
1041 * previously gated behind the off-by-default
1042 * `easy_invoice_pro_restrict_quote_to_client` option — that gate
1043 * is removed in 2.3.4 so the ownership check runs unconditionally.
1044 *
1045 * Returns false otherwise. Callers must reject the request when this
1046 * returns false; we don't reject from in here so the caller can choose
1047 * wp_send_json_error vs wp_die based on its transport.
1048 */
1049 public static function canActOnQuote(int $quote_id, $quote = null): bool {
1050 if ($quote_id <= 0) {
1051 return false;
1052 }
1053
1054 // Path 1: legitimate access-token flow (email link recipient).
1055 $presented = self::quoteTokenFromRequest();
1056 if ($presented !== '') {
1057 $stored = (string) get_post_meta($quote_id, '_easy_invoice_quote_access_token', true);
1058 if ($stored !== '' && hash_equals($stored, $presented)) {
1059 return true;
1060 }
1061 }
1062
1063 // Path 2: admin override.
1064 if (current_user_can('manage_options')) {
1065 return true;
1066 }
1067
1068 // Path 3: authenticated owner. ONLY when the current user is the
1069 // quote's bound client (email match). Previously this was
1070 // skipped entirely when the Pro option was 'no' (the default) —
1071 // which is what made the CVE exploitable. Now it always runs.
1072 if (is_user_logged_in() && $quote && method_exists($quote, 'getClientId') && $quote->getClientId()) {
1073 $current_user = wp_get_current_user();
1074 $client_repository = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository();
1075 $client = $client_repository->find($quote->getClientId());
1076 if ($client && strcasecmp((string) $client->getEmail(), (string) $current_user->user_email) === 0) {
1077 return true;
1078 }
1079 }
1080
1081 return false;
1082 }
1083
1084 /**
1085 * Handle AJAX request to accept a quote
1086 *
1087 * @since 1.0.0
1088 */
1089 public function handleAcceptQuote(): void {
1090 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
1091
1092 if ($quote_id <= 0) {
1093 wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]);
1094 }
1095
1096 // Quote-scoped nonce prevents cross-quote IDOR with a leaked global nonce.
1097 if (!wp_verify_nonce($_POST['nonce'] ?? '', $this->quoteAcceptDeclineNonceAction($quote_id))) {
1098 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
1099 }
1100
1101 $is_admin = current_user_can('manage_options');
1102 if ($is_admin) {
1103 $quote = $this->quote_repository->find($quote_id);
1104 } else {
1105 $quote = $this->quote_repository->findPublished($quote_id);
1106 }
1107
1108 if (!$quote) {
1109 wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]);
1110 }
1111
1112 // SECURITY (CVE-2026-9021): authorise unconditionally — admin, valid
1113 // access token (email-link path), or authenticated client whose
1114 // email matches the quote's bound client. The previous gating
1115 // behind easy_invoice_pro_restrict_quote_to_client was OFF by
1116 // default, letting any anonymous visitor who could read the public
1117 // single-quote page harvest the nonce and accept arbitrary quotes.
1118 if (!self::canActOnQuote($quote_id, $quote)) {
1119 wp_send_json_error(['message' => __('You do not have permission to accept this quote.', 'easy-invoice')]);
1120 }
1121
1122 $current_user = wp_get_current_user();
1123
1124 // Get global accept action setting
1125 $settings_controller = new \EasyInvoice\Controllers\SettingsController();
1126 $accept_action = $settings_controller::getQuoteAcceptAction();
1127
1128 // Update quote status to accepted
1129 $quote->setStatus('accepted');
1130 $quote->setAcceptedDate(date('Y-m-d H:i:s'));
1131 $quote->setAcceptedBy($current_user->ID);
1132
1133 // Save the quote
1134 $saved = $quote->save();
1135
1136 if (!$saved) {
1137 wp_send_json_error(['message' => __('Failed to accept quote.', 'easy-invoice')]);
1138 }
1139
1140 // Log the quote acceptance
1141 $this->quote_log_service->logAcceptance($quote_id, [
1142 'accept_action' => $accept_action,
1143 'user_type' => $is_admin ? 'admin' : 'client'
1144 ]);
1145
1146 // Perform the configured accept action
1147 $invoice_id = null;
1148 $action_message = '';
1149
1150 switch ($accept_action) {
1151 case 'convert':
1152 // Convert quote to invoice (Draft status)
1153 $invoice_id = $this->convertQuoteToInvoice($quote, 'draft');
1154 if ($invoice_id) {
1155 $this->quote_log_service->logConversionToInvoice($quote_id, $invoice_id);
1156 }
1157 $action_message = __('Quote converted to invoice successfully.', 'easy-invoice');
1158 break;
1159
1160 case 'convert_available':
1161 // Convert quote to invoice (Available status)
1162 $invoice_id = $this->convertQuoteToInvoice($quote, 'available');
1163 if ($invoice_id) {
1164 $this->quote_log_service->logConversionToInvoice($quote_id, $invoice_id);
1165 }
1166 $action_message = __('Quote converted to invoice successfully.', 'easy-invoice');
1167 break;
1168
1169 case 'convert_send':
1170 // Convert quote to invoice and send to client (Available status)
1171 $invoice_id = $this->convertQuoteToInvoice($quote, 'available');
1172 if ($invoice_id) {
1173 $this->sendInvoiceToClient($invoice_id);
1174 }
1175 $action_message = __('Quote converted to invoice and sent to client successfully.', 'easy-invoice');
1176 break;
1177
1178 case 'duplicate':
1179 // Create new invoice, keep quote as-is (Draft status)
1180 $invoice_id = $this->createInvoiceFromQuote($quote, 'draft');
1181 if ($invoice_id) {
1182 $this->quote_log_service->logDuplicationToInvoice($quote_id, $invoice_id);
1183 }
1184 $action_message = __('New invoice created from quote successfully.', 'easy-invoice');
1185 break;
1186
1187 case 'duplicate_send':
1188 // Create new invoice and send to client, keep quote as-is (Available status)
1189 $invoice_id = $this->createInvoiceFromQuote($quote, 'available');
1190 if ($invoice_id) {
1191 $this->sendInvoiceToClient($invoice_id);
1192 }
1193 $action_message = __('New invoice created and sent to client successfully.', 'easy-invoice');
1194 break;
1195
1196 case 'do_nothing':
1197 default:
1198 // Do nothing additional
1199 $action_message = __('Quote accepted successfully.', 'easy-invoice');
1200 break;
1201 }
1202
1203 // Send notification email to admin
1204 if (!$is_admin) {
1205 $this->sendQuoteAcceptanceNotification($quote);
1206 }
1207
1208 // Get URLs for the new invoice
1209 $invoice_url = null;
1210 $secure_url = null;
1211
1212 if ($invoice_id) {
1213 // Always use WordPress permalink
1214 $invoice_url = get_permalink($invoice_id);
1215 // If Pro and secure link available, use secure link
1216 if (class_exists('\EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController')) {
1217 $secure_url = \EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController::getInvoiceSecureLinkUrl($invoice_id);
1218 if ($secure_url) {
1219 $invoice_url = $secure_url;
1220 }
1221 }
1222 }
1223
1224 wp_send_json_success([
1225 'message' => $action_message,
1226 'invoice_id' => $invoice_id,
1227 'invoice_url' => $invoice_url,
1228 'secure_url' => $secure_url,
1229 'toast' => [
1230 'type' => 'success',
1231 'message' => $action_message
1232 ]
1233 ]);
1234 }
1235
1236 /**
1237 * Convert quote to invoice
1238 *
1239 * @param \EasyInvoice\Models\Quote $quote The quote to convert
1240 * @param string $status The status for the new invoice ('draft' or 'available')
1241 * @return int|null The invoice ID if successful, null otherwise
1242 */
1243 private function convertQuoteToInvoice($quote, $status = 'draft'): ?int {
1244 try {
1245 // Get invoice repository
1246 $invoice_repository = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository();
1247
1248 // Create invoice data from quote - convert ALL fields
1249 $invoice_data = [
1250 'title' => $quote->getTitle() ?: 'Invoice from Quote ' . $quote->getNumber(),
1251 'number' => $this->generateInvoiceNumber(),
1252 'status' => $status,
1253 'issue_date' => date('Y-m-d'),
1254 'due_date' => date('Y-m-d', strtotime('+30 days')),
1255 'client_id' => $quote->getClientId(),
1256 'customer_name' => $quote->getCustomerName(),
1257 'customer_email' => $quote->getCustomerEmail(),
1258 'customer_address' => $quote->getCustomerAddress(),
1259 'shipping_name' => $quote->getCustomerName(), // Use customer name as shipping name
1260 'shipping_address' => $quote->getCustomerAddress(), // Use customer address as shipping address
1261 'items' => $this->convertQuoteItemsToInvoiceItems($quote->getItems()),
1262 'notes' => $quote->getNotes(),
1263 'description' => $quote->getDescription(),
1264 'terms' => $quote->getTerms(),
1265 'internal_notes' => $quote->getInternalNotes(),
1266 'payment_instructions' => '', // Invoice-specific field, leave empty
1267 'payment_gateways' => [], // Invoice-specific field, leave empty
1268 'template' => $quote->getTemplate(),
1269 'subtotal' => $quote->getSubtotal(),
1270 'tax_rate' => $quote->getTaxRate(),
1271 'tax_amount' => $quote->getTaxAmount(),
1272 'discount_type' => $quote->getDiscountType(),
1273 'discount_value' => $quote->getDiscountValue(),
1274 'discount_amount' => $quote->getDiscountAmount(),
1275 'total' => $quote->getTotal(),
1276 'currency_code' => $quote->getCurrencyCode() ?: 'USD',
1277 'currency_position' => $quote->getCurrencyPosition() ?: 'left',
1278 'footer_text' => $quote->getFooterText(),
1279 'calculation_method' => 'standard', // Default calculation method for invoices
1280 'prices_include_tax' => $quote->getPricesIncludeTax(),
1281 'custom_fields' => $quote->getCustomFields(), // Transfer custom fields
1282 ];
1283
1284 // Create the invoice
1285 $invoice = $invoice_repository->create($invoice_data);
1286
1287 if ($invoice) {
1288 // Store the quote ID in the invoice's meta for tracking
1289 update_post_meta($invoice->getId(), '_converted_from_quote', $quote->getId());
1290
1291 // Update quote to reference the created invoice
1292 $quote->setCustomField('converted_invoice_id', $invoice->getId());
1293 $quote->save();
1294
1295 // Ensure secure link is generated for the new invoice (Pro version)
1296 if (class_exists('\EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController')) {
1297 // Trigger the save_post hook to generate secure link
1298 do_action('save_post_easy_invoice', $invoice->getId(), get_post($invoice->getId()));
1299 }
1300
1301 return $invoice->getId();
1302 }
1303
1304 return null;
1305 } catch (\Exception $e) {
1306 // Error converting quote to invoice
1307 return null;
1308 }
1309 }
1310
1311 /**
1312 * Create new invoice from quote (duplicate)
1313 *
1314 * @param \EasyInvoice\Models\Quote $quote The quote to duplicate
1315 * @param string $status The status for the new invoice ('draft' or 'available')
1316 * @return int|null The invoice ID if successful, null otherwise
1317 */
1318 private function createInvoiceFromQuote($quote, $status = 'draft'): ?int {
1319 try {
1320 // Get invoice repository
1321 $invoice_repository = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository();
1322
1323 // Create invoice data from quote - convert ALL fields
1324 $invoice_data = [
1325 'title' => 'Invoice from Quote ' . $quote->getNumber(),
1326 'number' => $this->generateInvoiceNumber(),
1327 'status' => $status,
1328 'issue_date' => date('Y-m-d'),
1329 'due_date' => date('Y-m-d', strtotime('+30 days')),
1330 'client_id' => $quote->getClientId(),
1331 'customer_name' => $quote->getCustomerName(),
1332 'customer_email' => $quote->getCustomerEmail(),
1333 'customer_address' => $quote->getCustomerAddress(),
1334 'shipping_name' => $quote->getCustomerName(), // Use customer name as shipping name
1335 'shipping_address' => $quote->getCustomerAddress(), // Use customer address as shipping address
1336 'items' => $this->convertQuoteItemsToInvoiceItems($quote->getItems()),
1337 'notes' => $quote->getNotes(),
1338 'description' => $quote->getDescription(),
1339 'terms' => $quote->getTerms(),
1340 'internal_notes' => $quote->getInternalNotes(),
1341 'payment_instructions' => '', // Invoice-specific field, leave empty
1342 'payment_gateways' => [], // Invoice-specific field, leave empty
1343 'template' => $quote->getTemplate(),
1344 'subtotal' => $quote->getSubtotal(),
1345 'tax_rate' => $quote->getTaxRate(),
1346 'tax_amount' => $quote->getTaxAmount(),
1347 'discount_type' => $quote->getDiscountType(),
1348 'discount_value' => $quote->getDiscountValue(),
1349 'discount_amount' => $quote->getDiscountAmount(),
1350 'total' => $quote->getTotal(),
1351 'currency_code' => $quote->getCurrencyCode() ?: 'USD',
1352 'currency_position' => $quote->getCurrencyPosition() ?: 'left',
1353 'footer_text' => $quote->getFooterText(),
1354 'calculation_method' => 'standard', // Default calculation method for invoices
1355 'prices_include_tax' => $quote->getPricesIncludeTax(),
1356 'custom_fields' => $quote->getCustomFields(), // Transfer custom fields
1357 ];
1358
1359 // Create the invoice
1360 $invoice = $invoice_repository->create($invoice_data);
1361
1362 if ($invoice) {
1363 // Link the invoice to the quote
1364 $quote->setCustomField('related_invoice_id', $invoice->getId());
1365 $quote->save();
1366
1367 // Ensure secure link is generated for the new invoice (Pro version)
1368 if (class_exists('\EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController')) {
1369 // Trigger the save_post hook to generate secure link
1370 do_action('save_post_easy_invoice', $invoice->getId(), get_post($invoice->getId()));
1371 }
1372
1373 return $invoice->getId();
1374 }
1375
1376 return null;
1377 } catch (\Exception $e) {
1378 // Error creating invoice from quote
1379 return null;
1380 }
1381 }
1382
1383 /**
1384 * Send invoice to client
1385 *
1386 * @param int $invoice_id The invoice ID
1387 * @return bool True if sent successfully
1388 */
1389 private function sendInvoiceToClient(int $invoice_id): bool {
1390 try {
1391 // Get invoice
1392 $invoice_repository = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository();
1393 $invoice = $invoice_repository->find($invoice_id);
1394
1395 if (!$invoice) {
1396 return false;
1397 }
1398
1399 // Get email manager
1400 $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
1401
1402 // Send invoice email
1403 $result = $email_manager->sendInvoiceEmail($invoice, 'new');
1404
1405 return $result['success'];
1406 } catch (\Exception $e) {
1407 // Error sending invoice to client
1408 return false;
1409 }
1410 }
1411
1412 /**
1413 * Convert quote items to invoice items
1414 *
1415 * @param array $quote_items Array of quote items
1416 * @return array Array of invoice items
1417 */
1418 private function convertQuoteItemsToInvoiceItems(array $quote_items): array {
1419 $invoice_items = [];
1420
1421 foreach ($quote_items as $quote_item) {
1422 if (is_object($quote_item) && method_exists($quote_item, 'toArray')) {
1423 // Convert QuoteItem object to InvoiceItem array
1424 $item_data = $quote_item->toArray();
1425 $invoice_items[] = [
1426 'name' => $item_data['name'] ?? '',
1427 'description' => $item_data['description'] ?? '',
1428 'quantity' => $item_data['quantity'] ?? 0,
1429 'price' => $item_data['price'] ?? 0,
1430 'amount' => $item_data['amount'] ?? 0,
1431 'taxable' => $item_data['taxable'] ?? true,
1432 // Map adjust_percentage to a similar field if needed
1433 'adjust_percentage' => $item_data['adjust_percentage'] ?? 0,
1434 ];
1435 } elseif (is_array($quote_item)) {
1436 // Convert array item directly
1437 $invoice_items[] = [
1438 'name' => $quote_item['name'] ?? $quote_item['title'] ?? '',
1439 'description' => $quote_item['description'] ?? '',
1440 'quantity' => $quote_item['quantity'] ?? 0,
1441 'price' => $quote_item['price'] ?? 0,
1442 'amount' => $quote_item['amount'] ?? $quote_item['total'] ?? 0,
1443 'taxable' => $quote_item['taxable'] ?? true,
1444 'adjust_percentage' => $quote_item['adjust_percentage'] ?? 0,
1445 ];
1446 }
1447 }
1448
1449 return $invoice_items;
1450 }
1451
1452 /**
1453 * Generate unique invoice number
1454 *
1455 * @return string The invoice number
1456 */
1457 private function generateInvoiceNumber(): string {
1458 // Try to use invoice number service if available
1459 if (class_exists('\\EasyInvoice\\Services\\InvoiceNumberService')) {
1460 $invoice_number_service = new \EasyInvoice\Services\InvoiceNumberService();
1461 return $invoice_number_service->generateUniqueNumber();
1462 }
1463
1464 // Fallback to timestamp-based number
1465 return 'INV-' . str_pad(time(), 6, '0', STR_PAD_LEFT);
1466 }
1467
1468 /**
1469 * Get changes between two quote versions
1470 *
1471 * @param \EasyInvoice\Models\Quote $old_quote Old quote
1472 * @param \EasyInvoice\Models\Quote $new_quote New quote
1473 * @return array Array of changes
1474 */
1475 private function getQuoteChanges($old_quote, $new_quote): array {
1476 $changes = [];
1477
1478 // Compare key fields
1479 $fields_to_compare = [
1480 'title' => 'Title',
1481 'status' => 'Status',
1482 'customer_name' => 'Customer Name',
1483 'customer_email' => 'Customer Email',
1484 'customer_address' => 'Customer Address',
1485 'issue_date' => 'Issue Date',
1486 'expiry_date' => 'Expiry Date',
1487 'total' => 'Total Amount',
1488 'notes' => 'Notes',
1489 'terms' => 'Terms',
1490 ];
1491
1492 foreach ($fields_to_compare as $field => $label) {
1493 $method_name = 'get' . easy_invoice_str_replace('_', '', ucwords($field, '_'));
1494
1495 if (method_exists($old_quote, $method_name) && method_exists($new_quote, $method_name)) {
1496 $old_value = $old_quote->$method_name();
1497 $new_value = $new_quote->$method_name();
1498
1499 if ($old_value !== $new_value) {
1500 $changes[$field] = $new_value;
1501 }
1502 }
1503 }
1504
1505 return $changes;
1506 }
1507
1508 /**
1509 * Handle AJAX request to decline a quote
1510 *
1511 * @since 1.0.0
1512 */
1513 public function handleDeclineQuote(): void {
1514 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
1515 $decline_reason = isset($_POST['decline_reason']) ? sanitize_textarea_field($_POST['decline_reason']) : '';
1516
1517 if ($quote_id <= 0) {
1518 wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]);
1519 }
1520
1521 if (!wp_verify_nonce($_POST['nonce'] ?? '', $this->quoteAcceptDeclineNonceAction($quote_id))) {
1522 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
1523 }
1524
1525 $is_admin = current_user_can('manage_options');
1526 if ($is_admin) {
1527 $quote = $this->quote_repository->find($quote_id);
1528 } else {
1529 $quote = $this->quote_repository->findPublished($quote_id);
1530 }
1531
1532 if (!$quote) {
1533 wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]);
1534 }
1535
1536 // Check if decline reason is required by global settings
1537 $settings_controller = new \EasyInvoice\Controllers\SettingsController();
1538 if ($settings_controller::isDeclineReasonRequired() && empty(trim($decline_reason))) {
1539 wp_send_json_error(['message' => __('Reason for declining is required.', 'easy-invoice')]);
1540 }
1541
1542 // SECURITY (CVE-2026-9021): unconditional authorisation — see
1543 // handleAcceptQuote for the full rationale. Same three paths:
1544 // admin / valid access token / authenticated bound client.
1545 if (!self::canActOnQuote($quote_id, $quote)) {
1546 wp_send_json_error(['message' => __('You do not have permission to decline this quote.', 'easy-invoice')]);
1547 }
1548
1549 $current_user = wp_get_current_user();
1550
1551 // Update quote status to declined
1552 $quote->setStatus('declined');
1553 $quote->setDeclinedDate(date('Y-m-d H:i:s'));
1554 $quote->setDeclinedBy($current_user->ID);
1555
1556 // Save decline reason if provided
1557 if (!empty($decline_reason)) {
1558 $quote->setDeclineReason($decline_reason);
1559 }
1560
1561 // Save the quote
1562 $saved = $quote->save();
1563
1564 if (!$saved) {
1565 wp_send_json_error(['message' => __('Failed to decline quote.', 'easy-invoice')]);
1566 }
1567
1568 // Log the quote decline
1569 $this->quote_log_service->logDecline($quote_id, $decline_reason, [
1570 'user_type' => $is_admin ? 'admin' : 'client'
1571 ]);
1572
1573 // Send notification email to admin
1574 if (!$is_admin) {
1575 $this->sendQuoteDeclineNotification($quote);
1576 }
1577
1578 wp_send_json_success([
1579 'message' => __('Quote declined successfully.', 'easy-invoice'),
1580 'toast' => [
1581 'type' => 'success',
1582 'message' => __('Quote declined successfully.', 'easy-invoice')
1583 ]
1584 ]);
1585 }
1586
1587 /**
1588 * Send quote acceptance notification to admin
1589 *
1590 * @param \EasyInvoice\Models\Quote $quote The quote that was accepted
1591 */
1592 private function sendQuoteAcceptanceNotification($quote): void {
1593 // Use EmailManager to send admin notification
1594 $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
1595 $email_manager->sendAdminQuoteNotification($quote, 'accepted');
1596 }
1597
1598 /**
1599 * Send quote decline notification to admin
1600 *
1601 * @param \EasyInvoice\Models\Quote $quote The quote that was declined
1602 */
1603 private function sendQuoteDeclineNotification($quote): void {
1604 // Use EmailManager to send admin notification
1605 $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
1606 $email_manager->sendAdminQuoteNotification($quote, 'declined');
1607 }
1608
1609 /**
1610 * Handle AJAX request to update existing quotes with missing data
1611 *
1612 * @since 1.0.0
1613 */
1614 public function handleUpdateExistingQuotes(): void {
1615 // Verify nonce - match the nonce being sent from JavaScript
1616 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
1617 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
1618 }
1619
1620 // Check permissions — bulk migration / repair: admin-only.
1621 if (!current_user_can('manage_options')) {
1622 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
1623 }
1624
1625 $updated_count = 0;
1626 $quotes = $this->quote_repository->findAll();
1627
1628 foreach ($quotes as $quote) {
1629 $post = get_post($quote->getId());
1630 if ($post && empty($post->post_name)) {
1631 // Generate a proper slug for this quote
1632 $post_title = $quote->getTitle() ?: $quote->getNumber() ?: 'Untitled Quote';
1633 $post_name = sanitize_title($post_title);
1634
1635 // Ensure uniqueness
1636 $original_slug = $post_name;
1637 $counter = 1;
1638 while (get_page_by_path($post_name, OBJECT, \EasyInvoice\Constants\PostTypes::EASY_INVOICE_QUOTE_POST_TYPE)) {
1639 $post_name = $original_slug . '-' . $counter;
1640 $counter++;
1641 }
1642
1643 // Update the post with the new slug
1644 wp_update_post([
1645 'ID' => $quote->getId(),
1646 'post_name' => $post_name
1647 ]);
1648
1649 $updated_count++;
1650 }
1651 }
1652
1653 wp_send_json_success([
1654 'message' => sprintf(__('Updated %d quotes with proper URLs.', 'easy-invoice'), $updated_count)
1655 ]);
1656 }
1657
1658 /**
1659 * Handle AJAX request to duplicate a quote
1660 *
1661 * @since 1.0.0
1662 */
1663 public function handleDuplicateQuote(): void {
1664 // Verify nonce
1665 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
1666 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
1667 }
1668
1669 // Check permissions
1670 if (!easy_invoice_user_can('ei_create_quote')) {
1671 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
1672 }
1673
1674 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
1675
1676 if ($quote_id <= 0) {
1677 wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]);
1678 }
1679
1680 $quote = $this->quote_repository->find($quote_id);
1681
1682 if (!$quote) {
1683 wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]);
1684 }
1685
1686 // Get global quote settings
1687 $settings_controller = new \EasyInvoice\Controllers\SettingsController();
1688 $quote_terms = $settings_controller::getQuoteTermsConditions();
1689 $quote_footer = $settings_controller::getQuoteFooterText();
1690 $quote_accept_button = get_option('easy_invoice_quote_accept_button', 'yes');
1691 $quote_accept_action = get_option('easy_invoice_quote_accept_action', 'email');
1692 $quote_accept_text = get_option('easy_invoice_quote_accept_text', __('Accept Quote', 'easy-invoice'));
1693 $quote_accepted_message = get_option('easy_invoice_quote_accepted_message', __('Thank you for accepting our quote!', 'easy-invoice'));
1694 $quote_declined_message = get_option('easy_invoice_quote_declined_message', __('Thank you for your consideration.', 'easy-invoice'));
1695
1696 // Create the duplicate quote
1697 $duplicate_data = [
1698 'title' => $quote->getTitle() . ' (Copy)',
1699 'status' => 'draft',
1700 'number' => $this->generateInvoiceNumber(), // Use invoice number service for consistency
1701 'issue_date' => date('Y-m-d'),
1702 'expiry_date' => date('Y-m-d', strtotime('+30 days')),
1703 'items' => $this->convertQuoteItemsToInvoiceItems($quote->getItems()), // Use invoice item conversion
1704 'notes' => $quote->getNotes(),
1705 'description' => $quote->getDescription(),
1706 'terms' => $quote_terms,
1707 'internal_notes' => $quote->getInternalNotes(),
1708 'accept_button' => $quote_accept_button,
1709 'accept_action' => $quote_accept_action,
1710 'accept_text' => $quote_accept_text,
1711 'accepted_message' => $quote_accepted_message,
1712 'declined_message' => $quote_declined_message,
1713 ];
1714
1715 // Set client ID to 0 for a new quote
1716 $duplicate_data['client_id'] = 0;
1717
1718 $duplicate_quote = $this->quote_repository->create($duplicate_data);
1719
1720 if ($duplicate_quote) {
1721 $this->quote_log_service->logActivity($quote_id, 'duplicate', 'Quote duplicated', ['duplicate_id' => $duplicate_quote->getId()]);
1722 wp_send_json_success([
1723 'message' => __('Quote duplicated successfully.', 'easy-invoice'),
1724 'quote_id' => $duplicate_quote->getId(),
1725 'toast' => [
1726 'type' => 'success',
1727 'message' => __('Quote duplicated successfully.', 'easy-invoice')
1728 ]
1729 ]);
1730 } else {
1731 wp_send_json_error(['message' => __('Failed to duplicate quote.', 'easy-invoice')]);
1732 }
1733 }
1734
1735 /**
1736 * Handle regular POST form actions for quote accept/decline
1737 *
1738 * @since 1.0.0
1739 */
1740 public function handleQuoteFormActions(): void {
1741 // Only process on POST requests
1742 if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
1743 return;
1744 }
1745
1746 // Handle accept quote
1747 if (isset($_POST['accept_quote']) && isset($_POST['quote_id'])) {
1748 $this->handleAcceptQuoteForm();
1749 }
1750
1751 // Handle decline quote
1752 if (isset($_POST['decline_quote']) && isset($_POST['quote_id'])) {
1753 $this->handleDeclineQuoteForm();
1754 }
1755 }
1756
1757 /**
1758 * Handle accept quote form submission
1759 *
1760 * @since 1.0.0
1761 */
1762 private function handleAcceptQuoteForm(): void {
1763 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
1764
1765 if ($quote_id <= 0) {
1766 wp_die(__('Invalid quote ID.', 'easy-invoice'));
1767 }
1768
1769 if (!wp_verify_nonce($_POST['quote_nonce'] ?? '', $this->quoteAcceptDeclineNonceAction($quote_id))) {
1770 wp_die(__('Security check failed.', 'easy-invoice'));
1771 }
1772
1773 $current_user = wp_get_current_user();
1774 $is_admin = current_user_can('manage_options');
1775
1776 if ($is_admin) {
1777 $quote = $this->quote_repository->find($quote_id);
1778 } else {
1779 $quote = $this->quote_repository->findPublished($quote_id);
1780 }
1781
1782 if (!$quote) {
1783 wp_die(__('Quote not found.', 'easy-invoice'));
1784 }
1785
1786 // SECURITY (CVE-2026-9021): unconditional authorisation. See
1787 // handleAcceptQuote (AJAX path) for full rationale.
1788 if (!self::canActOnQuote($quote_id, $quote)) {
1789 wp_die(__('You do not have permission to accept this quote.', 'easy-invoice'));
1790 }
1791
1792 // Update quote status to accepted
1793 $quote->setStatus('accepted');
1794 $quote->setAcceptedDate(date('Y-m-d H:i:s'));
1795 $quote->setAcceptedBy($current_user->ID);
1796
1797 // Save the quote
1798 $saved = $quote->save();
1799
1800 if (!$saved) {
1801 wp_die(__('Failed to accept quote.', 'easy-invoice'));
1802 }
1803
1804 // Send notification email to admin
1805 if (!$is_admin) {
1806 $this->sendQuoteAcceptanceNotification($quote);
1807 }
1808
1809 // Redirect back to the quote page with success message
1810 $redirect_url = add_query_arg('action', 'accepted', get_permalink($quote_id));
1811 wp_redirect($redirect_url);
1812 exit;
1813 }
1814
1815 /**
1816 * Handle decline quote form submission
1817 *
1818 * @since 1.0.0
1819 */
1820 private function handleDeclineQuoteForm(): void {
1821 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
1822
1823 if ($quote_id <= 0) {
1824 wp_die(__('Invalid quote ID.', 'easy-invoice'));
1825 }
1826
1827 if (!wp_verify_nonce($_POST['quote_nonce'] ?? '', $this->quoteAcceptDeclineNonceAction($quote_id))) {
1828 wp_die(__('Security check failed.', 'easy-invoice'));
1829 }
1830
1831 $current_user = wp_get_current_user();
1832 $is_admin = current_user_can('manage_options');
1833
1834 if ($is_admin) {
1835 $quote = $this->quote_repository->find($quote_id);
1836 } else {
1837 $quote = $this->quote_repository->findPublished($quote_id);
1838 }
1839
1840 if (!$quote) {
1841 wp_die(__('Quote not found.', 'easy-invoice'));
1842 }
1843
1844 // SECURITY (CVE-2026-9021): unconditional authorisation. See
1845 // handleAcceptQuote (AJAX path) for full rationale.
1846 if (!self::canActOnQuote($quote_id, $quote)) {
1847 wp_die(__('You do not have permission to decline this quote.', 'easy-invoice'));
1848 }
1849
1850 // Update quote status to declined
1851 $quote->setStatus('declined');
1852 $quote->setDeclinedDate(date('Y-m-d H:i:s'));
1853 $quote->setDeclinedBy($current_user->ID);
1854
1855 // Save the quote
1856 $saved = $quote->save();
1857
1858 if (!$saved) {
1859 wp_die(__('Failed to decline quote.', 'easy-invoice'));
1860 }
1861
1862 // Send notification email to admin
1863 if (!$is_admin) {
1864 $this->sendQuoteDeclineNotification($quote);
1865 }
1866
1867 // Redirect back to the quote page with success message
1868 $redirect_url = add_query_arg('action', 'declined', get_permalink($quote_id));
1869 wp_redirect($redirect_url);
1870 exit;
1871 }
1872
1873 /**
1874 * Handle AJAX request for bulk quote actions
1875 *
1876 * @since 1.0.0
1877 */
1878 public function handleBulkQuoteAction(): void {
1879 // Verify nonce
1880 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
1881 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
1882 }
1883
1884 // Check permissions — gate at ei_create_quote (state transitions like
1885 // trash/draft/restore). Permanent-delete actions are additionally
1886 // gated below by ei_delete_quote per action.
1887 if (!easy_invoice_user_can('ei_create_quote')) {
1888 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
1889 }
1890
1891 $quote_ids = isset($_POST['quote_ids']) ? array_map('intval', $_POST['quote_ids']) : [];
1892 $bulk_action = sanitize_text_field($_POST['bulk_action'] ?? '');
1893
1894 // Per-action gate: permanent delete requires the stricter delete cap.
1895 if (in_array($bulk_action, ['delete', 'permanent-delete', 'empty-trash'], true)
1896 && !easy_invoice_user_can('ei_delete_quote')) {
1897 wp_send_json_error(['message' => __('You do not have permission to delete quotes.', 'easy-invoice')]);
1898 }
1899
1900 if (empty($quote_ids)) {
1901 wp_send_json_error(['message' => __('No quotes selected.', 'easy-invoice')]);
1902 }
1903
1904 if (empty($bulk_action)) {
1905 wp_send_json_error(['message' => __('No action selected.', 'easy-invoice')]);
1906 }
1907
1908 $success_count = 0;
1909 $error_count = 0;
1910
1911 foreach ($quote_ids as $quote_id) {
1912 $quote = $this->quote_repository->find($quote_id);
1913
1914 if (!$quote) {
1915 $error_count++;
1916 continue;
1917 }
1918
1919 try {
1920 switch ($bulk_action) {
1921 case 'delete':
1922 if ($this->quote_repository->delete($quote_id)) {
1923 $this->quote_log_service->logDeletion($quote_id);
1924 $success_count++;
1925 } else {
1926 $error_count++;
1927 }
1928 break;
1929
1930 case 'trash':
1931 $old_status = $quote->getStatus();
1932 $quote->setStatus('cancelled'); // Using cancelled as trash status
1933 if ($quote->save()) {
1934 $this->quote_log_service->logStatusChange($quote_id, $old_status, 'cancelled');
1935 $success_count++;
1936 } else {
1937 $error_count++;
1938 }
1939 break;
1940
1941 case 'draft':
1942 $old_status = $quote->getStatus();
1943 $quote->setStatus('draft');
1944 if ($quote->save()) {
1945 $this->quote_log_service->logStatusChange($quote_id, $old_status, 'draft');
1946 $success_count++;
1947 } else {
1948 $error_count++;
1949 }
1950 break;
1951
1952 case 'restore':
1953 $old_status = $quote->getStatus();
1954 $quote->setStatus('draft');
1955 if ($quote->save()) {
1956 $this->quote_log_service->logRestoration($quote_id);
1957 $success_count++;
1958 } else {
1959 $error_count++;
1960 }
1961 break;
1962
1963 default:
1964 $error_count++;
1965 break;
1966 }
1967 } catch (\Exception $e) {
1968 $error_count++;
1969 // Error in bulk action
1970 }
1971 }
1972
1973 if ($error_count > 0) {
1974 wp_send_json_success([
1975 'message' => sprintf(__('Processed %d quotes successfully. %d failed.', 'easy-invoice'), $success_count, $error_count),
1976 'toast' => [
1977 'type' => 'warning',
1978 'message' => sprintf(__('Processed %d quotes successfully. %d failed.', 'easy-invoice'), $success_count, $error_count)
1979 ]
1980 ]);
1981 } else {
1982 wp_send_json_success([
1983 'message' => sprintf(__('Successfully processed %d quotes.', 'easy-invoice'), $success_count),
1984 'toast' => [
1985 'type' => 'success',
1986 'message' => sprintf(__('Successfully processed %d quotes.', 'easy-invoice'), $success_count)
1987 ]
1988 ]);
1989 }
1990 }
1991
1992 /**
1993 * Handle AJAX request to trash a quote
1994 *
1995 * @since 1.0.0
1996 */
1997 public function handleTrashQuote(): void {
1998 // Verify nonce
1999 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
2000 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
2001 }
2002
2003 // Check permissions — trash is reversible, gated at the create-quote cap.
2004 if (!easy_invoice_user_can('ei_create_quote')) {
2005 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
2006 }
2007
2008 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
2009
2010 if ($quote_id <= 0) {
2011 wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]);
2012 }
2013
2014 $quote = $this->quote_repository->find($quote_id);
2015
2016 if (!$quote) {
2017 wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]);
2018 }
2019
2020 // Set status to cancelled before moving to trash
2021 $old_status = $quote->getStatus();
2022 $quote->setStatus('cancelled');
2023 $quote->save();
2024
2025 // Move the post to trash status
2026 $result = wp_trash_post($quote_id);
2027
2028 if ($result) {
2029 $this->quote_log_service->logStatusChange($quote_id, $old_status, 'cancelled');
2030 wp_send_json_success([
2031 'message' => __('Quote moved to trash successfully.', 'easy-invoice'),
2032 'toast' => [
2033 'type' => 'success',
2034 'message' => __('Quote moved to trash successfully.', 'easy-invoice')
2035 ]
2036 ]);
2037 } else {
2038 wp_send_json_error(['message' => __('Failed to move quote to trash.', 'easy-invoice')]);
2039 }
2040 }
2041
2042 /**
2043 * Handle AJAX request to move a quote to draft
2044 *
2045 * @since 1.0.0
2046 */
2047 public function handleDraftQuote(): void {
2048 // Verify nonce
2049 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
2050 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
2051 }
2052
2053 // Check permissions — moving to draft is an edit, not a delete.
2054 if (!easy_invoice_user_can('ei_create_quote')) {
2055 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
2056 }
2057
2058 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
2059
2060 if ($quote_id <= 0) {
2061 wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]);
2062 }
2063
2064 $quote = $this->quote_repository->find($quote_id);
2065
2066 if (!$quote) {
2067 wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]);
2068 }
2069
2070 // Set status to draft
2071 $old_status = $quote->getStatus();
2072 $quote->setStatus('draft');
2073
2074 if ($quote->save()) {
2075 $this->quote_log_service->logStatusChange($quote_id, $old_status, 'draft');
2076 wp_send_json_success([
2077 'message' => __('Quote moved to draft successfully.', 'easy-invoice'),
2078 'toast' => [
2079 'type' => 'success',
2080 'message' => __('Quote moved to draft successfully.', 'easy-invoice')
2081 ]
2082 ]);
2083 } else {
2084 wp_send_json_error(['message' => __('Failed to move quote to draft.', 'easy-invoice')]);
2085 }
2086 }
2087
2088 /**
2089 * Handle AJAX request to restore a trashed quote
2090 *
2091 * @since 1.0.0
2092 */
2093 public function handleRestoreQuote(): void {
2094 // Verify nonce
2095 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
2096 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
2097 }
2098
2099 // Check permissions — restoring from trash is an edit operation.
2100 if (!easy_invoice_user_can('ei_create_quote')) {
2101 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
2102 }
2103
2104 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
2105
2106 if ($quote_id <= 0) {
2107 wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]);
2108 }
2109
2110 $quote = $this->quote_repository->find($quote_id);
2111
2112 if (!$quote) {
2113 wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]);
2114 }
2115
2116 // Restore the post from trash
2117 $result = wp_untrash_post($quote_id);
2118
2119 if ($result) {
2120 // After restoring from trash, set the meta status to available
2121 $quote->setStatus('available');
2122 $quote->save();
2123
2124 $this->quote_log_service->logRestoration($quote_id);
2125 wp_send_json_success([
2126 'message' => __('Quote restored successfully.', 'easy-invoice'),
2127 'toast' => [
2128 'type' => 'success',
2129 'message' => __('Quote restored successfully.', 'easy-invoice')
2130 ]
2131 ]);
2132 } else {
2133 wp_send_json_error(['message' => __('Failed to restore quote.', 'easy-invoice')]);
2134 }
2135 }
2136
2137 /**
2138 * Handle AJAX request to empty trash
2139 *
2140 * @since 1.0.0
2141 */
2142 public function handleEmptyTrash(): void {
2143 try {
2144 // Verify nonce
2145 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_nonce')) {
2146 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
2147 }
2148
2149 // Check permissions — emptying trash permanently deletes quotes.
2150 if (!easy_invoice_user_can('ei_delete_quote')) {
2151 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
2152 }
2153
2154 // Get all quotes in trash (post_status = 'trash')
2155 global $wpdb;
2156 $quote_ids = $wpdb->get_col($wpdb->prepare(
2157 "SELECT ID FROM {$wpdb->posts}
2158 WHERE post_type = %s
2159 AND post_status = 'trash'",
2160 PostTypes::EASY_INVOICE_QUOTE_POST_TYPE
2161 ));
2162
2163 if (empty($quote_ids)) {
2164 wp_send_json_error(['message' => __('No quotes found in trash.', 'easy-invoice')]);
2165 }
2166
2167 $success_count = 0;
2168 $error_count = 0;
2169
2170 foreach ($quote_ids as $quote_id) {
2171 if (wp_delete_post($quote_id, true)) {
2172 $this->quote_log_service->logDeletion($quote_id);
2173 $success_count++;
2174 } else {
2175 $error_count++;
2176 }
2177 }
2178
2179 if ($error_count > 0) {
2180 wp_send_json_success([
2181 'message' => sprintf(__('Emptied trash: %d quotes deleted successfully, %d failed.', 'easy-invoice'), $success_count, $error_count),
2182 'success_count' => $success_count,
2183 'error_count' => $error_count,
2184 'toast' => [
2185 'type' => 'warning',
2186 'message' => sprintf(__('Emptied trash: %d quotes deleted successfully, %d failed.', 'easy-invoice'), $success_count, $error_count)
2187 ]
2188 ]);
2189 } else {
2190 wp_send_json_success([
2191 'message' => sprintf(__('Successfully emptied trash: %d quotes deleted.', 'easy-invoice'), $success_count),
2192 'success_count' => $success_count,
2193 'error_count' => 0,
2194 'toast' => [
2195 'type' => 'success',
2196 'message' => sprintf(__('Successfully emptied trash: %d quotes deleted.', 'easy-invoice'), $success_count)
2197 ]
2198 ]);
2199 }
2200
2201 } catch (\Exception $e) {
2202 error_log('Error emptying quote trash: ' . $e->getMessage());
2203 wp_send_json_error([
2204 'message' => __('Failed to empty trash.', 'easy-invoice'),
2205 'debug' => $e->getMessage()
2206 ]);
2207 }
2208 }
2209
2210 /**
2211 * Handle AJAX request to get quote logs
2212 *
2213 * @since 1.0.0
2214 */
2215 public function handleGetQuoteLogs(): void {
2216 // Verify nonce
2217 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
2218 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
2219 }
2220
2221 // Check permissions — viewing quote activity log.
2222 if (!easy_invoice_user_can('ei_view_quotes')) {
2223 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
2224 }
2225
2226 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
2227
2228 if ($quote_id <= 0) {
2229 wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]);
2230 }
2231
2232 try {
2233 $logs = $this->quote_log_service->getLogs($quote_id);
2234
2235 // Convert QuoteLog objects to arrays for JSON response
2236 $logs_data = [];
2237 foreach ($logs as $log) {
2238 $logs_data[] = [
2239 'action' => $log->getAction(),
2240 'description' => $log->getDescription(),
2241 'user_id' => $log->getUserId(),
2242 'user_name' => $log->getUserName(),
2243 'ip_address' => $log->getIpAddress(),
2244 'user_agent' => $log->getUserAgent(),
2245 'additional_data' => $log->getAdditionalData(),
2246 'created_date' => $log->getCreatedDate(),
2247 ];
2248 }
2249
2250 wp_send_json_success([
2251 'logs' => $logs_data,
2252 'count' => count($logs_data)
2253 ]);
2254
2255 } catch (\Exception $e) {
2256 wp_send_json_error([
2257 'message' => __('Error retrieving quote logs.', 'easy-invoice'),
2258 'debug' => $e->getMessage()
2259 ]);
2260 }
2261 }
2262
2263 /**
2264 * Format currency amount using QuoteFormatter
2265 *
2266 * @param float $amount The amount to format
2267 * @param \EasyInvoice\Models\Quote|null $quote The quote object for currency settings
2268 * @return string Formatted currency string
2269 */
2270 private function formatCurrency(float $amount, $quote = null): string {
2271 $formatter = new \EasyInvoice\Helpers\QuoteFormatter($quote);
2272 return $formatter->format($amount);
2273 }
2274 }
2275