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

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

2,490 lines 100.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Quote Controller
4 *
5 * @package EasyInvoice
6 * @author Your Name
7 * @copyright Copyright (c) 2023, Your Company
8 * @license http://opensource.org/licenses/gpl-2.0.php GNU Public License
9 * @since 1.0.0
10 */
11
12 namespace EasyInvoice\Controllers;
13
14 use EasyInvoice\Repositories\QuoteRepository;
15 use EasyInvoice\Repositories\ClientRepository;
16 use EasyInvoice\Forms\FormProcessor;
17 use EasyInvoice\Constants\PagesSlugs;
18 use EasyInvoice\Constants\PostTypes;
19 use EasyInvoice\Services\QuoteLogService;
20
21 /**
22 * Quote Controller
23 *
24 * Handles quote-related operations and displays.
25 *
26 * @since 1.0.0
27 */
28 class QuoteController {
29
30 /**
31 * Quote repository
32 *
33 * @var QuoteRepository
34 */
35 private $quote_repository;
36
37 /**
38 * Client repository
39 *
40 * @var ClientRepository
41 */
42 private $client_repository;
43
44 /**
45 * Form processor
46 *
47 * @var FormProcessor
48 */
49 private $form_processor;
50
51 /**
52 * Quote log service
53 *
54 * @var QuoteLogService
55 */
56 private $quote_log_service;
57
58 /**
59 * Constructor
60 *
61 * @since 1.0.0
62 */
63 public function __construct() {
64 $this->quote_repository = new QuoteRepository();
65 $this->client_repository = new ClientRepository();
66 $this->form_processor = new FormProcessor();
67 $this->quote_log_service = new QuoteLogService();
68 }
69
70 /**
71 * Initialize the controller
72 *
73 * @since 1.0.0
74 */
75 public function init(): void {
76 // Allow plugins to extend the controller initialization
77 do_action('easy_invoice_quote_controller_before_init', $this);
78
79 // Add AJAX handlers
80 add_action('wp_ajax_easy_invoice_delete_quote', [$this, 'handleDeleteQuote']);
81 add_action('wp_ajax_easy_invoice_get_quote', [$this, 'handleGetQuote']);
82 add_action('wp_ajax_easy_invoice_load_quote_template', [$this, 'handleLoadQuoteTemplate']);
83 add_action('wp_ajax_easy_invoice_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 // A signed URL only when the site asked for signed URLs. This
1323 // used to test for the class alone, which is loadable whenever
1324 // Pro is installed — so accepting a quote sent the client to
1325 // /secure-invoice/<hash>/, and minted the hash on the way, on
1326 // sites that had Secure Links switched off or never enabled.
1327 // Every other caller (emails, shortcodes, reminders) reads the
1328 // setting first; this one did not.
1329 $secure_links_enabled = get_option('easy_invoice_pro_enable_secure_links', 'no') === 'yes';
1330 if ($secure_links_enabled && class_exists('\EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController')) {
1331 $secure_url = \EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController::getInvoiceSecureLinkUrl($invoice_id);
1332 if ($secure_url) {
1333 $invoice_url = $secure_url;
1334 }
1335 }
1336 }
1337
1338 wp_send_json_success([
1339 'message' => $action_message,
1340 'invoice_id' => $invoice_id,
1341 'invoice_url' => $invoice_url,
1342 'secure_url' => $secure_url,
1343 'toast' => [
1344 'type' => 'success',
1345 'message' => $action_message
1346 ]
1347 ]);
1348 }
1349
1350 /**
1351 * Convert quote to invoice
1352 *
1353 * @param \EasyInvoice\Models\Quote $quote The quote to convert
1354 * @param string $status The status for the new invoice ('draft' or 'available')
1355 * @return int|null The invoice ID if successful, null otherwise
1356 */
1357 /**
1358 * "Convert to invoice" on the quote row — for the quote the client accepted
1359 * by phone or in person, which the public Accept button never sees.
1360 *
1361 * @param array $actions Row actions.
1362 * @param object $quote Quote model.
1363 * @return array
1364 */
1365 public function addConvertRowAction($actions, $quote): array {
1366 $actions = is_array($actions) ? $actions : [];
1367 if (!easy_invoice_user_can('ei_create_invoice') || !is_callable([$quote, 'getId'])) {
1368 return $actions;
1369 }
1370 $converted = (int) get_post_meta((int) $quote->getId(), '_easy_invoice_quote_converted_invoice_id', true);
1371 if ($converted > 0 && get_post($converted)) {
1372 $actions['convert'] = sprintf(
1373 '<a href="%s" class="text-emerald-700 font-semibold" title="%s">%s</a>',
1374 esc_url(admin_url('admin.php?page=easy-invoice-builder&invoice_id=' . $converted)),
1375 esc_attr__('Open the invoice made from this quote', 'easy-invoice'),
1376 esc_html__('Invoice', 'easy-invoice')
1377 );
1378 return $actions;
1379 }
1380 $actions['convert'] = sprintf(
1381 '<a href="#" class="convert-quote text-indigo-600 font-semibold" data-quote-id="%d" data-quote-number="%s">%s</a>',
1382 (int) $quote->getId(),
1383 esc_attr((string) $quote->getNumber()),
1384 esc_html__('Convert to invoice', 'easy-invoice')
1385 );
1386 return $actions;
1387 }
1388
1389 /**
1390 * AJAX: make a draft invoice from a quote and mark the quote accepted.
1391 */
1392 public function handleConvertQuote(): void {
1393 if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'easy_invoice_admin_nonce')) {
1394 wp_send_json_error(['message' => __('Security check failed. Please reload the page and try again.', 'easy-invoice')]);
1395 }
1396 if (!easy_invoice_user_can('ei_create_invoice')) {
1397 wp_send_json_error(['message' => __('You do not have permission to create invoices.', 'easy-invoice')]);
1398 }
1399 $quote_id = isset($_POST['quote_id']) ? absint($_POST['quote_id']) : 0;
1400 $quote = $quote_id > 0 ? $this->quote_repository->find($quote_id) : null;
1401 if (!$quote) {
1402 wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]);
1403 }
1404 $existing = (int) get_post_meta($quote_id, '_easy_invoice_quote_converted_invoice_id', true);
1405 if ($existing > 0 && get_post($existing)) {
1406 wp_send_json_success(['invoice_id' => $existing, 'already' => true, 'message' => __('This quote already has an invoice.', 'easy-invoice')]);
1407 }
1408 $invoice_id = $this->convertQuoteToInvoice($quote, 'draft');
1409 if (!$invoice_id) {
1410 wp_send_json_error(['message' => __('The invoice could not be created.', 'easy-invoice')]);
1411 }
1412 update_post_meta($quote_id, '_easy_invoice_quote_converted_invoice_id', $invoice_id);
1413 update_post_meta($invoice_id, '_easy_invoice_converted_from_quote', $quote_id);
1414 if (!in_array((string) $quote->getStatus(), ['accepted', 'declined', 'cancelled'], true)) {
1415 update_post_meta($quote_id, '_easy_invoice_quote_status', 'accepted');
1416 }
1417 /**
1418 * Fires after an administrator converts a quote into an invoice by hand.
1419 *
1420 * @param int $quote_id Quote.
1421 * @param int $invoice_id New draft invoice.
1422 */
1423 do_action('easy_invoice_quote_converted_manually', $quote_id, $invoice_id);
1424 wp_send_json_success([
1425 'invoice_id' => $invoice_id,
1426 'message' => __('Draft invoice created from the quote.', 'easy-invoice'),
1427 'redirect' => admin_url('admin.php?page=easy-invoice-builder&invoice_id=' . $invoice_id),
1428 ]);
1429 }
1430
1431 private function convertQuoteToInvoice($quote, $status = 'draft'): ?int {
1432 try {
1433 // Get invoice repository
1434 $invoice_repository = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository();
1435
1436 // Create invoice data from quote - convert ALL fields
1437 $invoice_data = [
1438 'title' => $quote->getTitle() ?: 'Invoice from Quote ' . $quote->getNumber(),
1439 'number' => $this->generateInvoiceNumber(),
1440 'status' => $status,
1441 'issue_date' => current_time('Y-m-d'),
1442 'due_date' => wp_date('Y-m-d', strtotime('+30 days')),
1443 'client_id' => $quote->getClientId(),
1444 'customer_name' => $quote->getCustomerName(),
1445 'customer_email' => $quote->getCustomerEmail(),
1446 'customer_address' => $quote->getCustomerAddress(),
1447 'shipping_name' => $quote->getCustomerName(), // Use customer name as shipping name
1448 'shipping_address' => $quote->getCustomerAddress(), // Use customer address as shipping address
1449 'items' => $this->convertQuoteItemsToInvoiceItems($quote->getItems()),
1450 'notes' => $quote->getNotes(),
1451 'description' => $quote->getDescription(),
1452 'terms' => $quote->getTerms(),
1453 'internal_notes' => $quote->getInternalNotes(),
1454 'payment_instructions' => '', // Invoice-specific field, leave empty
1455 'payment_gateways' => [], // Invoice-specific field, leave empty
1456 'template' => $quote->getTemplate(),
1457 'subtotal' => $quote->getSubtotal(),
1458 'tax_rate' => $quote->getTaxRate(),
1459 'tax_enabled' => $quote->getTaxEnabled() ?: (get_option('easy_invoice_tax_enabled', 'no') === 'yes' ? 'yes' : 'no'),
1460 'tax_amount' => $quote->getTaxAmount(),
1461 'discount_type' => $quote->getDiscountType(),
1462 'discount_value' => $quote->getDiscountValue(),
1463 'discount_amount' => $quote->getDiscountAmount(),
1464 'total' => $quote->getTotal(),
1465 'currency_code' => $quote->getCurrencyCode() ?: 'USD',
1466 'currency_position' => $quote->getCurrencyPosition() ?: 'left',
1467 'footer_text' => $quote->getFooterText(),
1468 'calculation_method' => 'standard', // Default calculation method for invoices
1469 'prices_include_tax' => $quote->getPricesIncludeTax(),
1470 'custom_fields' => $quote->getCustomFields(), // Transfer custom fields
1471 ];
1472
1473 /**
1474 * Filter the data an invoice is created from when a quote is
1475 * converted, so addons can carry their own quote fields across.
1476 *
1477 * @param array $invoice_data
1478 * @param Quote $quote
1479 */
1480 $invoice_data = apply_filters('easy_invoice_quote_to_invoice_data', $invoice_data, $quote);
1481
1482 // Create the invoice
1483 $invoice = $invoice_repository->create($invoice_data);
1484
1485 if ($invoice) {
1486 // Store the quote ID in the invoice's meta for tracking
1487 update_post_meta($invoice->getId(), '_converted_from_quote', $quote->getId());
1488 update_post_meta($invoice->getId(), '_easy_invoice_converted_from_quote', $quote->getId());
1489
1490 // Update quote to reference the created invoice — the same key
1491 // the quote list and "convert" guard read, whichever path
1492 // (manual convert, accept-and-convert) produced the invoice.
1493 update_post_meta($quote->getId(), '_easy_invoice_quote_converted_invoice_id', (int) $invoice->getId());
1494 $quote->setCustomField('converted_invoice_id', $invoice->getId());
1495 $quote->save();
1496
1497 // Ensure secure link is generated for the new invoice (Pro version)
1498 if (class_exists('\EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController')) {
1499 // Trigger the save_post hook to generate secure link.
1500 //
1501 // Core's save_post_{post_type} passes three arguments — $post_id,
1502 // $post and $update — and callbacks are written against that
1503 // signature. Firing it with two put a client-facing fatal on the
1504 // quote-acceptance path: Team Roles' audit logger declares all three
1505 // as required, so accepting a quote raised ArgumentCountError and
1506 // the customer got "There has been a critical error on this website"
1507 // after the invoice had already been created. Passing `true` for
1508 // $update because the invoice row exists by this point.
1509 do_action('save_post_easy_invoice', $invoice->getId(), get_post($invoice->getId()), true);
1510 }
1511
1512 return $invoice->getId();
1513 }
1514
1515 return null;
1516 } catch (\Exception $e) {
1517 // Error converting quote to invoice
1518 return null;
1519 }
1520 }
1521
1522 /**
1523 * Create new invoice from quote (duplicate)
1524 *
1525 * @param \EasyInvoice\Models\Quote $quote The quote to duplicate
1526 * @param string $status The status for the new invoice ('draft' or 'available')
1527 * @return int|null The invoice ID if successful, null otherwise
1528 */
1529 private function createInvoiceFromQuote($quote, $status = 'draft'): ?int {
1530 try {
1531 // Get invoice repository
1532 $invoice_repository = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository();
1533
1534 // Create invoice data from quote - convert ALL fields
1535 $invoice_data = [
1536 'title' => 'Invoice from Quote ' . $quote->getNumber(),
1537 'number' => $this->generateInvoiceNumber(),
1538 'status' => $status,
1539 'issue_date' => current_time('Y-m-d'),
1540 'due_date' => wp_date('Y-m-d', strtotime('+30 days')),
1541 'client_id' => $quote->getClientId(),
1542 'customer_name' => $quote->getCustomerName(),
1543 'customer_email' => $quote->getCustomerEmail(),
1544 'customer_address' => $quote->getCustomerAddress(),
1545 'shipping_name' => $quote->getCustomerName(), // Use customer name as shipping name
1546 'shipping_address' => $quote->getCustomerAddress(), // Use customer address as shipping address
1547 'items' => $this->convertQuoteItemsToInvoiceItems($quote->getItems()),
1548 'notes' => $quote->getNotes(),
1549 'description' => $quote->getDescription(),
1550 'terms' => $quote->getTerms(),
1551 'internal_notes' => $quote->getInternalNotes(),
1552 'payment_instructions' => '', // Invoice-specific field, leave empty
1553 'payment_gateways' => [], // Invoice-specific field, leave empty
1554 'template' => $quote->getTemplate(),
1555 'subtotal' => $quote->getSubtotal(),
1556 'tax_rate' => $quote->getTaxRate(),
1557 'tax_enabled' => $quote->getTaxEnabled() ?: (get_option('easy_invoice_tax_enabled', 'no') === 'yes' ? 'yes' : 'no'),
1558 'tax_amount' => $quote->getTaxAmount(),
1559 'discount_type' => $quote->getDiscountType(),
1560 'discount_value' => $quote->getDiscountValue(),
1561 'discount_amount' => $quote->getDiscountAmount(),
1562 'total' => $quote->getTotal(),
1563 'currency_code' => $quote->getCurrencyCode() ?: 'USD',
1564 'currency_position' => $quote->getCurrencyPosition() ?: 'left',
1565 'footer_text' => $quote->getFooterText(),
1566 'calculation_method' => 'standard', // Default calculation method for invoices
1567 'prices_include_tax' => $quote->getPricesIncludeTax(),
1568 'custom_fields' => $quote->getCustomFields(), // Transfer custom fields
1569 ];
1570
1571 /**
1572 * Filter the data an invoice is created from when a quote is
1573 * converted, so addons can carry their own quote fields across.
1574 *
1575 * @param array $invoice_data
1576 * @param Quote $quote
1577 */
1578 $invoice_data = apply_filters('easy_invoice_quote_to_invoice_data', $invoice_data, $quote);
1579
1580 // Create the invoice
1581 $invoice = $invoice_repository->create($invoice_data);
1582
1583 if ($invoice) {
1584 // Link the invoice to the quote
1585 $quote->setCustomField('related_invoice_id', $invoice->getId());
1586 $quote->save();
1587
1588 // Ensure secure link is generated for the new invoice (Pro version)
1589 if (class_exists('\EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController')) {
1590 // Trigger the save_post hook to generate secure link.
1591 //
1592 // Core's save_post_{post_type} passes three arguments — $post_id,
1593 // $post and $update — and callbacks are written against that
1594 // signature. Firing it with two put a client-facing fatal on the
1595 // quote-acceptance path: Team Roles' audit logger declares all three
1596 // as required, so accepting a quote raised ArgumentCountError and
1597 // the customer got "There has been a critical error on this website"
1598 // after the invoice had already been created. Passing `true` for
1599 // $update because the invoice row exists by this point.
1600 do_action('save_post_easy_invoice', $invoice->getId(), get_post($invoice->getId()), true);
1601 }
1602
1603 return $invoice->getId();
1604 }
1605
1606 return null;
1607 } catch (\Exception $e) {
1608 // Error creating invoice from quote
1609 return null;
1610 }
1611 }
1612
1613 /**
1614 * Send invoice to client
1615 *
1616 * @param int $invoice_id The invoice ID
1617 * @return bool True if sent successfully
1618 */
1619 private function sendInvoiceToClient(int $invoice_id): bool {
1620 try {
1621 // Get invoice
1622 $invoice_repository = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository();
1623 $invoice = $invoice_repository->find($invoice_id);
1624
1625 if (!$invoice) {
1626 return false;
1627 }
1628
1629 // Get email manager
1630 $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
1631
1632 // Send invoice email
1633 $result = $email_manager->sendInvoiceEmail($invoice, 'new');
1634
1635 return $result['success'];
1636 } catch (\Exception $e) {
1637 // Error sending invoice to client
1638 return false;
1639 }
1640 }
1641
1642 /**
1643 * Convert quote items to invoice items
1644 *
1645 * @param array $quote_items Array of quote items
1646 * @return array Array of invoice items
1647 */
1648 private function convertQuoteItemsToInvoiceItems(array $quote_items): array {
1649 $invoice_items = [];
1650
1651 foreach ($quote_items as $quote_item) {
1652 if (is_object($quote_item) && method_exists($quote_item, 'toArray')) {
1653 // A saved quote stores its lines as title/total, an invoice as
1654 // name/amount; read through the model, which knows both, or
1655 // the converted invoice has nameless lines that add up to 0.
1656 $item_data = $quote_item->toArray();
1657 $name = (string) (is_callable([$quote_item, 'getName']) ? $quote_item->getName() : '');
1658 if ('' === $name) {
1659 $name = (string) ($item_data['name'] ?? $item_data['title'] ?? '');
1660 }
1661 $amount = $item_data['amount'] ?? $item_data['total'] ?? null;
1662 if (null === $amount || '' === $amount) {
1663 $amount = is_callable([$quote_item, 'getAmount']) ? $quote_item->getAmount() : (float) ($item_data['quantity'] ?? 0) * (float) ($item_data['price'] ?? 0);
1664 }
1665 $invoice_items[] = [
1666 'name' => $name,
1667 'description' => $item_data['description'] ?? '',
1668 'quantity' => $item_data['quantity'] ?? 0,
1669 'price' => $item_data['price'] ?? 0,
1670 'amount' => $amount,
1671 'taxable' => $item_data['taxable'] ?? true,
1672 // Map adjust_percentage to a similar field if needed
1673 'adjust_percentage' => $item_data['adjust_percentage'] ?? 0,
1674 ];
1675 } elseif (is_array($quote_item)) {
1676 // Convert array item directly
1677 $invoice_items[] = [
1678 'name' => $quote_item['name'] ?? $quote_item['title'] ?? '',
1679 'description' => $quote_item['description'] ?? '',
1680 'quantity' => $quote_item['quantity'] ?? 0,
1681 'price' => $quote_item['price'] ?? 0,
1682 'amount' => $quote_item['amount'] ?? $quote_item['total'] ?? 0,
1683 'taxable' => $quote_item['taxable'] ?? true,
1684 'adjust_percentage' => $quote_item['adjust_percentage'] ?? 0,
1685 ];
1686 }
1687 }
1688
1689 return $invoice_items;
1690 }
1691
1692 /**
1693 * Generate unique invoice number
1694 *
1695 * @return string The invoice number
1696 */
1697 private function generateInvoiceNumber(): string {
1698 // Try to use invoice number service if available
1699 if (class_exists('\\EasyInvoice\\Services\\InvoiceNumberService')) {
1700 $invoice_number_service = new \EasyInvoice\Services\InvoiceNumberService();
1701 return $invoice_number_service->generateUniqueNumber();
1702 }
1703
1704 // Fallback to timestamp-based number
1705 return 'INV-' . str_pad(time(), 6, '0', STR_PAD_LEFT);
1706 }
1707
1708 /**
1709 * Get changes between two quote versions
1710 *
1711 * @param \EasyInvoice\Models\Quote $old_quote Old quote
1712 * @param \EasyInvoice\Models\Quote $new_quote New quote
1713 * @return array Array of changes
1714 */
1715 private function getQuoteChanges($old_quote, $new_quote): array {
1716 $changes = [];
1717
1718 // Compare key fields
1719 $fields_to_compare = [
1720 'title' => 'Title',
1721 'status' => 'Status',
1722 'customer_name' => 'Customer Name',
1723 'customer_email' => 'Customer Email',
1724 'customer_address' => 'Customer Address',
1725 'issue_date' => 'Issue Date',
1726 'expiry_date' => 'Expiry Date',
1727 'total' => 'Total Amount',
1728 'notes' => 'Notes',
1729 'terms' => 'Terms',
1730 ];
1731
1732 foreach ($fields_to_compare as $field => $label) {
1733 $method_name = 'get' . easy_invoice_str_replace('_', '', ucwords($field, '_'));
1734
1735 if (method_exists($old_quote, $method_name) && method_exists($new_quote, $method_name)) {
1736 $old_value = $old_quote->$method_name();
1737 $new_value = $new_quote->$method_name();
1738
1739 if ($old_value !== $new_value) {
1740 $changes[$field] = $new_value;
1741 }
1742 }
1743 }
1744
1745 return $changes;
1746 }
1747
1748 /**
1749 * Handle AJAX request to decline a quote
1750 *
1751 * @since 1.0.0
1752 */
1753 public function handleDeclineQuote(): void {
1754 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
1755 $decline_reason = isset($_POST['decline_reason']) ? sanitize_textarea_field($_POST['decline_reason']) : '';
1756
1757 if ($quote_id <= 0) {
1758 wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]);
1759 }
1760
1761 if (!wp_verify_nonce($_POST['nonce'] ?? '', $this->quoteAcceptDeclineNonceAction($quote_id))) {
1762 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
1763 }
1764
1765 $is_admin = current_user_can('manage_options');
1766 if ($is_admin) {
1767 $quote = $this->quote_repository->find($quote_id);
1768 } else {
1769 $quote = $this->quote_repository->findPublished($quote_id);
1770 }
1771
1772 if (!$quote) {
1773 wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]);
1774 }
1775
1776 // Check if decline reason is required by global settings
1777 $settings_controller = new \EasyInvoice\Controllers\SettingsController();
1778 if ($settings_controller::isDeclineReasonRequired() && empty(trim($decline_reason))) {
1779 wp_send_json_error(['message' => __('Reason for declining is required.', 'easy-invoice')]);
1780 }
1781
1782 // SECURITY (CVE-2026-9021): unconditional authorisation — see
1783 // handleAcceptQuote for the full rationale. Same three paths:
1784 // admin / valid access token / authenticated bound client.
1785 if (!self::canActOnQuote($quote_id, $quote)) {
1786 wp_send_json_error(['message' => __('You do not have permission to decline this quote.', 'easy-invoice')]);
1787 }
1788
1789 $ei_open = self::openForDecision($quote);
1790 if (is_wp_error($ei_open)) {
1791 wp_send_json_error(['message' => $ei_open->get_error_message()]);
1792 }
1793
1794 $current_user = wp_get_current_user();
1795
1796 // Update quote status to declined
1797 $quote->setStatus('declined');
1798 $quote->setDeclinedDate(gmdate('Y-m-d H:i:s'));
1799 $quote->setDeclinedBy($current_user->ID);
1800
1801 // Save decline reason if provided
1802 if (!empty($decline_reason)) {
1803 $quote->setDeclineReason($decline_reason);
1804 }
1805
1806 // Save the quote
1807 $saved = $quote->save();
1808
1809 if (!$saved) {
1810 wp_send_json_error(['message' => __('Failed to decline quote.', 'easy-invoice')]);
1811 }
1812
1813 // Log the quote decline
1814 $this->quote_log_service->logDecline($quote_id, $decline_reason, [
1815 'user_type' => $is_admin ? 'admin' : 'client'
1816 ]);
1817
1818 // Send notification email to admin
1819 if (!$is_admin) {
1820 $this->sendQuoteDeclineNotification($quote);
1821 }
1822
1823 wp_send_json_success([
1824 'message' => __('Quote declined successfully.', 'easy-invoice'),
1825 'toast' => [
1826 'type' => 'success',
1827 'message' => __('Quote declined successfully.', 'easy-invoice')
1828 ]
1829 ]);
1830 }
1831
1832 /**
1833 * Send quote acceptance notification to admin
1834 *
1835 * @param \EasyInvoice\Models\Quote $quote The quote that was accepted
1836 */
1837 private function sendQuoteAcceptanceNotification($quote): void {
1838 // Use EmailManager to send admin notification
1839 $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
1840 $email_manager->sendAdminQuoteNotification($quote, 'accepted');
1841 }
1842
1843 /**
1844 * Send quote decline notification to admin
1845 *
1846 * @param \EasyInvoice\Models\Quote $quote The quote that was declined
1847 */
1848 private function sendQuoteDeclineNotification($quote): void {
1849 // Use EmailManager to send admin notification
1850 $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
1851 $email_manager->sendAdminQuoteNotification($quote, 'declined');
1852 }
1853
1854
1855 /**
1856 * Handle AJAX request to duplicate a quote
1857 *
1858 * @since 1.0.0
1859 */
1860 public function handleDuplicateQuote(): void {
1861 // Verify nonce
1862 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
1863 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
1864 }
1865
1866 // Check permissions
1867 if (!easy_invoice_user_can('ei_create_quote')) {
1868 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
1869 }
1870
1871 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
1872
1873 if ($quote_id <= 0) {
1874 wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]);
1875 }
1876
1877 $quote = $this->quote_repository->find($quote_id);
1878
1879 if (!$quote) {
1880 wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]);
1881 }
1882
1883 // Get global quote settings
1884 $settings_controller = new \EasyInvoice\Controllers\SettingsController();
1885 $quote_terms = $settings_controller::getQuoteTermsConditions();
1886 $quote_footer = $settings_controller::getQuoteFooterText();
1887 $quote_accept_button = get_option('easy_invoice_quote_accept_button', 'yes');
1888 $quote_accept_action = get_option('easy_invoice_quote_accept_action', 'email');
1889 $quote_accept_text = get_option('easy_invoice_quote_accept_text', __('Accept Quote', 'easy-invoice'));
1890 $quote_accepted_message = get_option('easy_invoice_quote_accepted_message', __('Thank you for accepting our quote!', 'easy-invoice'));
1891 $quote_declined_message = get_option('easy_invoice_quote_declined_message', __('Thank you for your consideration.', 'easy-invoice'));
1892
1893 // Create the duplicate quote
1894 $duplicate_data = [
1895 'title' => $quote->getTitle() . ' (Copy)',
1896 'status' => 'draft',
1897 'number' => $this->generateInvoiceNumber(), // Use invoice number service for consistency
1898 'issue_date' => current_time('Y-m-d'),
1899 'expiry_date' => wp_date('Y-m-d', strtotime('+30 days')),
1900 'items' => $this->convertQuoteItemsToInvoiceItems($quote->getItems()), // Use invoice item conversion
1901 'notes' => $quote->getNotes(),
1902 'description' => $quote->getDescription(),
1903 'terms' => $quote_terms,
1904 'internal_notes' => $quote->getInternalNotes(),
1905 'accept_button' => $quote_accept_button,
1906 'accept_action' => $quote_accept_action,
1907 'accept_text' => $quote_accept_text,
1908 'accepted_message' => $quote_accepted_message,
1909 'declined_message' => $quote_declined_message,
1910 ];
1911
1912 // Set client ID to 0 for a new quote
1913 $duplicate_data['client_id'] = 0;
1914
1915 $duplicate_quote = $this->quote_repository->create($duplicate_data);
1916
1917 if ($duplicate_quote) {
1918 $this->quote_log_service->logActivity($quote_id, 'duplicate', 'Quote duplicated', ['duplicate_id' => $duplicate_quote->getId()]);
1919 wp_send_json_success([
1920 'message' => __('Quote duplicated successfully.', 'easy-invoice'),
1921 'quote_id' => $duplicate_quote->getId(),
1922 'toast' => [
1923 'type' => 'success',
1924 'message' => __('Quote duplicated successfully.', 'easy-invoice')
1925 ]
1926 ]);
1927 } else {
1928 wp_send_json_error(['message' => __('Failed to duplicate quote.', 'easy-invoice')]);
1929 }
1930 }
1931
1932 /**
1933 * Handle regular POST form actions for quote accept/decline
1934 *
1935 * @since 1.0.0
1936 */
1937 public function handleQuoteFormActions(): void {
1938 // Only process on POST requests
1939 if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
1940 return;
1941 }
1942
1943 // Handle accept quote
1944 if (isset($_POST['accept_quote']) && isset($_POST['quote_id'])) {
1945 $this->handleAcceptQuoteForm();
1946 }
1947
1948 // Handle decline quote
1949 if (isset($_POST['decline_quote']) && isset($_POST['quote_id'])) {
1950 $this->handleDeclineQuoteForm();
1951 }
1952 }
1953
1954 /**
1955 * Handle accept quote form submission
1956 *
1957 * @since 1.0.0
1958 */
1959 private function handleAcceptQuoteForm(): void {
1960 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
1961
1962 if ($quote_id <= 0) {
1963 wp_die(esc_html__('Invalid quote ID.', 'easy-invoice'));
1964 }
1965
1966 if (!wp_verify_nonce($_POST['quote_nonce'] ?? '', $this->quoteAcceptDeclineNonceAction($quote_id))) {
1967 wp_die(esc_html__('Security check failed.', 'easy-invoice'));
1968 }
1969
1970 $current_user = wp_get_current_user();
1971 $is_admin = current_user_can('manage_options');
1972
1973 if ($is_admin) {
1974 $quote = $this->quote_repository->find($quote_id);
1975 } else {
1976 $quote = $this->quote_repository->findPublished($quote_id);
1977 }
1978
1979 if (!$quote) {
1980 wp_die(esc_html__('Quote not found.', 'easy-invoice'));
1981 }
1982
1983 // SECURITY (CVE-2026-9021): unconditional authorisation. See
1984 // handleAcceptQuote (AJAX path) for full rationale.
1985 if (!self::canActOnQuote($quote_id, $quote)) {
1986 wp_die(esc_html__('You do not have permission to accept this quote.', 'easy-invoice'));
1987 }
1988
1989 $ei_open = self::openForDecision($quote);
1990 if (is_wp_error($ei_open)) {
1991 wp_die(esc_html($ei_open->get_error_message()));
1992 }
1993
1994 // Update quote status to accepted
1995 $quote->setStatus('accepted');
1996 $quote->setAcceptedDate(gmdate('Y-m-d H:i:s'));
1997 $quote->setAcceptedBy($current_user->ID);
1998
1999 // Save the quote
2000 $saved = $quote->save();
2001
2002 if (!$saved) {
2003 wp_die(esc_html__('Failed to accept quote.', 'easy-invoice'));
2004 }
2005
2006 // Send notification email to admin
2007 if (!$is_admin) {
2008 $this->sendQuoteAcceptanceNotification($quote);
2009 }
2010
2011 // Redirect back to the quote page with success message
2012 $redirect_url = add_query_arg('action', 'accepted', get_permalink($quote_id));
2013 wp_safe_redirect($redirect_url);
2014 exit;
2015 }
2016
2017 /**
2018 * Handle decline quote form submission
2019 *
2020 * @since 1.0.0
2021 */
2022 private function handleDeclineQuoteForm(): void {
2023 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
2024
2025 if ($quote_id <= 0) {
2026 wp_die(esc_html__('Invalid quote ID.', 'easy-invoice'));
2027 }
2028
2029 if (!wp_verify_nonce($_POST['quote_nonce'] ?? '', $this->quoteAcceptDeclineNonceAction($quote_id))) {
2030 wp_die(esc_html__('Security check failed.', 'easy-invoice'));
2031 }
2032
2033 $current_user = wp_get_current_user();
2034 $is_admin = current_user_can('manage_options');
2035
2036 if ($is_admin) {
2037 $quote = $this->quote_repository->find($quote_id);
2038 } else {
2039 $quote = $this->quote_repository->findPublished($quote_id);
2040 }
2041
2042 if (!$quote) {
2043 wp_die(esc_html__('Quote not found.', 'easy-invoice'));
2044 }
2045
2046 // SECURITY (CVE-2026-9021): unconditional authorisation. See
2047 // handleAcceptQuote (AJAX path) for full rationale.
2048 if (!self::canActOnQuote($quote_id, $quote)) {
2049 wp_die(esc_html__('You do not have permission to decline this quote.', 'easy-invoice'));
2050 }
2051
2052 $ei_open = self::openForDecision($quote);
2053 if (is_wp_error($ei_open)) {
2054 wp_die(esc_html($ei_open->get_error_message()));
2055 }
2056
2057 // Update quote status to declined
2058 $quote->setStatus('declined');
2059 $quote->setDeclinedDate(gmdate('Y-m-d H:i:s'));
2060 $quote->setDeclinedBy($current_user->ID);
2061
2062 // Save the quote
2063 $saved = $quote->save();
2064
2065 if (!$saved) {
2066 wp_die(esc_html__('Failed to decline quote.', 'easy-invoice'));
2067 }
2068
2069 // Send notification email to admin
2070 if (!$is_admin) {
2071 $this->sendQuoteDeclineNotification($quote);
2072 }
2073
2074 // Redirect back to the quote page with success message
2075 $redirect_url = add_query_arg('action', 'declined', get_permalink($quote_id));
2076 wp_safe_redirect($redirect_url);
2077 exit;
2078 }
2079
2080 /**
2081 * Handle AJAX request for bulk quote actions
2082 *
2083 * @since 1.0.0
2084 */
2085 public function handleBulkQuoteAction(): void {
2086 // Verify nonce
2087 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
2088 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
2089 }
2090
2091 // Check permissions — gate at ei_create_quote (state transitions like
2092 // trash/draft/restore). Permanent-delete actions are additionally
2093 // gated below by ei_delete_quote per action.
2094 if (!easy_invoice_user_can('ei_create_quote')) {
2095 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
2096 }
2097
2098 $quote_ids = isset($_POST['quote_ids']) ? array_map('intval', $_POST['quote_ids']) : [];
2099 $bulk_action = sanitize_text_field($_POST['bulk_action'] ?? '');
2100
2101 // Per-action gate: permanent delete requires the stricter delete cap.
2102 if (in_array($bulk_action, ['delete', 'permanent-delete', 'empty-trash'], true)
2103 && !easy_invoice_user_can('ei_delete_quote')) {
2104 wp_send_json_error(['message' => __('You do not have permission to delete quotes.', 'easy-invoice')]);
2105 }
2106
2107 if (empty($quote_ids)) {
2108 wp_send_json_error(['message' => __('No quotes selected.', 'easy-invoice')]);
2109 }
2110
2111 if (empty($bulk_action)) {
2112 wp_send_json_error(['message' => __('No action selected.', 'easy-invoice')]);
2113 }
2114
2115 $success_count = 0;
2116 $error_count = 0;
2117
2118 foreach ($quote_ids as $quote_id) {
2119 $quote = $this->quote_repository->find($quote_id);
2120
2121 if (!$quote) {
2122 $error_count++;
2123 continue;
2124 }
2125
2126 try {
2127 switch ($bulk_action) {
2128 case 'delete':
2129 if ($this->quote_repository->delete($quote_id)) {
2130 $this->quote_log_service->logDeletion($quote_id);
2131 $success_count++;
2132 } else {
2133 $error_count++;
2134 }
2135 break;
2136
2137 case 'trash':
2138 $old_status = $quote->getStatus();
2139 $quote->setStatus('cancelled'); // Using cancelled as trash status
2140 if ($quote->save()) {
2141 $this->quote_log_service->logStatusChange($quote_id, $old_status, 'cancelled');
2142 $success_count++;
2143 } else {
2144 $error_count++;
2145 }
2146 break;
2147
2148 case 'draft':
2149 $old_status = $quote->getStatus();
2150 $quote->setStatus('draft');
2151 if ($quote->save()) {
2152 $this->quote_log_service->logStatusChange($quote_id, $old_status, 'draft');
2153 $success_count++;
2154 } else {
2155 $error_count++;
2156 }
2157 break;
2158
2159 case 'restore':
2160 $old_status = $quote->getStatus();
2161 $quote->setStatus('draft');
2162 if ($quote->save()) {
2163 $this->quote_log_service->logRestoration($quote_id);
2164 $success_count++;
2165 } else {
2166 $error_count++;
2167 }
2168 break;
2169
2170 default:
2171 $error_count++;
2172 break;
2173 }
2174 } catch (\Exception $e) {
2175 $error_count++;
2176 // Error in bulk action
2177 }
2178 }
2179
2180 if ($error_count > 0) {
2181 wp_send_json_success([
2182 /* translators: %1$d: number processed; %2$d: number failed. */
2183 'message' => sprintf(__('Processed %1$d quotes successfully. %2$d failed.', 'easy-invoice'), $success_count, $error_count),
2184 'toast' => [
2185 'type' => 'warning',
2186 /* translators: %1$d: number processed; %2$d: number failed. */
2187 'message' => sprintf(__('Processed %1$d quotes successfully. %2$d failed.', 'easy-invoice'), $success_count, $error_count)
2188 ]
2189 ]);
2190 } else {
2191 wp_send_json_success([
2192 /* translators: %d: number processed. */
2193 'message' => sprintf(__('Successfully processed %d quotes.', 'easy-invoice'), $success_count),
2194 'toast' => [
2195 'type' => 'success',
2196 /* translators: %d: number processed. */
2197 'message' => sprintf(__('Successfully processed %d quotes.', 'easy-invoice'), $success_count)
2198 ]
2199 ]);
2200 }
2201 }
2202
2203 /**
2204 * Handle AJAX request to trash a quote
2205 *
2206 * @since 1.0.0
2207 */
2208 public function handleTrashQuote(): void {
2209 // Verify nonce
2210 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
2211 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
2212 }
2213
2214 // Check permissions — trash is reversible, gated at the create-quote cap.
2215 if (!easy_invoice_user_can('ei_create_quote')) {
2216 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
2217 }
2218
2219 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
2220
2221 if ($quote_id <= 0) {
2222 wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]);
2223 }
2224
2225 $quote = $this->quote_repository->find($quote_id);
2226
2227 if (!$quote) {
2228 wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]);
2229 }
2230
2231 // Set status to cancelled before moving to trash
2232 $old_status = $quote->getStatus();
2233 $quote->setStatus('cancelled');
2234 $quote->save();
2235
2236 // Move the post to trash status
2237 $result = wp_trash_post($quote_id);
2238
2239 if ($result) {
2240 $this->quote_log_service->logStatusChange($quote_id, $old_status, 'cancelled');
2241 wp_send_json_success([
2242 'message' => __('Quote moved to trash successfully.', 'easy-invoice'),
2243 'toast' => [
2244 'type' => 'success',
2245 'message' => __('Quote moved to trash successfully.', 'easy-invoice')
2246 ]
2247 ]);
2248 } else {
2249 wp_send_json_error(['message' => __('Failed to move quote to trash.', 'easy-invoice')]);
2250 }
2251 }
2252
2253 /**
2254 * Handle AJAX request to move a quote to draft
2255 *
2256 * @since 1.0.0
2257 */
2258 public function handleDraftQuote(): void {
2259 // Verify nonce
2260 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
2261 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
2262 }
2263
2264 // Check permissions — moving to draft is an edit, not a delete.
2265 if (!easy_invoice_user_can('ei_create_quote')) {
2266 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
2267 }
2268
2269 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
2270
2271 if ($quote_id <= 0) {
2272 wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]);
2273 }
2274
2275 $quote = $this->quote_repository->find($quote_id);
2276
2277 if (!$quote) {
2278 wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]);
2279 }
2280
2281 // Set status to draft
2282 $old_status = $quote->getStatus();
2283 $quote->setStatus('draft');
2284
2285 if ($quote->save()) {
2286 $this->quote_log_service->logStatusChange($quote_id, $old_status, 'draft');
2287 wp_send_json_success([
2288 'message' => __('Quote moved to draft successfully.', 'easy-invoice'),
2289 'toast' => [
2290 'type' => 'success',
2291 'message' => __('Quote moved to draft successfully.', 'easy-invoice')
2292 ]
2293 ]);
2294 } else {
2295 wp_send_json_error(['message' => __('Failed to move quote to draft.', 'easy-invoice')]);
2296 }
2297 }
2298
2299 /**
2300 * Handle AJAX request to restore a trashed quote
2301 *
2302 * @since 1.0.0
2303 */
2304 public function handleRestoreQuote(): void {
2305 // Verify nonce
2306 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
2307 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
2308 }
2309
2310 // Check permissions — restoring from trash is an edit operation.
2311 if (!easy_invoice_user_can('ei_create_quote')) {
2312 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
2313 }
2314
2315 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
2316
2317 if ($quote_id <= 0) {
2318 wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]);
2319 }
2320
2321 $quote = $this->quote_repository->find($quote_id);
2322
2323 if (!$quote) {
2324 wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]);
2325 }
2326
2327 // Restore the post from trash
2328 $result = wp_untrash_post($quote_id);
2329
2330 if ($result) {
2331 // After restoring from trash, set the meta status to available
2332 $quote->setStatus('available');
2333 $quote->save();
2334
2335 $this->quote_log_service->logRestoration($quote_id);
2336 wp_send_json_success([
2337 'message' => __('Quote restored successfully.', 'easy-invoice'),
2338 'toast' => [
2339 'type' => 'success',
2340 'message' => __('Quote restored successfully.', 'easy-invoice')
2341 ]
2342 ]);
2343 } else {
2344 wp_send_json_error(['message' => __('Failed to restore quote.', 'easy-invoice')]);
2345 }
2346 }
2347
2348 /**
2349 * Handle AJAX request to empty trash
2350 *
2351 * @since 1.0.0
2352 */
2353 public function handleEmptyTrash(): void {
2354 try {
2355 // Verify nonce
2356 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_nonce')) {
2357 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
2358 }
2359
2360 // Check permissions — emptying trash permanently deletes quotes.
2361 if (!easy_invoice_user_can('ei_delete_quote')) {
2362 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
2363 }
2364
2365 // Get all quotes in trash (post_status = 'trash')
2366 global $wpdb;
2367 $quote_ids = $wpdb->get_col($wpdb->prepare(
2368 "SELECT ID FROM {$wpdb->posts}
2369 WHERE post_type = %s
2370 AND post_status = 'trash'",
2371 PostTypes::EASY_INVOICE_QUOTE_POST_TYPE
2372 ));
2373
2374 if (empty($quote_ids)) {
2375 wp_send_json_error(['message' => __('No quotes found in trash.', 'easy-invoice')]);
2376 }
2377
2378 $success_count = 0;
2379 $error_count = 0;
2380
2381 foreach ($quote_ids as $quote_id) {
2382 if (wp_delete_post($quote_id, true)) {
2383 $this->quote_log_service->logDeletion($quote_id);
2384 $success_count++;
2385 } else {
2386 $error_count++;
2387 }
2388 }
2389
2390 if ($error_count > 0) {
2391 wp_send_json_success([
2392 /* translators: %1$d: number processed; %2$d: number failed. */
2393 'message' => sprintf(__('Emptied trash: %1$d quotes deleted successfully, %2$d failed.', 'easy-invoice'), $success_count, $error_count),
2394 'success_count' => $success_count,
2395 'error_count' => $error_count,
2396 'toast' => [
2397 'type' => 'warning',
2398 /* translators: %1$d: number processed; %2$d: number failed. */
2399 'message' => sprintf(__('Emptied trash: %1$d quotes deleted successfully, %2$d failed.', 'easy-invoice'), $success_count, $error_count)
2400 ]
2401 ]);
2402 } else {
2403 wp_send_json_success([
2404 /* translators: %d: number processed. */
2405 'message' => sprintf(__('Successfully emptied trash: %d quotes deleted.', 'easy-invoice'), $success_count),
2406 'success_count' => $success_count,
2407 'error_count' => 0,
2408 'toast' => [
2409 'type' => 'success',
2410 /* translators: %d: number processed. */
2411 'message' => sprintf(__('Successfully emptied trash: %d quotes deleted.', 'easy-invoice'), $success_count)
2412 ]
2413 ]);
2414 }
2415
2416 } catch (\Exception $e) {
2417 error_log('Error emptying quote trash: ' . $e->getMessage());
2418 wp_send_json_error([
2419 'message' => __('Failed to empty trash.', 'easy-invoice'),
2420 'debug' => $e->getMessage()
2421 ]);
2422 }
2423 }
2424
2425 /**
2426 * Handle AJAX request to get quote logs
2427 *
2428 * @since 1.0.0
2429 */
2430 public function handleGetQuoteLogs(): void {
2431 // Verify nonce
2432 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
2433 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
2434 }
2435
2436 // Check permissions — viewing quote activity log.
2437 if (!easy_invoice_user_can('ei_view_quotes')) {
2438 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
2439 }
2440
2441 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
2442
2443 if ($quote_id <= 0) {
2444 wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]);
2445 }
2446
2447 try {
2448 $logs = $this->quote_log_service->getLogs($quote_id);
2449
2450 // Convert QuoteLog objects to arrays for JSON response
2451 $logs_data = [];
2452 foreach ($logs as $log) {
2453 $logs_data[] = [
2454 'action' => $log->getAction(),
2455 'description' => $log->getDescription(),
2456 'user_id' => $log->getUserId(),
2457 'user_name' => $log->getUserName(),
2458 'ip_address' => $log->getIpAddress(),
2459 'user_agent' => $log->getUserAgent(),
2460 'additional_data' => $log->getAdditionalData(),
2461 'created_date' => $log->getCreatedDate(),
2462 ];
2463 }
2464
2465 wp_send_json_success([
2466 'logs' => $logs_data,
2467 'count' => count($logs_data)
2468 ]);
2469
2470 } catch (\Exception $e) {
2471 wp_send_json_error([
2472 'message' => __('Error retrieving quote logs.', 'easy-invoice'),
2473 'debug' => $e->getMessage()
2474 ]);
2475 }
2476 }
2477
2478 /**
2479 * Format currency amount using QuoteFormatter
2480 *
2481 * @param float $amount The amount to format
2482 * @param \EasyInvoice\Models\Quote|null $quote The quote object for currency settings
2483 * @return string Formatted currency string
2484 */
2485 private function formatCurrency(float $amount, $quote = null): string {
2486 $formatter = new \EasyInvoice\Helpers\QuoteFormatter($quote);
2487 return $formatter->format($amount);
2488 }
2489 }
2490