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
← All changes | includes/Controllers/QuoteController.php +965 -573 2.1.0 → 2.4.4 View file →
@@ -25,9 +25,9 @@
25 25 *
26 26 * @since 1.0.0
27 27 */
28 28 class QuoteController {
29 -
29 +
30 30 /**
31 31 * Quote repository
32 32 *
33 33 * @var QuoteRepository
@@ -32,9 +32,9 @@
32 32 *
33 33 * @var QuoteRepository
34 34 */
35 35 private $quote_repository;
36 -
36 +
37 37 /**
38 38 * Client repository
39 39 *
40 40 * @var ClientRepository
@@ -39,9 +39,9 @@
39 39 *
40 40 * @var ClientRepository
41 41 */
42 42 private $client_repository;
43 -
43 +
44 44 /**
45 45 * Form processor
46 46 *
47 47 * @var FormProcessor
@@ -46,9 +46,9 @@
46 46 *
47 47 * @var FormProcessor
48 48 */
49 49 private $form_processor;
50 -
50 +
51 51 /**
52 52 * Quote log service
53 53 *
54 54 * @var QuoteLogService
@@ -53,9 +53,9 @@
53 53 *
54 54 * @var QuoteLogService
55 55 */
56 56 private $quote_log_service;
57 -
57 +
58 58 /**
59 59 * Constructor
60 60 *
61 61 * @since 1.0.0
@@ -65,9 +65,9 @@
65 65 $this->client_repository = new ClientRepository();
66 66 $this->form_processor = new FormProcessor();
67 67 $this->quote_log_service = new QuoteLogService();
68 68 }
69 -
69 +
70 70 /**
71 71 * Initialize the controller
72 72 *
73 73 * @since 1.0.0
@@ -74,43 +74,50 @@
74 74 */
75 75 public function init(): void {
76 76 // Allow plugins to extend the controller initialization
77 77 do_action('easy_invoice_quote_controller_before_init', $this);
78 -
78 +
79 79 // Add AJAX handlers
80 80 add_action('wp_ajax_easy_invoice_delete_quote', [$this, 'handleDeleteQuote']);
81 81 add_action('wp_ajax_easy_invoice_get_quote', [$this, 'handleGetQuote']);
82 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);
83 85 add_action('wp_ajax_easy_invoice_create_new_quote', [$this, 'handleCreateNewQuote']);
84 - add_action('wp_ajax_easy_invoice_search_clients', [$this, 'handleSearchClients']);
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.
85 93 add_action('wp_ajax_easy_invoice_load_quote_form', [$this, 'handleLoadQuoteForm']);
86 94 add_action('wp_ajax_easy_invoice_accept_quote', [$this, 'handleAcceptQuote']);
87 95 add_action('wp_ajax_easy_invoice_decline_quote', [$this, 'handleDeclineQuote']);
88 96 add_action('wp_ajax_nopriv_easy_invoice_accept_quote', [$this, 'handleAcceptQuote']);
89 97 add_action('wp_ajax_nopriv_easy_invoice_decline_quote', [$this, 'handleDeclineQuote']);
90 - add_action('wp_ajax_easy_invoice_update_existing_quotes', [$this, 'handleUpdateExistingQuotes']);
91 -
98 +
92 99 // Add missing AJAX handlers for quote listing actions
93 100 add_action('wp_ajax_easy_invoice_bulk_quote_action', [$this, 'handleBulkQuoteAction']);
94 101 add_action('wp_ajax_easy_invoice_trash_quote', [$this, 'handleTrashQuote']);
95 102 add_action('wp_ajax_easy_invoice_draft_quote', [$this, 'handleDraftQuote']);
96 -
103 +
97 104 // Add regular POST form handlers for quote actions
98 105 add_action('init', [$this, 'handleQuoteFormActions']);
99 -
106 +
100 107 // Add new AJAX handler for restoring a trashed quote
101 108 add_action('wp_ajax_easy_invoice_restore_quote', [ $this, 'handleRestoreQuote' ]);
102 -
109 +
103 110 // Add new AJAX handler for emptying trash
104 111 add_action('wp_ajax_easy_invoice_empty_trash', [ $this, 'handleEmptyTrash' ]);
105 -
112 +
106 113 // Add new AJAX handler for getting quote logs
107 114 add_action('wp_ajax_easy_invoice_get_quote_logs', [ $this, 'handleGetQuoteLogs' ]);
108 -
115 +
109 116 // Allow plugins to extend the controller initialization
110 117 do_action('easy_invoice_quote_controller_after_init', $this);
111 118 }
112 -
119 +
113 120 /**
114 121 * Display quote pages
115 122 *
116 123 * @since 1.0.0
@@ -118,36 +125,36 @@
118 125 */
119 126 public function display(array $args = []): void {
120 127 // Allow plugins to modify display arguments
121 128 $args = apply_filters('easy_invoice_quote_controller_display_args', $args);
122 -
129 +
123 130 $page = $args['page'] ?? '';
124 -
131 +
125 132 // Allow plugins to modify the page before processing
126 133 $page = apply_filters('easy_invoice_quote_controller_display_page', $page, $args);
127 -
134 +
128 135 switch ($page) {
129 136 case PagesSlugs::ALL_QUOTES:
130 137 $this->displayListing();
131 138 break;
132 -
139 +
133 140 case PagesSlugs::QUOTE_NEW:
134 141 $this->displayBuilder();
135 142 break;
136 -
143 +
137 144 case PagesSlugs::QUOTE_PREVIEW:
138 145 $this->displayPreview($args);
139 146 break;
140 -
147 +
141 148 default:
142 149 $this->displayListing();
143 150 break;
144 151 }
145 -
152 +
146 153 // Allow plugins to perform actions after display
147 154 do_action('easy_invoice_quote_controller_after_display', $page, $args);
148 155 }
149 -
156 +
150 157 /**
151 158 * Display quote listing page
152 159 *
153 160 * @since 1.0.0
@@ -157,19 +164,19 @@
157 164 global $wpdb;
158 165
159 166 // Get trash count first (based on post_status)
160 167 $trash_count = (int)$wpdb->get_var($wpdb->prepare(
161 - "SELECT COUNT(*) FROM {$wpdb->posts}
168 + "SELECT COUNT(*) FROM {$wpdb->posts}
162 169 WHERE post_type = %s AND post_status = 'trash'",
163 170 PostTypes::EASY_INVOICE_QUOTE_POST_TYPE
164 171 ));
165 -
172 +
166 173 // Get counts for each meta status (excluding trashed posts)
167 174 $status_counts = $wpdb->get_results($wpdb->prepare(
168 - "SELECT COALESCE(pm.meta_value, 'draft') as status, COUNT(*) as count
169 - FROM {$wpdb->posts} p
175 + "SELECT COALESCE(pm.meta_value, 'draft') as status, COUNT(*) as count
176 + FROM {$wpdb->posts} p
170 177 LEFT JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id AND pm.meta_key = '_easy_invoice_quote_status'
171 - WHERE p.post_type = %s
178 + WHERE p.post_type = %s
172 179 AND p.post_status != 'trash'
173 180 GROUP BY COALESCE(pm.meta_value, 'draft')",
174 181 PostTypes::EASY_INVOICE_QUOTE_POST_TYPE
175 182 ));
@@ -187,9 +194,9 @@
187 194 // Process status counts
188 195 foreach ($status_counts as $status) {
189 196 $count = (int)$status->count;
190 197 $all_count += $count; // Add to total (excluding trash)
191 -
198 +
192 199 switch ($status->status) {
193 200 case 'draft':
194 201 $draft_count = $count;
195 202 break;
@@ -216,11 +223,12 @@
216 223
217 224 // Now handle the display filtering
218 225 // Allow plugins to perform actions before displaying listing
219 226 do_action('easy_invoice_quote_controller_before_display_listing');
220 -
227 +
221 228 // Get filter parameters
222 229 $status_filter = isset($_GET['status']) ? sanitize_text_field($_GET['status']) : '';
230 + $client_filter = isset($_GET['client_id']) ? absint($_GET['client_id']) : 0;
223 231 $search_query = isset($_GET['search']) ? sanitize_text_field(wp_unslash($_GET['search'])) : '';
224 232 $current_view = isset($_GET['view']) ? sanitize_text_field($_GET['view']) : 'all';
225 233 $current_page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1;
226 234 $per_page = 20;
@@ -240,9 +248,9 @@
240 248 // Handle view filtering
241 249 if ($current_view === 'trash' || $current_view === 'cancelled') {
242 250 // For trash and cancelled views, look at post_status = 'trash'
243 251 $query_args['post_status'] = 'trash';
244 -
252 +
245 253 // For cancelled view, also filter by meta status
246 254 if ($current_view === 'cancelled') {
247 255 $query_args['meta_query'] = [
248 256 [
@@ -254,9 +262,9 @@
254 262 }
255 263 } else {
256 264 // For all other views, exclude trashed posts
257 265 $query_args['post_status'] = ['publish', 'draft', 'private', 'pending'];
258 -
266 +
259 267 if ($current_view !== 'all') {
260 268 // For specific status views, add meta query
261 269 $query_args['meta_query'] = [
262 270 [
@@ -267,12 +275,59 @@
267 275 ];
268 276 }
269 277 }
270 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 +
271 326 // Add search if provided
272 327 if (!empty($search_query)) {
273 328 $search_ids = [];
274 -
329 +
275 330 // Build base query args for search
276 331 $search_query_args = [
277 332 'post_type' => PostTypes::EASY_INVOICE_QUOTE_POST_TYPE,
278 333 'post_status' => $query_args['post_status'],
@@ -308,15 +363,16 @@
308 363 ]
309 364 ]
310 365 ]);
311 366 $meta_search = new \WP_Query($meta_search_args);
312 -
313 - if ($meta_search->have_posts()) {
314 - $search_ids = array_merge($search_ids, wp_list_pluck($meta_search->posts, 'ID'));
315 - }
316 -
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 +
317 373 $search_ids = array_unique($search_ids);
318 -
374 +
319 375 if (!empty($search_ids)) {
320 376 $query_args['post__in'] = $search_ids;
321 377 } else {
322 378 $query_args['post__in'] = [0];
@@ -327,9 +383,9 @@
327 383 $query_args = apply_filters('easy_invoice_quote_controller_final_query_args', $query_args);
328 384 // Get filtered quotes for display
329 385 $wp_query = new \WP_Query($query_args);
330 386 $quotes = [];
331 -
387 +
332 388 if ($wp_query->have_posts()) {
333 389 foreach ($wp_query->posts as $post) {
334 390 $quote = $this->quote_repository->find($post->ID);
335 391 if ($quote) {
@@ -339,9 +395,9 @@
339 395 }
340 396
341 397 // Allow plugins to modify the quotes array
342 398 $quotes = apply_filters('easy_invoice_quote_controller_quotes_list', $quotes, $wp_query);
343 -
399 +
344 400 // Get pagination info from WordPress query
345 401 $total_quotes = $wp_query->found_posts;
346 402 $total_pages = $wp_query->max_num_pages;
347 403
@@ -379,14 +435,37 @@
379 435 $cancelled_count = $status->count;
380 436 break;
381 437 }
382 438 }
383 -
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 +
384 461 // Prepare template data
385 462 $template_data = [
386 463 'quotes' => $quotes,
387 464 'current_view' => $current_view,
388 465 'status_filter' => $status_filter,
466 + 'client_filter' => $client_filter,
467 + 'clients_list' => $clients_list,
389 468 'search_query' => $search_query,
390 469 'all_count' => (int)$all_count,
391 470 'trash_count' => (int)$trash_count,
392 471 'draft_count' => (int)$draft_count,
@@ -402,19 +481,19 @@
402 481 'total_quotes' => $total_quotes,
403 482 'total_pages' => $total_pages,
404 483 'wp_query' => $wp_query
405 484 ];
406 -
485 +
407 486 // Allow plugins to modify template data
408 487 $template_data = apply_filters('easy_invoice_quote_controller_template_data', $template_data);
409 -
488 +
410 489 // Display the template
411 490 include EASY_INVOICE_PLUGIN_DIR . 'templates/quotes/listing.php';
412 -
491 +
413 492 // Allow plugins to perform actions after displaying listing
414 493 do_action('easy_invoice_quote_controller_after_display_listing', $template_data);
415 494 }
416 -
495 +
417 496 /**
418 497 * Display quote builder page
419 498 *
420 499 * @since 1.0.0
@@ -421,18 +500,21 @@
421 500 */
422 501 private function displayBuilder(): void {
423 502 // Allow plugins to perform actions before displaying builder
424 503 do_action('easy_invoice_quote_controller_before_display_builder');
425 -
504 +
426 505 $quote_id = isset($_GET['id']) ? (int) $_GET['id'] : 0;
427 506 $quote = null;
428 -
507 +
429 508 if ($quote_id > 0) {
430 509 $quote = $this->quote_repository->find($quote_id);
431 510 }
432 -
433 - $clients = $this->client_repository->all();
434 -
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 +
435 517 // Allow plugins to modify the data
436 518 $quote = apply_filters('easy_invoice_quote_controller_builder_quote', $quote, $quote_id);
437 519 $clients = apply_filters('easy_invoice_quote_controller_builder_clients', $clients);
438 520
@@ -437,13 +519,13 @@
437 519 $clients = apply_filters('easy_invoice_quote_controller_builder_clients', $clients);
438 520
439 521 // Include the builder template
440 522 include EASY_INVOICE_PLUGIN_DIR . 'templates/quotes/builder.php';
441 -
523 +
442 524 // Allow plugins to perform actions after displaying builder
443 525 do_action('easy_invoice_quote_controller_after_display_builder', $quote, $clients);
444 526 }
445 -
527 +
446 528 /**
447 529 * Display quote preview page
448 530 *
449 531 * @since 1.0.0
@@ -451,30 +533,30 @@
451 533 */
452 534 private function displayPreview(array $args): void {
453 535 // Allow plugins to perform actions before displaying preview
454 536 do_action('easy_invoice_quote_controller_before_display_preview', $args);
455 -
537 +
456 538 $quote_id = isset($_GET['id']) ? (int) $_GET['id'] : 0;
457 -
539 +
458 540 if ($quote_id <= 0) {
459 - wp_die(__('Quote not found.', 'easy-invoice'));
541 + wp_die(esc_html__('Quote not found.', 'easy-invoice'));
460 542 }
461 -
543 +
462 544 $quote = $this->quote_repository->find($quote_id);
463 545 if (!$quote) {
464 - wp_die(__('Quote not found.', 'easy-invoice'));
546 + wp_die(esc_html__('Quote not found.', 'easy-invoice'));
465 547 }
466 -
548 +
467 549 // Allow plugins to modify the quote
468 550 $quote = apply_filters('easy_invoice_quote_controller_preview_quote', $quote, $quote_id);
469 -
551 +
470 552 // Include the preview template
471 553 include EASY_INVOICE_PLUGIN_DIR . 'templates/quotes/preview.php';
472 -
554 +
473 555 // Allow plugins to perform actions after displaying preview
474 556 do_action('easy_invoice_quote_controller_after_display_preview', $quote, $args);
475 557 }
476 -
558 +
477 559 /**
478 560 * Handle delete quote AJAX request
479 561 *
480 562 * @since 1.0.0
@@ -483,24 +565,24 @@
483 565 // Verify nonce
484 566 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
485 567 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
486 568 }
487 -
569 +
488 570 // Check permissions
489 - if (!current_user_can('manage_options')) {
571 + if (!easy_invoice_user_can('ei_delete_quote')) {
490 572 wp_send_json_error(['message' => __('Insufficient permissions.', 'easy-invoice')]);
491 573 }
492 -
574 +
493 575 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
494 -
576 +
495 577 if ($quote_id <= 0) {
496 578 wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]);
497 579 }
498 -
580 +
499 581 if ($this->quote_repository->delete($quote_id)) {
500 582 // Log the quote deletion
501 583 $this->quote_log_service->logDeletion($quote_id);
502 -
584 +
503 585 wp_send_json_success([
504 586 'message' => __('Quote deleted successfully.', 'easy-invoice'),
505 587 'toast' => [
506 588 'type' => 'success',
@@ -510,9 +592,9 @@
510 592 } else {
511 593 wp_send_json_error(['message' => __('Failed to delete quote.', 'easy-invoice')]);
512 594 }
513 595 }
514 -
596 +
515 597 /**
516 598 * Handle get quote AJAX request
517 599 *
518 600 * @since 1.0.0
@@ -521,29 +603,29 @@
521 603 // Verify nonce
522 604 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_get_quote')) {
523 605 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
524 606 }
525 -
607 +
526 608 // Check permissions
527 - if (!current_user_can('manage_options')) {
609 + if (!easy_invoice_user_can('ei_view_quotes')) {
528 610 wp_send_json_error(['message' => __('Insufficient permissions.', 'easy-invoice')]);
529 611 }
530 -
612 +
531 613 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
532 -
614 +
533 615 if ($quote_id <= 0) {
534 616 wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]);
535 617 }
536 -
618 +
537 619 $quote = $this->quote_repository->find($quote_id);
538 -
620 +
539 621 if (!$quote) {
540 622 wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]);
541 623 }
542 -
624 +
543 625 wp_send_json_success(['quote' => $quote->toArray()]);
544 626 }
545 -
627 +
546 628 /**
547 629 * Handle AJAX request to load quote template
548 630 *
549 631 * @since 1.0.0
@@ -552,47 +634,161 @@
552 634 // Verify nonce
553 635 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_nonce')) {
554 636 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
555 637 }
556 -
638 +
557 639 // Check permissions
558 - if (!current_user_can('manage_options')) {
640 + if (!easy_invoice_user_can('ei_create_quote')) {
559 641 wp_send_json_error(['message' => __('Insufficient permissions.', 'easy-invoice')]);
560 642 }
561 -
643 +
562 644 $template_id = sanitize_text_field($_POST['template'] ?? '');
563 645 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
564 -
646 +
565 647 if (empty($template_id)) {
566 648 wp_send_json_error(['message' => __('Template ID is required.', 'easy-invoice')]);
567 649 }
568 -
569 - // Load quote if provided
570 - $quote = null;
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();
571 670 if ($quote_id > 0) {
572 - $quote = $this->quote_repository->find($quote_id);
671 + $loaded = $this->quote_repository->find($quote_id);
672 + if ($loaded) {
673 + $quote = $loaded;
674 + }
573 675 }
574 -
575 - // Check if template file exists
576 - $template_file = EASY_INVOICE_PLUGIN_DIR . 'templates/quote-templates/' . $template_id . '.php';
577 -
578 - if (!file_exists($template_file)) {
579 - wp_send_json_error(['message' => __('Template not found.', 'easy-invoice')]);
580 - }
581 -
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 +
582 679 // Start output buffering to capture template HTML
583 680 ob_start();
584 -
681 +
585 682 // Include the template file
586 683 include $template_file;
587 -
684 +
588 685 // Get the captured HTML
589 686 $html = ob_get_clean();
590 -
687 +
591 688 wp_send_json_success(['html' => $html]);
592 689 }
593 -
690 +
594 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 + /**
595 791 * Handle AJAX request to create a new quote with just the title
596 792 *
597 793 * @since 1.0.0
598 794 */
@@ -601,9 +797,9 @@
601 797 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
602 798 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
603 799 }
604 800 // Check permissions
605 - if (!current_user_can('manage_options')) {
801 + if (!easy_invoice_user_can('ei_create_quote')) {
606 802 wp_send_json_error(['message' => __('Insufficient permissions.', 'easy-invoice')]);
607 803 }
608 804 $title = isset($_POST['title']) ? sanitize_text_field($_POST['title']) : '';
609 805 if (empty($title)) {
@@ -608,9 +804,9 @@
608 804 $title = isset($_POST['title']) ? sanitize_text_field($_POST['title']) : '';
609 805 if (empty($title)) {
610 806 wp_send_json_error(['message' => __('Quote title is required.', 'easy-invoice')]);
611 807 }
612 -
808 +
613 809 // Generate a unique quote number
614 810 $quote_number = '';
615 811 if (class_exists('\\EasyInvoice\\Services\\QuoteNumberService')) {
616 812 $quote_number_service = new \EasyInvoice\Services\QuoteNumberService();
@@ -618,9 +814,9 @@
618 814 } else {
619 815 // Fallback if service doesn't exist
620 816 $quote_number = 'QT-' . str_pad(time(), 6, '0', STR_PAD_LEFT);
621 817 }
622 -
818 +
623 819 // Get global quote settings
624 820 $settings_controller = new \EasyInvoice\Controllers\SettingsController();
625 821 $quote_terms = $settings_controller::getQuoteTermsConditions();
626 822 $quote_footer = $settings_controller::getQuoteFooterText();
@@ -628,16 +824,16 @@
628 824 $quote_accept_action = get_option('easy_invoice_quote_accept_action', 'email');
629 825 $quote_accept_text = get_option('easy_invoice_quote_accept_text', __('Accept Quote', 'easy-invoice'));
630 826 $quote_accepted_message = get_option('easy_invoice_quote_accepted_message', __('Thank you for accepting our quote!', 'easy-invoice'));
631 827 $quote_declined_message = get_option('easy_invoice_quote_declined_message', __('Thank you for your consideration.', 'easy-invoice'));
632 -
828 +
633 829 // Create the quote with just the title and default values
634 830 $data = [
635 831 'title' => $title,
636 832 'status' => 'draft',
637 833 'number' => $quote_number, // Use the generated unique number
638 - 'issue_date' => date('Y-m-d'),
639 - 'expiry_date' => date('Y-m-d', strtotime('+30 days')),
834 + 'issue_date' => current_time('Y-m-d'),
835 + 'expiry_date' => wp_date('Y-m-d', strtotime('+30 days')),
640 836 'items' => [],
641 837 'notes' => '', // Ensure notes is never null
642 838 'terms' => $quote_terms, // Use global terms setting
643 839 'footer_text' => $quote_footer, // Use global footer setting
@@ -645,9 +841,12 @@
645 841 'accept_action' => $quote_accept_action, // Use global accept action setting
646 842 'accept_text' => $quote_accept_text, // Use global accept text setting
647 843 'accepted_message' => $quote_accepted_message, // Use global accepted message setting
648 844 'declined_message' => $quote_declined_message, // Use global declined message setting
845 + 'template' => get_option('easy_invoice_last_quote_template', 'standard')
649 846 ];
847 +
848 +
650 849 $quote = $this->quote_repository->create($data);
651 850 if (!$quote) {
652 851 wp_send_json_error(['message' => __('Failed to create quote.', 'easy-invoice')]);
653 852 }
@@ -652,9 +851,9 @@
652 851 wp_send_json_error(['message' => __('Failed to create quote.', 'easy-invoice')]);
653 852 }
654 853 wp_send_json_success(['quote_id' => $quote->getId()]);
655 854 }
656 -
855 +
657 856 /**
658 857 * Handle AJAX request to load quote form for modal
659 858 *
660 859 * @since 1.0.0
@@ -663,14 +862,14 @@
663 862 // Verify nonce
664 863 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
665 864 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
666 865 }
667 -
866 +
668 867 // Check permissions
669 - if (!current_user_can('manage_options')) {
868 + if (!easy_invoice_user_can('ei_create_quote')) {
670 869 wp_send_json_error(['message' => __('Insufficient permissions.', 'easy-invoice')]);
671 870 }
672 -
871 +
673 872 // Get global quote settings
674 873 $settings_controller = new \EasyInvoice\Controllers\SettingsController();
675 874 $quote_terms = $settings_controller::getQuoteTermsConditions();
676 875 $quote_footer = $settings_controller::getQuoteFooterText();
@@ -678,15 +877,15 @@
678 877 $quote_accept_action = get_option('easy_invoice_quote_accept_action', 'email');
679 878 $quote_accept_text = get_option('easy_invoice_quote_accept_text', __('Accept Quote', 'easy-invoice'));
680 879 $quote_accepted_message = get_option('easy_invoice_quote_accepted_message', __('Thank you for accepting our quote!', 'easy-invoice'));
681 880 $quote_declined_message = get_option('easy_invoice_quote_declined_message', __('Thank you for your consideration.', 'easy-invoice'));
682 -
881 +
683 882 // Create a new quote object for the form
684 883 $quote_number_service = function_exists('easy_invoice_get_quote_number_service') ? easy_invoice_get_quote_number_service() : null;
685 884 $quote_data = array(
686 885 'number' => $quote_number_service ? $quote_number_service->getNextNumber() : 'QT-1',
687 - 'date' => date('Y-m-d'),
688 - 'expiry_date' => date('Y-m-d', strtotime('+30 days')),
886 + 'date' => current_time('Y-m-d'),
887 + 'expiry_date' => wp_date('Y-m-d', strtotime('+30 days')),
689 888 'client_id' => 0,
690 889 'client_name' => '',
691 890 'client_email' => '',
692 891 'client_phone' => '',
@@ -711,9 +910,9 @@
711 910 'accept_text' => $quote_accept_text, // Use global accept text setting
712 911 'accepted_message' => $quote_accepted_message, // Use global accepted message setting
713 912 'declined_message' => $quote_declined_message, // Use global declined message setting
714 913 );
715 -
914 +
716 915 // Create a temporary WP_Post object for new quote
717 916 $empty_post = new \WP_Post((object) array(
718 917 'ID' => 0,
719 918 'post_author' => get_current_user_id(),
@@ -733,11 +932,11 @@
733 932 'post_mime_type' => '',
734 933 'comment_count' => 0,
735 934 'filter' => 'raw',
736 935 ));
737 -
936 +
738 937 $quote = new \EasyInvoice\Models\Quote($empty_post);
739 -
938 +
740 939 // Set default values on the quote object
741 940 foreach ($quote_data as $key => $value) {
742 941 $setter = 'set' . easy_invoice_str_replace('_', '', ucwords($key, '_'));
743 942 if (method_exists($quote, $setter)) {
@@ -764,142 +963,298 @@
764 963 break;
765 964 }
766 965 }
767 966 }
768 -
967 +
769 968 // Initialize empty items array
770 969 $quote->setItems([]);
771 -
970 +
772 971 // Set variables needed by the form template
773 972 $quote_id = 0;
774 - $clients = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository()->all();
973 + $clients = [];
775 974 $quote_form_manager = new \EasyInvoice\Forms\Quote\QuoteFormManager();
776 975 $quote_items_json = json_encode([]);
777 976 $admin_nonce = wp_create_nonce('easy_invoice_admin_nonce');
778 977 $quote_field_config = $quote_form_manager->getFieldConfigForJavaScript();
779 -
978 +
780 979 // Start output buffering to capture form HTML
781 980 ob_start();
782 -
981 +
783 982 // Include the quote form template
784 983 include EASY_INVOICE_PLUGIN_DIR . 'templates/quotes/form.php';
785 -
984 +
786 985 // Get the captured HTML
787 986 $html = ob_get_clean();
788 -
987 +
789 988 wp_send_json_success(['html' => $html]);
790 989 }
791 -
990 +
991 +
792 992 /**
793 - * Handle search clients AJAX request
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.
794 1001 *
795 - * @since 1.0.0
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.
796 1016 */
797 - public function handleSearchClients(): void {
798 - // Verify nonce
799 - if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_nonce')) {
800 - wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
1017 + public static function quoteAccessToken(int $quote_id): string {
1018 + if ($quote_id <= 0) {
1019 + return '';
801 1020 }
802 -
803 - // Check permissions
804 - if (!current_user_can('manage_options')) {
805 - wp_send_json_error(['message' => __('Insufficient permissions.', 'easy-invoice')]);
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);
806 1031 }
807 -
808 - $query = sanitize_text_field($_POST['query'] ?? '');
809 -
810 - // If query is empty, get all clients
811 - if (empty($query)) {
812 - $clients = $this->client_repository->all();
813 - } else {
814 - // Search clients by name, email, or company
815 - $clients = $this->client_repository->search($query);
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 '';
816 1049 }
817 -
818 - $results = [];
819 - foreach ($clients as $client) {
820 - $results[] = [
821 - 'id' => $client->getId(),
822 - 'name' => $client->getBusinessClientName() ?: ($client->getFirstName() . ' ' . $client->getLastName()),
823 - 'email' => $client->getEmail(),
824 - 'company' => $client->getBusinessClientName(),
825 - 'phone' => $client->getExtraInfo(),
826 - 'website' => $client->getWebsite(),
827 - 'address' => $client->getAddress()
828 - ];
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']));
829 1063 }
830 -
831 - wp_send_json_success($results);
1064 + /** This filter is documented in includes/Controllers/InvoiceController.php */
1065 + return (string) apply_filters('easy_invoice_presented_access_token', $token, 'quote');
832 1066 }
833 -
1067 +
834 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 + /**
835 1162 * Handle AJAX request to accept a quote
836 1163 *
837 1164 * @since 1.0.0
838 1165 */
839 1166 public function handleAcceptQuote(): void {
840 - // Verify nonce
841 - if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_quote_action')) {
842 - wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
843 - }
844 -
845 1167 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
846 -
1168 +
847 1169 if ($quote_id <= 0) {
848 1170 wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]);
849 1171 }
850 -
851 - // Get the quote
852 - $quote = $this->quote_repository->find($quote_id);
853 -
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 +
854 1185 if (!$quote) {
855 1186 wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]);
856 1187 }
857 -
858 - // Check if user has permission to accept this quote
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 +
859 1204 $current_user = wp_get_current_user();
860 - $is_admin = current_user_can('manage_options');
861 -
862 - if (!$is_admin) {
863 - // For non-admins, check if they are the client
864 - if ($quote->getClientId()) {
865 - $client_repository = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository();
866 - $client = $client_repository->find($quote->getClientId());
867 -
868 - if (!$client || $client->getEmail() !== $current_user->user_email) {
869 - wp_send_json_error(['message' => __('You do not have permission to accept this quote.', 'easy-invoice')]);
870 - }
871 - } else {
872 - wp_send_json_error(['message' => __('You do not have permission to accept this quote.', 'easy-invoice')]);
873 - }
874 - }
875 -
1205 +
876 1206 // Get global accept action setting
877 1207 $settings_controller = new \EasyInvoice\Controllers\SettingsController();
878 1208 $accept_action = $settings_controller::getQuoteAcceptAction();
879 -
1209 +
880 1210 // Update quote status to accepted
881 1211 $quote->setStatus('accepted');
882 - $quote->setAcceptedDate(date('Y-m-d H:i:s'));
1212 + $quote->setAcceptedDate(gmdate('Y-m-d H:i:s'));
883 1213 $quote->setAcceptedBy($current_user->ID);
884 -
1214 +
885 1215 // Save the quote
886 1216 $saved = $quote->save();
887 -
1217 +
888 1218 if (!$saved) {
889 1219 wp_send_json_error(['message' => __('Failed to accept quote.', 'easy-invoice')]);
890 1220 }
891 -
1221 +
892 1222 // Log the quote acceptance
893 1223 $this->quote_log_service->logAcceptance($quote_id, [
894 1224 'accept_action' => $accept_action,
895 1225 'user_type' => $is_admin ? 'admin' : 'client'
896 1226 ]);
897 -
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 +
898 1253 // Perform the configured accept action
899 1254 $invoice_id = null;
900 1255 $action_message = '';
901 -
1256 +
902 1257 switch ($accept_action) {
903 1258 case 'convert':
904 1259 // Convert quote to invoice (Draft status)
905 1260 $invoice_id = $this->convertQuoteToInvoice($quote, 'draft');
@@ -907,9 +1262,9 @@
907 1262 $this->quote_log_service->logConversionToInvoice($quote_id, $invoice_id);
908 1263 }
909 1264 $action_message = __('Quote converted to invoice successfully.', 'easy-invoice');
910 1265 break;
911 -
1266 +
912 1267 case 'convert_available':
913 1268 // Convert quote to invoice (Available status)
914 1269 $invoice_id = $this->convertQuoteToInvoice($quote, 'available');
915 1270 if ($invoice_id) {
@@ -916,9 +1271,9 @@
916 1271 $this->quote_log_service->logConversionToInvoice($quote_id, $invoice_id);
917 1272 }
918 1273 $action_message = __('Quote converted to invoice successfully.', 'easy-invoice');
919 1274 break;
920 -
1275 +
921 1276 case 'convert_send':
922 1277 // Convert quote to invoice and send to client (Available status)
923 1278 $invoice_id = $this->convertQuoteToInvoice($quote, 'available');
924 1279 if ($invoice_id) {
@@ -925,9 +1280,9 @@
925 1280 $this->sendInvoiceToClient($invoice_id);
926 1281 }
927 1282 $action_message = __('Quote converted to invoice and sent to client successfully.', 'easy-invoice');
928 1283 break;
929 -
1284 +
930 1285 case 'duplicate':
931 1286 // Create new invoice, keep quote as-is (Draft status)
932 1287 $invoice_id = $this->createInvoiceFromQuote($quote, 'draft');
933 1288 if ($invoice_id) {
@@ -934,9 +1289,9 @@
934 1289 $this->quote_log_service->logDuplicationToInvoice($quote_id, $invoice_id);
935 1290 }
936 1291 $action_message = __('New invoice created from quote successfully.', 'easy-invoice');
937 1292 break;
938 -
1293 +
939 1294 case 'duplicate_send':
940 1295 // Create new invoice and send to client, keep quote as-is (Available status)
941 1296 $invoice_id = $this->createInvoiceFromQuote($quote, 'available');
942 1297 if ($invoice_id) {
@@ -943,9 +1298,9 @@
943 1298 $this->sendInvoiceToClient($invoice_id);
944 1299 }
945 1300 $action_message = __('New invoice created and sent to client successfully.', 'easy-invoice');
946 1301 break;
947 -
1302 +
948 1303 case 'do_nothing':
949 1304 default:
950 1305 // Do nothing additional
951 1306 $action_message = __('Quote accepted successfully.', 'easy-invoice');
@@ -950,33 +1305,37 @@
950 1305 // Do nothing additional
951 1306 $action_message = __('Quote accepted successfully.', 'easy-invoice');
952 1307 break;
953 1308 }
954 -
1309 +
955 1310 // Send notification email to admin
956 1311 if (!$is_admin) {
957 1312 $this->sendQuoteAcceptanceNotification($quote);
958 1313 }
959 -
1314 +
960 1315 // Get URLs for the new invoice
961 1316 $invoice_url = null;
962 1317 $secure_url = null;
963 -
1318 +
964 1319 if ($invoice_id) {
965 - // Get regular invoice URL
1320 + // Always use WordPress permalink
966 1321 $invoice_url = get_permalink($invoice_id);
967 -
968 - // If permalink is not available, construct a fallback URL
969 - if (!$invoice_url || $invoice_url === get_permalink(0)) {
970 - $invoice_url = home_url('/invoice/' . $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 + }
971 1335 }
972 -
973 - // Get secure URL for the new invoice if it exists (Pro version)
974 - if (class_exists('\EasyInvoicePro\Controllers\PermalinkController')) {
975 - $secure_url = \EasyInvoicePro\Controllers\PermalinkController::getInvoiceSecureLinkUrl($invoice_id);
976 - }
977 1336 }
978 -
1337 +
979 1338 wp_send_json_success([
980 1339 'message' => $action_message,
981 1340 'invoice_id' => $invoice_id,
982 1341 'invoice_url' => $invoice_url,
@@ -986,9 +1345,9 @@
986 1345 'message' => $action_message
987 1346 ]
988 1347 ]);
989 1348 }
990 -
1349 +
991 1350 /**
992 1351 * Convert quote to invoice
993 1352 *
994 1353 * @param \EasyInvoice\Models\Quote $quote The quote to convert
@@ -994,20 +1353,94 @@
994 1353 * @param \EasyInvoice\Models\Quote $quote The quote to convert
995 1354 * @param string $status The status for the new invoice ('draft' or 'available')
996 1355 * @return int|null The invoice ID if successful, null otherwise
997 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 +
998 1431 private function convertQuoteToInvoice($quote, $status = 'draft'): ?int {
999 1432 try {
1000 1433 // Get invoice repository
1001 1434 $invoice_repository = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository();
1002 -
1435 +
1003 1436 // Create invoice data from quote - convert ALL fields
1004 1437 $invoice_data = [
1005 1438 'title' => $quote->getTitle() ?: 'Invoice from Quote ' . $quote->getNumber(),
1006 1439 'number' => $this->generateInvoiceNumber(),
1007 1440 'status' => $status,
1008 - 'issue_date' => date('Y-m-d'),
1009 - 'due_date' => date('Y-m-d', strtotime('+30 days')),
1441 + 'issue_date' => current_time('Y-m-d'),
1442 + 'due_date' => wp_date('Y-m-d', strtotime('+30 days')),
1010 1443 'client_id' => $quote->getClientId(),
1011 1444 'customer_name' => $quote->getCustomerName(),
1012 1445 'customer_email' => $quote->getCustomerEmail(),
1013 1446 'customer_address' => $quote->getCustomerAddress(),
@@ -1022,8 +1455,9 @@
1022 1455 'payment_gateways' => [], // Invoice-specific field, leave empty
1023 1456 'template' => $quote->getTemplate(),
1024 1457 'subtotal' => $quote->getSubtotal(),
1025 1458 'tax_rate' => $quote->getTaxRate(),
1459 + 'tax_enabled' => $quote->getTaxEnabled() ?: (get_option('easy_invoice_tax_enabled', 'no') === 'yes' ? 'yes' : 'no'),
1026 1460 'tax_amount' => $quote->getTaxAmount(),
1027 1461 'discount_type' => $quote->getDiscountType(),
1028 1462 'discount_value' => $quote->getDiscountValue(),
1029 1463 'discount_amount' => $quote->getDiscountAmount(),
@@ -1034,29 +1468,51 @@
1034 1468 'calculation_method' => 'standard', // Default calculation method for invoices
1035 1469 'prices_include_tax' => $quote->getPricesIncludeTax(),
1036 1470 'custom_fields' => $quote->getCustomFields(), // Transfer custom fields
1037 1471 ];
1038 -
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 +
1039 1482 // Create the invoice
1040 1483 $invoice = $invoice_repository->create($invoice_data);
1041 -
1484 +
1042 1485 if ($invoice) {
1043 1486 // Store the quote ID in the invoice's meta for tracking
1044 1487 update_post_meta($invoice->getId(), '_converted_from_quote', $quote->getId());
1045 -
1046 - // Update quote to reference the created invoice
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());
1047 1494 $quote->setCustomField('converted_invoice_id', $invoice->getId());
1048 1495 $quote->save();
1049 -
1496 +
1050 1497 // Ensure secure link is generated for the new invoice (Pro version)
1051 - if (class_exists('\EasyInvoicePro\Controllers\PermalinkController')) {
1052 - // Trigger the save_post hook to generate secure link
1053 - do_action('save_post_easy_invoice', $invoice->getId(), get_post($invoice->getId()));
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);
1054 1510 }
1055 -
1511 +
1056 1512 return $invoice->getId();
1057 1513 }
1058 -
1514 +
1059 1515 return null;
1060 1516 } catch (\Exception $e) {
1061 1517 // Error converting quote to invoice
1062 1518 return null;
@@ -1061,9 +1517,9 @@
1061 1517 // Error converting quote to invoice
1062 1518 return null;
1063 1519 }
1064 1520 }
1065 -
1521 +
1066 1522 /**
1067 1523 * Create new invoice from quote (duplicate)
1068 1524 *
1069 1525 * @param \EasyInvoice\Models\Quote $quote The quote to duplicate
@@ -1073,16 +1529,16 @@
1073 1529 private function createInvoiceFromQuote($quote, $status = 'draft'): ?int {
1074 1530 try {
1075 1531 // Get invoice repository
1076 1532 $invoice_repository = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository();
1077 -
1533 +
1078 1534 // Create invoice data from quote - convert ALL fields
1079 1535 $invoice_data = [
1080 1536 'title' => 'Invoice from Quote ' . $quote->getNumber(),
1081 1537 'number' => $this->generateInvoiceNumber(),
1082 1538 'status' => $status,
1083 - 'issue_date' => date('Y-m-d'),
1084 - 'due_date' => date('Y-m-d', strtotime('+30 days')),
1539 + 'issue_date' => current_time('Y-m-d'),
1540 + 'due_date' => wp_date('Y-m-d', strtotime('+30 days')),
1085 1541 'client_id' => $quote->getClientId(),
1086 1542 'customer_name' => $quote->getCustomerName(),
1087 1543 'customer_email' => $quote->getCustomerEmail(),
1088 1544 'customer_address' => $quote->getCustomerAddress(),
@@ -1097,8 +1553,9 @@
1097 1553 'payment_gateways' => [], // Invoice-specific field, leave empty
1098 1554 'template' => $quote->getTemplate(),
1099 1555 'subtotal' => $quote->getSubtotal(),
1100 1556 'tax_rate' => $quote->getTaxRate(),
1557 + 'tax_enabled' => $quote->getTaxEnabled() ?: (get_option('easy_invoice_tax_enabled', 'no') === 'yes' ? 'yes' : 'no'),
1101 1558 'tax_amount' => $quote->getTaxAmount(),
1102 1559 'discount_type' => $quote->getDiscountType(),
1103 1560 'discount_value' => $quote->getDiscountValue(),
1104 1561 'discount_amount' => $quote->getDiscountAmount(),
@@ -1109,26 +1566,44 @@
1109 1566 'calculation_method' => 'standard', // Default calculation method for invoices
1110 1567 'prices_include_tax' => $quote->getPricesIncludeTax(),
1111 1568 'custom_fields' => $quote->getCustomFields(), // Transfer custom fields
1112 1569 ];
1113 -
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 +
1114 1580 // Create the invoice
1115 1581 $invoice = $invoice_repository->create($invoice_data);
1116 -
1582 +
1117 1583 if ($invoice) {
1118 1584 // Link the invoice to the quote
1119 1585 $quote->setCustomField('related_invoice_id', $invoice->getId());
1120 1586 $quote->save();
1121 -
1587 +
1122 1588 // Ensure secure link is generated for the new invoice (Pro version)
1123 - if (class_exists('\EasyInvoicePro\Controllers\PermalinkController')) {
1124 - // Trigger the save_post hook to generate secure link
1125 - do_action('save_post_easy_invoice', $invoice->getId(), get_post($invoice->getId()));
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);
1126 1601 }
1127 -
1602 +
1128 1603 return $invoice->getId();
1129 1604 }
1130 -
1605 +
1131 1606 return null;
1132 1607 } catch (\Exception $e) {
1133 1608 // Error creating invoice from quote
1134 1609 return null;
@@ -1133,9 +1608,9 @@
1133 1608 // Error creating invoice from quote
1134 1609 return null;
1135 1610 }
1136 1611 }
1137 -
1612 +
1138 1613 /**
1139 1614 * Send invoice to client
1140 1615 *
1141 1616 * @param int $invoice_id The invoice ID
@@ -1145,19 +1620,19 @@
1145 1620 try {
1146 1621 // Get invoice
1147 1622 $invoice_repository = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository();
1148 1623 $invoice = $invoice_repository->find($invoice_id);
1149 -
1624 +
1150 1625 if (!$invoice) {
1151 1626 return false;
1152 1627 }
1153 -
1628 +
1154 1629 // Get email manager
1155 1630 $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
1156 -
1631 +
1157 1632 // Send invoice email
1158 1633 $result = $email_manager->sendInvoiceEmail($invoice, 'new');
1159 -
1634 +
1160 1635 return $result['success'];
1161 1636 } catch (\Exception $e) {
1162 1637 // Error sending invoice to client
1163 1638 return false;
@@ -1162,9 +1637,9 @@
1162 1637 // Error sending invoice to client
1163 1638 return false;
1164 1639 }
1165 1640 }
1166 -
1641 +
1167 1642 /**
1168 1643 * Convert quote items to invoice items
1169 1644 *
1170 1645 * @param array $quote_items Array of quote items
@@ -1171,19 +1646,29 @@
1171 1646 * @return array Array of invoice items
1172 1647 */
1173 1648 private function convertQuoteItemsToInvoiceItems(array $quote_items): array {
1174 1649 $invoice_items = [];
1175 -
1650 +
1176 1651 foreach ($quote_items as $quote_item) {
1177 1652 if (is_object($quote_item) && method_exists($quote_item, 'toArray')) {
1178 - // Convert QuoteItem object to InvoiceItem array
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.
1179 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 + }
1180 1665 $invoice_items[] = [
1181 - 'name' => $item_data['name'] ?? '',
1666 + 'name' => $name,
1182 1667 'description' => $item_data['description'] ?? '',
1183 - 'quantity' => $item_data['quantity'] ?? 1,
1668 + 'quantity' => $item_data['quantity'] ?? 0,
1184 1669 'price' => $item_data['price'] ?? 0,
1185 - 'amount' => $item_data['amount'] ?? 0,
1670 + 'amount' => $amount,
1186 1671 'taxable' => $item_data['taxable'] ?? true,
1187 1672 // Map adjust_percentage to a similar field if needed
1188 1673 'adjust_percentage' => $item_data['adjust_percentage'] ?? 0,
1189 1674 ];
@@ -1191,9 +1676,9 @@
1191 1676 // Convert array item directly
1192 1677 $invoice_items[] = [
1193 1678 'name' => $quote_item['name'] ?? $quote_item['title'] ?? '',
1194 1679 'description' => $quote_item['description'] ?? '',
1195 - 'quantity' => $quote_item['quantity'] ?? 1,
1680 + 'quantity' => $quote_item['quantity'] ?? 0,
1196 1681 'price' => $quote_item['price'] ?? 0,
1197 1682 'amount' => $quote_item['amount'] ?? $quote_item['total'] ?? 0,
1198 1683 'taxable' => $quote_item['taxable'] ?? true,
1199 1684 'adjust_percentage' => $quote_item['adjust_percentage'] ?? 0,
@@ -1199,12 +1684,12 @@
1199 1684 'adjust_percentage' => $quote_item['adjust_percentage'] ?? 0,
1200 1685 ];
1201 1686 }
1202 1687 }
1203 -
1688 +
1204 1689 return $invoice_items;
1205 1690 }
1206 -
1691 +
1207 1692 /**
1208 1693 * Generate unique invoice number
1209 1694 *
1210 1695 * @return string The invoice number
@@ -1214,13 +1699,13 @@
1214 1699 if (class_exists('\\EasyInvoice\\Services\\InvoiceNumberService')) {
1215 1700 $invoice_number_service = new \EasyInvoice\Services\InvoiceNumberService();
1216 1701 return $invoice_number_service->generateUniqueNumber();
1217 1702 }
1218 -
1703 +
1219 1704 // Fallback to timestamp-based number
1220 1705 return 'INV-' . str_pad(time(), 6, '0', STR_PAD_LEFT);
1221 1706 }
1222 -
1707 +
1223 1708 /**
1224 1709 * Get changes between two quote versions
1225 1710 *
1226 1711 * @param \EasyInvoice\Models\Quote $old_quote Old quote
@@ -1228,9 +1713,9 @@
1228 1713 * @return array Array of changes
1229 1714 */
1230 1715 private function getQuoteChanges($old_quote, $new_quote): array {
1231 1716 $changes = [];
1232 -
1717 +
1233 1718 // Compare key fields
1234 1719 $fields_to_compare = [
1235 1720 'title' => 'Title',
1236 1721 'status' => 'Status',
@@ -1242,25 +1727,25 @@
1242 1727 'total' => 'Total Amount',
1243 1728 'notes' => 'Notes',
1244 1729 'terms' => 'Terms',
1245 1730 ];
1246 -
1731 +
1247 1732 foreach ($fields_to_compare as $field => $label) {
1248 1733 $method_name = 'get' . easy_invoice_str_replace('_', '', ucwords($field, '_'));
1249 -
1734 +
1250 1735 if (method_exists($old_quote, $method_name) && method_exists($new_quote, $method_name)) {
1251 1736 $old_value = $old_quote->$method_name();
1252 1737 $new_value = $new_quote->$method_name();
1253 -
1738 +
1254 1739 if ($old_value !== $new_value) {
1255 1740 $changes[$field] = $new_value;
1256 1741 }
1257 1742 }
1258 1743 }
1259 -
1744 +
1260 1745 return $changes;
1261 1746 }
1262 -
1747 +
1263 1748 /**
1264 1749 * Handle AJAX request to decline a quote
1265 1750 *
1266 1751 * @since 1.0.0
@@ -1265,78 +1750,77 @@
1265 1750 *
1266 1751 * @since 1.0.0
1267 1752 */
1268 1753 public function handleDeclineQuote(): void {
1269 - // Verify nonce
1270 - if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_quote_action')) {
1271 - wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
1272 - }
1273 -
1274 1754 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
1275 1755 $decline_reason = isset($_POST['decline_reason']) ? sanitize_textarea_field($_POST['decline_reason']) : '';
1276 -
1756 +
1277 1757 if ($quote_id <= 0) {
1278 1758 wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]);
1279 1759 }
1280 -
1281 - // Get the quote
1282 - $quote = $this->quote_repository->find($quote_id);
1283 -
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 +
1284 1772 if (!$quote) {
1285 1773 wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]);
1286 1774 }
1287 -
1775 +
1288 1776 // Check if decline reason is required by global settings
1289 1777 $settings_controller = new \EasyInvoice\Controllers\SettingsController();
1290 1778 if ($settings_controller::isDeclineReasonRequired() && empty(trim($decline_reason))) {
1291 1779 wp_send_json_error(['message' => __('Reason for declining is required.', 'easy-invoice')]);
1292 1780 }
1293 -
1294 - // Check if user has permission to decline this quote
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 +
1295 1794 $current_user = wp_get_current_user();
1296 - $is_admin = current_user_can('manage_options');
1297 -
1298 - if (!$is_admin) {
1299 - // For non-admins, check if they are the client
1300 - if ($quote->getClientId()) {
1301 - $client_repository = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository();
1302 - $client = $client_repository->find($quote->getClientId());
1303 -
1304 - if (!$client || $client->getEmail() !== $current_user->user_email) {
1305 - wp_send_json_error(['message' => __('You do not have permission to decline this quote.', 'easy-invoice')]);
1306 - }
1307 - } else {
1308 - wp_send_json_error(['message' => __('You do not have permission to decline this quote.', 'easy-invoice')]);
1309 - }
1310 - }
1311 -
1795 +
1312 1796 // Update quote status to declined
1313 1797 $quote->setStatus('declined');
1314 - $quote->setDeclinedDate(date('Y-m-d H:i:s'));
1798 + $quote->setDeclinedDate(gmdate('Y-m-d H:i:s'));
1315 1799 $quote->setDeclinedBy($current_user->ID);
1316 -
1800 +
1317 1801 // Save decline reason if provided
1318 1802 if (!empty($decline_reason)) {
1319 1803 $quote->setDeclineReason($decline_reason);
1320 1804 }
1321 -
1805 +
1322 1806 // Save the quote
1323 1807 $saved = $quote->save();
1324 -
1808 +
1325 1809 if (!$saved) {
1326 1810 wp_send_json_error(['message' => __('Failed to decline quote.', 'easy-invoice')]);
1327 1811 }
1328 -
1812 +
1329 1813 // Log the quote decline
1330 1814 $this->quote_log_service->logDecline($quote_id, $decline_reason, [
1331 1815 'user_type' => $is_admin ? 'admin' : 'client'
1332 1816 ]);
1333 -
1817 +
1334 1818 // Send notification email to admin
1335 1819 if (!$is_admin) {
1336 1820 $this->sendQuoteDeclineNotification($quote);
1337 1821 }
1338 -
1822 +
1339 1823 wp_send_json_success([
1340 1824 'message' => __('Quote declined successfully.', 'easy-invoice'),
1341 1825 'toast' => [
1342 1826 'type' => 'success',
@@ -1343,9 +1827,9 @@
1343 1827 'message' => __('Quote declined successfully.', 'easy-invoice')
1344 1828 ]
1345 1829 ]);
1346 1830 }
1347 -
1831 +
1348 1832 /**
1349 1833 * Send quote acceptance notification to admin
1350 1834 *
1351 1835 * @param \EasyInvoice\Models\Quote $quote The quote that was accepted
@@ -1350,41 +1834,13 @@
1350 1834 *
1351 1835 * @param \EasyInvoice\Models\Quote $quote The quote that was accepted
1352 1836 */
1353 1837 private function sendQuoteAcceptanceNotification($quote): void {
1354 - $admin_email = get_option('admin_email');
1355 - $site_name = get_bloginfo('name');
1356 -
1357 - $subject = sprintf(__('Quote %s has been accepted', 'easy-invoice'), $quote->getNumber());
1358 -
1359 - $message = sprintf(
1360 - __('Hello,
1838 + // Use EmailManager to send admin notification
1839 + $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
1840 + $email_manager->sendAdminQuoteNotification($quote, 'accepted');
1841 + }
1361 1842
1362 -The quote %s for %s has been accepted by the client.
1363 -
1364 -Quote Details:
1365 -- Quote Number: %s
1366 -- Client: %s
1367 -- Total Amount: %s
1368 -- Accepted Date: %s
1369 -
1370 -You can view the quote at: %s
1371 -
1372 -Best regards,
1373 -%s', 'easy-invoice'),
1374 - $quote->getNumber(),
1375 - $quote->getCustomerName(),
1376 - $quote->getNumber(),
1377 - $quote->getCustomerName(),
1378 - $this->formatCurrency($quote->getTotal(), $quote),
1379 - date_i18n(get_option('date_format') . ' ' . get_option('time_format')),
1380 - get_permalink($quote->getId()),
1381 - $site_name
1382 - );
1383 -
1384 - wp_mail($admin_email, $subject, $message);
1385 - }
1386 -
1387 1843 /**
1388 1844 * Send quote decline notification to admin
1389 1845 *
1390 1846 * @param \EasyInvoice\Models\Quote $quote The quote that was declined
@@ -1389,91 +1845,15 @@
1389 1845 *
1390 1846 * @param \EasyInvoice\Models\Quote $quote The quote that was declined
1391 1847 */
1392 1848 private function sendQuoteDeclineNotification($quote): void {
1393 - $admin_email = get_option('admin_email');
1394 - $site_name = get_bloginfo('name');
1395 -
1396 - $subject = sprintf(__('Quote %s has been declined', 'easy-invoice'), $quote->getNumber());
1397 -
1398 - $message = sprintf(
1399 - __('Hello,
1849 + // Use EmailManager to send admin notification
1850 + $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
1851 + $email_manager->sendAdminQuoteNotification($quote, 'declined');
1852 + }
1400 1853
1401 -The quote %s for %s has been declined by the client.
1402 1854
1403 -Quote Details:
1404 -- Quote Number: %s
1405 -- Client: %s
1406 -- Total Amount: %s
1407 -- Declined Date: %s
1408 -
1409 -You can view the quote at: %s
1410 -
1411 -Best regards,
1412 -%s', 'easy-invoice'),
1413 - $quote->getNumber(),
1414 - $quote->getCustomerName(),
1415 - $quote->getNumber(),
1416 - $quote->getCustomerName(),
1417 - $this->formatCurrency($quote->getTotal(), $quote),
1418 - date_i18n(get_option('date_format') . ' ' . get_option('time_format')),
1419 - get_permalink($quote->getId()),
1420 - $site_name
1421 - );
1422 -
1423 - wp_mail($admin_email, $subject, $message);
1424 - }
1425 -
1426 1855 /**
1427 - * Handle AJAX request to update existing quotes with missing data
1428 - *
1429 - * @since 1.0.0
1430 - */
1431 - public function handleUpdateExistingQuotes(): void {
1432 - // Verify nonce - match the nonce being sent from JavaScript
1433 - if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
1434 - wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
1435 - }
1436 -
1437 - // Check permissions
1438 - if (!current_user_can('manage_options')) {
1439 - wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
1440 - }
1441 -
1442 - $updated_count = 0;
1443 - $quotes = $this->quote_repository->findAll();
1444 -
1445 - foreach ($quotes as $quote) {
1446 - $post = get_post($quote->getId());
1447 - if ($post && empty($post->post_name)) {
1448 - // Generate a proper slug for this quote
1449 - $post_title = $quote->getTitle() ?: $quote->getNumber() ?: 'Untitled Quote';
1450 - $post_name = sanitize_title($post_title);
1451 -
1452 - // Ensure uniqueness
1453 - $original_slug = $post_name;
1454 - $counter = 1;
1455 - while (get_page_by_path($post_name, OBJECT, \EasyInvoice\Constants\PostTypes::EASY_INVOICE_QUOTE_POST_TYPE)) {
1456 - $post_name = $original_slug . '-' . $counter;
1457 - $counter++;
1458 - }
1459 -
1460 - // Update the post with the new slug
1461 - wp_update_post([
1462 - 'ID' => $quote->getId(),
1463 - 'post_name' => $post_name
1464 - ]);
1465 -
1466 - $updated_count++;
1467 - }
1468 - }
1469 -
1470 - wp_send_json_success([
1471 - 'message' => sprintf(__('Updated %d quotes with proper URLs.', 'easy-invoice'), $updated_count)
1472 - ]);
1473 - }
1474 -
1475 - /**
1476 1856 * Handle AJAX request to duplicate a quote
1477 1857 *
1478 1858 * @since 1.0.0
1479 1859 */
@@ -1481,26 +1861,26 @@
1481 1861 // Verify nonce
1482 1862 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
1483 1863 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
1484 1864 }
1485 -
1865 +
1486 1866 // Check permissions
1487 - if (!current_user_can('manage_options')) {
1867 + if (!easy_invoice_user_can('ei_create_quote')) {
1488 1868 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
1489 1869 }
1490 -
1870 +
1491 1871 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
1492 -
1872 +
1493 1873 if ($quote_id <= 0) {
1494 1874 wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]);
1495 1875 }
1496 -
1876 +
1497 1877 $quote = $this->quote_repository->find($quote_id);
1498 -
1878 +
1499 1879 if (!$quote) {
1500 1880 wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]);
1501 1881 }
1502 -
1882 +
1503 1883 // Get global quote settings
1504 1884 $settings_controller = new \EasyInvoice\Controllers\SettingsController();
1505 1885 $quote_terms = $settings_controller::getQuoteTermsConditions();
1506 1886 $quote_footer = $settings_controller::getQuoteFooterText();
@@ -1508,16 +1888,16 @@
1508 1888 $quote_accept_action = get_option('easy_invoice_quote_accept_action', 'email');
1509 1889 $quote_accept_text = get_option('easy_invoice_quote_accept_text', __('Accept Quote', 'easy-invoice'));
1510 1890 $quote_accepted_message = get_option('easy_invoice_quote_accepted_message', __('Thank you for accepting our quote!', 'easy-invoice'));
1511 1891 $quote_declined_message = get_option('easy_invoice_quote_declined_message', __('Thank you for your consideration.', 'easy-invoice'));
1512 -
1892 +
1513 1893 // Create the duplicate quote
1514 1894 $duplicate_data = [
1515 1895 'title' => $quote->getTitle() . ' (Copy)',
1516 1896 'status' => 'draft',
1517 1897 'number' => $this->generateInvoiceNumber(), // Use invoice number service for consistency
1518 - 'issue_date' => date('Y-m-d'),
1519 - 'expiry_date' => date('Y-m-d', strtotime('+30 days')),
1898 + 'issue_date' => current_time('Y-m-d'),
1899 + 'expiry_date' => wp_date('Y-m-d', strtotime('+30 days')),
1520 1900 'items' => $this->convertQuoteItemsToInvoiceItems($quote->getItems()), // Use invoice item conversion
1521 1901 'notes' => $quote->getNotes(),
1522 1902 'description' => $quote->getDescription(),
1523 1903 'terms' => $quote_terms,
@@ -1527,14 +1907,14 @@
1527 1907 'accept_text' => $quote_accept_text,
1528 1908 'accepted_message' => $quote_accepted_message,
1529 1909 'declined_message' => $quote_declined_message,
1530 1910 ];
1531 -
1911 +
1532 1912 // Set client ID to 0 for a new quote
1533 1913 $duplicate_data['client_id'] = 0;
1534 -
1914 +
1535 1915 $duplicate_quote = $this->quote_repository->create($duplicate_data);
1536 -
1916 +
1537 1917 if ($duplicate_quote) {
1538 1918 $this->quote_log_service->logActivity($quote_id, 'duplicate', 'Quote duplicated', ['duplicate_id' => $duplicate_quote->getId()]);
1539 1919 wp_send_json_success([
1540 1920 'message' => __('Quote duplicated successfully.', 'easy-invoice'),
@@ -1547,9 +1927,9 @@
1547 1927 } else {
1548 1928 wp_send_json_error(['message' => __('Failed to duplicate quote.', 'easy-invoice')]);
1549 1929 }
1550 1930 }
1551 -
1931 +
1552 1932 /**
1553 1933 * Handle regular POST form actions for quote accept/decline
1554 1934 *
1555 1935 * @since 1.0.0
@@ -1558,20 +1938,20 @@
1558 1938 // Only process on POST requests
1559 1939 if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
1560 1940 return;
1561 1941 }
1562 -
1942 +
1563 1943 // Handle accept quote
1564 1944 if (isset($_POST['accept_quote']) && isset($_POST['quote_id'])) {
1565 1945 $this->handleAcceptQuoteForm();
1566 1946 }
1567 -
1947 +
1568 1948 // Handle decline quote
1569 1949 if (isset($_POST['decline_quote']) && isset($_POST['quote_id'])) {
1570 1950 $this->handleDeclineQuoteForm();
1571 1951 }
1572 1952 }
1573 -
1953 +
1574 1954 /**
1575 1955 * Handle accept quote form submission
1576 1956 *
1577 1957 * @since 1.0.0
@@ -1576,67 +1956,65 @@
1576 1956 *
1577 1957 * @since 1.0.0
1578 1958 */
1579 1959 private function handleAcceptQuoteForm(): void {
1580 - // Verify nonce
1581 - if (!wp_verify_nonce($_POST['quote_nonce'] ?? '', 'easy_invoice_quote_action')) {
1582 - wp_die(__('Security check failed.', 'easy-invoice'));
1583 - }
1584 -
1585 1960 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
1586 -
1961 +
1587 1962 if ($quote_id <= 0) {
1588 - wp_die(__('Invalid quote ID.', 'easy-invoice'));
1963 + wp_die(esc_html__('Invalid quote ID.', 'easy-invoice'));
1589 1964 }
1590 -
1591 - // Get the quote
1592 - $quote = $this->quote_repository->find($quote_id);
1593 -
1594 - if (!$quote) {
1595 - wp_die(__('Quote not found.', 'easy-invoice'));
1965 +
1966 + if (!wp_verify_nonce($_POST['quote_nonce'] ?? '', $this->quoteAcceptDeclineNonceAction($quote_id))) {
1967 + wp_die(esc_html__('Security check failed.', 'easy-invoice'));
1596 1968 }
1597 -
1598 - // Check if user has permission to accept this quote
1969 +
1599 1970 $current_user = wp_get_current_user();
1600 1971 $is_admin = current_user_can('manage_options');
1601 -
1602 - if (!$is_admin) {
1603 - // For non-admins, check if they are the client
1604 - if ($quote->getClientId()) {
1605 - $client_repository = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository();
1606 - $client = $client_repository->find($quote->getClientId());
1607 -
1608 - if (!$client || $client->getEmail() !== $current_user->user_email) {
1609 - wp_die(__('You do not have permission to accept this quote.', 'easy-invoice'));
1610 - }
1611 - } else {
1612 - wp_die(__('You do not have permission to accept this quote.', 'easy-invoice'));
1613 - }
1972 +
1973 + if ($is_admin) {
1974 + $quote = $this->quote_repository->find($quote_id);
1975 + } else {
1976 + $quote = $this->quote_repository->findPublished($quote_id);
1614 1977 }
1615 -
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 +
1616 1994 // Update quote status to accepted
1617 1995 $quote->setStatus('accepted');
1618 - $quote->setAcceptedDate(date('Y-m-d H:i:s'));
1996 + $quote->setAcceptedDate(gmdate('Y-m-d H:i:s'));
1619 1997 $quote->setAcceptedBy($current_user->ID);
1620 -
1998 +
1621 1999 // Save the quote
1622 2000 $saved = $quote->save();
1623 -
2001 +
1624 2002 if (!$saved) {
1625 - wp_die(__('Failed to accept quote.', 'easy-invoice'));
2003 + wp_die(esc_html__('Failed to accept quote.', 'easy-invoice'));
1626 2004 }
1627 -
2005 +
1628 2006 // Send notification email to admin
1629 2007 if (!$is_admin) {
1630 2008 $this->sendQuoteAcceptanceNotification($quote);
1631 2009 }
1632 -
2010 +
1633 2011 // Redirect back to the quote page with success message
1634 2012 $redirect_url = add_query_arg('action', 'accepted', get_permalink($quote_id));
1635 - wp_redirect($redirect_url);
2013 + wp_safe_redirect($redirect_url);
1636 2014 exit;
1637 2015 }
1638 -
2016 +
1639 2017 /**
1640 2018 * Handle decline quote form submission
1641 2019 *
1642 2020 * @since 1.0.0
@@ -1641,67 +2019,65 @@
1641 2019 *
1642 2020 * @since 1.0.0
1643 2021 */
1644 2022 private function handleDeclineQuoteForm(): void {
1645 - // Verify nonce
1646 - if (!wp_verify_nonce($_POST['quote_nonce'] ?? '', 'easy_invoice_quote_action')) {
1647 - wp_die(__('Security check failed.', 'easy-invoice'));
1648 - }
1649 -
1650 2023 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
1651 -
2024 +
1652 2025 if ($quote_id <= 0) {
1653 - wp_die(__('Invalid quote ID.', 'easy-invoice'));
2026 + wp_die(esc_html__('Invalid quote ID.', 'easy-invoice'));
1654 2027 }
1655 -
1656 - // Get the quote
1657 - $quote = $this->quote_repository->find($quote_id);
1658 -
1659 - if (!$quote) {
1660 - wp_die(__('Quote not found.', 'easy-invoice'));
2028 +
2029 + if (!wp_verify_nonce($_POST['quote_nonce'] ?? '', $this->quoteAcceptDeclineNonceAction($quote_id))) {
2030 + wp_die(esc_html__('Security check failed.', 'easy-invoice'));
1661 2031 }
1662 -
1663 - // Check if user has permission to decline this quote
2032 +
1664 2033 $current_user = wp_get_current_user();
1665 2034 $is_admin = current_user_can('manage_options');
1666 -
1667 - if (!$is_admin) {
1668 - // For non-admins, check if they are the client
1669 - if ($quote->getClientId()) {
1670 - $client_repository = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository();
1671 - $client = $client_repository->find($quote->getClientId());
1672 -
1673 - if (!$client || $client->getEmail() !== $current_user->user_email) {
1674 - wp_die(__('You do not have permission to decline this quote.', 'easy-invoice'));
1675 - }
1676 - } else {
1677 - wp_die(__('You do not have permission to decline this quote.', 'easy-invoice'));
1678 - }
2035 +
2036 + if ($is_admin) {
2037 + $quote = $this->quote_repository->find($quote_id);
2038 + } else {
2039 + $quote = $this->quote_repository->findPublished($quote_id);
1679 2040 }
1680 -
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 +
1681 2057 // Update quote status to declined
1682 2058 $quote->setStatus('declined');
1683 - $quote->setDeclinedDate(date('Y-m-d H:i:s'));
2059 + $quote->setDeclinedDate(gmdate('Y-m-d H:i:s'));
1684 2060 $quote->setDeclinedBy($current_user->ID);
1685 -
2061 +
1686 2062 // Save the quote
1687 2063 $saved = $quote->save();
1688 -
2064 +
1689 2065 if (!$saved) {
1690 - wp_die(__('Failed to decline quote.', 'easy-invoice'));
2066 + wp_die(esc_html__('Failed to decline quote.', 'easy-invoice'));
1691 2067 }
1692 -
2068 +
1693 2069 // Send notification email to admin
1694 2070 if (!$is_admin) {
1695 2071 $this->sendQuoteDeclineNotification($quote);
1696 2072 }
1697 -
2073 +
1698 2074 // Redirect back to the quote page with success message
1699 2075 $redirect_url = add_query_arg('action', 'declined', get_permalink($quote_id));
1700 - wp_redirect($redirect_url);
2076 + wp_safe_redirect($redirect_url);
1701 2077 exit;
1702 2078 }
1703 -
2079 +
1704 2080 /**
1705 2081 * Handle AJAX request for bulk quote actions
1706 2082 *
1707 2083 * @since 1.0.0
@@ -1710,36 +2086,44 @@
1710 2086 // Verify nonce
1711 2087 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
1712 2088 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
1713 2089 }
1714 -
1715 - // Check permissions
1716 - if (!current_user_can('manage_options')) {
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')) {
1717 2095 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
1718 2096 }
1719 -
2097 +
1720 2098 $quote_ids = isset($_POST['quote_ids']) ? array_map('intval', $_POST['quote_ids']) : [];
1721 2099 $bulk_action = sanitize_text_field($_POST['bulk_action'] ?? '');
1722 -
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 +
1723 2107 if (empty($quote_ids)) {
1724 2108 wp_send_json_error(['message' => __('No quotes selected.', 'easy-invoice')]);
1725 2109 }
1726 -
2110 +
1727 2111 if (empty($bulk_action)) {
1728 2112 wp_send_json_error(['message' => __('No action selected.', 'easy-invoice')]);
1729 2113 }
1730 -
2114 +
1731 2115 $success_count = 0;
1732 2116 $error_count = 0;
1733 -
2117 +
1734 2118 foreach ($quote_ids as $quote_id) {
1735 2119 $quote = $this->quote_repository->find($quote_id);
1736 -
2120 +
1737 2121 if (!$quote) {
1738 2122 $error_count++;
1739 2123 continue;
1740 2124 }
1741 -
2125 +
1742 2126 try {
1743 2127 switch ($bulk_action) {
1744 2128 case 'delete':
1745 2129 if ($this->quote_repository->delete($quote_id)) {
@@ -1748,9 +2132,9 @@
1748 2132 } else {
1749 2133 $error_count++;
1750 2134 }
1751 2135 break;
1752 -
2136 +
1753 2137 case 'trash':
1754 2138 $old_status = $quote->getStatus();
1755 2139 $quote->setStatus('cancelled'); // Using cancelled as trash status
1756 2140 if ($quote->save()) {
@@ -1759,9 +2143,9 @@
1759 2143 } else {
1760 2144 $error_count++;
1761 2145 }
1762 2146 break;
1763 -
2147 +
1764 2148 case 'draft':
1765 2149 $old_status = $quote->getStatus();
1766 2150 $quote->setStatus('draft');
1767 2151 if ($quote->save()) {
@@ -1770,9 +2154,9 @@
1770 2154 } else {
1771 2155 $error_count++;
1772 2156 }
1773 2157 break;
1774 -
2158 +
1775 2159 case 'restore':
1776 2160 $old_status = $quote->getStatus();
1777 2161 $quote->setStatus('draft');
1778 2162 if ($quote->save()) {
@@ -1781,9 +2165,9 @@
1781 2165 } else {
1782 2166 $error_count++;
1783 2167 }
1784 2168 break;
1785 -
2169 +
1786 2170 default:
1787 2171 $error_count++;
1788 2172 break;
1789 2173 }
@@ -1791,28 +2175,32 @@
1791 2175 $error_count++;
1792 2176 // Error in bulk action
1793 2177 }
1794 2178 }
1795 -
2179 +
1796 2180 if ($error_count > 0) {
1797 2181 wp_send_json_success([
1798 - 'message' => sprintf(__('Processed %d quotes successfully. %d failed.', 'easy-invoice'), $success_count, $error_count),
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),
1799 2184 'toast' => [
1800 2185 'type' => 'warning',
1801 - 'message' => sprintf(__('Processed %d quotes successfully. %d failed.', 'easy-invoice'), $success_count, $error_count)
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)
1802 2188 ]
1803 2189 ]);
1804 2190 } else {
1805 2191 wp_send_json_success([
2192 + /* translators: %d: number processed. */
1806 2193 'message' => sprintf(__('Successfully processed %d quotes.', 'easy-invoice'), $success_count),
1807 2194 'toast' => [
1808 2195 'type' => 'success',
2196 + /* translators: %d: number processed. */
1809 2197 'message' => sprintf(__('Successfully processed %d quotes.', 'easy-invoice'), $success_count)
1810 2198 ]
1811 2199 ]);
1812 2200 }
1813 2201 }
1814 -
2202 +
1815 2203 /**
1816 2204 * Handle AJAX request to trash a quote
1817 2205 *
1818 2206 * @since 1.0.0
@@ -1821,34 +2209,34 @@
1821 2209 // Verify nonce
1822 2210 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
1823 2211 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
1824 2212 }
1825 -
1826 - // Check permissions
1827 - if (!current_user_can('manage_options')) {
2213 +
2214 + // Check permissions — trash is reversible, gated at the create-quote cap.
2215 + if (!easy_invoice_user_can('ei_create_quote')) {
1828 2216 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
1829 2217 }
1830 -
2218 +
1831 2219 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
1832 -
2220 +
1833 2221 if ($quote_id <= 0) {
1834 2222 wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]);
1835 2223 }
1836 -
2224 +
1837 2225 $quote = $this->quote_repository->find($quote_id);
1838 -
2226 +
1839 2227 if (!$quote) {
1840 2228 wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]);
1841 2229 }
1842 -
2230 +
1843 2231 // Set status to cancelled before moving to trash
1844 2232 $old_status = $quote->getStatus();
1845 2233 $quote->setStatus('cancelled');
1846 2234 $quote->save();
1847 -
2235 +
1848 2236 // Move the post to trash status
1849 2237 $result = wp_trash_post($quote_id);
1850 -
2238 +
1851 2239 if ($result) {
1852 2240 $this->quote_log_service->logStatusChange($quote_id, $old_status, 'cancelled');
1853 2241 wp_send_json_success([
1854 2242 'message' => __('Quote moved to trash successfully.', 'easy-invoice'),
@@ -1860,9 +2248,9 @@
1860 2248 } else {
1861 2249 wp_send_json_error(['message' => __('Failed to move quote to trash.', 'easy-invoice')]);
1862 2250 }
1863 2251 }
1864 -
2252 +
1865 2253 /**
1866 2254 * Handle AJAX request to move a quote to draft
1867 2255 *
1868 2256 * @since 1.0.0
@@ -1871,30 +2259,30 @@
1871 2259 // Verify nonce
1872 2260 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
1873 2261 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
1874 2262 }
1875 -
1876 - // Check permissions
1877 - if (!current_user_can('manage_options')) {
2263 +
2264 + // Check permissions — moving to draft is an edit, not a delete.
2265 + if (!easy_invoice_user_can('ei_create_quote')) {
1878 2266 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
1879 2267 }
1880 -
2268 +
1881 2269 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
1882 -
2270 +
1883 2271 if ($quote_id <= 0) {
1884 2272 wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]);
1885 2273 }
1886 -
2274 +
1887 2275 $quote = $this->quote_repository->find($quote_id);
1888 -
2276 +
1889 2277 if (!$quote) {
1890 2278 wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]);
1891 2279 }
1892 -
2280 +
1893 2281 // Set status to draft
1894 2282 $old_status = $quote->getStatus();
1895 2283 $quote->setStatus('draft');
1896 -
2284 +
1897 2285 if ($quote->save()) {
1898 2286 $this->quote_log_service->logStatusChange($quote_id, $old_status, 'draft');
1899 2287 wp_send_json_success([
1900 2288 'message' => __('Quote moved to draft successfully.', 'easy-invoice'),
@@ -1906,9 +2294,9 @@
1906 2294 } else {
1907 2295 wp_send_json_error(['message' => __('Failed to move quote to draft.', 'easy-invoice')]);
1908 2296 }
1909 2297 }
1910 -
2298 +
1911 2299 /**
1912 2300 * Handle AJAX request to restore a trashed quote
1913 2301 *
1914 2302 * @since 1.0.0
@@ -1917,34 +2305,34 @@
1917 2305 // Verify nonce
1918 2306 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
1919 2307 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
1920 2308 }
1921 -
1922 - // Check permissions
1923 - if (!current_user_can('manage_options')) {
2309 +
2310 + // Check permissions — restoring from trash is an edit operation.
2311 + if (!easy_invoice_user_can('ei_create_quote')) {
1924 2312 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
1925 2313 }
1926 -
2314 +
1927 2315 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
1928 -
2316 +
1929 2317 if ($quote_id <= 0) {
1930 2318 wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]);
1931 2319 }
1932 -
2320 +
1933 2321 $quote = $this->quote_repository->find($quote_id);
1934 -
2322 +
1935 2323 if (!$quote) {
1936 2324 wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]);
1937 2325 }
1938 -
2326 +
1939 2327 // Restore the post from trash
1940 2328 $result = wp_untrash_post($quote_id);
1941 -
2329 +
1942 2330 if ($result) {
1943 2331 // After restoring from trash, set the meta status to available
1944 2332 $quote->setStatus('available');
1945 2333 $quote->save();
1946 -
2334 +
1947 2335 $this->quote_log_service->logRestoration($quote_id);
1948 2336 wp_send_json_success([
1949 2337 'message' => __('Quote restored successfully.', 'easy-invoice'),
1950 2338 'toast' => [
@@ -1955,9 +2343,9 @@
1955 2343 } else {
1956 2344 wp_send_json_error(['message' => __('Failed to restore quote.', 'easy-invoice')]);
1957 2345 }
1958 2346 }
1959 -
2347 +
1960 2348 /**
1961 2349 * Handle AJAX request to empty trash
1962 2350 *
1963 2351 * @since 1.0.0
@@ -1967,30 +2355,30 @@
1967 2355 // Verify nonce
1968 2356 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_nonce')) {
1969 2357 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
1970 2358 }
1971 -
1972 - // Check permissions
1973 - if (!current_user_can('manage_options')) {
2359 +
2360 + // Check permissions — emptying trash permanently deletes quotes.
2361 + if (!easy_invoice_user_can('ei_delete_quote')) {
1974 2362 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
1975 2363 }
1976 -
2364 +
1977 2365 // Get all quotes in trash (post_status = 'trash')
1978 2366 global $wpdb;
1979 2367 $quote_ids = $wpdb->get_col($wpdb->prepare(
1980 - "SELECT ID FROM {$wpdb->posts}
1981 - WHERE post_type = %s
2368 + "SELECT ID FROM {$wpdb->posts}
2369 + WHERE post_type = %s
1982 2370 AND post_status = 'trash'",
1983 2371 PostTypes::EASY_INVOICE_QUOTE_POST_TYPE
1984 2372 ));
1985 -
2373 +
1986 2374 if (empty($quote_ids)) {
1987 2375 wp_send_json_error(['message' => __('No quotes found in trash.', 'easy-invoice')]);
1988 2376 }
1989 -
2377 +
1990 2378 $success_count = 0;
1991 2379 $error_count = 0;
1992 -
2380 +
1993 2381 foreach ($quote_ids as $quote_id) {
1994 2382 if (wp_delete_post($quote_id, true)) {
1995 2383 $this->quote_log_service->logDeletion($quote_id);
1996 2384 $success_count++;
@@ -1997,31 +2385,35 @@
1997 2385 } else {
1998 2386 $error_count++;
1999 2387 }
2000 2388 }
2001 -
2389 +
2002 2390 if ($error_count > 0) {
2003 2391 wp_send_json_success([
2004 - 'message' => sprintf(__('Emptied trash: %d quotes deleted successfully, %d failed.', 'easy-invoice'), $success_count, $error_count),
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),
2005 2394 'success_count' => $success_count,
2006 2395 'error_count' => $error_count,
2007 2396 'toast' => [
2008 2397 'type' => 'warning',
2009 - 'message' => sprintf(__('Emptied trash: %d quotes deleted successfully, %d failed.', 'easy-invoice'), $success_count, $error_count)
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)
2010 2400 ]
2011 2401 ]);
2012 2402 } else {
2013 2403 wp_send_json_success([
2404 + /* translators: %d: number processed. */
2014 2405 'message' => sprintf(__('Successfully emptied trash: %d quotes deleted.', 'easy-invoice'), $success_count),
2015 2406 'success_count' => $success_count,
2016 2407 'error_count' => 0,
2017 2408 'toast' => [
2018 2409 'type' => 'success',
2410 + /* translators: %d: number processed. */
2019 2411 'message' => sprintf(__('Successfully emptied trash: %d quotes deleted.', 'easy-invoice'), $success_count)
2020 2412 ]
2021 2413 ]);
2022 2414 }
2023 -
2415 +
2024 2416 } catch (\Exception $e) {
2025 2417 error_log('Error emptying quote trash: ' . $e->getMessage());
2026 2418 wp_send_json_error([
2027 2419 'message' => __('Failed to empty trash.', 'easy-invoice'),
@@ -2028,9 +2420,9 @@
2028 2420 'debug' => $e->getMessage()
2029 2421 ]);
2030 2422 }
2031 2423 }
2032 -
2424 +
2033 2425 /**
2034 2426 * Handle AJAX request to get quote logs
2035 2427 *
2036 2428 * @since 1.0.0
@@ -2039,23 +2431,23 @@
2039 2431 // Verify nonce
2040 2432 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
2041 2433 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
2042 2434 }
2043 -
2044 - // Check permissions
2045 - if (!current_user_can('manage_options')) {
2435 +
2436 + // Check permissions — viewing quote activity log.
2437 + if (!easy_invoice_user_can('ei_view_quotes')) {
2046 2438 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
2047 2439 }
2048 -
2440 +
2049 2441 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
2050 -
2442 +
2051 2443 if ($quote_id <= 0) {
2052 2444 wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]);
2053 2445 }
2054 -
2446 +
2055 2447 try {
2056 2448 $logs = $this->quote_log_service->getLogs($quote_id);
2057 -
2449 +
2058 2450 // Convert QuoteLog objects to arrays for JSON response
2059 2451 $logs_data = [];
2060 2452 foreach ($logs as $log) {
2061 2453 $logs_data[] = [
@@ -2068,14 +2460,14 @@
2068 2460 'additional_data' => $log->getAdditionalData(),
2069 2461 'created_date' => $log->getCreatedDate(),
2070 2462 ];
2071 2463 }
2072 -
2464 +
2073 2465 wp_send_json_success([
2074 2466 'logs' => $logs_data,
2075 2467 'count' => count($logs_data)
2076 2468 ]);
2077 -
2469 +
2078 2470 } catch (\Exception $e) {
2079 2471 wp_send_json_error([
2080 2472 'message' => __('Error retrieving quote logs.', 'easy-invoice'),
2081 2473 'debug' => $e->getMessage()
@@ -2081,9 +2473,9 @@
2081 2473 'debug' => $e->getMessage()
2082 2474 ]);
2083 2475 }
2084 2476 }
2085 -
2477 +
2086 2478 /**
2087 2479 * Format currency amount using QuoteFormatter
2088 2480 *
2089 2481 * @param float $amount The amount to format
@@ -2093,5 +2485,5 @@
2093 2485 private function formatCurrency(float $amount, $quote = null): string {
2094 2486 $formatter = new \EasyInvoice\Helpers\QuoteFormatter($quote);
2095 2487 return $formatter->format($amount);
2096 2488 }
2097 -}
2489 +}