| @@ -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')); |
| @@ -55,9 +56,8 @@ | ||
| 55 | 56 | add_action('wp_ajax_nopriv_easy_invoice_generate_quote_pdf', array($this, 'generateQuotePdf')); |
| 56 | 57 | |
| 57 | 58 | // Quote document actions |
| 58 | 59 | add_action('wp_ajax_easy_invoice_download_quote_pdf', array($this, 'downloadQuotePdf')); |
| 59 | - add_action('wp_ajax_nopriv_easy_invoice_download_quote_pdf', array($this, 'downloadQuotePdf')); | |
| 60 | 60 | add_action('wp_ajax_easy_invoice_save_quote', array($this, 'saveQuote')); |
| 61 | 61 | |
| 62 | 62 | // Client actions |
| 63 | 63 | add_action('wp_ajax_easy_invoice_save_client', array($this, 'saveClient')); |
| @@ -76,12 +76,22 @@ | ||
| 76 | 76 | */ |
| 77 | 77 | public function saveInvoice() { |
| 78 | 78 | $this->verifyNonce('easy_invoice_nonce'); |
| 79 | 79 | |
| 80 | - if (!current_user_can('manage_options')) { | |
| 80 | + if (!easy_invoice_user_can('ei_create_invoice')) { | |
| 81 | 81 | $this->sendError(__('You do not have permission to perform this action', 'easy-invoice')); |
| 82 | 82 | } |
| 83 | 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 | + | |
| 84 | 94 | // Get the raw invoice data from the form |
| 85 | 95 | $raw_invoice_data = isset($_POST['invoice_data']) ? $_POST['invoice_data'] : $_POST; |
| 86 | 96 | |
| 87 | 97 | // Remove non-invoice fields |
| @@ -92,10 +102,11 @@ | ||
| 92 | 102 | $invoice_form_manager = new \EasyInvoice\Forms\Invoice\InvoiceFormManager(); |
| 93 | 103 | $invoice_data = $invoice_form_manager->processFormData($raw_invoice_data); |
| 94 | 104 | |
| 95 | 105 | if (!empty($invoice_data['errors'])) { |
| 106 | + // The toast is what the user sees; the field may sit on another tab. | |
| 96 | 107 | wp_send_json_error([ |
| 97 | - 'message' => 'Validation failed', | |
| 108 | + 'message' => implode(' ', array_map('strval', $invoice_data['errors'])), | |
| 98 | 109 | 'errors' => $invoice_data['errors'] |
| 99 | 110 | ]); |
| 100 | 111 | } |
| 101 | 112 | |
| @@ -157,8 +168,17 @@ | ||
| 157 | 168 | if (!$invoice) { |
| 158 | 169 | $this->sendError(__('Failed to create invoice', 'easy-invoice')); |
| 159 | 170 | } |
| 160 | 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 | + | |
| 161 | 181 | // Use FormProcessor to save form data to database |
| 162 | 182 | $form_processor = new \EasyInvoice\Forms\FormProcessor(); |
| 163 | 183 | $all_fields = $invoice_form_manager->getAllFields(); |
| 164 | 184 | $form_processor->saveFormDataToDatabase($invoice_data['data'], $all_fields, $invoice); |
| @@ -214,9 +234,10 @@ | ||
| 214 | 234 | */ |
| 215 | 235 | public function saveAndSendInvoice() { |
| 216 | 236 | $this->verifyNonce('easy_invoice_nonce'); |
| 217 | 237 | |
| 218 | - 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')) { | |
| 219 | 240 | $this->sendError(__('You do not have permission to perform this action', 'easy-invoice')); |
| 220 | 241 | } |
| 221 | 242 | |
| 222 | 243 | // First save the invoice |
| @@ -248,9 +269,9 @@ | ||
| 248 | 269 | */ |
| 249 | 270 | public function deleteInvoice() { |
| 250 | 271 | $this->verifyNonce('easy_invoice_nonce'); |
| 251 | 272 | |
| 252 | - if (!current_user_can('manage_options')) { | |
| 273 | + if (!easy_invoice_user_can('ei_delete_invoice')) { | |
| 253 | 274 | $this->sendError(__('You do not have permission to perform this action', 'easy-invoice')); |
| 254 | 275 | } |
| 255 | 276 | |
| 256 | 277 | $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0; |
| @@ -277,9 +298,9 @@ | ||
| 277 | 298 | */ |
| 278 | 299 | public function getInvoice() { |
| 279 | 300 | $this->verifyNonce('easy_invoice_nonce'); |
| 280 | 301 | |
| 281 | - if (!current_user_can('manage_options')) { | |
| 302 | + if (!easy_invoice_user_can('ei_view_invoices')) { | |
| 282 | 303 | $this->sendError(__('You do not have permission to perform this action', 'easy-invoice')); |
| 283 | 304 | } |
| 284 | 305 | |
| 285 | 306 | $invoice_id = isset($_REQUEST['invoice_id']) ? intval($_REQUEST['invoice_id']) : 0; |
| @@ -305,9 +326,9 @@ | ||
| 305 | 326 | */ |
| 306 | 327 | public function saveClient() { |
| 307 | 328 | $this->verifyNonce('easy_invoice_nonce'); |
| 308 | 329 | |
| 309 | - if (!current_user_can('manage_options')) { | |
| 330 | + if (!easy_invoice_user_can('ei_manage_clients')) { | |
| 310 | 331 | $this->sendError(__('You do not have permission to perform this action', 'easy-invoice')); |
| 311 | 332 | } |
| 312 | 333 | |
| 313 | 334 | $client_id = isset($_POST['client_id']) ? intval($_POST['client_id']) : 0; |
| @@ -353,14 +374,17 @@ | ||
| 353 | 374 | public function deleteClient() { |
| 354 | 375 | try { |
| 355 | 376 | $this->verifyNonce('easy_invoice_nonce'); |
| 356 | 377 | |
| 357 | - if (!current_user_can('manage_options')) { | |
| 378 | + if (!easy_invoice_user_can('ei_manage_clients')) { | |
| 358 | 379 | $this->sendError(__('You do not have permission to perform this action', 'easy-invoice')); |
| 359 | 380 | } |
| 360 | 381 | |
| 361 | 382 | $client_id = isset($_POST['client_id']) ? intval($_POST['client_id']) : 0; |
| 362 | - $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); | |
| 363 | 387 | |
| 364 | 388 | if ($client_id <= 0) { |
| 365 | 389 | $this->sendError(__('Invalid client ID', 'easy-invoice')); |
| 366 | 390 | } |
| @@ -387,18 +411,25 @@ | ||
| 387 | 411 | "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = '_easy_invoice_quote_client_id' AND meta_value = %d", |
| 388 | 412 | $client_id |
| 389 | 413 | )); |
| 390 | 414 | |
| 391 | - $payment_count = $wpdb->get_var($wpdb->prepare( | |
| 392 | - "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = '_easy_payment_client_id' AND meta_value = %d", | |
| 393 | - $client_id | |
| 394 | - )); | |
| 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)); | |
| 395 | 421 | |
| 396 | 422 | $total_documents = $invoice_count + $quote_count + $payment_count; |
| 397 | 423 | |
| 398 | 424 | if ($delete_associated_documents) { |
| 399 | 425 | // Delete all associated documents |
| 400 | - $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)', | |
| 401 | 432 | $client_id, $invoice_count, $quote_count, $payment_count)); |
| 402 | 433 | |
| 403 | 434 | // Delete invoices |
| 404 | 435 | if ($invoice_count > 0) { |
| @@ -422,22 +453,56 @@ | ||
| 422 | 453 | } |
| 423 | 454 | } |
| 424 | 455 | |
| 425 | 456 | // Delete payments |
| 426 | - if ($payment_count > 0) { | |
| 427 | - $payments = $wpdb->get_col($wpdb->prepare( | |
| 428 | - "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_easy_invoice_payment_client_id' AND meta_value = %d", | |
| 429 | - $client_id | |
| 430 | - )); | |
| 431 | - foreach ($payments as $payment_id) { | |
| 432 | - wp_delete_post($payment_id, true); | |
| 433 | - } | |
| 434 | - } | |
| 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. | |
| 435 | 479 | |
| 436 | - $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 | + ); | |
| 437 | 501 | } else { |
| 438 | 502 | // Only remove client associations, preserve documents |
| 439 | - $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)', | |
| 440 | 505 | $client_id, $invoice_count, $quote_count, $payment_count)); |
| 441 | 506 | |
| 442 | 507 | // Remove client associations from invoices |
| 443 | 508 | if ($invoice_count > 0) { |
| @@ -462,11 +527,17 @@ | ||
| 462 | 527 | ['meta_key' => '_easy_payment_client_id', 'meta_value' => $client_id] |
| 463 | 528 | ); |
| 464 | 529 | } |
| 465 | 530 | |
| 531 | + /* translators: %d: number of documents. */ | |
| 466 | 532 | $message = sprintf(__('Client deleted successfully. %d documents preserved but client associations removed.', 'easy-invoice'), $total_documents); |
| 467 | 533 | } |
| 468 | 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 | + | |
| 469 | 540 | // Delete the WordPress user |
| 470 | 541 | require_once(ABSPATH . 'wp-admin/includes/user.php'); |
| 471 | 542 | $result = wp_delete_user($client_id); |
| 472 | 543 | |
| @@ -473,8 +544,20 @@ | ||
| 473 | 544 | if (!$result) { |
| 474 | 545 | $this->sendError(__('Failed to delete client', 'easy-invoice')); |
| 475 | 546 | } |
| 476 | 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 | + | |
| 477 | 560 | $this->sendSuccess(array( |
| 478 | 561 | 'message' => $message, |
| 479 | 562 | 'client_id' => $client_id, |
| 480 | 563 | 'documents_deleted' => $delete_associated_documents, |
| @@ -491,9 +574,9 @@ | ||
| 491 | 574 | */ |
| 492 | 575 | public function getClient() { |
| 493 | 576 | $this->verifyNonce('easy_invoice_nonce'); |
| 494 | 577 | |
| 495 | - if (!current_user_can('manage_options')) { | |
| 578 | + if (!easy_invoice_user_can('ei_view_clients')) { | |
| 496 | 579 | $this->sendError(__('You do not have permission to perform this action', 'easy-invoice')); |
| 497 | 580 | } |
| 498 | 581 | |
| 499 | 582 | $client_id = isset($_REQUEST['client_id']) ? intval($_REQUEST['client_id']) : 0; |
| @@ -607,10 +690,21 @@ | ||
| 607 | 690 | private function sendSuccess($data = array()) { |
| 608 | 691 | // Check if we should suppress global toast |
| 609 | 692 | $suppress_toast = isset($_POST['suppress_global_toast']) && $_POST['suppress_global_toast'] === 'true'; |
| 610 | 693 | |
| 611 | - // Add toast notification if not already present and not suppressed | |
| 612 | - 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) { | |
| 613 | 707 | $message = isset($data['message']) ? $data['message'] : __('Operation completed successfully', 'easy-invoice'); |
| 614 | 708 | $data['toast'] = array( |
| 615 | 709 | 'type' => 'success', |
| 616 | 710 | 'message' => $message, |
| @@ -618,9 +712,9 @@ | ||
| 618 | 712 | ); |
| 619 | 713 | } |
| 620 | 714 | |
| 621 | 715 | // Remove toast data if suppressed |
| 622 | - if ($suppress_toast && isset($data['toast'])) { | |
| 716 | + if ($is_assoc_or_empty && $suppress_toast && isset($data['toast'])) { | |
| 623 | 717 | unset($data['toast']); |
| 624 | 718 | } |
| 625 | 719 | |
| 626 | 720 | wp_send_json_success($data); |
| @@ -647,9 +741,9 @@ | ||
| 647 | 741 | // Verify nonce |
| 648 | 742 | $this->verifyNonce('easy_invoice_nonce'); |
| 649 | 743 | |
| 650 | 744 | // Check if user has required capability |
| 651 | - if (!current_user_can('edit_posts')) { | |
| 745 | + if (!easy_invoice_user_can('ei_view_invoices')) { | |
| 652 | 746 | $this->sendError(__('You do not have permission to download invoices', 'easy-invoice')); |
| 653 | 747 | } |
| 654 | 748 | |
| 655 | 749 | // Get invoice ID |
| @@ -682,9 +776,9 @@ | ||
| 682 | 776 | */ |
| 683 | 777 | public function sendInvoiceEmail() { |
| 684 | 778 | $this->verifyNonce('easy_invoice_nonce'); |
| 685 | 779 | |
| 686 | - if (!current_user_can('manage_options')) { | |
| 780 | + if (!easy_invoice_user_can('ei_send_invoice')) { | |
| 687 | 781 | $this->sendError(__('You do not have permission to perform this action', 'easy-invoice')); |
| 688 | 782 | } |
| 689 | 783 | |
| 690 | 784 | // Get invoice ID from POST data |
| @@ -705,8 +799,15 @@ | ||
| 705 | 799 | $email_manager = \EasyInvoice\Services\EmailManager::getInstance(); |
| 706 | 800 | $result = $email_manager->sendInvoiceEmail($invoice, 'new'); |
| 707 | 801 | |
| 708 | 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 | + } | |
| 709 | 810 | $this->sendSuccess(array( |
| 710 | 811 | 'message' => $result['message'] |
| 711 | 812 | )); |
| 712 | 813 | } else { |
| @@ -721,9 +822,9 @@ | ||
| 721 | 822 | // Verify nonce |
| 722 | 823 | $this->verifyNonce('easy_invoice_nonce'); |
| 723 | 824 | |
| 724 | 825 | // Check if user has required capability |
| 725 | - if (!current_user_can('edit_posts')) { | |
| 826 | + if (!easy_invoice_user_can('ei_view_quotes')) { | |
| 726 | 827 | $this->sendError(__('You do not have permission to download quotes', 'easy-invoice')); |
| 727 | 828 | } |
| 728 | 829 | |
| 729 | 830 | // Get quote ID |
| @@ -748,9 +849,10 @@ | ||
| 748 | 849 | 'quote_data' => $quote->toArray(), |
| 749 | 850 | 'download_url' => add_query_arg(array( |
| 750 | 851 | 'action' => 'easy_invoice_generate_quote_pdf', |
| 751 | 852 | 'quote_id' => $quote_id, |
| 752 | - '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) | |
| 753 | 855 | ), admin_url('admin-ajax.php')) |
| 754 | 856 | )); |
| 755 | 857 | } |
| 756 | 858 | |
| @@ -759,9 +861,9 @@ | ||
| 759 | 861 | */ |
| 760 | 862 | public function saveQuote() { |
| 761 | 863 | $this->verifyNonce('easy_invoice_nonce'); |
| 762 | 864 | |
| 763 | - if (!current_user_can('manage_options')) { | |
| 865 | + if (!easy_invoice_user_can('ei_create_quote')) { | |
| 764 | 866 | $this->sendError(__('You do not have permission to perform this action', 'easy-invoice')); |
| 765 | 867 | } |
| 766 | 868 | |
| 767 | 869 | // Get the raw quote data from the form |
| @@ -776,9 +878,9 @@ | ||
| 776 | 878 | $quote_data = $quote_form_manager->processFormData($raw_quote_data); |
| 777 | 879 | |
| 778 | 880 | if (!empty($quote_data['errors'])) { |
| 779 | 881 | wp_send_json_error([ |
| 780 | - 'message' => 'Validation failed', | |
| 882 | + 'message' => implode(' ', array_map('strval', $quote_data['errors'])), | |
| 781 | 883 | 'errors' => $quote_data['errors'] |
| 782 | 884 | ]); |
| 783 | 885 | } |
| 784 | 886 | |
| @@ -846,8 +948,13 @@ | ||
| 846 | 948 | if (!$quote) { |
| 847 | 949 | $this->sendError(__('Failed to create quote', 'easy-invoice')); |
| 848 | 950 | } |
| 849 | 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 | + | |
| 850 | 957 | // Use FormProcessor to save form data to database |
| 851 | 958 | $form_processor = new \EasyInvoice\Forms\FormProcessor(); |
| 852 | 959 | $all_fields = $quote_form_manager->getAllFields(); |
| 853 | 960 | $form_processor->saveFormDataToDatabase($quote_data['data'], $all_fields, $quote); |
| @@ -912,9 +1019,11 @@ | ||
| 912 | 1019 | */ |
| 913 | 1020 | public function checkEmailExists() { |
| 914 | 1021 | $this->verifyNonce('easy_invoice_nonce'); |
| 915 | 1022 | |
| 916 | - 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')) { | |
| 917 | 1026 | $this->sendError(__('You do not have permission to perform this action', 'easy-invoice')); |
| 918 | 1027 | } |
| 919 | 1028 | |
| 920 | 1029 | $email = isset($_POST['email']) ? sanitize_email($_POST['email']) : ''; |
| @@ -937,9 +1046,10 @@ | ||
| 937 | 1046 | */ |
| 938 | 1047 | public function generatePassword() { |
| 939 | 1048 | $this->verifyNonce('easy_invoice_nonce'); |
| 940 | 1049 | |
| 941 | - 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')) { | |
| 942 | 1052 | $this->sendError(__('You do not have permission to perform this action', 'easy-invoice')); |
| 943 | 1053 | } |
| 944 | 1054 | |
| 945 | 1055 | $password = wp_generate_password(16, true, true); |
| @@ -1022,9 +1132,9 @@ | ||
| 1022 | 1132 | public function addClient() { |
| 1023 | 1133 | |
| 1024 | 1134 | $this->verifyNonce('easy_invoice_nonce'); |
| 1025 | 1135 | |
| 1026 | - if (!current_user_can('manage_options')) { | |
| 1136 | + if (!easy_invoice_user_can('ei_manage_clients')) { | |
| 1027 | 1137 | $this->sendError(__('You do not have permission to perform this action', 'easy-invoice')); |
| 1028 | 1138 | } |
| 1029 | 1139 | |
| 1030 | 1140 | // Check if required fields are present |
| @@ -1030,23 +1140,27 @@ | ||
| 1030 | 1140 | // Check if required fields are present |
| 1031 | 1141 | $required_fields = ['business_client_name', 'email', 'username']; |
| 1032 | 1142 | foreach ($required_fields as $field) { |
| 1033 | 1143 | if (!isset($_POST[$field]) || empty($_POST[$field])) { |
| 1034 | - $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)); | |
| 1035 | 1146 | } |
| 1036 | 1147 | } |
| 1037 | 1148 | |
| 1038 | 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 | |
| 1039 | 1153 | $client_data = [ |
| 1040 | - ClientFields::BUSINESS_CLIENT_NAME => sanitize_text_field($_POST['business_client_name']), | |
| 1041 | - ClientFields::EMAIL => sanitize_email($_POST['email']), | |
| 1042 | - ClientFields::USERNAME => sanitize_user($_POST['username']), | |
| 1043 | - ClientFields::PASSWORD => $_POST['password'], | |
| 1044 | - ClientFields::ADDRESS => sanitize_textarea_field($_POST['address']), | |
| 1045 | - ClientFields::EXTRA_INFO => sanitize_textarea_field($_POST['extra_info']), | |
| 1046 | - ClientFields::FIRST_NAME => sanitize_text_field($_POST['first_name']), | |
| 1047 | - ClientFields::LAST_NAME => sanitize_text_field($_POST['last_name']), | |
| 1048 | - 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'])) : '', | |
| 1049 | 1163 | ClientFields::PHONE => isset($_POST['phone']) ? sanitize_text_field($_POST['phone']) : '', |
| 1050 | 1164 | ]; |
| 1051 | 1165 | |
| 1052 | 1166 | |
| @@ -1065,16 +1179,27 @@ | ||
| 1065 | 1179 | // Create new client |
| 1066 | 1180 | $client = $repository->create($client_data); |
| 1067 | 1181 | |
| 1068 | 1182 | if (!$client) { |
| 1069 | - $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')); | |
| 1070 | 1185 | } |
| 1071 | 1186 | |
| 1072 | 1187 | $client_id = $client->getId(); |
| 1073 | 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 | + | |
| 1074 | 1197 | $response_data = array( |
| 1075 | 1198 | 'message' => __('Client added successfully', 'easy-invoice'), |
| 1076 | 1199 | 'client_id' => $client_id, |
| 1200 | + 'role' => $role, | |
| 1201 | + 'role_label' => ucfirst($role), | |
| 1077 | 1202 | 'client' => $client->toArray(), |
| 1078 | 1203 | ); |
| 1079 | 1204 | |
| 1080 | 1205 | $this->sendSuccess($response_data); |
| @@ -1085,9 +1210,9 @@ | ||
| 1085 | 1210 | */ |
| 1086 | 1211 | public function updateClient() { |
| 1087 | 1212 | $this->verifyNonce('easy_invoice_nonce'); |
| 1088 | 1213 | |
| 1089 | - if (!current_user_can('manage_options')) { | |
| 1214 | + if (!easy_invoice_user_can('ei_manage_clients')) { | |
| 1090 | 1215 | $this->sendError(__('You do not have permission to perform this action', 'easy-invoice')); |
| 1091 | 1216 | } |
| 1092 | 1217 | |
| 1093 | 1218 | $client_id = isset($_POST['client_id']) ? intval($_POST['client_id']) : 0; |
| @@ -1097,18 +1222,18 @@ | ||
| 1097 | 1222 | } |
| 1098 | 1223 | |
| 1099 | 1224 | // Prepare client data |
| 1100 | 1225 | $client_data = [ |
| 1101 | - ClientFields::BUSINESS_CLIENT_NAME => sanitize_text_field($_POST['business_client_name']), | |
| 1102 | - ClientFields::EMAIL => sanitize_email($_POST['email']), | |
| 1103 | - ClientFields::USERNAME => sanitize_user($_POST['username']), | |
| 1104 | - ClientFields::PASSWORD => $_POST['password'], // Keep password as is, don't sanitize | |
| 1105 | - 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'] ?? '')), | |
| 1106 | 1231 | ClientFields::PHONE => isset($_POST['phone']) ? sanitize_text_field($_POST['phone']) : '', |
| 1107 | - ClientFields::EXTRA_INFO => sanitize_textarea_field($_POST['extra_info']), | |
| 1108 | - ClientFields::FIRST_NAME => sanitize_text_field($_POST['first_name']), | |
| 1109 | - ClientFields::LAST_NAME => sanitize_text_field($_POST['last_name']), | |
| 1110 | - 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'] ?? '')) | |
| 1111 | 1236 | ]; |
| 1112 | 1237 | |
| 1113 | 1238 | // Remove empty values except password (password can be empty for updates) |
| 1114 | 1239 | $client_data = array_filter($client_data, function($value, $key) { |
| @@ -1143,8 +1268,9 @@ | ||
| 1143 | 1268 | */ |
| 1144 | 1269 | public function updateInvoicesData() { |
| 1145 | 1270 | $this->verifyNonce('easy_invoice_admin_nonce'); |
| 1146 | 1271 | |
| 1272 | + // Bulk migration / repair of invoice records — admin-only. | |
| 1147 | 1273 | if (!current_user_can('manage_options')) { |
| 1148 | 1274 | $this->sendError(__('You do not have permission to perform this action', 'easy-invoice')); |
| 1149 | 1275 | } |
| 1150 | 1276 | |
| @@ -1221,8 +1347,9 @@ | ||
| 1221 | 1347 | } |
| 1222 | 1348 | } |
| 1223 | 1349 | |
| 1224 | 1350 | $this->sendSuccess(array( |
| 1351 | + /* translators: %d: number updated. */ | |
| 1225 | 1352 | 'message' => sprintf(__('Updated %d invoices with missing data', 'easy-invoice'), $updated_count), |
| 1226 | 1353 | 'updated_count' => $updated_count |
| 1227 | 1354 | )); |
| 1228 | 1355 | } |
| @@ -1240,21 +1367,30 @@ | ||
| 1240 | 1367 | if (!$invoice_id) { |
| 1241 | 1368 | $this->sendError(__('Invalid invoice ID', 'easy-invoice')); |
| 1242 | 1369 | } |
| 1243 | 1370 | |
| 1244 | - // Get invoice from repository (only published invoices for public access) | |
| 1245 | 1371 | $repository = InvoiceServiceProvider::getInvoiceRepository(); |
| 1372 | + $invoice = $repository->find($invoice_id); | |
| 1246 | 1373 | |
| 1247 | - // For admins, allow access to any invoice status | |
| 1248 | - if (current_user_can('manage_options')) { | |
| 1249 | - $invoice = $repository->find($invoice_id); | |
| 1250 | - } else { | |
| 1251 | - // For non-admins, only allow access to published invoices | |
| 1252 | - $invoice = $repository->findPublished($invoice_id); | |
| 1374 | + if (!$invoice) { | |
| 1375 | + $this->sendError(__('Invoice not found', 'easy-invoice')); | |
| 1253 | 1376 | } |
| 1254 | 1377 | |
| 1255 | - if (!$invoice) { | |
| 1256 | - $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')); | |
| 1257 | 1393 | } |
| 1258 | 1394 | |
| 1259 | 1395 | // Get invoice data for PDF generation |
| 1260 | 1396 | $invoice_data = \EasyInvoice\Includes\Helpers\PdfHelper::getInvoiceDataForPdf($invoice); |
| @@ -1266,9 +1402,12 @@ | ||
| 1266 | 1402 | 'invoice_data' => $invoice_data, |
| 1267 | 1403 | 'download_url' => add_query_arg(array( |
| 1268 | 1404 | 'action' => 'easy_invoice_generate_pdf', |
| 1269 | 1405 | 'invoice_id' => $invoice_id, |
| 1270 | - '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) | |
| 1271 | 1410 | ), admin_url('admin-ajax.php')) |
| 1272 | 1411 | )); |
| 1273 | 1412 | } |
| 1274 | 1413 | |
| @@ -1286,19 +1425,21 @@ | ||
| 1286 | 1425 | } |
| 1287 | 1426 | |
| 1288 | 1427 | // Get invoice from repository (only published invoices for public access) |
| 1289 | 1428 | $repository = InvoiceServiceProvider::getInvoiceRepository(); |
| 1429 | + $invoice = $repository->find($invoice_id); | |
| 1290 | 1430 | |
| 1291 | - // For admins, allow access to any invoice status | |
| 1292 | - if (current_user_can('manage_options')) { | |
| 1293 | - $invoice = $repository->find($invoice_id); | |
| 1294 | - } else { | |
| 1295 | - // For non-admins, only allow access to published invoices | |
| 1296 | - $invoice = $repository->findPublished($invoice_id); | |
| 1431 | + if (!$invoice) { | |
| 1432 | + $this->sendError(__('Invoice not found', 'easy-invoice')); | |
| 1297 | 1433 | } |
| 1298 | 1434 | |
| 1299 | - if (!$invoice) { | |
| 1300 | - $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')); | |
| 1301 | 1442 | } |
| 1302 | 1443 | |
| 1303 | 1444 | // Use EmailManager to send the email |
| 1304 | 1445 | $email_manager = \EasyInvoice\Services\EmailManager::getInstance(); |
| @@ -1313,13 +1454,61 @@ | ||
| 1313 | 1454 | } |
| 1314 | 1455 | } |
| 1315 | 1456 | |
| 1316 | 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 | + /** | |
| 1317 | 1498 | * Generate invoice PDF |
| 1318 | 1499 | */ |
| 1319 | 1500 | public function generateInvoicePdf() { |
| 1320 | - // Verify nonce | |
| 1321 | - $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'); | |
| 1322 | 1511 | |
| 1323 | 1512 | // Get invoice ID |
| 1324 | 1513 | $invoice_id = isset($_REQUEST['invoice_id']) ? intval($_REQUEST['invoice_id']) : 0; |
| 1325 | 1514 | |
| @@ -1326,8 +1515,57 @@ | ||
| 1326 | 1515 | if (!$invoice_id) { |
| 1327 | 1516 | $this->sendError(__('Invalid invoice ID', 'easy-invoice')); |
| 1328 | 1517 | } |
| 1329 | 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 | + | |
| 1330 | 1568 | // Get invoice from repository |
| 1331 | 1569 | $repository = InvoiceServiceProvider::getInvoiceRepository(); |
| 1332 | 1570 | |
| 1333 | 1571 | // For admins, allow access to any invoice status |
| @@ -1341,16 +1579,24 @@ | ||
| 1341 | 1579 | if (!$invoice) { |
| 1342 | 1580 | $this->sendError(__('Invoice not found', 'easy-invoice')); |
| 1343 | 1581 | } |
| 1344 | 1582 | |
| 1345 | - // 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). | |
| 1346 | 1587 | $invoice_url = get_permalink($invoice_id); |
| 1347 | - if ($invoice_url) { | |
| 1348 | - wp_redirect(add_query_arg('auto_download_pdf', '1', $invoice_url)); | |
| 1349 | - exit; | |
| 1350 | - } else { | |
| 1588 | + if (!$invoice_url) { | |
| 1351 | 1589 | $this->sendError(__('Could not generate invoice URL', 'easy-invoice')); |
| 1352 | 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); | |
| 1353 | 1599 | } |
| 1354 | 1600 | |
| 1355 | 1601 | /** |
| 1356 | 1602 | * Generate quote PDF |
| @@ -1355,10 +1601,12 @@ | ||
| 1355 | 1601 | /** |
| 1356 | 1602 | * Generate quote PDF |
| 1357 | 1603 | */ |
| 1358 | 1604 | public function generateQuotePdf() { |
| 1359 | - // Verify nonce | |
| 1360 | - $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'); | |
| 1361 | 1609 | |
| 1362 | 1610 | // Get quote ID |
| 1363 | 1611 | $quote_id = isset($_REQUEST['quote_id']) ? intval($_REQUEST['quote_id']) : 0; |
| 1364 | 1612 | |
| @@ -1365,24 +1613,111 @@ | ||
| 1365 | 1613 | if (!$quote_id) { |
| 1366 | 1614 | $this->sendError(__('Invalid quote ID', 'easy-invoice')); |
| 1367 | 1615 | } |
| 1368 | 1616 | |
| 1369 | - // Get quote from repository | |
| 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 | + | |
| 1654 | + // Get quote from repository — mirror invoice PDF: only published quotes for non-admins (incl. nopriv). | |
| 1370 | 1655 | $repository = \EasyInvoice\Providers\QuoteServiceProvider::getQuoteRepository(); |
| 1371 | - $quote = $repository->find($quote_id); | |
| 1656 | + if (current_user_can('manage_options')) { | |
| 1657 | + $quote = $repository->find($quote_id); | |
| 1658 | + } else { | |
| 1659 | + $quote = $repository->findPublished($quote_id); | |
| 1660 | + } | |
| 1372 | 1661 | |
| 1373 | 1662 | if (!$quote) { |
| 1374 | 1663 | $this->sendError(__('Quote not found', 'easy-invoice')); |
| 1375 | 1664 | } |
| 1376 | 1665 | |
| 1377 | - // 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. | |
| 1378 | 1670 | $quote_url = get_permalink($quote_id); |
| 1379 | - if ($quote_url) { | |
| 1380 | - 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); | |
| 1381 | 1707 | exit; |
| 1382 | - } else { | |
| 1383 | - $this->sendError(__('Could not generate quote URL', 'easy-invoice')); | |
| 1384 | 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; | |
| 1385 | 1720 | } |
| 1386 | 1721 | |
| 1387 | 1722 | /** |
| 1388 | 1723 | * Search clients for the dropdown |
| @@ -1389,9 +1724,9 @@ | ||
| 1389 | 1724 | */ |
| 1390 | 1725 | public function searchClients() { |
| 1391 | 1726 | $this->verifyNonce('easy_invoice_nonce'); |
| 1392 | 1727 | |
| 1393 | - if (!current_user_can('manage_options')) { | |
| 1728 | + if (!easy_invoice_user_can('ei_view_clients')) { | |
| 1394 | 1729 | $this->sendError(__('You do not have permission to perform this action', 'easy-invoice')); |
| 1395 | 1730 | } |
| 1396 | 1731 | |
| 1397 | 1732 | $query = isset($_POST['query']) ? sanitize_text_field($_POST['query']) : ''; |
| @@ -1401,10 +1736,26 @@ | ||
| 1401 | 1736 | |
| 1402 | 1737 | // Search clients |
| 1403 | 1738 | $clients = $client_repository->search($query); |
| 1404 | 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. | |
| 1405 | 1756 | if (empty($clients)) { |
| 1406 | - $this->sendSuccess(array()); | |
| 1757 | + wp_send_json_success(array()); | |
| 1407 | 1758 | } |
| 1408 | 1759 | |
| 1409 | 1760 | // Format clients for dropdown |
| 1410 | 1761 | $formatted_clients = array(); |
| @@ -1441,9 +1792,9 @@ | ||
| 1441 | 1792 | 'display_name' => $client_name . ' (' . $email . ')' |
| 1442 | 1793 | ); |
| 1443 | 1794 | } |
| 1444 | 1795 | |
| 1445 | - $this->sendSuccess($formatted_clients); | |
| 1796 | + wp_send_json_success($formatted_clients); | |
| 1446 | 1797 | } |
| 1447 | 1798 | |
| 1448 | 1799 | /** |
| 1449 | 1800 | * Save additional CSS for invoice/quote |
| @@ -1449,9 +1800,9 @@ | ||
| 1449 | 1800 | * Save additional CSS for invoice/quote |
| 1450 | 1801 | */ |
| 1451 | 1802 | public function saveAdditionalCSS() { |
| 1452 | 1803 | // Verify nonce |
| 1453 | - if (!wp_verify_nonce($_POST['nonce'], 'save_additional_css_nonce')) { | |
| 1804 | + if (!wp_verify_nonce(($_POST['nonce'] ?? ''), 'save_additional_css_nonce')) { | |
| 1454 | 1805 | $this->sendError('Security check failed'); |
| 1455 | 1806 | return; |
| 1456 | 1807 | } |
| 1457 | 1808 | |