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

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