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

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

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