PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.1.18
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.1.18
2.4.3 2.4.4 2.4.2 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 All 60 releases
easy-invoice / includes / Controllers / QuoteController.php

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

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