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

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