PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.3
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.3
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/Admin/EasyInvoiceAjax.php +443 -95 2.1.19 → 2.4.3 View file →
@@ -24,9 +24,8 @@
24 24 * Initialize AJAX handlers
25 25 */
26 26 public function init() {
27 27 // Invoice actions
28 - //add_action('wp_ajax_easy_invoice_save', array($this, 'saveInvoice'));
29 28 add_action('wp_ajax_easy_invoice_delete', array($this, 'deleteInvoice'));
30 29 add_action('wp_ajax_easy_invoice_get', array($this, 'getInvoice'));
31 30 add_action('wp_ajax_easy_invoice_save_invoice', array($this, 'saveInvoice'));
32 31 add_action('wp_ajax_easy_invoice_save_and_send_invoice', array($this, 'saveAndSendInvoice'));
@@ -41,10 +40,12 @@
41 40
42 41 // Single page actions (for public access)
43 42 add_action('wp_ajax_easy_invoice_download_invoice_pdf', array($this, 'downloadInvoicePdf'));
44 43 add_action('wp_ajax_easy_invoice_send_invoice_email', array($this, 'sendInvoiceEmailPublic'));
44 + add_action('wp_ajax_easy_invoice_send_quote_email', array($this, 'sendQuoteEmailPublic'));
45 45 add_action('wp_ajax_nopriv_easy_invoice_download_invoice_pdf', array($this, 'downloadInvoicePdf'));
46 46 add_action('wp_ajax_nopriv_easy_invoice_send_invoice_email', array($this, 'sendInvoiceEmailPublic'));
47 + add_action('wp_ajax_nopriv_easy_invoice_send_quote_email', array($this, 'sendQuoteEmailPublic'));
47 48
48 49 // PDF generation actions
49 50 add_action('wp_ajax_easy_invoice_generate_pdf', array($this, 'generateInvoicePdf'));
50 51 add_action('wp_ajax_easy_invoice_generate_quote_pdf', array($this, 'generateQuotePdf'));
@@ -75,12 +76,22 @@
75 76 */
76 77 public function saveInvoice() {
77 78 $this->verifyNonce('easy_invoice_nonce');
78 79
79 - if (!current_user_can('manage_options')) {
80 + if (!easy_invoice_user_can('ei_create_invoice')) {
80 81 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
81 82 }
82 83
84 + // Lifecycle-stage edit gate. Addons (PartialPayments) can return
85 + // false here to block edits to invoices whose state shouldn't
86 + // change anymore — e.g. a deposit invoice that has already been
87 + // paid (where editing items would silently invalidate the
88 + // deposit/balance pair the customer already saw).
89 + $editing_invoice_id = isset($_POST['invoice_id']) ? (int) $_POST['invoice_id'] : 0;
90 + if ($editing_invoice_id > 0 && !apply_filters('easy_invoice_can_edit_invoice', true, $editing_invoice_id)) {
91 + $this->sendError(__('This deposit invoice has already been paid and is locked from further edits. Add the new line item to the linked balance invoice instead.', 'easy-invoice'));
92 + }
93 +
83 94 // Get the raw invoice data from the form
84 95 $raw_invoice_data = isset($_POST['invoice_data']) ? $_POST['invoice_data'] : $_POST;
85 96
86 97 // Remove non-invoice fields
@@ -91,10 +102,11 @@
91 102 $invoice_form_manager = new \EasyInvoice\Forms\Invoice\InvoiceFormManager();
92 103 $invoice_data = $invoice_form_manager->processFormData($raw_invoice_data);
93 104
94 105 if (!empty($invoice_data['errors'])) {
106 + // The toast is what the user sees; the field may sit on another tab.
95 107 wp_send_json_error([
96 - 'message' => 'Validation failed',
108 + 'message' => implode(' ', array_map('strval', $invoice_data['errors'])),
97 109 'errors' => $invoice_data['errors']
98 110 ]);
99 111 }
100 112
@@ -156,8 +168,17 @@
156 168 if (!$invoice) {
157 169 $this->sendError(__('Failed to create invoice', 'easy-invoice'));
158 170 }
159 171
172 + // The repository claimed a number under its lock (or generated the next
173 + // one when the number the builder peeked on page load was taken
174 + // meanwhile). Carry that claimed number into the form data: the
175 + // FormProcessor below writes every posted field, and the stale peek
176 + // would otherwise overwrite the claim — two builders open at once
177 + // then saved two documents with the same number.
178 + $invoice_data['data']['number'] = $invoice->getNumber();
179 + unset($invoice_data['data']['invoice_number']);
180 +
160 181 // Use FormProcessor to save form data to database
161 182 $form_processor = new \EasyInvoice\Forms\FormProcessor();
162 183 $all_fields = $invoice_form_manager->getAllFields();
163 184 $form_processor->saveFormDataToDatabase($invoice_data['data'], $all_fields, $invoice);
@@ -213,9 +234,10 @@
213 234 */
214 235 public function saveAndSendInvoice() {
215 236 $this->verifyNonce('easy_invoice_nonce');
216 237
217 - if (!current_user_can('manage_options')) {
238 + // Compound action: needs both create-edit and send rights.
239 + if (!easy_invoice_user_can('ei_create_invoice') || !easy_invoice_user_can('ei_send_invoice')) {
218 240 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
219 241 }
220 242
221 243 // First save the invoice
@@ -247,9 +269,9 @@
247 269 */
248 270 public function deleteInvoice() {
249 271 $this->verifyNonce('easy_invoice_nonce');
250 272
251 - if (!current_user_can('manage_options')) {
273 + if (!easy_invoice_user_can('ei_delete_invoice')) {
252 274 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
253 275 }
254 276
255 277 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
@@ -276,9 +298,9 @@
276 298 */
277 299 public function getInvoice() {
278 300 $this->verifyNonce('easy_invoice_nonce');
279 301
280 - if (!current_user_can('manage_options')) {
302 + if (!easy_invoice_user_can('ei_view_invoices')) {
281 303 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
282 304 }
283 305
284 306 $invoice_id = isset($_REQUEST['invoice_id']) ? intval($_REQUEST['invoice_id']) : 0;
@@ -304,9 +326,9 @@
304 326 */
305 327 public function saveClient() {
306 328 $this->verifyNonce('easy_invoice_nonce');
307 329
308 - if (!current_user_can('manage_options')) {
330 + if (!easy_invoice_user_can('ei_manage_clients')) {
309 331 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
310 332 }
311 333
312 334 $client_id = isset($_POST['client_id']) ? intval($_POST['client_id']) : 0;
@@ -352,14 +374,17 @@
352 374 public function deleteClient() {
353 375 try {
354 376 $this->verifyNonce('easy_invoice_nonce');
355 377
356 - if (!current_user_can('manage_options')) {
378 + if (!easy_invoice_user_can('ei_manage_clients')) {
357 379 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
358 380 }
359 381
360 382 $client_id = isset($_POST['client_id']) ? intval($_POST['client_id']) : 0;
361 - $delete_associated_documents = isset($_POST['delete_associated_documents']) ? (bool)$_POST['delete_associated_documents'] : false;
383 + // The dialog posts the literal strings "true" / "false"; a bool cast
384 + // made "false" true, so "Delete client only" removed the documents too.
385 + $delete_associated_documents = isset($_POST['delete_associated_documents'])
386 + && filter_var(wp_unslash($_POST['delete_associated_documents']), FILTER_VALIDATE_BOOLEAN);
362 387
363 388 if ($client_id <= 0) {
364 389 $this->sendError(__('Invalid client ID', 'easy-invoice'));
365 390 }
@@ -386,18 +411,25 @@
386 411 "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = '_easy_invoice_quote_client_id' AND meta_value = %d",
387 412 $client_id
388 413 ));
389 414
390 - $payment_count = $wpdb->get_var($wpdb->prepare(
391 - "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = '_easy_payment_client_id' AND meta_value = %d",
392 - $client_id
393 - ));
415 + // Counted through the client's invoices, because that is the only
416 + // link there is: payments record '_invoice_id' and no client id.
417 + // This counted meta '_easy_payment_client_id', which nothing writes,
418 + // so the confirmation dialog told every user there were no payments
419 + // no matter how many there were.
420 + $payment_count = count(\EasyInvoice\Services\ClientLedger::paymentIds((int) $client_id));
394 421
395 422 $total_documents = $invoice_count + $quote_count + $payment_count;
396 423
397 424 if ($delete_associated_documents) {
398 425 // Delete all associated documents
399 - $this->log(sprintf('Deleting client %d with all associated documents (%d invoices, %d quotes, %d payments)',
426 + // error_log(), not $this->log(): no such method exists on this class or
427 + // any trait it uses, so both branches of this handler raised
428 + // "Call to undefined method" — deleting a client failed with a critical
429 + // error whichever option the administrator chose. Matches the logging
430 + // used elsewhere in the plugin.
431 + error_log(sprintf('Easy Invoice: deleting client %d with all associated documents (%d invoices, %d quotes, %d payments)',
400 432 $client_id, $invoice_count, $quote_count, $payment_count));
401 433
402 434 // Delete invoices
403 435 if ($invoice_count > 0) {
@@ -421,22 +453,56 @@
421 453 }
422 454 }
423 455
424 456 // Delete payments
425 - if ($payment_count > 0) {
426 - $payments = $wpdb->get_col($wpdb->prepare(
427 - "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_easy_invoice_payment_client_id' AND meta_value = %d",
428 - $client_id
429 - ));
430 - foreach ($payments as $payment_id) {
431 - wp_delete_post($payment_id, true);
432 - }
433 - }
457 + //
458 + // This queried '_easy_invoice_payment_client_id' while the matching
459 + // count above (and the meta cleanup below, and
460 + // ClientRepository::countClientPayments) all query
461 + // '_easy_payment_client_id' — so the count and the deletion disagreed
462 + // on which key identifies a client's payments. Unified on
463 + // '_easy_payment_client_id', the key the other three sites use.
464 + //
465 + // Payments are RETAINED, on purpose, and the message below says so.
466 + //
467 + // Neither of those meta keys is ever written: a payment stores
468 + // '_invoice_id' and carries no client id at all, so it is linked to
469 + // a client only through its invoice. Client deletion has therefore
470 + // never removed a payment, whatever the dialog implied.
471 + //
472 + // $payment_count is now resolved correctly through ClientLedger, so
473 + // the count is true even though the behaviour is unchanged. Making
474 + // the deletion true as well would destroy records this path has
475 + // never touched — a payment is the evidence money changed hands, and
476 + // the reasoning that stops InvoiceRetention deleting an issued
477 + // invoice applies to it. That is a deliberate decision to keep them,
478 + // not an oversight, so it is stated to the user rather than hidden.
434 479
435 - $message = sprintf(__('Client and all associated documents (%d total) deleted successfully', 'easy-invoice'), $total_documents);
480 + // Say what was kept as well as what went. "All associated documents
481 + // deleted" was never true where payments were concerned, and a
482 + // merchant who believes their payment records are gone will look
483 + // for them in the wrong place at the wrong time of year.
484 + $message = $payment_count > 0
485 + ? sprintf(
486 + /* translators: 1: number of documents deleted, 2: number of payment records kept. */
487 + _n(
488 + 'Client deleted, along with %1$d document. %2$d payment record was kept as a financial record.',
489 + 'Client deleted, along with %1$d documents. %2$d payment records were kept as financial records.',
490 + $payment_count,
491 + 'easy-invoice'
492 + ),
493 + $invoice_count + $quote_count,
494 + $payment_count
495 + )
496 + : sprintf(
497 + /* translators: %d: number of documents deleted. */
498 + __('Client and all associated documents (%d total) deleted successfully', 'easy-invoice'),
499 + $total_documents
500 + );
436 501 } else {
437 502 // Only remove client associations, preserve documents
438 - $this->log(sprintf('Removing client associations for client %d (%d invoices, %d quotes, %d payments)',
503 + // See the note on the other branch above.
504 + error_log(sprintf('Easy Invoice: removing client associations for client %d (%d invoices, %d quotes, %d payments)',
439 505 $client_id, $invoice_count, $quote_count, $payment_count));
440 506
441 507 // Remove client associations from invoices
442 508 if ($invoice_count > 0) {
@@ -461,11 +527,17 @@
461 527 ['meta_key' => '_easy_payment_client_id', 'meta_value' => $client_id]
462 528 );
463 529 }
464 530
531 + /* translators: %d: number of documents. */
465 532 $message = sprintf(__('Client deleted successfully. %d documents preserved but client associations removed.', 'easy-invoice'), $total_documents);
466 533 }
467 534
535 + // Snapshot identity BEFORE delete — once wp_delete_user runs the
536 + // user record is gone and we can't backfill the audit context.
537 + $deleted_login = $user && $user->user_login ? $user->user_login : '';
538 + $deleted_email = $user && $user->user_email ? $user->user_email : '';
539 +
468 540 // Delete the WordPress user
469 541 require_once(ABSPATH . 'wp-admin/includes/user.php');
470 542 $result = wp_delete_user($client_id);
471 543
@@ -472,8 +544,20 @@
472 544 if (!$result) {
473 545 $this->sendError(__('Failed to delete client', 'easy-invoice'));
474 546 }
475 547
548 + // Audit: record the delete with enough context to investigate later.
549 + if (function_exists('easy_invoice_audit_log')) {
550 + easy_invoice_audit_log('client_deleted', 'client', $client_id, [
551 + 'login' => $deleted_login,
552 + 'email' => $deleted_email,
553 + 'invoices_affected' => (int) $invoice_count,
554 + 'quotes_affected' => (int) $quote_count,
555 + 'payments_affected' => (int) $payment_count,
556 + 'cascade_delete' => $delete_associated_documents,
557 + ]);
558 + }
559 +
476 560 $this->sendSuccess(array(
477 561 'message' => $message,
478 562 'client_id' => $client_id,
479 563 'documents_deleted' => $delete_associated_documents,
@@ -490,9 +574,9 @@
490 574 */
491 575 public function getClient() {
492 576 $this->verifyNonce('easy_invoice_nonce');
493 577
494 - if (!current_user_can('manage_options')) {
578 + if (!easy_invoice_user_can('ei_view_clients')) {
495 579 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
496 580 }
497 581
498 582 $client_id = isset($_REQUEST['client_id']) ? intval($_REQUEST['client_id']) : 0;
@@ -606,10 +690,21 @@
606 690 private function sendSuccess($data = array()) {
607 691 // Check if we should suppress global toast
608 692 $suppress_toast = isset($_POST['suppress_global_toast']) && $_POST['suppress_global_toast'] === 'true';
609 693
610 - // Add toast notification if not already present and not suppressed
611 - if (!isset($data['toast']) && !$suppress_toast) {
694 + // Only inject the toast key when $data is an associative array
695 + // (or empty). If $data is a numeric-indexed list (e.g. search
696 + // results), adding a string key would mutate the array shape:
697 + // PHP keeps the mixed keys, but `wp_send_json_success` then
698 + // serialises the value as a JSON OBJECT instead of an array,
699 + // breaking any frontend that does `response.data.length` or
700 + // `response.data.forEach(...)` — the exact bug that caused the
701 + // client-search dropdown to silently render empty results.
702 + $is_assoc_or_empty = !is_array($data)
703 + || empty($data)
704 + || array_keys($data) !== range(0, count($data) - 1);
705 +
706 + if ($is_assoc_or_empty && !isset($data['toast']) && !$suppress_toast) {
612 707 $message = isset($data['message']) ? $data['message'] : __('Operation completed successfully', 'easy-invoice');
613 708 $data['toast'] = array(
614 709 'type' => 'success',
615 710 'message' => $message,
@@ -617,9 +712,9 @@
617 712 );
618 713 }
619 714
620 715 // Remove toast data if suppressed
621 - if ($suppress_toast && isset($data['toast'])) {
716 + if ($is_assoc_or_empty && $suppress_toast && isset($data['toast'])) {
622 717 unset($data['toast']);
623 718 }
624 719
625 720 wp_send_json_success($data);
@@ -646,9 +741,9 @@
646 741 // Verify nonce
647 742 $this->verifyNonce('easy_invoice_nonce');
648 743
649 744 // Check if user has required capability
650 - if (!current_user_can('edit_posts')) {
745 + if (!easy_invoice_user_can('ei_view_invoices')) {
651 746 $this->sendError(__('You do not have permission to download invoices', 'easy-invoice'));
652 747 }
653 748
654 749 // Get invoice ID
@@ -681,9 +776,9 @@
681 776 */
682 777 public function sendInvoiceEmail() {
683 778 $this->verifyNonce('easy_invoice_nonce');
684 779
685 - if (!current_user_can('manage_options')) {
780 + if (!easy_invoice_user_can('ei_send_invoice')) {
686 781 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
687 782 }
688 783
689 784 // Get invoice ID from POST data
@@ -704,8 +799,15 @@
704 799 $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
705 800 $result = $email_manager->sendInvoiceEmail($invoice, 'new');
706 801
707 802 if ($result['success']) {
803 + // Audit: who sent which invoice to which client, at what time.
804 + if (function_exists('easy_invoice_audit_log')) {
805 + easy_invoice_audit_log('invoice_sent', 'invoice', $invoice_id, [
806 + 'recipient' => is_callable([$invoice, 'getCustomerEmail']) ? $invoice->getCustomerEmail() : '',
807 + 'context' => 'new',
808 + ]);
809 + }
708 810 $this->sendSuccess(array(
709 811 'message' => $result['message']
710 812 ));
711 813 } else {
@@ -720,9 +822,9 @@
720 822 // Verify nonce
721 823 $this->verifyNonce('easy_invoice_nonce');
722 824
723 825 // Check if user has required capability
724 - if (!current_user_can('edit_posts')) {
826 + if (!easy_invoice_user_can('ei_view_quotes')) {
725 827 $this->sendError(__('You do not have permission to download quotes', 'easy-invoice'));
726 828 }
727 829
728 830 // Get quote ID
@@ -747,9 +849,10 @@
747 849 'quote_data' => $quote->toArray(),
748 850 'download_url' => add_query_arg(array(
749 851 'action' => 'easy_invoice_generate_quote_pdf',
750 852 'quote_id' => $quote_id,
751 - 'nonce' => wp_create_nonce('generate_quote_pdf')
853 + // Bound to this quote — see the invoice equivalent above.
854 + 'nonce' => wp_create_nonce('generate_quote_pdf_' . $quote_id)
752 855 ), admin_url('admin-ajax.php'))
753 856 ));
754 857 }
755 858
@@ -758,9 +861,9 @@
758 861 */
759 862 public function saveQuote() {
760 863 $this->verifyNonce('easy_invoice_nonce');
761 864
762 - if (!current_user_can('manage_options')) {
865 + if (!easy_invoice_user_can('ei_create_quote')) {
763 866 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
764 867 }
765 868
766 869 // Get the raw quote data from the form
@@ -775,9 +878,9 @@
775 878 $quote_data = $quote_form_manager->processFormData($raw_quote_data);
776 879
777 880 if (!empty($quote_data['errors'])) {
778 881 wp_send_json_error([
779 - 'message' => 'Validation failed',
882 + 'message' => implode(' ', array_map('strval', $quote_data['errors'])),
780 883 'errors' => $quote_data['errors']
781 884 ]);
782 885 }
783 886
@@ -845,8 +948,13 @@
845 948 if (!$quote) {
846 949 $this->sendError(__('Failed to create quote', 'easy-invoice'));
847 950 }
848 951
952 + // Same as invoices: keep the number the repository claimed, not the one
953 + // the builder peeked on page load (see saveInvoice()).
954 + $quote_data['data']['number'] = $quote->getNumber();
955 + unset($quote_data['data']['quote_number']);
956 +
849 957 // Use FormProcessor to save form data to database
850 958 $form_processor = new \EasyInvoice\Forms\FormProcessor();
851 959 $all_fields = $quote_form_manager->getAllFields();
852 960 $form_processor->saveFormDataToDatabase($quote_data['data'], $all_fields, $quote);
@@ -911,9 +1019,11 @@
911 1019 */
912 1020 public function checkEmailExists() {
913 1021 $this->verifyNonce('easy_invoice_nonce');
914 1022
915 - if (!current_user_can('manage_options')) {
1023 + // Email-lookup is used during client creation; anyone who can manage
1024 + // clients can check duplicates.
1025 + if (!easy_invoice_user_can('ei_manage_clients')) {
916 1026 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
917 1027 }
918 1028
919 1029 $email = isset($_POST['email']) ? sanitize_email($_POST['email']) : '';
@@ -936,9 +1046,10 @@
936 1046 */
937 1047 public function generatePassword() {
938 1048 $this->verifyNonce('easy_invoice_nonce');
939 1049
940 - if (!current_user_can('manage_options')) {
1050 + // Used when creating a client (WP user); same gate as client management.
1051 + if (!easy_invoice_user_can('ei_manage_clients')) {
941 1052 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
942 1053 }
943 1054
944 1055 $password = wp_generate_password(16, true, true);
@@ -1021,9 +1132,9 @@
1021 1132 public function addClient() {
1022 1133
1023 1134 $this->verifyNonce('easy_invoice_nonce');
1024 1135
1025 - if (!current_user_can('manage_options')) {
1136 + if (!easy_invoice_user_can('ei_manage_clients')) {
1026 1137 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
1027 1138 }
1028 1139
1029 1140 // Check if required fields are present
@@ -1029,23 +1140,27 @@
1029 1140 // Check if required fields are present
1030 1141 $required_fields = ['business_client_name', 'email', 'username'];
1031 1142 foreach ($required_fields as $field) {
1032 1143 if (!isset($_POST[$field]) || empty($_POST[$field])) {
1033 - $this->sendError(__('Missing required field: ' . $field, 'easy-invoice'));
1144 + /* translators: %s: form field name. */
1145 + $this->sendError(sprintf(__('Missing required field: %s', 'easy-invoice'), $field));
1034 1146 }
1035 1147 }
1036 1148
1037 1149 // Prepare client data
1150 + // Optional fields may be absent from the request entirely.
1151 + $post_text = static function ($key) { return isset($_POST[$key]) ? sanitize_text_field(wp_unslash($_POST[$key])) : ''; }; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- checked above.
1152 + $post_area = static function ($key) { return isset($_POST[$key]) ? sanitize_textarea_field(wp_unslash($_POST[$key])) : ''; }; // phpcs:ignore WordPress.Security.NonceVerification.Missing
1038 1153 $client_data = [
1039 - ClientFields::BUSINESS_CLIENT_NAME => sanitize_text_field($_POST['business_client_name']),
1040 - ClientFields::EMAIL => sanitize_email($_POST['email']),
1041 - ClientFields::USERNAME => sanitize_user($_POST['username']),
1042 - ClientFields::PASSWORD => $_POST['password'],
1043 - ClientFields::ADDRESS => sanitize_textarea_field($_POST['address']),
1044 - ClientFields::EXTRA_INFO => sanitize_textarea_field($_POST['extra_info']),
1045 - ClientFields::FIRST_NAME => sanitize_text_field($_POST['first_name']),
1046 - ClientFields::LAST_NAME => sanitize_text_field($_POST['last_name']),
1047 - ClientFields::WEBSITE => esc_url_raw($_POST['website']),
1154 + ClientFields::BUSINESS_CLIENT_NAME => $post_text('business_client_name'),
1155 + ClientFields::EMAIL => sanitize_email(wp_unslash(($_POST['email'] ?? ''))),
1156 + ClientFields::USERNAME => sanitize_user(wp_unslash(($_POST['username'] ?? ''))),
1157 + ClientFields::PASSWORD => isset($_POST['password']) ? (string) wp_unslash($_POST['password']) : '',
1158 + ClientFields::ADDRESS => $post_area('address'),
1159 + ClientFields::EXTRA_INFO => $post_area('extra_info'),
1160 + ClientFields::FIRST_NAME => $post_text('first_name'),
1161 + ClientFields::LAST_NAME => $post_text('last_name'),
1162 + ClientFields::WEBSITE => isset($_POST['website']) ? esc_url_raw(wp_unslash($_POST['website'])) : '',
1048 1163 ClientFields::PHONE => isset($_POST['phone']) ? sanitize_text_field($_POST['phone']) : '',
1049 1164 ];
1050 1165
1051 1166
@@ -1064,16 +1179,27 @@
1064 1179 // Create new client
1065 1180 $client = $repository->create($client_data);
1066 1181
1067 1182 if (!$client) {
1068 - $this->sendError(__('Failed to create client', 'easy-invoice'));
1183 + $reason = method_exists($repository, 'getLastError') ? $repository->getLastError() : '';
1184 + $this->sendError($reason !== '' ? $reason : __('Failed to create client', 'easy-invoice'));
1069 1185 }
1070 1186
1071 1187 $client_id = $client->getId();
1072 1188
1189 + // Pull the WP role assigned during user creation. The
1190 + // Clients-page row template needs this so the new-row badge
1191 + // matches the role that will be re-rendered server-side on the
1192 + // next page load. Without this, the JS template would have to
1193 + // hardcode a role label and could drift from PHP's value.
1194 + $user = get_user_by('id', $client_id);
1195 + $role = ($user && !empty($user->roles)) ? (string) $user->roles[0] : 'customer';
1196 +
1073 1197 $response_data = array(
1074 1198 'message' => __('Client added successfully', 'easy-invoice'),
1075 1199 'client_id' => $client_id,
1200 + 'role' => $role,
1201 + 'role_label' => ucfirst($role),
1076 1202 'client' => $client->toArray(),
1077 1203 );
1078 1204
1079 1205 $this->sendSuccess($response_data);
@@ -1084,9 +1210,9 @@
1084 1210 */
1085 1211 public function updateClient() {
1086 1212 $this->verifyNonce('easy_invoice_nonce');
1087 1213
1088 - if (!current_user_can('manage_options')) {
1214 + if (!easy_invoice_user_can('ei_manage_clients')) {
1089 1215 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
1090 1216 }
1091 1217
1092 1218 $client_id = isset($_POST['client_id']) ? intval($_POST['client_id']) : 0;
@@ -1096,18 +1222,18 @@
1096 1222 }
1097 1223
1098 1224 // Prepare client data
1099 1225 $client_data = [
1100 - ClientFields::BUSINESS_CLIENT_NAME => sanitize_text_field($_POST['business_client_name']),
1101 - ClientFields::EMAIL => sanitize_email($_POST['email']),
1102 - ClientFields::USERNAME => sanitize_user($_POST['username']),
1103 - ClientFields::PASSWORD => $_POST['password'], // Keep password as is, don't sanitize
1104 - ClientFields::ADDRESS => sanitize_textarea_field($_POST['address']),
1226 + ClientFields::BUSINESS_CLIENT_NAME => sanitize_text_field(($_POST['business_client_name'] ?? '')),
1227 + ClientFields::EMAIL => sanitize_email(($_POST['email'] ?? '')),
1228 + ClientFields::USERNAME => sanitize_user(($_POST['username'] ?? '')),
1229 + ClientFields::PASSWORD => ($_POST['password'] ?? ''), // Keep password as is, don't sanitize
1230 + ClientFields::ADDRESS => sanitize_textarea_field(($_POST['address'] ?? '')),
1105 1231 ClientFields::PHONE => isset($_POST['phone']) ? sanitize_text_field($_POST['phone']) : '',
1106 - ClientFields::EXTRA_INFO => sanitize_textarea_field($_POST['extra_info']),
1107 - ClientFields::FIRST_NAME => sanitize_text_field($_POST['first_name']),
1108 - ClientFields::LAST_NAME => sanitize_text_field($_POST['last_name']),
1109 - ClientFields::WEBSITE => esc_url_raw($_POST['website'])
1232 + ClientFields::EXTRA_INFO => sanitize_textarea_field(($_POST['extra_info'] ?? '')),
1233 + ClientFields::FIRST_NAME => sanitize_text_field(($_POST['first_name'] ?? '')),
1234 + ClientFields::LAST_NAME => sanitize_text_field(($_POST['last_name'] ?? '')),
1235 + ClientFields::WEBSITE => esc_url_raw(($_POST['website'] ?? ''))
1110 1236 ];
1111 1237
1112 1238 // Remove empty values except password (password can be empty for updates)
1113 1239 $client_data = array_filter($client_data, function($value, $key) {
@@ -1142,8 +1268,9 @@
1142 1268 */
1143 1269 public function updateInvoicesData() {
1144 1270 $this->verifyNonce('easy_invoice_admin_nonce');
1145 1271
1272 + // Bulk migration / repair of invoice records — admin-only.
1146 1273 if (!current_user_can('manage_options')) {
1147 1274 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
1148 1275 }
1149 1276
@@ -1220,8 +1347,9 @@
1220 1347 }
1221 1348 }
1222 1349
1223 1350 $this->sendSuccess(array(
1351 + /* translators: %d: number updated. */
1224 1352 'message' => sprintf(__('Updated %d invoices with missing data', 'easy-invoice'), $updated_count),
1225 1353 'updated_count' => $updated_count
1226 1354 ));
1227 1355 }
@@ -1239,21 +1367,30 @@
1239 1367 if (!$invoice_id) {
1240 1368 $this->sendError(__('Invalid invoice ID', 'easy-invoice'));
1241 1369 }
1242 1370
1243 - // Get invoice from repository (only published invoices for public access)
1244 1371 $repository = InvoiceServiceProvider::getInvoiceRepository();
1372 + $invoice = $repository->find($invoice_id);
1245 1373
1246 - // For admins, allow access to any invoice status
1247 - if (current_user_can('manage_options')) {
1248 - $invoice = $repository->find($invoice_id);
1249 - } else {
1250 - // For non-admins, only allow access to published invoices
1251 - $invoice = $repository->findPublished($invoice_id);
1374 + if (!$invoice) {
1375 + $this->sendError(__('Invoice not found', 'easy-invoice'));
1252 1376 }
1253 1377
1254 - if (!$invoice) {
1255 - $this->sendError(__('Invoice not found', 'easy-invoice'));
1378 + // Authorisation.
1379 + //
1380 + // This used to read: admins get find(), everyone else gets findPublished().
1381 + // That was not a check at all — Models\Invoice::save() writes every invoice
1382 + // with post_status 'publish' regardless of its workflow status, so
1383 + // findPublished() returned the same record find() would have, for anyone.
1384 + // This endpoint is registered for wp_ajax_nopriv, so the effective gate was
1385 + // the nonce alone and any caller holding one could pull the PDF data for an
1386 + // arbitrary invoice id, including drafts.
1387 + //
1388 + // Uses the same helper as the rest of the plugin so there is a single
1389 + // definition of who may see a document: valid ?ik= token, administrator, or
1390 + // the logged-in client the invoice is bound to.
1391 + if (!\EasyInvoice\Controllers\InvoiceController::canSubmitPaymentForInvoice($invoice_id, $invoice)) {
1392 + $this->sendError(__('You do not have permission to access this invoice', 'easy-invoice'));
1256 1393 }
1257 1394
1258 1395 // Get invoice data for PDF generation
1259 1396 $invoice_data = \EasyInvoice\Includes\Helpers\PdfHelper::getInvoiceDataForPdf($invoice);
@@ -1265,9 +1402,12 @@
1265 1402 'invoice_data' => $invoice_data,
1266 1403 'download_url' => add_query_arg(array(
1267 1404 'action' => 'easy_invoice_generate_pdf',
1268 1405 'invoice_id' => $invoice_id,
1269 - 'nonce' => wp_create_nonce('generate_pdf')
1406 + // Bound to this invoice: an unscoped 'generate_pdf' nonce could be
1407 + // taken from a document the caller may legitimately see and replayed
1408 + // against any other invoice id.
1409 + 'nonce' => wp_create_nonce('generate_pdf_' . $invoice_id)
1270 1410 ), admin_url('admin-ajax.php'))
1271 1411 ));
1272 1412 }
1273 1413
@@ -1285,19 +1425,21 @@
1285 1425 }
1286 1426
1287 1427 // Get invoice from repository (only published invoices for public access)
1288 1428 $repository = InvoiceServiceProvider::getInvoiceRepository();
1429 + $invoice = $repository->find($invoice_id);
1289 1430
1290 - // For admins, allow access to any invoice status
1291 - if (current_user_can('manage_options')) {
1292 - $invoice = $repository->find($invoice_id);
1293 - } else {
1294 - // For non-admins, only allow access to published invoices
1295 - $invoice = $repository->findPublished($invoice_id);
1431 + if (!$invoice) {
1432 + $this->sendError(__('Invoice not found', 'easy-invoice'));
1296 1433 }
1297 1434
1298 - if (!$invoice) {
1299 - $this->sendError(__('Invoice not found', 'easy-invoice'));
1435 + // Authorisation. The previous admin / findPublished() split was not a check:
1436 + // every invoice is saved with post_status 'publish', so findPublished()
1437 + // returned exactly what find() would, for any caller. This endpoint is
1438 + // registered nopriv, so without this an unauthorised caller could make the
1439 + // site email an arbitrary invoice out to its client. See downloadInvoicePdf().
1440 + if (!\EasyInvoice\Controllers\InvoiceController::canSubmitPaymentForInvoice($invoice_id, $invoice)) {
1441 + $this->sendError(__('You do not have permission to access this invoice', 'easy-invoice'));
1300 1442 }
1301 1443
1302 1444 // Use EmailManager to send the email
1303 1445 $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
@@ -1312,13 +1454,61 @@
1312 1454 }
1313 1455 }
1314 1456
1315 1457 /**
1458 + * Send quote via email (admin + public; guests only for published quotes).
1459 + */
1460 + public function sendQuoteEmailPublic() {
1461 + $this->verifyNonce('easy_invoice_send_quote_email');
1462 +
1463 + $quote_id = isset($_POST['quote_id']) ? intval($_POST['quote_id']) : 0;
1464 +
1465 + if (!$quote_id) {
1466 + $this->sendError(__('Invalid quote ID', 'easy-invoice'));
1467 + }
1468 +
1469 + $repository = \EasyInvoice\Providers\QuoteServiceProvider::getQuoteRepository();
1470 + $quote = $repository->find($quote_id);
1471 +
1472 + if (!$quote) {
1473 + $this->sendError(__('Quote not found', 'easy-invoice'));
1474 + }
1475 +
1476 + // Authorisation — same reasoning as the invoice path above. Quotes are also
1477 + // always stored with post_status 'publish', so findPublished() gated nothing.
1478 + if (!\EasyInvoice\Controllers\QuoteController::canActOnQuote($quote_id, $quote)) {
1479 + $this->sendError(__('You do not have permission to access this quote', 'easy-invoice'));
1480 + }
1481 +
1482 + $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
1483 + $result = $email_manager->sendQuoteEmail($quote, 'new');
1484 +
1485 + if ($result['success']) {
1486 + $quote_log_service = new \EasyInvoice\Services\QuoteLogService();
1487 + $quote_log_service->logSent($quote_id, $quote->getCustomerEmail());
1488 +
1489 + $this->sendSuccess(array(
1490 + 'message' => $result['message'],
1491 + ));
1492 + } else {
1493 + $this->sendError($result['message']);
1494 + }
1495 + }
1496 +
1497 + /**
1316 1498 * Generate invoice PDF
1317 1499 */
1318 1500 public function generateInvoicePdf() {
1319 - // Verify nonce
1320 - $this->verifyNonce('generate_pdf');
1501 + // Explicitly bust intermediate caching on this admin-ajax URL. Some
1502 + // page-caching stacks (WP Rocket, LiteSpeed, Cloudflare full-page
1503 + // cache, some CDNs) will cache a 302 Location header keyed by URL —
1504 + // the URL always looks the same to the cache because both the nonce
1505 + // AND the target invoice-permalink change per user, so a first-hit
1506 + // response can be replayed to other users, breaking the redirect or
1507 + // returning a blank body.
1508 + nocache_headers();
1509 + header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
1510 + header('Pragma: no-cache');
1321 1511
1322 1512 // Get invoice ID
1323 1513 $invoice_id = isset($_REQUEST['invoice_id']) ? intval($_REQUEST['invoice_id']) : 0;
1324 1514
@@ -1325,8 +1515,57 @@
1325 1515 if (!$invoice_id) {
1326 1516 $this->sendError(__('Invalid invoice ID', 'easy-invoice'));
1327 1517 }
1328 1518
1519 + // Authorisation with graceful fallback.
1520 + //
1521 + // The original design gated this endpoint on a per-request WP
1522 + // nonce, which is fragile in real deployments: page-caching
1523 + // layers (WP Rocket, LiteSpeed, Cloudflare full-page cache)
1524 + // cache the intermediate JSON response that mints the URL,
1525 + // browser SameSite / ITP behaviour, and admin_url()
1526 + // scheme-mismatch after login can all cause wp_verify_nonce()
1527 + // to return false on the intended recipient's tab — leaving
1528 + // the user stranded on this admin-ajax URL with no download.
1529 + //
1530 + // Accept ANY of:
1531 + // 1. A valid `generate_pdf` nonce (fast path — most users,
1532 + // most of the time, when the session cookie survives).
1533 + // 2. An admin session (manage_options) — bypasses the nonce
1534 + // because the invoice-listing button that creates this
1535 + // URL is admin-only and the admin owns the request.
1536 + // 3. A valid per-invoice access token (?ik=<token>) — the
1537 + // same model canSubmitPaymentForInvoice uses, so emailed
1538 + // invoice links can also drive a session-less download.
1539 + // Only when all three paths fail do we refuse.
1540 + $authorized = false;
1541 +
1542 + // Match the same dual-key nonce lookup the removed verifyNonce()
1543 + // helper did — `nonce` (client-form format used by our JS) AND
1544 + // `_nonce` (standard WP form field name) — so any external
1545 + // caller of this endpoint that used _nonce still works.
1546 + $submitted_nonce = '';
1547 + if (isset($_REQUEST['nonce'])) {
1548 + $submitted_nonce = (string) $_REQUEST['nonce'];
1549 + } elseif (isset($_REQUEST['_nonce'])) {
1550 + $submitted_nonce = (string) $_REQUEST['_nonce'];
1551 + }
1552 + if ($submitted_nonce !== '' && wp_verify_nonce($submitted_nonce, 'generate_pdf_' . $invoice_id)) {
1553 + $authorized = true;
1554 + } elseif (current_user_can('manage_options')) {
1555 + $authorized = true;
1556 + } elseif (isset($_REQUEST['ik']) && is_string($_REQUEST['ik'])) {
1557 + $presented = sanitize_text_field(wp_unslash($_REQUEST['ik']));
1558 + $stored = (string) get_post_meta($invoice_id, '_easy_invoice_invoice_access_token', true);
1559 + if ($stored !== '' && $presented !== '' && hash_equals($stored, $presented)) {
1560 + $authorized = true;
1561 + }
1562 + }
1563 +
1564 + if (!$authorized) {
1565 + $this->sendError(__('Security check failed', 'easy-invoice'));
1566 + }
1567 +
1329 1568 // Get invoice from repository
1330 1569 $repository = InvoiceServiceProvider::getInvoiceRepository();
1331 1570
1332 1571 // For admins, allow access to any invoice status
@@ -1340,16 +1579,24 @@
1340 1579 if (!$invoice) {
1341 1580 $this->sendError(__('Invoice not found', 'easy-invoice'));
1342 1581 }
1343 1582
1344 - // Redirect to the invoice single page with PDF generation
1583 + // Redirect to the invoice single page with PDF generation.
1584 + // Forward the ?ik= access token onwards so the single-page
1585 + // template can also authorise the recipient (the same token
1586 + // that got us through Path 3 above).
1345 1587 $invoice_url = get_permalink($invoice_id);
1346 - if ($invoice_url) {
1347 - wp_redirect(add_query_arg('auto_download_pdf', '1', $invoice_url));
1348 - exit;
1349 - } else {
1588 + if (!$invoice_url) {
1350 1589 $this->sendError(__('Could not generate invoice URL', 'easy-invoice'));
1351 1590 }
1591 +
1592 + $target_args = ['auto_download_pdf' => '1'];
1593 + if (isset($_REQUEST['ik']) && is_string($_REQUEST['ik']) && $_REQUEST['ik'] !== '') {
1594 + $target_args['ik'] = sanitize_text_field(wp_unslash($_REQUEST['ik']));
1595 + }
1596 + $target_url = add_query_arg($target_args, $invoice_url);
1597 +
1598 + $this->redirectWithFallback($target_url);
1352 1599 }
1353 1600
1354 1601 /**
1355 1602 * Generate quote PDF
@@ -1354,10 +1601,12 @@
1354 1601 /**
1355 1602 * Generate quote PDF
1356 1603 */
1357 1604 public function generateQuotePdf() {
1358 - // Verify nonce
1359 - $this->verifyNonce('generate_quote_pdf');
1605 + // Same cache-busting as generateInvoicePdf — see comment there.
1606 + nocache_headers();
1607 + header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
1608 + header('Pragma: no-cache');
1360 1609
1361 1610 // Get quote ID
1362 1611 $quote_id = isset($_REQUEST['quote_id']) ? intval($_REQUEST['quote_id']) : 0;
1363 1612
@@ -1364,8 +1613,45 @@
1364 1613 if (!$quote_id) {
1365 1614 $this->sendError(__('Invalid quote ID', 'easy-invoice'));
1366 1615 }
1367 1616
1617 + // Authorisation with graceful fallback. Same three-path model
1618 + // as generateInvoicePdf — see that method's comment for the
1619 + // full rationale (nonce fragility across caching layers,
1620 + // cross-tab session cookie behaviour, etc.). Paths accepted:
1621 + //
1622 + // 1. Valid `generate_quote_pdf` nonce (fast path).
1623 + // 2. Admin session (manage_options) — the quote-listing
1624 + // button that mints this URL is admin-only.
1625 + // 3. Valid per-quote access token (?qk=<token>) — mirrors
1626 + // the CVE-2026-9021 model so emailed quote links can
1627 + // drive a session-less PDF download.
1628 + $authorized = false;
1629 +
1630 + // Same dual-key nonce lookup as the invoice handler — see
1631 + // generateInvoicePdf for the backward-compat rationale.
1632 + $submitted_nonce = '';
1633 + if (isset($_REQUEST['nonce'])) {
1634 + $submitted_nonce = (string) $_REQUEST['nonce'];
1635 + } elseif (isset($_REQUEST['_nonce'])) {
1636 + $submitted_nonce = (string) $_REQUEST['_nonce'];
1637 + }
1638 + if ($submitted_nonce !== '' && wp_verify_nonce($submitted_nonce, 'generate_quote_pdf_' . $quote_id)) {
1639 + $authorized = true;
1640 + } elseif (current_user_can('manage_options')) {
1641 + $authorized = true;
1642 + } elseif (isset($_REQUEST['qk']) && is_string($_REQUEST['qk'])) {
1643 + $presented = sanitize_text_field(wp_unslash($_REQUEST['qk']));
1644 + $stored = (string) get_post_meta($quote_id, '_easy_invoice_quote_access_token', true);
1645 + if ($stored !== '' && $presented !== '' && hash_equals($stored, $presented)) {
1646 + $authorized = true;
1647 + }
1648 + }
1649 +
1650 + if (!$authorized) {
1651 + $this->sendError(__('Security check failed', 'easy-invoice'));
1652 + }
1653 +
1368 1654 // Get quote from repository — mirror invoice PDF: only published quotes for non-admins (incl. nopriv).
1369 1655 $repository = \EasyInvoice\Providers\QuoteServiceProvider::getQuoteRepository();
1370 1656 if (current_user_can('manage_options')) {
1371 1657 $quote = $repository->find($quote_id);
@@ -1376,16 +1662,62 @@
1376 1662 if (!$quote) {
1377 1663 $this->sendError(__('Quote not found', 'easy-invoice'));
1378 1664 }
1379 1665
1380 - // Redirect to the quote single page with PDF generation
1666 + // Redirect to the quote single page with PDF generation.
1667 + // Forward the ?qk= access token so the single-page template
1668 + // can also authorise the recipient with the same key that
1669 + // got us through Path 3.
1381 1670 $quote_url = get_permalink($quote_id);
1382 - if ($quote_url) {
1383 - wp_redirect(add_query_arg('auto_download_pdf', '1', $quote_url));
1671 + if (!$quote_url) {
1672 + $this->sendError(__('Could not generate quote URL', 'easy-invoice'));
1673 + }
1674 +
1675 + $target_args = ['auto_download_pdf' => '1'];
1676 + if (isset($_REQUEST['qk']) && is_string($_REQUEST['qk']) && $_REQUEST['qk'] !== '') {
1677 + $target_args['qk'] = sanitize_text_field(wp_unslash($_REQUEST['qk']));
1678 + }
1679 + $target_url = add_query_arg($target_args, $quote_url);
1680 +
1681 + $this->redirectWithFallback($target_url);
1682 + }
1683 +
1684 + /**
1685 + * Redirect the current request to $url, with a client-side fallback
1686 + * when the server-side redirect can't fire.
1687 + *
1688 + * `wp_safe_redirect()` silently no-ops if headers have already been sent
1689 + * (BOM in a plugin file, plugin echoing during an action, PHP warning
1690 + * output, etc.). Because we also `exit;` immediately after, that failure
1691 + * mode produces a 200 OK with an empty body — the reported blank-page
1692 + * bug on the invoice-listing PDF download.
1693 + *
1694 + * This helper detects the headers-sent case and emits a minimal HTML
1695 + * document that redirects via meta-refresh (works with JS disabled) and
1696 + * `window.location.replace()` (JS-enabled, doesn't add a history entry).
1697 + * Both point at the same escaped URL so misconfigured stacks still get
1698 + * the user to the target page.
1699 + */
1700 + private function redirectWithFallback(string $url): void {
1701 + // Suppress cache one more time in case some plugin filtered our
1702 + // earlier headers away between then and now.
1703 + nocache_headers();
1704 +
1705 + if (!headers_sent()) {
1706 + wp_safe_redirect($url);
1384 1707 exit;
1385 - } else {
1386 - $this->sendError(__('Could not generate quote URL', 'easy-invoice'));
1387 1708 }
1709 +
1710 + // Fallback: server-side redirect impossible. Emit a client-side one.
1711 + $safe_url = esc_url_raw($url);
1712 + echo '<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">';
1713 + echo '<meta http-equiv="refresh" content="0; url=' . esc_attr($safe_url) . '">';
1714 + echo '<title>Redirecting…</title>';
1715 + echo '<script>window.location.replace(' . wp_json_encode($safe_url) . ');</script>';
1716 + echo '</head><body>';
1717 + echo '<p>Redirecting to <a href="' . esc_url($safe_url) . '">' . esc_html($safe_url) . '</a>…</p>';
1718 + echo '</body></html>';
1719 + exit;
1388 1720 }
1389 1721
1390 1722 /**
1391 1723 * Search clients for the dropdown
@@ -1392,9 +1724,9 @@
1392 1724 */
1393 1725 public function searchClients() {
1394 1726 $this->verifyNonce('easy_invoice_nonce');
1395 1727
1396 - if (!current_user_can('manage_options')) {
1728 + if (!easy_invoice_user_can('ei_view_clients')) {
1397 1729 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
1398 1730 }
1399 1731
1400 1732 $query = isset($_POST['query']) ? sanitize_text_field($_POST['query']) : '';
@@ -1404,10 +1736,26 @@
1404 1736
1405 1737 // Search clients
1406 1738 $clients = $client_repository->search($query);
1407 1739
1740 + // Row-level security: restrict to assigned clients for Sales reps
1741 + // (users with ei_view_clients but no ei_view_all_clients). Null
1742 + // return = unrestricted, no-op.
1743 + if (function_exists('easy_invoice_visible_client_ids')) {
1744 + $visible = easy_invoice_visible_client_ids();
1745 + if (is_array($visible)) {
1746 + $allowed = array_flip(array_map('intval', $visible));
1747 + $clients = array_values(array_filter($clients, static function ($c) use ($allowed) {
1748 + return isset($allowed[(int) $c->getId()]);
1749 + }));
1750 + }
1751 + }
1752 +
1753 + // Bypass $this->sendSuccess() — search is a read endpoint and
1754 + // shouldn't show "Operation completed successfully" toasts on
1755 + // every keystroke. Use wp_send_json_success directly.
1408 1756 if (empty($clients)) {
1409 - $this->sendSuccess(array());
1757 + wp_send_json_success(array());
1410 1758 }
1411 1759
1412 1760 // Format clients for dropdown
1413 1761 $formatted_clients = array();
@@ -1444,9 +1792,9 @@
1444 1792 'display_name' => $client_name . ' (' . $email . ')'
1445 1793 );
1446 1794 }
1447 1795
1448 - $this->sendSuccess($formatted_clients);
1796 + wp_send_json_success($formatted_clients);
1449 1797 }
1450 1798
1451 1799 /**
1452 1800 * Save additional CSS for invoice/quote
@@ -1452,9 +1800,9 @@
1452 1800 * Save additional CSS for invoice/quote
1453 1801 */
1454 1802 public function saveAdditionalCSS() {
1455 1803 // Verify nonce
1456 - if (!wp_verify_nonce($_POST['nonce'], 'save_additional_css_nonce')) {
1804 + if (!wp_verify_nonce(($_POST['nonce'] ?? ''), 'save_additional_css_nonce')) {
1457 1805 $this->sendError('Security check failed');
1458 1806 return;
1459 1807 }
1460 1808