PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.0
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.0
2.4.0 2.4.1 2.3.8 2.3.7 2.3.6 2.3.5 2.3.4 2.3.3 2.3.2 2.3.1 2.2.0 2.1.21 2.1.20 2.1.19 2.1.18 2.1.0 2.1.1 2.1.10 2.1.11 2.1.12 2.1.13 2.1.14 2.1.15 2.1.16 2.1.2 All 57 releases
← All changes | includes/Controllers/QuoteController.php +568 -234 2.1.112.4.0 View file →
@@ -79,16 +79,23 @@
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']);
@@ -219,8 +226,9 @@
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;
@@ -267,8 +275,55 @@
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
@@ -309,11 +364,12 @@
309 364 ]
310 365 ]);
311 366 $meta_search = new \WP_Query($meta_search_args);
312 367
313 - if ($meta_search->have_posts()) {
314 - $search_ids = array_merge($search_ids, wp_list_pluck($meta_search->posts, 'ID'));
315 - }
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));
316 372
317 373 $search_ids = array_unique($search_ids);
318 374
319 375 if (!empty($search_ids)) {
@@ -380,13 +436,36 @@
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,
@@ -429,9 +508,12 @@
429 508 if ($quote_id > 0) {
430 509 $quote = $this->quote_repository->find($quote_id);
431 510 }
432 511
433 - $clients = $this->client_repository->all();
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 = [];
434 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);
@@ -455,14 +537,14 @@
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);
@@ -485,9 +567,9 @@
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;
@@ -523,9 +605,9 @@
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;
@@ -554,9 +636,9 @@
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'] ?? '');
@@ -575,13 +657,25 @@
575 657 if (!$template_file) {
576 658 wp_send_json_error(['message' => __('Template not found.', 'easy-invoice')]);
577 659 }
578 660
579 - // Load quote if provided
580 - $quote = null;
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();
581 670 if ($quote_id > 0) {
582 - $quote = $this->quote_repository->find($quote_id);
671 + $loaded = $this->quote_repository->find($quote_id);
672 + if ($loaded) {
673 + $quote = $loaded;
674 + }
583 675 }
676 + // Unsaved edits from the builder take precedence over the stored values.
677 + $quote = \EasyInvoice\Helpers\PreviewOverlay::apply($quote, isset($_POST['form_data']) ? (string) wp_unslash($_POST['form_data']) : '', 'quote');
584 678
585 679 // Start output buffering to capture template HTML
586 680 ob_start();
587 681
@@ -703,9 +797,9 @@
703 797 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
704 798 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
705 799 }
706 800 // Check permissions
707 - if (!current_user_can('manage_options')) {
801 + if (!easy_invoice_user_can('ei_create_quote')) {
708 802 wp_send_json_error(['message' => __('Insufficient permissions.', 'easy-invoice')]);
709 803 }
710 804 $title = isset($_POST['title']) ? sanitize_text_field($_POST['title']) : '';
711 805 if (empty($title)) {
@@ -736,10 +830,10 @@
736 830 $data = [
737 831 'title' => $title,
738 832 'status' => 'draft',
739 833 'number' => $quote_number, // Use the generated unique number
740 - 'issue_date' => date('Y-m-d'),
741 - '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')),
742 836 'items' => [],
743 837 'notes' => '', // Ensure notes is never null
744 838 'terms' => $quote_terms, // Use global terms setting
745 839 'footer_text' => $quote_footer, // Use global footer setting
@@ -770,9 +864,9 @@
770 864 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
771 865 }
772 866
773 867 // Check permissions
774 - if (!current_user_can('manage_options')) {
868 + if (!easy_invoice_user_can('ei_create_quote')) {
775 869 wp_send_json_error(['message' => __('Insufficient permissions.', 'easy-invoice')]);
776 870 }
777 871
778 872 // Get global quote settings
@@ -788,10 +882,10 @@
788 882 // Create a new quote object for the form
789 883 $quote_number_service = function_exists('easy_invoice_get_quote_number_service') ? easy_invoice_get_quote_number_service() : null;
790 884 $quote_data = array(
791 885 'number' => $quote_number_service ? $quote_number_service->getNextNumber() : 'QT-1',
792 - 'date' => date('Y-m-d'),
793 - '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')),
794 888 'client_id' => 0,
795 889 'client_name' => '',
796 890 'client_email' => '',
797 891 'client_phone' => '',
@@ -875,9 +969,9 @@
875 969 $quote->setItems([]);
876 970
877 971 // Set variables needed by the form template
878 972 $quote_id = 0;
879 - $clients = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository()->all();
973 + $clients = [];
880 974 $quote_form_manager = new \EasyInvoice\Forms\Quote\QuoteFormManager();
881 975 $quote_items_json = json_encode([]);
882 976 $admin_nonce = wp_create_nonce('easy_invoice_admin_nonce');
883 977 $quote_field_config = $quote_form_manager->getFieldConfigForJavaScript();
@@ -893,48 +987,176 @@
893 987
894 988 wp_send_json_success(['html' => $html]);
895 989 }
896 990
991 +
897 992 /**
898 - * 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.
899 1001 *
900 - * @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.
901 1016 */
902 - public function handleSearchClients(): void {
903 - // Verify nonce
904 - if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_nonce')) {
905 - 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 '';
906 1020 }
1021 + $token = (string) get_post_meta($quote_id, '_easy_invoice_quote_access_token', true);
1022 + if ($token === '' || strlen($token) < 32) {
1023 + try {
1024 + $token = bin2hex(random_bytes(16));
1025 + } catch (\Throwable $e) {
1026 + // Fallback for systems without CSPRNG. wp_generate_password uses
1027 + // random_bytes internally on modern PHP — same entropy source.
1028 + $token = wp_generate_password(32, false, false);
1029 + }
1030 + update_post_meta($quote_id, '_easy_invoice_quote_access_token', $token);
1031 + }
1032 + return $token;
1033 + }
907 1034
908 - // Check permissions
909 - if (!current_user_can('manage_options')) {
910 - wp_send_json_error(['message' => __('Insufficient permissions.', 'easy-invoice')]);
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 '';
911 1049 }
1050 + $token = (string) get_post_meta($quote_id, '_easy_invoice_quote_access_token', true);
1051 + return strlen($token) >= 32 ? $token : '';
1052 + }
912 1053
913 - $query = sanitize_text_field($_POST['query'] ?? '');
1054 + /**
1055 + * Constant-time comparison helper for the access token.
1056 + */
1057 + private static function quoteTokenFromRequest(): string {
1058 + $token = '';
1059 + if (isset($_POST['access_token'])) {
1060 + $token = sanitize_text_field(wp_unslash($_POST['access_token']));
1061 + } elseif (isset($_GET['qk'])) {
1062 + $token = sanitize_text_field(wp_unslash($_GET['qk']));
1063 + }
1064 + /** This filter is documented in includes/Controllers/InvoiceController.php */
1065 + return (string) apply_filters('easy_invoice_presented_access_token', $token, 'quote');
1066 + }
914 1067
915 - // If query is empty, get all clients
916 - if (empty($query)) {
917 - $clients = $this->client_repository->all();
918 - } else {
919 - // Search clients by name, email, or company
920 - $clients = $this->client_repository->search($query);
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'));
921 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 + }
922 1118
923 - $results = [];
924 - foreach ($clients as $client) {
925 - $results[] = [
926 - 'id' => $client->getId(),
927 - 'name' => $client->getBusinessClientName() ?: ($client->getFirstName() . ' ' . $client->getLastName()),
928 - 'email' => $client->getEmail(),
929 - 'company' => $client->getBusinessClientName(),
930 - 'phone' => $client->getExtraInfo(),
931 - 'website' => $client->getWebsite(),
932 - 'address' => $client->getAddress()
933 - ];
1119 + public static function canActOnQuote(int $quote_id, $quote = null): bool {
1120 + if ($quote_id <= 0) {
1121 + return false;
934 1122 }
935 1123
936 - wp_send_json_success($results);
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;
937 1159 }
938 1160
939 1161 /**
940 1162 * Handle AJAX request to accept a quote
@@ -941,13 +1163,8 @@
941 1163 *
942 1164 * @since 1.0.0
943 1165 */
944 1166 public function handleAcceptQuote(): void {
945 - // Verify nonce
946 - if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_quote_action')) {
947 - wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
948 - }
949 -
950 1167 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
951 1168
952 1169 if ($quote_id <= 0) {
953 1170 wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]);
@@ -952,35 +1169,41 @@
952 1169 if ($quote_id <= 0) {
953 1170 wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]);
954 1171 }
955 1172
956 - // Get the quote
957 - $quote = $this->quote_repository->find($quote_id);
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 + }
958 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 +
959 1185 if (!$quote) {
960 1186 wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]);
961 1187 }
962 1188
963 - // Check if user has permission to accept this quote
964 - $current_user = wp_get_current_user();
965 - $is_admin = current_user_can('manage_options');
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 + }
966 1198
967 - $restrict = get_option('easy_invoice_pro_restrict_quote_to_client', 'no');
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 + }
968 1203
969 - if (!$is_admin && $restrict === 'yes') {
970 - // For non-admins, check if they are the client
971 - if ($quote->getClientId()) {
972 - $client_repository = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository();
973 - $client = $client_repository->find($quote->getClientId());
1204 + $current_user = wp_get_current_user();
974 1205
975 - if (!$client || $client->getEmail() !== $current_user->user_email) {
976 - wp_send_json_error(['message' => __('You do not have permission to accept this quote.', 'easy-invoice')]);
977 - }
978 - } else {
979 - wp_send_json_error(['message' => __('You do not have permission to accept this quote.', 'easy-invoice')]);
980 - }
981 - }
982 -
983 1206 // Get global accept action setting
984 1207 $settings_controller = new \EasyInvoice\Controllers\SettingsController();
985 1208 $accept_action = $settings_controller::getQuoteAcceptAction();
986 1209
@@ -985,9 +1208,9 @@
985 1208 $accept_action = $settings_controller::getQuoteAcceptAction();
986 1209
987 1210 // Update quote status to accepted
988 1211 $quote->setStatus('accepted');
989 - $quote->setAcceptedDate(date('Y-m-d H:i:s'));
1212 + $quote->setAcceptedDate(gmdate('Y-m-d H:i:s'));
990 1213 $quote->setAcceptedBy($current_user->ID);
991 1214
992 1215 // Save the quote
993 1216 $saved = $quote->save();
@@ -1001,8 +1224,33 @@
1001 1224 'accept_action' => $accept_action,
1002 1225 'user_type' => $is_admin ? 'admin' : 'client'
1003 1226 ]);
1004 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 +
1005 1253 // Perform the configured accept action
1006 1254 $invoice_id = null;
1007 1255 $action_message = '';
1008 1256
@@ -1071,10 +1319,10 @@
1071 1319 if ($invoice_id) {
1072 1320 // Always use WordPress permalink
1073 1321 $invoice_url = get_permalink($invoice_id);
1074 1322 // If Pro and secure link available, use secure link
1075 - if (class_exists('\EasyInvoicePro\Controllers\PermalinkController')) {
1076 - $secure_url = \EasyInvoicePro\Controllers\PermalinkController::getInvoiceSecureLinkUrl($invoice_id);
1323 + if (class_exists('\EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController')) {
1324 + $secure_url = \EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController::getInvoiceSecureLinkUrl($invoice_id);
1077 1325 if ($secure_url) {
1078 1326 $invoice_url = $secure_url;
1079 1327 }
1080 1328 }
@@ -1098,8 +1346,82 @@
1098 1346 * @param \EasyInvoice\Models\Quote $quote The quote to convert
1099 1347 * @param string $status The status for the new invoice ('draft' or 'available')
1100 1348 * @return int|null The invoice ID if successful, null otherwise
1101 1349 */
1350 + /**
1351 + * "Convert to invoice" on the quote row — for the quote the client accepted
1352 + * by phone or in person, which the public Accept button never sees.
1353 + *
1354 + * @param array $actions Row actions.
1355 + * @param object $quote Quote model.
1356 + * @return array
1357 + */
1358 + public function addConvertRowAction($actions, $quote): array {
1359 + $actions = is_array($actions) ? $actions : [];
1360 + if (!easy_invoice_user_can('ei_create_invoice') || !is_callable([$quote, 'getId'])) {
1361 + return $actions;
1362 + }
1363 + $converted = (int) get_post_meta((int) $quote->getId(), '_easy_invoice_quote_converted_invoice_id', true);
1364 + if ($converted > 0 && get_post($converted)) {
1365 + $actions['convert'] = sprintf(
1366 + '<a href="%s" class="text-emerald-700 font-semibold" title="%s">%s</a>',
1367 + esc_url(admin_url('admin.php?page=easy-invoice-builder&invoice_id=' . $converted)),
1368 + esc_attr__('Open the invoice made from this quote', 'easy-invoice'),
1369 + esc_html__('Invoice', 'easy-invoice')
1370 + );
1371 + return $actions;
1372 + }
1373 + $actions['convert'] = sprintf(
1374 + '<a href="#" class="convert-quote text-indigo-600 font-semibold" data-quote-id="%d" data-quote-number="%s">%s</a>',
1375 + (int) $quote->getId(),
1376 + esc_attr((string) $quote->getNumber()),
1377 + esc_html__('Convert to invoice', 'easy-invoice')
1378 + );
1379 + return $actions;
1380 + }
1381 +
1382 + /**
1383 + * AJAX: make a draft invoice from a quote and mark the quote accepted.
1384 + */
1385 + public function handleConvertQuote(): void {
1386 + if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'easy_invoice_admin_nonce')) {
1387 + wp_send_json_error(['message' => __('Security check failed. Please reload the page and try again.', 'easy-invoice')]);
1388 + }
1389 + if (!easy_invoice_user_can('ei_create_invoice')) {
1390 + wp_send_json_error(['message' => __('You do not have permission to create invoices.', 'easy-invoice')]);
1391 + }
1392 + $quote_id = isset($_POST['quote_id']) ? absint($_POST['quote_id']) : 0;
1393 + $quote = $quote_id > 0 ? $this->quote_repository->find($quote_id) : null;
1394 + if (!$quote) {
1395 + wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]);
1396 + }
1397 + $existing = (int) get_post_meta($quote_id, '_easy_invoice_quote_converted_invoice_id', true);
1398 + if ($existing > 0 && get_post($existing)) {
1399 + wp_send_json_success(['invoice_id' => $existing, 'already' => true, 'message' => __('This quote already has an invoice.', 'easy-invoice')]);
1400 + }
1401 + $invoice_id = $this->convertQuoteToInvoice($quote, 'draft');
1402 + if (!$invoice_id) {
1403 + wp_send_json_error(['message' => __('The invoice could not be created.', 'easy-invoice')]);
1404 + }
1405 + update_post_meta($quote_id, '_easy_invoice_quote_converted_invoice_id', $invoice_id);
1406 + update_post_meta($invoice_id, '_easy_invoice_converted_from_quote', $quote_id);
1407 + if (!in_array((string) $quote->getStatus(), ['accepted', 'declined', 'cancelled'], true)) {
1408 + update_post_meta($quote_id, '_easy_invoice_quote_status', 'accepted');
1409 + }
1410 + /**
1411 + * Fires after an administrator converts a quote into an invoice by hand.
1412 + *
1413 + * @param int $quote_id Quote.
1414 + * @param int $invoice_id New draft invoice.
1415 + */
1416 + do_action('easy_invoice_quote_converted_manually', $quote_id, $invoice_id);
1417 + wp_send_json_success([
1418 + 'invoice_id' => $invoice_id,
1419 + 'message' => __('Draft invoice created from the quote.', 'easy-invoice'),
1420 + 'redirect' => admin_url('admin.php?page=easy-invoice-builder&invoice_id=' . $invoice_id),
1421 + ]);
1422 + }
1423 +
1102 1424 private function convertQuoteToInvoice($quote, $status = 'draft'): ?int {
1103 1425 try {
1104 1426 // Get invoice repository
1105 1427 $invoice_repository = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository();
@@ -1108,10 +1430,10 @@
1108 1430 $invoice_data = [
1109 1431 'title' => $quote->getTitle() ?: 'Invoice from Quote ' . $quote->getNumber(),
1110 1432 'number' => $this->generateInvoiceNumber(),
1111 1433 'status' => $status,
1112 - 'issue_date' => date('Y-m-d'),
1113 - 'due_date' => date('Y-m-d', strtotime('+30 days')),
1434 + 'issue_date' => current_time('Y-m-d'),
1435 + 'due_date' => wp_date('Y-m-d', strtotime('+30 days')),
1114 1436 'client_id' => $quote->getClientId(),
1115 1437 'customer_name' => $quote->getCustomerName(),
1116 1438 'customer_email' => $quote->getCustomerEmail(),
1117 1439 'customer_address' => $quote->getCustomerAddress(),
@@ -1126,8 +1448,9 @@
1126 1448 'payment_gateways' => [], // Invoice-specific field, leave empty
1127 1449 'template' => $quote->getTemplate(),
1128 1450 'subtotal' => $quote->getSubtotal(),
1129 1451 'tax_rate' => $quote->getTaxRate(),
1452 + 'tax_enabled' => $quote->getTaxEnabled() ?: (get_option('easy_invoice_tax_enabled', 'no') === 'yes' ? 'yes' : 'no'),
1130 1453 'tax_amount' => $quote->getTaxAmount(),
1131 1454 'discount_type' => $quote->getDiscountType(),
1132 1455 'discount_value' => $quote->getDiscountValue(),
1133 1456 'discount_amount' => $quote->getDiscountAmount(),
@@ -1139,8 +1462,17 @@
1139 1462 'prices_include_tax' => $quote->getPricesIncludeTax(),
1140 1463 'custom_fields' => $quote->getCustomFields(), // Transfer custom fields
1141 1464 ];
1142 1465
1466 + /**
1467 + * Filter the data an invoice is created from when a quote is
1468 + * converted, so addons can carry their own quote fields across.
1469 + *
1470 + * @param array $invoice_data
1471 + * @param Quote $quote
1472 + */
1473 + $invoice_data = apply_filters('easy_invoice_quote_to_invoice_data', $invoice_data, $quote);
1474 +
1143 1475 // Create the invoice
1144 1476 $invoice = $invoice_repository->create($invoice_data);
1145 1477
1146 1478 if ($invoice) {
@@ -1145,17 +1477,30 @@
1145 1477
1146 1478 if ($invoice) {
1147 1479 // Store the quote ID in the invoice's meta for tracking
1148 1480 update_post_meta($invoice->getId(), '_converted_from_quote', $quote->getId());
1481 + update_post_meta($invoice->getId(), '_easy_invoice_converted_from_quote', $quote->getId());
1149 1482
1150 - // Update quote to reference the created invoice
1483 + // Update quote to reference the created invoice — the same key
1484 + // the quote list and "convert" guard read, whichever path
1485 + // (manual convert, accept-and-convert) produced the invoice.
1486 + update_post_meta($quote->getId(), '_easy_invoice_quote_converted_invoice_id', (int) $invoice->getId());
1151 1487 $quote->setCustomField('converted_invoice_id', $invoice->getId());
1152 1488 $quote->save();
1153 1489
1154 1490 // Ensure secure link is generated for the new invoice (Pro version)
1155 - if (class_exists('\EasyInvoicePro\Controllers\PermalinkController')) {
1156 - // Trigger the save_post hook to generate secure link
1157 - do_action('save_post_easy_invoice', $invoice->getId(), get_post($invoice->getId()));
1491 + if (class_exists('\EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController')) {
1492 + // Trigger the save_post hook to generate secure link.
1493 + //
1494 + // Core's save_post_{post_type} passes three arguments — $post_id,
1495 + // $post and $update — and callbacks are written against that
1496 + // signature. Firing it with two put a client-facing fatal on the
1497 + // quote-acceptance path: Team Roles' audit logger declares all three
1498 + // as required, so accepting a quote raised ArgumentCountError and
1499 + // the customer got "There has been a critical error on this website"
1500 + // after the invoice had already been created. Passing `true` for
1501 + // $update because the invoice row exists by this point.
1502 + do_action('save_post_easy_invoice', $invoice->getId(), get_post($invoice->getId()), true);
1158 1503 }
1159 1504
1160 1505 return $invoice->getId();
1161 1506 }
@@ -1183,10 +1528,10 @@
1183 1528 $invoice_data = [
1184 1529 'title' => 'Invoice from Quote ' . $quote->getNumber(),
1185 1530 'number' => $this->generateInvoiceNumber(),
1186 1531 'status' => $status,
1187 - 'issue_date' => date('Y-m-d'),
1188 - 'due_date' => date('Y-m-d', strtotime('+30 days')),
1532 + 'issue_date' => current_time('Y-m-d'),
1533 + 'due_date' => wp_date('Y-m-d', strtotime('+30 days')),
1189 1534 'client_id' => $quote->getClientId(),
1190 1535 'customer_name' => $quote->getCustomerName(),
1191 1536 'customer_email' => $quote->getCustomerEmail(),
1192 1537 'customer_address' => $quote->getCustomerAddress(),
@@ -1201,8 +1546,9 @@
1201 1546 'payment_gateways' => [], // Invoice-specific field, leave empty
1202 1547 'template' => $quote->getTemplate(),
1203 1548 'subtotal' => $quote->getSubtotal(),
1204 1549 'tax_rate' => $quote->getTaxRate(),
1550 + 'tax_enabled' => $quote->getTaxEnabled() ?: (get_option('easy_invoice_tax_enabled', 'no') === 'yes' ? 'yes' : 'no'),
1205 1551 'tax_amount' => $quote->getTaxAmount(),
1206 1552 'discount_type' => $quote->getDiscountType(),
1207 1553 'discount_value' => $quote->getDiscountValue(),
1208 1554 'discount_amount' => $quote->getDiscountAmount(),
@@ -1214,8 +1560,17 @@
1214 1560 'prices_include_tax' => $quote->getPricesIncludeTax(),
1215 1561 'custom_fields' => $quote->getCustomFields(), // Transfer custom fields
1216 1562 ];
1217 1563
1564 + /**
1565 + * Filter the data an invoice is created from when a quote is
1566 + * converted, so addons can carry their own quote fields across.
1567 + *
1568 + * @param array $invoice_data
1569 + * @param Quote $quote
1570 + */
1571 + $invoice_data = apply_filters('easy_invoice_quote_to_invoice_data', $invoice_data, $quote);
1572 +
1218 1573 // Create the invoice
1219 1574 $invoice = $invoice_repository->create($invoice_data);
1220 1575
1221 1576 if ($invoice) {
@@ -1223,11 +1578,20 @@
1223 1578 $quote->setCustomField('related_invoice_id', $invoice->getId());
1224 1579 $quote->save();
1225 1580
1226 1581 // Ensure secure link is generated for the new invoice (Pro version)
1227 - if (class_exists('\EasyInvoicePro\Controllers\PermalinkController')) {
1228 - // Trigger the save_post hook to generate secure link
1229 - do_action('save_post_easy_invoice', $invoice->getId(), get_post($invoice->getId()));
1582 + if (class_exists('\EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController')) {
1583 + // Trigger the save_post hook to generate secure link.
1584 + //
1585 + // Core's save_post_{post_type} passes three arguments — $post_id,
1586 + // $post and $update — and callbacks are written against that
1587 + // signature. Firing it with two put a client-facing fatal on the
1588 + // quote-acceptance path: Team Roles' audit logger declares all three
1589 + // as required, so accepting a quote raised ArgumentCountError and
1590 + // the customer got "There has been a critical error on this website"
1591 + // after the invoice had already been created. Passing `true` for
1592 + // $update because the invoice row exists by this point.
1593 + do_action('save_post_easy_invoice', $invoice->getId(), get_post($invoice->getId()), true);
1230 1594 }
1231 1595
1232 1596 return $invoice->getId();
1233 1597 }
@@ -1278,16 +1642,26 @@
1278 1642 $invoice_items = [];
1279 1643
1280 1644 foreach ($quote_items as $quote_item) {
1281 1645 if (is_object($quote_item) && method_exists($quote_item, 'toArray')) {
1282 - // Convert QuoteItem object to InvoiceItem array
1646 + // A saved quote stores its lines as title/total, an invoice as
1647 + // name/amount; read through the model, which knows both, or
1648 + // the converted invoice has nameless lines that add up to 0.
1283 1649 $item_data = $quote_item->toArray();
1650 + $name = (string) (is_callable([$quote_item, 'getName']) ? $quote_item->getName() : '');
1651 + if ('' === $name) {
1652 + $name = (string) ($item_data['name'] ?? $item_data['title'] ?? '');
1653 + }
1654 + $amount = $item_data['amount'] ?? $item_data['total'] ?? null;
1655 + if (null === $amount || '' === $amount) {
1656 + $amount = is_callable([$quote_item, 'getAmount']) ? $quote_item->getAmount() : (float) ($item_data['quantity'] ?? 0) * (float) ($item_data['price'] ?? 0);
1657 + }
1284 1658 $invoice_items[] = [
1285 - 'name' => $item_data['name'] ?? '',
1659 + 'name' => $name,
1286 1660 'description' => $item_data['description'] ?? '',
1287 1661 'quantity' => $item_data['quantity'] ?? 0,
1288 1662 'price' => $item_data['price'] ?? 0,
1289 - 'amount' => $item_data['amount'] ?? 0,
1663 + 'amount' => $amount,
1290 1664 'taxable' => $item_data['taxable'] ?? true,
1291 1665 // Map adjust_percentage to a similar field if needed
1292 1666 'adjust_percentage' => $item_data['adjust_percentage'] ?? 0,
1293 1667 ];
@@ -1369,13 +1743,8 @@
1369 1743 *
1370 1744 * @since 1.0.0
1371 1745 */
1372 1746 public function handleDeclineQuote(): void {
1373 - // Verify nonce
1374 - if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_quote_action')) {
1375 - wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
1376 - }
1377 -
1378 1747 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
1379 1748 $decline_reason = isset($_POST['decline_reason']) ? sanitize_textarea_field($_POST['decline_reason']) : '';
1380 1749
1381 1750 if ($quote_id <= 0) {
@@ -1381,11 +1750,19 @@
1381 1750 if ($quote_id <= 0) {
1382 1751 wp_send_json_error(['message' => __('Invalid quote ID.', 'easy-invoice')]);
1383 1752 }
1384 1753
1385 - // Get the quote
1386 - $quote = $this->quote_repository->find($quote_id);
1754 + if (!wp_verify_nonce($_POST['nonce'] ?? '', $this->quoteAcceptDeclineNonceAction($quote_id))) {
1755 + wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
1756 + }
1387 1757
1758 + $is_admin = current_user_can('manage_options');
1759 + if ($is_admin) {
1760 + $quote = $this->quote_repository->find($quote_id);
1761 + } else {
1762 + $quote = $this->quote_repository->findPublished($quote_id);
1763 + }
1764 +
1388 1765 if (!$quote) {
1389 1766 wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]);
1390 1767 }
1391 1768
@@ -1394,31 +1771,25 @@
1394 1771 if ($settings_controller::isDeclineReasonRequired() && empty(trim($decline_reason))) {
1395 1772 wp_send_json_error(['message' => __('Reason for declining is required.', 'easy-invoice')]);
1396 1773 }
1397 1774
1398 - // Check if user has permission to decline this quote
1399 - $current_user = wp_get_current_user();
1400 - $is_admin = current_user_can('manage_options');
1775 + // SECURITY (CVE-2026-9021): unconditional authorisation — see
1776 + // handleAcceptQuote for the full rationale. Same three paths:
1777 + // admin / valid access token / authenticated bound client.
1778 + if (!self::canActOnQuote($quote_id, $quote)) {
1779 + wp_send_json_error(['message' => __('You do not have permission to decline this quote.', 'easy-invoice')]);
1780 + }
1401 1781
1402 - $restrict = get_option('easy_invoice_pro_restrict_quote_to_client', 'no');
1782 + $ei_open = self::openForDecision($quote);
1783 + if (is_wp_error($ei_open)) {
1784 + wp_send_json_error(['message' => $ei_open->get_error_message()]);
1785 + }
1403 1786
1404 - if (!$is_admin && $restrict === 'yes') {
1405 - // For non-admins, check if they are the client
1406 - if ($quote->getClientId()) {
1407 - $client_repository = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository();
1408 - $client = $client_repository->find($quote->getClientId());
1787 + $current_user = wp_get_current_user();
1409 1788
1410 - if (!$client || $client->getEmail() !== $current_user->user_email) {
1411 - wp_send_json_error(['message' => __('You do not have permission to decline this quote.', 'easy-invoice')]);
1412 - }
1413 - } else {
1414 - wp_send_json_error(['message' => __('You do not have permission to decline this quote.', 'easy-invoice')]);
1415 - }
1416 - }
1417 -
1418 1789 // Update quote status to declined
1419 1790 $quote->setStatus('declined');
1420 - $quote->setDeclinedDate(date('Y-m-d H:i:s'));
1791 + $quote->setDeclinedDate(gmdate('Y-m-d H:i:s'));
1421 1792 $quote->setDeclinedBy($current_user->ID);
1422 1793
1423 1794 // Save decline reason if provided
1424 1795 if (!empty($decline_reason)) {
@@ -1472,57 +1843,9 @@
1472 1843 $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
1473 1844 $email_manager->sendAdminQuoteNotification($quote, 'declined');
1474 1845 }
1475 1846
1476 - /**
1477 - * Handle AJAX request to update existing quotes with missing data
1478 - *
1479 - * @since 1.0.0
1480 - */
1481 - public function handleUpdateExistingQuotes(): void {
1482 - // Verify nonce - match the nonce being sent from JavaScript
1483 - if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
1484 - wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
1485 - }
1486 1847
1487 - // Check permissions
1488 - if (!current_user_can('manage_options')) {
1489 - wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
1490 - }
1491 -
1492 - $updated_count = 0;
1493 - $quotes = $this->quote_repository->findAll();
1494 -
1495 - foreach ($quotes as $quote) {
1496 - $post = get_post($quote->getId());
1497 - if ($post && empty($post->post_name)) {
1498 - // Generate a proper slug for this quote
1499 - $post_title = $quote->getTitle() ?: $quote->getNumber() ?: 'Untitled Quote';
1500 - $post_name = sanitize_title($post_title);
1501 -
1502 - // Ensure uniqueness
1503 - $original_slug = $post_name;
1504 - $counter = 1;
1505 - while (get_page_by_path($post_name, OBJECT, \EasyInvoice\Constants\PostTypes::EASY_INVOICE_QUOTE_POST_TYPE)) {
1506 - $post_name = $original_slug . '-' . $counter;
1507 - $counter++;
1508 - }
1509 -
1510 - // Update the post with the new slug
1511 - wp_update_post([
1512 - 'ID' => $quote->getId(),
1513 - 'post_name' => $post_name
1514 - ]);
1515 -
1516 - $updated_count++;
1517 - }
1518 - }
1519 -
1520 - wp_send_json_success([
1521 - 'message' => sprintf(__('Updated %d quotes with proper URLs.', 'easy-invoice'), $updated_count)
1522 - ]);
1523 - }
1524 -
1525 1848 /**
1526 1849 * Handle AJAX request to duplicate a quote
1527 1850 *
1528 1851 * @since 1.0.0
@@ -1533,9 +1856,9 @@
1533 1856 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
1534 1857 }
1535 1858
1536 1859 // Check permissions
1537 - if (!current_user_can('manage_options')) {
1860 + if (!easy_invoice_user_can('ei_create_quote')) {
1538 1861 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
1539 1862 }
1540 1863
1541 1864 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
@@ -1564,10 +1887,10 @@
1564 1887 $duplicate_data = [
1565 1888 'title' => $quote->getTitle() . ' (Copy)',
1566 1889 'status' => 'draft',
1567 1890 'number' => $this->generateInvoiceNumber(), // Use invoice number service for consistency
1568 - 'issue_date' => date('Y-m-d'),
1569 - 'expiry_date' => date('Y-m-d', strtotime('+30 days')),
1891 + 'issue_date' => current_time('Y-m-d'),
1892 + 'expiry_date' => wp_date('Y-m-d', strtotime('+30 days')),
1570 1893 'items' => $this->convertQuoteItemsToInvoiceItems($quote->getItems()), // Use invoice item conversion
1571 1894 'notes' => $quote->getNotes(),
1572 1895 'description' => $quote->getDescription(),
1573 1896 'terms' => $quote_terms,
@@ -1626,48 +1949,45 @@
1626 1949 *
1627 1950 * @since 1.0.0
1628 1951 */
1629 1952 private function handleAcceptQuoteForm(): void {
1630 - // Verify nonce
1631 - if (!wp_verify_nonce($_POST['quote_nonce'] ?? '', 'easy_invoice_quote_action')) {
1632 - wp_die(__('Security check failed.', 'easy-invoice'));
1633 - }
1634 -
1635 1953 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
1636 1954
1637 1955 if ($quote_id <= 0) {
1638 - wp_die(__('Invalid quote ID.', 'easy-invoice'));
1956 + wp_die(esc_html__('Invalid quote ID.', 'easy-invoice'));
1639 1957 }
1640 1958
1641 - // Get the quote
1642 - $quote = $this->quote_repository->find($quote_id);
1643 -
1644 - if (!$quote) {
1645 - wp_die(__('Quote not found.', 'easy-invoice'));
1959 + if (!wp_verify_nonce($_POST['quote_nonce'] ?? '', $this->quoteAcceptDeclineNonceAction($quote_id))) {
1960 + wp_die(esc_html__('Security check failed.', 'easy-invoice'));
1646 1961 }
1647 1962
1648 - // Check if user has permission to accept this quote
1649 1963 $current_user = wp_get_current_user();
1650 1964 $is_admin = current_user_can('manage_options');
1651 1965
1652 - $restrict = get_option('easy_invoice_pro_restrict_quote_to_client', 'no');
1966 + if ($is_admin) {
1967 + $quote = $this->quote_repository->find($quote_id);
1968 + } else {
1969 + $quote = $this->quote_repository->findPublished($quote_id);
1970 + }
1653 1971
1654 - if (!$is_admin && $restrict === 'yes') { // For non-admins, check if they are the client
1655 - if ($quote->getClientId()) {
1656 - $client_repository = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository();
1657 - $client = $client_repository->find($quote->getClientId());
1972 + if (!$quote) {
1973 + wp_die(esc_html__('Quote not found.', 'easy-invoice'));
1974 + }
1658 1975
1659 - if (!$client || $client->getEmail() !== $current_user->user_email) {
1660 - wp_die(__('You do not have permission to accept this quote.', 'easy-invoice'));
1661 - }
1662 - } else {
1663 - wp_die(__('You do not have permission to accept this quote.', 'easy-invoice'));
1664 - }
1976 + // SECURITY (CVE-2026-9021): unconditional authorisation. See
1977 + // handleAcceptQuote (AJAX path) for full rationale.
1978 + if (!self::canActOnQuote($quote_id, $quote)) {
1979 + wp_die(esc_html__('You do not have permission to accept this quote.', 'easy-invoice'));
1665 1980 }
1666 1981
1982 + $ei_open = self::openForDecision($quote);
1983 + if (is_wp_error($ei_open)) {
1984 + wp_die(esc_html($ei_open->get_error_message()));
1985 + }
1986 +
1667 1987 // Update quote status to accepted
1668 1988 $quote->setStatus('accepted');
1669 - $quote->setAcceptedDate(date('Y-m-d H:i:s'));
1989 + $quote->setAcceptedDate(gmdate('Y-m-d H:i:s'));
1670 1990 $quote->setAcceptedBy($current_user->ID);
1671 1991
1672 1992 // Save the quote
1673 1993 $saved = $quote->save();
@@ -1672,9 +1992,9 @@
1672 1992 // Save the quote
1673 1993 $saved = $quote->save();
1674 1994
1675 1995 if (!$saved) {
1676 - wp_die(__('Failed to accept quote.', 'easy-invoice'));
1996 + wp_die(esc_html__('Failed to accept quote.', 'easy-invoice'));
1677 1997 }
1678 1998
1679 1999 // Send notification email to admin
1680 2000 if (!$is_admin) {
@@ -1682,9 +2002,9 @@
1682 2002 }
1683 2003
1684 2004 // Redirect back to the quote page with success message
1685 2005 $redirect_url = add_query_arg('action', 'accepted', get_permalink($quote_id));
1686 - wp_redirect($redirect_url);
2006 + wp_safe_redirect($redirect_url);
1687 2007 exit;
1688 2008 }
1689 2009
1690 2010 /**
@@ -1692,47 +2012,45 @@
1692 2012 *
1693 2013 * @since 1.0.0
1694 2014 */
1695 2015 private function handleDeclineQuoteForm(): void {
1696 - // Verify nonce
1697 - if (!wp_verify_nonce($_POST['quote_nonce'] ?? '', 'easy_invoice_quote_action')) {
1698 - wp_die(__('Security check failed.', 'easy-invoice'));
1699 - }
1700 -
1701 2016 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
1702 2017
1703 2018 if ($quote_id <= 0) {
1704 - wp_die(__('Invalid quote ID.', 'easy-invoice'));
2019 + wp_die(esc_html__('Invalid quote ID.', 'easy-invoice'));
1705 2020 }
1706 2021
1707 - // Get the quote
1708 - $quote = $this->quote_repository->find($quote_id);
1709 -
1710 - if (!$quote) {
1711 - wp_die(__('Quote not found.', 'easy-invoice'));
2022 + if (!wp_verify_nonce($_POST['quote_nonce'] ?? '', $this->quoteAcceptDeclineNonceAction($quote_id))) {
2023 + wp_die(esc_html__('Security check failed.', 'easy-invoice'));
1712 2024 }
1713 2025
1714 - // Check if user has permission to decline this quote
1715 2026 $current_user = wp_get_current_user();
1716 2027 $is_admin = current_user_can('manage_options');
1717 2028
1718 - if (!$is_admin) {
1719 - // For non-admins, check if they are the client
1720 - if ($quote->getClientId()) {
1721 - $client_repository = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository();
1722 - $client = $client_repository->find($quote->getClientId());
2029 + if ($is_admin) {
2030 + $quote = $this->quote_repository->find($quote_id);
2031 + } else {
2032 + $quote = $this->quote_repository->findPublished($quote_id);
2033 + }
1723 2034
1724 - if (!$client || $client->getEmail() !== $current_user->user_email) {
1725 - wp_die(__('You do not have permission to decline this quote.', 'easy-invoice'));
1726 - }
1727 - } else {
1728 - wp_die(__('You do not have permission to decline this quote.', 'easy-invoice'));
1729 - }
2035 + if (!$quote) {
2036 + wp_die(esc_html__('Quote not found.', 'easy-invoice'));
1730 2037 }
1731 2038
2039 + // SECURITY (CVE-2026-9021): unconditional authorisation. See
2040 + // handleAcceptQuote (AJAX path) for full rationale.
2041 + if (!self::canActOnQuote($quote_id, $quote)) {
2042 + wp_die(esc_html__('You do not have permission to decline this quote.', 'easy-invoice'));
2043 + }
2044 +
2045 + $ei_open = self::openForDecision($quote);
2046 + if (is_wp_error($ei_open)) {
2047 + wp_die(esc_html($ei_open->get_error_message()));
2048 + }
2049 +
1732 2050 // Update quote status to declined
1733 2051 $quote->setStatus('declined');
1734 - $quote->setDeclinedDate(date('Y-m-d H:i:s'));
2052 + $quote->setDeclinedDate(gmdate('Y-m-d H:i:s'));
1735 2053 $quote->setDeclinedBy($current_user->ID);
1736 2054
1737 2055 // Save the quote
1738 2056 $saved = $quote->save();
@@ -1737,9 +2055,9 @@
1737 2055 // Save the quote
1738 2056 $saved = $quote->save();
1739 2057
1740 2058 if (!$saved) {
1741 - wp_die(__('Failed to decline quote.', 'easy-invoice'));
2059 + wp_die(esc_html__('Failed to decline quote.', 'easy-invoice'));
1742 2060 }
1743 2061
1744 2062 // Send notification email to admin
1745 2063 if (!$is_admin) {
@@ -1747,9 +2065,9 @@
1747 2065 }
1748 2066
1749 2067 // Redirect back to the quote page with success message
1750 2068 $redirect_url = add_query_arg('action', 'declined', get_permalink($quote_id));
1751 - wp_redirect($redirect_url);
2069 + wp_safe_redirect($redirect_url);
1752 2070 exit;
1753 2071 }
1754 2072
1755 2073 /**
@@ -1762,10 +2080,12 @@
1762 2080 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
1763 2081 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
1764 2082 }
1765 2083
1766 - // Check permissions
1767 - if (!current_user_can('manage_options')) {
2084 + // Check permissions — gate at ei_create_quote (state transitions like
2085 + // trash/draft/restore). Permanent-delete actions are additionally
2086 + // gated below by ei_delete_quote per action.
2087 + if (!easy_invoice_user_can('ei_create_quote')) {
1768 2088 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
1769 2089 }
1770 2090
1771 2091 $quote_ids = isset($_POST['quote_ids']) ? array_map('intval', $_POST['quote_ids']) : [];
@@ -1770,8 +2090,14 @@
1770 2090
1771 2091 $quote_ids = isset($_POST['quote_ids']) ? array_map('intval', $_POST['quote_ids']) : [];
1772 2092 $bulk_action = sanitize_text_field($_POST['bulk_action'] ?? '');
1773 2093
2094 + // Per-action gate: permanent delete requires the stricter delete cap.
2095 + if (in_array($bulk_action, ['delete', 'permanent-delete', 'empty-trash'], true)
2096 + && !easy_invoice_user_can('ei_delete_quote')) {
2097 + wp_send_json_error(['message' => __('You do not have permission to delete quotes.', 'easy-invoice')]);
2098 + }
2099 +
1774 2100 if (empty($quote_ids)) {
1775 2101 wp_send_json_error(['message' => __('No quotes selected.', 'easy-invoice')]);
1776 2102 }
1777 2103
@@ -1845,19 +2171,23 @@
1845 2171 }
1846 2172
1847 2173 if ($error_count > 0) {
1848 2174 wp_send_json_success([
1849 - 'message' => sprintf(__('Processed %d quotes successfully. %d failed.', 'easy-invoice'), $success_count, $error_count),
2175 + /* translators: %1$d: number processed; %2$d: number failed. */
2176 + 'message' => sprintf(__('Processed %1$d quotes successfully. %2$d failed.', 'easy-invoice'), $success_count, $error_count),
1850 2177 'toast' => [
1851 2178 'type' => 'warning',
1852 - 'message' => sprintf(__('Processed %d quotes successfully. %d failed.', 'easy-invoice'), $success_count, $error_count)
2179 + /* translators: %1$d: number processed; %2$d: number failed. */
2180 + 'message' => sprintf(__('Processed %1$d quotes successfully. %2$d failed.', 'easy-invoice'), $success_count, $error_count)
1853 2181 ]
1854 2182 ]);
1855 2183 } else {
1856 2184 wp_send_json_success([
2185 + /* translators: %d: number processed. */
1857 2186 'message' => sprintf(__('Successfully processed %d quotes.', 'easy-invoice'), $success_count),
1858 2187 'toast' => [
1859 2188 'type' => 'success',
2189 + /* translators: %d: number processed. */
1860 2190 'message' => sprintf(__('Successfully processed %d quotes.', 'easy-invoice'), $success_count)
1861 2191 ]
1862 2192 ]);
1863 2193 }
@@ -1873,10 +2203,10 @@
1873 2203 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
1874 2204 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
1875 2205 }
1876 2206
1877 - // Check permissions
1878 - if (!current_user_can('manage_options')) {
2207 + // Check permissions — trash is reversible, gated at the create-quote cap.
2208 + if (!easy_invoice_user_can('ei_create_quote')) {
1879 2209 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
1880 2210 }
1881 2211
1882 2212 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
@@ -1923,10 +2253,10 @@
1923 2253 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
1924 2254 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
1925 2255 }
1926 2256
1927 - // Check permissions
1928 - if (!current_user_can('manage_options')) {
2257 + // Check permissions — moving to draft is an edit, not a delete.
2258 + if (!easy_invoice_user_can('ei_create_quote')) {
1929 2259 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
1930 2260 }
1931 2261
1932 2262 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
@@ -1969,10 +2299,10 @@
1969 2299 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
1970 2300 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
1971 2301 }
1972 2302
1973 - // Check permissions
1974 - if (!current_user_can('manage_options')) {
2303 + // Check permissions — restoring from trash is an edit operation.
2304 + if (!easy_invoice_user_can('ei_create_quote')) {
1975 2305 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
1976 2306 }
1977 2307
1978 2308 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
@@ -2019,10 +2349,10 @@
2019 2349 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_nonce')) {
2020 2350 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
2021 2351 }
2022 2352
2023 - // Check permissions
2024 - if (!current_user_can('manage_options')) {
2353 + // Check permissions — emptying trash permanently deletes quotes.
2354 + if (!easy_invoice_user_can('ei_delete_quote')) {
2025 2355 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
2026 2356 }
2027 2357
2028 2358 // Get all quotes in trash (post_status = 'trash')
@@ -2051,23 +2381,27 @@
2051 2381 }
2052 2382
2053 2383 if ($error_count > 0) {
2054 2384 wp_send_json_success([
2055 - 'message' => sprintf(__('Emptied trash: %d quotes deleted successfully, %d failed.', 'easy-invoice'), $success_count, $error_count),
2385 + /* translators: %1$d: number processed; %2$d: number failed. */
2386 + 'message' => sprintf(__('Emptied trash: %1$d quotes deleted successfully, %2$d failed.', 'easy-invoice'), $success_count, $error_count),
2056 2387 'success_count' => $success_count,
2057 2388 'error_count' => $error_count,
2058 2389 'toast' => [
2059 2390 'type' => 'warning',
2060 - 'message' => sprintf(__('Emptied trash: %d quotes deleted successfully, %d failed.', 'easy-invoice'), $success_count, $error_count)
2391 + /* translators: %1$d: number processed; %2$d: number failed. */
2392 + 'message' => sprintf(__('Emptied trash: %1$d quotes deleted successfully, %2$d failed.', 'easy-invoice'), $success_count, $error_count)
2061 2393 ]
2062 2394 ]);
2063 2395 } else {
2064 2396 wp_send_json_success([
2397 + /* translators: %d: number processed. */
2065 2398 'message' => sprintf(__('Successfully emptied trash: %d quotes deleted.', 'easy-invoice'), $success_count),
2066 2399 'success_count' => $success_count,
2067 2400 'error_count' => 0,
2068 2401 'toast' => [
2069 2402 'type' => 'success',
2403 + /* translators: %d: number processed. */
2070 2404 'message' => sprintf(__('Successfully emptied trash: %d quotes deleted.', 'easy-invoice'), $success_count)
2071 2405 ]
2072 2406 ]);
2073 2407 }
@@ -2091,10 +2425,10 @@
2091 2425 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
2092 2426 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
2093 2427 }
2094 2428
2095 - // Check permissions
2096 - if (!current_user_can('manage_options')) {
2429 + // Check permissions — viewing quote activity log.
2430 + if (!easy_invoice_user_can('ei_view_quotes')) {
2097 2431 wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
2098 2432 }
2099 2433
2100 2434 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;