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 +435 -157 2.3.3 → 2.4.4 View file →
@@ -79,8 +79,10 @@
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 86 // The `easy_invoice_search_clients` AJAX is owned by EasyInvoiceAjax.
85 87 // The duplicate registration that used to live here raced with
86 88 // EasyInvoiceAjax::searchClients() — only the first-registered
@@ -92,9 +94,8 @@
92 94 add_action('wp_ajax_easy_invoice_accept_quote', [$this, 'handleAcceptQuote']);
93 95 add_action('wp_ajax_easy_invoice_decline_quote', [$this, 'handleDeclineQuote']);
94 96 add_action('wp_ajax_nopriv_easy_invoice_accept_quote', [$this, 'handleAcceptQuote']);
95 97 add_action('wp_ajax_nopriv_easy_invoice_decline_quote', [$this, 'handleDeclineQuote']);
96 - add_action('wp_ajax_easy_invoice_update_existing_quotes', [$this, 'handleUpdateExistingQuotes']);
97 98
98 99 // Add missing AJAX handlers for quote listing actions
99 100 add_action('wp_ajax_easy_invoice_bulk_quote_action', [$this, 'handleBulkQuoteAction']);
100 101 add_action('wp_ajax_easy_invoice_trash_quote', [$this, 'handleTrashQuote']);
@@ -363,11 +364,12 @@
363 364 ]
364 365 ]);
365 366 $meta_search = new \WP_Query($meta_search_args);
366 367
367 - if ($meta_search->have_posts()) {
368 - $search_ids = array_merge($search_ids, wp_list_pluck($meta_search->posts, 'ID'));
369 - }
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));
370 372
371 373 $search_ids = array_unique($search_ids);
372 374
373 375 if (!empty($search_ids)) {
@@ -506,9 +508,12 @@
506 508 if ($quote_id > 0) {
507 509 $quote = $this->quote_repository->find($quote_id);
508 510 }
509 511
510 - $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 = [];
511 516
512 517 // Allow plugins to modify the data
513 518 $quote = apply_filters('easy_invoice_quote_controller_builder_quote', $quote, $quote_id);
514 519 $clients = apply_filters('easy_invoice_quote_controller_builder_clients', $clients);
@@ -532,14 +537,14 @@
532 537
533 538 $quote_id = isset($_GET['id']) ? (int) $_GET['id'] : 0;
534 539
535 540 if ($quote_id <= 0) {
536 - wp_die(__('Quote not found.', 'easy-invoice'));
541 + wp_die(esc_html__('Quote not found.', 'easy-invoice'));
537 542 }
538 543
539 544 $quote = $this->quote_repository->find($quote_id);
540 545 if (!$quote) {
541 - wp_die(__('Quote not found.', 'easy-invoice'));
546 + wp_die(esc_html__('Quote not found.', 'easy-invoice'));
542 547 }
543 548
544 549 // Allow plugins to modify the quote
545 550 $quote = apply_filters('easy_invoice_quote_controller_preview_quote', $quote, $quote_id);
@@ -652,13 +657,25 @@
652 657 if (!$template_file) {
653 658 wp_send_json_error(['message' => __('Template not found.', 'easy-invoice')]);
654 659 }
655 660
656 - // Load quote if provided
657 - $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();
658 670 if ($quote_id > 0) {
659 - $quote = $this->quote_repository->find($quote_id);
671 + $loaded = $this->quote_repository->find($quote_id);
672 + if ($loaded) {
673 + $quote = $loaded;
674 + }
660 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');
661 678
662 679 // Start output buffering to capture template HTML
663 680 ob_start();
664 681
@@ -813,10 +830,10 @@
813 830 $data = [
814 831 'title' => $title,
815 832 'status' => 'draft',
816 833 'number' => $quote_number, // Use the generated unique number
817 - 'issue_date' => date('Y-m-d'),
818 - 'expiry_date' => date('Y-m-d', strtotime('+30 days')),
834 + 'issue_date' => current_time('Y-m-d'),
835 + 'expiry_date' => wp_date('Y-m-d', strtotime('+30 days')),
819 836 'items' => [],
820 837 'notes' => '', // Ensure notes is never null
821 838 'terms' => $quote_terms, // Use global terms setting
822 839 'footer_text' => $quote_footer, // Use global footer setting
@@ -865,10 +882,10 @@
865 882 // Create a new quote object for the form
866 883 $quote_number_service = function_exists('easy_invoice_get_quote_number_service') ? easy_invoice_get_quote_number_service() : null;
867 884 $quote_data = array(
868 885 'number' => $quote_number_service ? $quote_number_service->getNextNumber() : 'QT-1',
869 - 'date' => date('Y-m-d'),
870 - 'expiry_date' => date('Y-m-d', strtotime('+30 days')),
886 + 'date' => current_time('Y-m-d'),
887 + 'expiry_date' => wp_date('Y-m-d', strtotime('+30 days')),
871 888 'client_id' => 0,
872 889 'client_name' => '',
873 890 'client_email' => '',
874 891 'client_phone' => '',
@@ -952,9 +969,9 @@
952 969 $quote->setItems([]);
953 970
954 971 // Set variables needed by the form template
955 972 $quote_id = 0;
956 - $clients = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository()->all();
973 + $clients = [];
957 974 $quote_form_manager = new \EasyInvoice\Forms\Quote\QuoteFormManager();
958 975 $quote_items_json = json_encode([]);
959 976 $admin_nonce = wp_create_nonce('easy_invoice_admin_nonce');
960 977 $quote_field_config = $quote_form_manager->getFieldConfigForJavaScript();
@@ -979,8 +996,170 @@
979 996 return 'easy_invoice_quote_action_' . $quote_id;
980 997 }
981 998
982 999 /**
1000 + * Get the per-quote access token. Lazily generated on first read.
1001 + *
1002 + * Previously the public quote page embedded an `easy_invoice_quote_action_{id}`
1003 + * nonce that, combined with the off-by-default `easy_invoice_pro_restrict_quote_to_client`
1004 + * option, let any visitor accept or decline any published quote
1005 + * (CVE-2026-9021). The token replaces that public-nonce-as-authorisation
1006 + * model: it's a cryptographically random per-quote secret that's only
1007 + * leaked to the legitimate quote recipient via the emailed link's
1008 + * `?qk=...` parameter, and is required server-side by the accept /
1009 + * decline handlers (alongside an unconditional ownership check on
1010 + * authenticated callers).
1011 + *
1012 + * The token is single-purpose (just accept/decline gating) and lives
1013 + * in private post meta. We generate 32 hex chars (128 bits of entropy)
1014 + * which is well above what's brute-forceable inside the lifetime of a
1015 + * published quote.
1016 + */
1017 + public static function quoteAccessToken(int $quote_id): string {
1018 + if ($quote_id <= 0) {
1019 + return '';
1020 + }
1021 + $token = (string) get_post_meta($quote_id, '_easy_invoice_quote_access_token', true);
1022 + if ($token === '' || strlen($token) < 32) {
1023 + try {
1024 + $token = bin2hex(random_bytes(16));
1025 + } catch (\Throwable $e) {
1026 + // Fallback for systems without CSPRNG. wp_generate_password uses
1027 + // random_bytes internally on modern PHP — same entropy source.
1028 + $token = wp_generate_password(32, false, false);
1029 + }
1030 + update_post_meta($quote_id, '_easy_invoice_quote_access_token', $token);
1031 + }
1032 + return $token;
1033 + }
1034 +
1035 + /**
1036 + * Read-only sibling of quoteAccessToken(). Returns the persisted
1037 + * token if one already exists, or an empty string otherwise — never
1038 + * mints. Use this from user-controlled rendering contexts (e.g. the
1039 + * `[easy_quote_url]` shortcode) where allowing an arbitrary caller
1040 + * to MINT an Accept/Decline-authorising token for an attacker-chosen
1041 + * quote would be a privilege-escalation vector.
1042 + *
1043 + * Trusted server contexts (the EmailManager quote-send path) should
1044 + * keep calling quoteAccessToken() so first-send still works.
1045 + */
1046 + public static function quoteAccessTokenIfExists(int $quote_id): string {
1047 + if ($quote_id <= 0) {
1048 + return '';
1049 + }
1050 + $token = (string) get_post_meta($quote_id, '_easy_invoice_quote_access_token', true);
1051 + return strlen($token) >= 32 ? $token : '';
1052 + }
1053 +
1054 + /**
1055 + * Constant-time comparison helper for the access token.
1056 + */
1057 + private static function quoteTokenFromRequest(): string {
1058 + $token = '';
1059 + if (isset($_POST['access_token'])) {
1060 + $token = sanitize_text_field(wp_unslash($_POST['access_token']));
1061 + } elseif (isset($_GET['qk'])) {
1062 + $token = sanitize_text_field(wp_unslash($_GET['qk']));
1063 + }
1064 + /** This filter is documented in includes/Controllers/InvoiceController.php */
1065 + return (string) apply_filters('easy_invoice_presented_access_token', $token, 'quote');
1066 + }
1067 +
1068 + /**
1069 + * Central authorisation check for quote accept/decline. Returns true
1070 + * when ANY of these is true:
1071 + *
1072 + * 1. The request carries a valid per-quote access token (the legitimate
1073 + * email-recipient flow). Constant-time compared with hash_equals.
1074 + * 2. The current user is logged in AND has admin-grade capability
1075 + * (manage_options) — admin-side accept/decline.
1076 + * 3. The current user is logged in AND is the quote's bound client
1077 + * (email match against the quote's client_id record). This was
1078 + * previously gated behind the off-by-default
1079 + * `easy_invoice_pro_restrict_quote_to_client` option — that gate
1080 + * is removed in 2.3.4 so the ownership check runs unconditionally.
1081 + *
1082 + * Returns false otherwise. Callers must reject the request when this
1083 + * returns false; we don't reject from in here so the caller can choose
1084 + * wp_send_json_error vs wp_die based on its transport.
1085 + */
1086 + /**
1087 + * Whether a quote can still be accepted or declined: it must be open
1088 + * (draft, available or sent) and not past its expiry date.
1089 + *
1090 + * @param object $quote Quote model.
1091 + * @return true|\WP_Error Error carrying the reason to show the client.
1092 + */
1093 + public static function openForDecision($quote) {
1094 + $status = is_callable([$quote, 'getStatus']) ? strtolower((string) $quote->getStatus()) : '';
1095 + if ('accepted' === $status) {
1096 + return new \WP_Error('easy_invoice_quote_closed', __('This quote has already been accepted.', 'easy-invoice'));
1097 + }
1098 + if ('declined' === $status) {
1099 + return new \WP_Error('easy_invoice_quote_closed', __('This quote has already been declined.', 'easy-invoice'));
1100 + }
1101 + if (!in_array($status, ['draft', 'available', 'sent', 'expired'], true)) {
1102 + return new \WP_Error('easy_invoice_quote_closed', __('This quote is no longer open.', 'easy-invoice'));
1103 + }
1104 + $expiry = is_callable([$quote, 'getExpiryDate']) ? (string) $quote->getExpiryDate() : '';
1105 + $expired = 'expired' === $status
1106 + || ('' !== $expiry && strtotime($expiry) && gmdate('Y-m-d', strtotime($expiry)) < gmdate('Y-m-d', current_time('timestamp')));
1107 + if ($expired) {
1108 + return new \WP_Error(
1109 + 'easy_invoice_quote_expired',
1110 + '' !== $expiry
1111 + /* translators: %s: expiry date. */
1112 + ? sprintf(__('This quote expired on %s. Please ask for a new one.', 'easy-invoice'), date_i18n(get_option('date_format'), strtotime($expiry)))
1113 + : __('This quote has expired. Please ask for a new one.', 'easy-invoice')
1114 + );
1115 + }
1116 + return true;
1117 + }
1118 +
1119 + public static function canActOnQuote(int $quote_id, $quote = null): bool {
1120 + if ($quote_id <= 0) {
1121 + return false;
1122 + }
1123 +
1124 + // Path 1: legitimate access-token flow (email link recipient).
1125 + $presented = self::quoteTokenFromRequest();
1126 + if ($presented !== '') {
1127 + $stored = (string) get_post_meta($quote_id, '_easy_invoice_quote_access_token', true);
1128 + if ($stored !== '' && hash_equals($stored, $presented)) {
1129 + return true;
1130 + }
1131 + }
1132 +
1133 + // Path 2: admin override.
1134 + if (current_user_can('manage_options')) {
1135 + return true;
1136 + }
1137 +
1138 + // Path 3: authenticated owner. ONLY when the current user is the
1139 + // quote's bound client (email match). Previously this was
1140 + // skipped entirely when the Pro option was 'no' (the default) —
1141 + // which is what made the CVE exploitable. Now it always runs.
1142 + //
1143 + // Note: Quote model resolves `getClientId()` via __call magic,
1144 + // so method_exists() returns FALSE for it (PHP's method_exists
1145 + // does not recognise __call-resolved methods). Use is_callable
1146 + // instead — it correctly returns TRUE when the receiver has a
1147 + // __call that can field the message, so this guard actually
1148 + // permits the bound-client path on real Quote objects.
1149 + if (is_user_logged_in() && $quote && is_callable([$quote, 'getClientId']) && $quote->getClientId()) {
1150 + $current_user = wp_get_current_user();
1151 + $client_repository = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository();
1152 + $client = $client_repository->find($quote->getClientId());
1153 + if ($client && strcasecmp((string) $client->getEmail(), (string) $current_user->user_email) === 0) {
1154 + return true;
1155 + }
1156 + }
1157 +
1158 + return false;
1159 + }
1160 +
1161 + /**
983 1162 * Handle AJAX request to accept a quote
984 1163 *
985 1164 * @since 1.0.0
986 1165 */
@@ -1006,27 +1185,25 @@
1006 1185 if (!$quote) {
1007 1186 wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]);
1008 1187 }
1009 1188
1010 - // Check if user has permission to accept this quote
1011 - $current_user = wp_get_current_user();
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 + }
1012 1198
1013 - $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 + }
1014 1203
1015 - if (!$is_admin && $restrict === 'yes') {
1016 - // For non-admins, check if they are the client
1017 - if ($quote->getClientId()) {
1018 - $client_repository = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository();
1019 - $client = $client_repository->find($quote->getClientId());
1204 + $current_user = wp_get_current_user();
1020 1205
1021 - if (!$client || $client->getEmail() !== $current_user->user_email) {
1022 - wp_send_json_error(['message' => __('You do not have permission to accept this quote.', 'easy-invoice')]);
1023 - }
1024 - } else {
1025 - wp_send_json_error(['message' => __('You do not have permission to accept this quote.', 'easy-invoice')]);
1026 - }
1027 - }
1028 -
1029 1206 // Get global accept action setting
1030 1207 $settings_controller = new \EasyInvoice\Controllers\SettingsController();
1031 1208 $accept_action = $settings_controller::getQuoteAcceptAction();
1032 1209
@@ -1031,9 +1208,9 @@
1031 1208 $accept_action = $settings_controller::getQuoteAcceptAction();
1032 1209
1033 1210 // Update quote status to accepted
1034 1211 $quote->setStatus('accepted');
1035 - $quote->setAcceptedDate(date('Y-m-d H:i:s'));
1212 + $quote->setAcceptedDate(gmdate('Y-m-d H:i:s'));
1036 1213 $quote->setAcceptedBy($current_user->ID);
1037 1214
1038 1215 // Save the quote
1039 1216 $saved = $quote->save();
@@ -1047,8 +1224,33 @@
1047 1224 'accept_action' => $accept_action,
1048 1225 'user_type' => $is_admin ? 'admin' : 'client'
1049 1226 ]);
1050 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 +
1051 1253 // Perform the configured accept action
1052 1254 $invoice_id = null;
1053 1255 $action_message = '';
1054 1256
@@ -1116,10 +1318,17 @@
1116 1318
1117 1319 if ($invoice_id) {
1118 1320 // Always use WordPress permalink
1119 1321 $invoice_url = get_permalink($invoice_id);
1120 - // If Pro and secure link available, use secure link
1121 - if (class_exists('\EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController')) {
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')) {
1122 1331 $secure_url = \EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController::getInvoiceSecureLinkUrl($invoice_id);
1123 1332 if ($secure_url) {
1124 1333 $invoice_url = $secure_url;
1125 1334 }
@@ -1144,8 +1353,82 @@
1144 1353 * @param \EasyInvoice\Models\Quote $quote The quote to convert
1145 1354 * @param string $status The status for the new invoice ('draft' or 'available')
1146 1355 * @return int|null The invoice ID if successful, null otherwise
1147 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 +
1148 1431 private function convertQuoteToInvoice($quote, $status = 'draft'): ?int {
1149 1432 try {
1150 1433 // Get invoice repository
1151 1434 $invoice_repository = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository();
@@ -1154,10 +1437,10 @@
1154 1437 $invoice_data = [
1155 1438 'title' => $quote->getTitle() ?: 'Invoice from Quote ' . $quote->getNumber(),
1156 1439 'number' => $this->generateInvoiceNumber(),
1157 1440 'status' => $status,
1158 - 'issue_date' => date('Y-m-d'),
1159 - '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')),
1160 1443 'client_id' => $quote->getClientId(),
1161 1444 'customer_name' => $quote->getCustomerName(),
1162 1445 'customer_email' => $quote->getCustomerEmail(),
1163 1446 'customer_address' => $quote->getCustomerAddress(),
@@ -1172,8 +1455,9 @@
1172 1455 'payment_gateways' => [], // Invoice-specific field, leave empty
1173 1456 'template' => $quote->getTemplate(),
1174 1457 'subtotal' => $quote->getSubtotal(),
1175 1458 'tax_rate' => $quote->getTaxRate(),
1459 + 'tax_enabled' => $quote->getTaxEnabled() ?: (get_option('easy_invoice_tax_enabled', 'no') === 'yes' ? 'yes' : 'no'),
1176 1460 'tax_amount' => $quote->getTaxAmount(),
1177 1461 'discount_type' => $quote->getDiscountType(),
1178 1462 'discount_value' => $quote->getDiscountValue(),
1179 1463 'discount_amount' => $quote->getDiscountAmount(),
@@ -1185,8 +1469,17 @@
1185 1469 'prices_include_tax' => $quote->getPricesIncludeTax(),
1186 1470 'custom_fields' => $quote->getCustomFields(), // Transfer custom fields
1187 1471 ];
1188 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 +
1189 1482 // Create the invoice
1190 1483 $invoice = $invoice_repository->create($invoice_data);
1191 1484
1192 1485 if ($invoice) {
@@ -1191,17 +1484,30 @@
1191 1484
1192 1485 if ($invoice) {
1193 1486 // Store the quote ID in the invoice's meta for tracking
1194 1487 update_post_meta($invoice->getId(), '_converted_from_quote', $quote->getId());
1488 + update_post_meta($invoice->getId(), '_easy_invoice_converted_from_quote', $quote->getId());
1195 1489
1196 - // Update quote to reference the created invoice
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());
1197 1494 $quote->setCustomField('converted_invoice_id', $invoice->getId());
1198 1495 $quote->save();
1199 1496
1200 1497 // Ensure secure link is generated for the new invoice (Pro version)
1201 1498 if (class_exists('\EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController')) {
1202 - // Trigger the save_post hook to generate secure link
1203 - do_action('save_post_easy_invoice', $invoice->getId(), get_post($invoice->getId()));
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);
1204 1510 }
1205 1511
1206 1512 return $invoice->getId();
1207 1513 }
@@ -1229,10 +1535,10 @@
1229 1535 $invoice_data = [
1230 1536 'title' => 'Invoice from Quote ' . $quote->getNumber(),
1231 1537 'number' => $this->generateInvoiceNumber(),
1232 1538 'status' => $status,
1233 - 'issue_date' => date('Y-m-d'),
1234 - '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')),
1235 1541 'client_id' => $quote->getClientId(),
1236 1542 'customer_name' => $quote->getCustomerName(),
1237 1543 'customer_email' => $quote->getCustomerEmail(),
1238 1544 'customer_address' => $quote->getCustomerAddress(),
@@ -1247,8 +1553,9 @@
1247 1553 'payment_gateways' => [], // Invoice-specific field, leave empty
1248 1554 'template' => $quote->getTemplate(),
1249 1555 'subtotal' => $quote->getSubtotal(),
1250 1556 'tax_rate' => $quote->getTaxRate(),
1557 + 'tax_enabled' => $quote->getTaxEnabled() ?: (get_option('easy_invoice_tax_enabled', 'no') === 'yes' ? 'yes' : 'no'),
1251 1558 'tax_amount' => $quote->getTaxAmount(),
1252 1559 'discount_type' => $quote->getDiscountType(),
1253 1560 'discount_value' => $quote->getDiscountValue(),
1254 1561 'discount_amount' => $quote->getDiscountAmount(),
@@ -1260,8 +1567,17 @@
1260 1567 'prices_include_tax' => $quote->getPricesIncludeTax(),
1261 1568 'custom_fields' => $quote->getCustomFields(), // Transfer custom fields
1262 1569 ];
1263 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 +
1264 1580 // Create the invoice
1265 1581 $invoice = $invoice_repository->create($invoice_data);
1266 1582
1267 1583 if ($invoice) {
@@ -1270,10 +1586,19 @@
1270 1586 $quote->save();
1271 1587
1272 1588 // Ensure secure link is generated for the new invoice (Pro version)
1273 1589 if (class_exists('\EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController')) {
1274 - // Trigger the save_post hook to generate secure link
1275 - do_action('save_post_easy_invoice', $invoice->getId(), get_post($invoice->getId()));
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);
1276 1601 }
1277 1602
1278 1603 return $invoice->getId();
1279 1604 }
@@ -1324,16 +1649,26 @@
1324 1649 $invoice_items = [];
1325 1650
1326 1651 foreach ($quote_items as $quote_item) {
1327 1652 if (is_object($quote_item) && method_exists($quote_item, 'toArray')) {
1328 - // 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.
1329 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 + }
1330 1665 $invoice_items[] = [
1331 - 'name' => $item_data['name'] ?? '',
1666 + 'name' => $name,
1332 1667 'description' => $item_data['description'] ?? '',
1333 1668 'quantity' => $item_data['quantity'] ?? 0,
1334 1669 'price' => $item_data['price'] ?? 0,
1335 - 'amount' => $item_data['amount'] ?? 0,
1670 + 'amount' => $amount,
1336 1671 'taxable' => $item_data['taxable'] ?? true,
1337 1672 // Map adjust_percentage to a similar field if needed
1338 1673 'adjust_percentage' => $item_data['adjust_percentage'] ?? 0,
1339 1674 ];
@@ -1443,30 +1778,25 @@
1443 1778 if ($settings_controller::isDeclineReasonRequired() && empty(trim($decline_reason))) {
1444 1779 wp_send_json_error(['message' => __('Reason for declining is required.', 'easy-invoice')]);
1445 1780 }
1446 1781
1447 - // Check if user has permission to decline this quote
1448 - $current_user = wp_get_current_user();
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 + }
1449 1788
1450 - $restrict = get_option('easy_invoice_pro_restrict_quote_to_client', 'no');
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 + }
1451 1793
1452 - if (!$is_admin && $restrict === 'yes') {
1453 - // For non-admins, check if they are the client
1454 - if ($quote->getClientId()) {
1455 - $client_repository = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository();
1456 - $client = $client_repository->find($quote->getClientId());
1794 + $current_user = wp_get_current_user();
1457 1795
1458 - if (!$client || $client->getEmail() !== $current_user->user_email) {
1459 - wp_send_json_error(['message' => __('You do not have permission to decline this quote.', 'easy-invoice')]);
1460 - }
1461 - } else {
1462 - wp_send_json_error(['message' => __('You do not have permission to decline this quote.', 'easy-invoice')]);
1463 - }
1464 - }
1465 -
1466 1796 // Update quote status to declined
1467 1797 $quote->setStatus('declined');
1468 - $quote->setDeclinedDate(date('Y-m-d H:i:s'));
1798 + $quote->setDeclinedDate(gmdate('Y-m-d H:i:s'));
1469 1799 $quote->setDeclinedBy($current_user->ID);
1470 1800
1471 1801 // Save decline reason if provided
1472 1802 if (!empty($decline_reason)) {
@@ -1520,57 +1850,9 @@
1520 1850 $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
1521 1851 $email_manager->sendAdminQuoteNotification($quote, 'declined');
1522 1852 }
1523 1853
1524 - /**
1525 - * Handle AJAX request to update existing quotes with missing data
1526 - *
1527 - * @since 1.0.0
1528 - */
1529 - public function handleUpdateExistingQuotes(): void {
1530 - // Verify nonce - match the nonce being sent from JavaScript
1531 - if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
1532 - wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
1533 - }
1534 1854
1535 - // Check permissions — bulk migration / repair: admin-only.
1536 - if (!current_user_can('manage_options')) {
1537 - wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
1538 - }
1539 -
1540 - $updated_count = 0;
1541 - $quotes = $this->quote_repository->findAll();
1542 -
1543 - foreach ($quotes as $quote) {
1544 - $post = get_post($quote->getId());
1545 - if ($post && empty($post->post_name)) {
1546 - // Generate a proper slug for this quote
1547 - $post_title = $quote->getTitle() ?: $quote->getNumber() ?: 'Untitled Quote';
1548 - $post_name = sanitize_title($post_title);
1549 -
1550 - // Ensure uniqueness
1551 - $original_slug = $post_name;
1552 - $counter = 1;
1553 - while (get_page_by_path($post_name, OBJECT, \EasyInvoice\Constants\PostTypes::EASY_INVOICE_QUOTE_POST_TYPE)) {
1554 - $post_name = $original_slug . '-' . $counter;
1555 - $counter++;
1556 - }
1557 -
1558 - // Update the post with the new slug
1559 - wp_update_post([
1560 - 'ID' => $quote->getId(),
1561 - 'post_name' => $post_name
1562 - ]);
1563 -
1564 - $updated_count++;
1565 - }
1566 - }
1567 -
1568 - wp_send_json_success([
1569 - 'message' => sprintf(__('Updated %d quotes with proper URLs.', 'easy-invoice'), $updated_count)
1570 - ]);
1571 - }
1572 -
1573 1855 /**
1574 1856 * Handle AJAX request to duplicate a quote
1575 1857 *
1576 1858 * @since 1.0.0
@@ -1612,10 +1894,10 @@
1612 1894 $duplicate_data = [
1613 1895 'title' => $quote->getTitle() . ' (Copy)',
1614 1896 'status' => 'draft',
1615 1897 'number' => $this->generateInvoiceNumber(), // Use invoice number service for consistency
1616 - 'issue_date' => date('Y-m-d'),
1617 - 'expiry_date' => date('Y-m-d', strtotime('+30 days')),
1898 + 'issue_date' => current_time('Y-m-d'),
1899 + 'expiry_date' => wp_date('Y-m-d', strtotime('+30 days')),
1618 1900 'items' => $this->convertQuoteItemsToInvoiceItems($quote->getItems()), // Use invoice item conversion
1619 1901 'notes' => $quote->getNotes(),
1620 1902 'description' => $quote->getDescription(),
1621 1903 'terms' => $quote_terms,
@@ -1677,13 +1959,13 @@
1677 1959 private function handleAcceptQuoteForm(): void {
1678 1960 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
1679 1961
1680 1962 if ($quote_id <= 0) {
1681 - wp_die(__('Invalid quote ID.', 'easy-invoice'));
1963 + wp_die(esc_html__('Invalid quote ID.', 'easy-invoice'));
1682 1964 }
1683 1965
1684 1966 if (!wp_verify_nonce($_POST['quote_nonce'] ?? '', $this->quoteAcceptDeclineNonceAction($quote_id))) {
1685 - wp_die(__('Security check failed.', 'easy-invoice'));
1967 + wp_die(esc_html__('Security check failed.', 'easy-invoice'));
1686 1968 }
1687 1969
1688 1970 $current_user = wp_get_current_user();
1689 1971 $is_admin = current_user_can('manage_options');
@@ -1694,32 +1976,25 @@
1694 1976 $quote = $this->quote_repository->findPublished($quote_id);
1695 1977 }
1696 1978
1697 1979 if (!$quote) {
1698 - wp_die(__('Quote not found.', 'easy-invoice'));
1980 + wp_die(esc_html__('Quote not found.', 'easy-invoice'));
1699 1981 }
1700 1982
1701 - // Check if user has permission to accept this quote
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 + }
1702 1988
1703 - $restrict = get_option('easy_invoice_pro_restrict_quote_to_client', 'no');
1704 -
1705 - if (!$is_admin && $restrict === 'yes') {
1706 - // For non-admins, check if they are the client
1707 - if ($quote->getClientId()) {
1708 - $client_repository = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository();
1709 - $client = $client_repository->find($quote->getClientId());
1710 -
1711 - if (!$client || $client->getEmail() !== $current_user->user_email) {
1712 - wp_die(__('You do not have permission to accept this quote.', 'easy-invoice'));
1713 - }
1714 - } else {
1715 - wp_die(__('You do not have permission to accept this quote.', 'easy-invoice'));
1716 - }
1989 + $ei_open = self::openForDecision($quote);
1990 + if (is_wp_error($ei_open)) {
1991 + wp_die(esc_html($ei_open->get_error_message()));
1717 1992 }
1718 1993
1719 1994 // Update quote status to accepted
1720 1995 $quote->setStatus('accepted');
1721 - $quote->setAcceptedDate(date('Y-m-d H:i:s'));
1996 + $quote->setAcceptedDate(gmdate('Y-m-d H:i:s'));
1722 1997 $quote->setAcceptedBy($current_user->ID);
1723 1998
1724 1999 // Save the quote
1725 2000 $saved = $quote->save();
@@ -1724,9 +1999,9 @@
1724 1999 // Save the quote
1725 2000 $saved = $quote->save();
1726 2001
1727 2002 if (!$saved) {
1728 - wp_die(__('Failed to accept quote.', 'easy-invoice'));
2003 + wp_die(esc_html__('Failed to accept quote.', 'easy-invoice'));
1729 2004 }
1730 2005
1731 2006 // Send notification email to admin
1732 2007 if (!$is_admin) {
@@ -1734,9 +2009,9 @@
1734 2009 }
1735 2010
1736 2011 // Redirect back to the quote page with success message
1737 2012 $redirect_url = add_query_arg('action', 'accepted', get_permalink($quote_id));
1738 - wp_redirect($redirect_url);
2013 + wp_safe_redirect($redirect_url);
1739 2014 exit;
1740 2015 }
1741 2016
1742 2017 /**
@@ -1747,13 +2022,13 @@
1747 2022 private function handleDeclineQuoteForm(): void {
1748 2023 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
1749 2024
1750 2025 if ($quote_id <= 0) {
1751 - wp_die(__('Invalid quote ID.', 'easy-invoice'));
2026 + wp_die(esc_html__('Invalid quote ID.', 'easy-invoice'));
1752 2027 }
1753 2028
1754 2029 if (!wp_verify_nonce($_POST['quote_nonce'] ?? '', $this->quoteAcceptDeclineNonceAction($quote_id))) {
1755 - wp_die(__('Security check failed.', 'easy-invoice'));
2030 + wp_die(esc_html__('Security check failed.', 'easy-invoice'));
1756 2031 }
1757 2032
1758 2033 $current_user = wp_get_current_user();
1759 2034 $is_admin = current_user_can('manage_options');
@@ -1764,30 +2039,25 @@
1764 2039 $quote = $this->quote_repository->findPublished($quote_id);
1765 2040 }
1766 2041
1767 2042 if (!$quote) {
1768 - wp_die(__('Quote not found.', 'easy-invoice'));
2043 + wp_die(esc_html__('Quote not found.', 'easy-invoice'));
1769 2044 }
1770 2045
1771 - // Check if user has permission to decline this quote
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 + }
1772 2051
1773 - if (!$is_admin) {
1774 - // For non-admins, check if they are the client
1775 - if ($quote->getClientId()) {
1776 - $client_repository = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository();
1777 - $client = $client_repository->find($quote->getClientId());
1778 -
1779 - if (!$client || $client->getEmail() !== $current_user->user_email) {
1780 - wp_die(__('You do not have permission to decline this quote.', 'easy-invoice'));
1781 - }
1782 - } else {
1783 - wp_die(__('You do not have permission to decline this quote.', 'easy-invoice'));
1784 - }
2052 + $ei_open = self::openForDecision($quote);
2053 + if (is_wp_error($ei_open)) {
2054 + wp_die(esc_html($ei_open->get_error_message()));
1785 2055 }
1786 2056
1787 2057 // Update quote status to declined
1788 2058 $quote->setStatus('declined');
1789 - $quote->setDeclinedDate(date('Y-m-d H:i:s'));
2059 + $quote->setDeclinedDate(gmdate('Y-m-d H:i:s'));
1790 2060 $quote->setDeclinedBy($current_user->ID);
1791 2061
1792 2062 // Save the quote
1793 2063 $saved = $quote->save();
@@ -1792,9 +2062,9 @@
1792 2062 // Save the quote
1793 2063 $saved = $quote->save();
1794 2064
1795 2065 if (!$saved) {
1796 - wp_die(__('Failed to decline quote.', 'easy-invoice'));
2066 + wp_die(esc_html__('Failed to decline quote.', 'easy-invoice'));
1797 2067 }
1798 2068
1799 2069 // Send notification email to admin
1800 2070 if (!$is_admin) {
@@ -1802,9 +2072,9 @@
1802 2072 }
1803 2073
1804 2074 // Redirect back to the quote page with success message
1805 2075 $redirect_url = add_query_arg('action', 'declined', get_permalink($quote_id));
1806 - wp_redirect($redirect_url);
2076 + wp_safe_redirect($redirect_url);
1807 2077 exit;
1808 2078 }
1809 2079
1810 2080 /**
@@ -1908,19 +2178,23 @@
1908 2178 }
1909 2179
1910 2180 if ($error_count > 0) {
1911 2181 wp_send_json_success([
1912 - '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),
1913 2184 'toast' => [
1914 2185 'type' => 'warning',
1915 - '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)
1916 2188 ]
1917 2189 ]);
1918 2190 } else {
1919 2191 wp_send_json_success([
2192 + /* translators: %d: number processed. */
1920 2193 'message' => sprintf(__('Successfully processed %d quotes.', 'easy-invoice'), $success_count),
1921 2194 'toast' => [
1922 2195 'type' => 'success',
2196 + /* translators: %d: number processed. */
1923 2197 'message' => sprintf(__('Successfully processed %d quotes.', 'easy-invoice'), $success_count)
1924 2198 ]
1925 2199 ]);
1926 2200 }
@@ -2114,23 +2388,27 @@
2114 2388 }
2115 2389
2116 2390 if ($error_count > 0) {
2117 2391 wp_send_json_success([
2118 - '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),
2119 2394 'success_count' => $success_count,
2120 2395 'error_count' => $error_count,
2121 2396 'toast' => [
2122 2397 'type' => 'warning',
2123 - '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)
2124 2400 ]
2125 2401 ]);
2126 2402 } else {
2127 2403 wp_send_json_success([
2404 + /* translators: %d: number processed. */
2128 2405 'message' => sprintf(__('Successfully emptied trash: %d quotes deleted.', 'easy-invoice'), $success_count),
2129 2406 'success_count' => $success_count,
2130 2407 'error_count' => 0,
2131 2408 'toast' => [
2132 2409 'type' => 'success',
2410 + /* translators: %d: number processed. */
2133 2411 'message' => sprintf(__('Successfully emptied trash: %d quotes deleted.', 'easy-invoice'), $success_count)
2134 2412 ]
2135 2413 ]);
2136 2414 }