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

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