PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.3.2
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.3.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 2.1.15 2.1.16 2.1.2 All 57 releases
easy-invoice / includes / Controllers / QuoteController.php

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

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