PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.0
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.0
2.4.0 2.4.1 2.3.8 2.3.7 2.3.6 2.3.5 2.3.4 2.3.3 2.3.2 2.3.1 2.2.0 2.1.21 2.1.20 2.1.19 2.1.18 2.1.0 2.1.1 2.1.10 2.1.11 2.1.12 2.1.13 2.1.14 2.1.15 2.1.16 2.1.2 All 57 releases
← All changes | includes/Controllers/InvoiceController.php +430 -429 2.1.112.4.0 View file →
@@ -37,17 +37,15 @@
37 37
38 38 // Register additional AJAX handlers
39 39 $this->registerAjaxHandlers();
40 40
41 - // Add meta box for manual payment verification
42 - add_action('add_meta_boxes_easy-invoice', array($this, 'add_manual_payment_meta_box'));
41 + // NOTE: `easy_invoice_create_new_invoice` is registered by
42 + // registerAjaxHandlers() (called above), not here. It used to be registered
43 + // in both places, so WordPress held two handlers for the same action and
44 + // ajax_create_new_invoice() was hooked twice on a single request — harmless
45 + // only because the handler exits on reply. Registered once now; keep this
46 + // tombstone so it doesn't get added back.
43 47
44 - // AJAX handler for creating a sample invoice
45 - add_action('wp_ajax_easy_invoice_create_sample_invoice', array($this, 'ajax_create_sample_invoice'));
46 -
47 - // AJAX handler for creating a new invoice with title
48 - add_action('wp_ajax_easy_invoice_create_new_invoice', array($this, 'ajax_create_new_invoice'));
49 -
50 48 // Allow plugins to extend the controller initialization
51 49 do_action('easy_invoice_invoice_controller_after_init', $this);
52 50 }
53 51
@@ -103,8 +101,9 @@
103 101 // Get filter parameters
104 102 $status_filter = isset($_GET['status']) ? sanitize_text_field($_GET['status']) : '';
105 103 $recurring_filter = isset($_GET['recurring']) ? sanitize_text_field($_GET['recurring']) : '';
106 104 $subscription_filter = isset($_GET['subscription']) ? sanitize_text_field($_GET['subscription']) : '';
105 + $client_filter = isset($_GET['client_id']) ? absint($_GET['client_id']) : 0;
107 106 $search_query = isset($_GET['search']) ? sanitize_text_field(wp_unslash($_GET['search'])) : '';
108 107 $current_view = isset($_GET['view']) ? sanitize_text_field($_GET['view']) : 'all';
109 108 $current_page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1;
110 109 $per_page = 20;
@@ -116,16 +115,38 @@
116 115 // Set post status based on view
117 116 if ($current_view === 'trash') {
118 117 $args['post_status'] = 'trash';
119 118 } elseif ($current_view === 'draft') {
120 - $args['post_status'] = 'draft';
119 + // A draft is an invoice whose *status* is draft (every invoice is a
120 + // published post); the tab used to look for draft posts and always
121 + // read 0 while the Draft filter pill listed several.
122 + $status_filter = 'draft';
121 123 }
122 124
123 125 // Build meta query array
124 126 $meta_query = [];
125 127
126 - // Add status filter if provided
127 - if (!empty($status_filter)) {
128 + // Add status filter if provided. "Overdue" is a state an invoice is in
129 + // (owed and past its due date), not a status anything writes, so the
130 + // filter derives it: any invoice still awaiting money whose due date
131 + // has passed, plus the few that carry the literal status.
132 + $overdue_ids = null;
133 + if ('overdue' === $status_filter) {
134 + // One direct lookup. As a nested OR/AND meta_query (with a DATE cast)
135 + // WordPress joined postmeta three times and scanned it — 17 s at
136 + // 10,000 invoices. Due dates are stored Y-m-d, so a string compare
137 + // is a date compare.
138 + global $wpdb;
139 + $overdue_ids = array_map('intval', (array) $wpdb->get_col($wpdb->prepare(
140 + "SELECT s.post_id FROM {$wpdb->postmeta} s
141 + LEFT JOIN {$wpdb->postmeta} d ON d.post_id = s.post_id AND d.meta_key = '_easy_invoice_due_date'
142 + WHERE s.meta_key = '_easy_invoice_status'
143 + AND (s.meta_value = 'overdue'
144 + OR (s.meta_value IN ('available', 'unpaid', 'partial', 'sent', 'pending')
145 + AND d.meta_value IS NOT NULL AND d.meta_value <> '' AND d.meta_value < %s))",
146 + gmdate('Y-m-d', current_time('timestamp'))
147 + )));
148 + } elseif (!empty($status_filter)) {
128 149 $meta_query[] = [
129 150 'key' => '_easy_invoice_status',
130 151 'value' => $status_filter,
131 152 'compare' => '='
@@ -131,8 +152,49 @@
131 152 'compare' => '='
132 153 ];
133 154 }
134 155
156 + // Add client filter if provided.
157 + //
158 + // Easy Invoice stores either:
159 + // • `_easy_invoice_client_id` — populated when the user picks a
160 + // client from the dropdown in the Invoice Builder, OR
161 + // • `_easy_invoice_customer_email` (+ customer_name) — populated when
162 + // the biller types ad-hoc customer info inline.
163 + //
164 + // To make the filter useful for both flows we match on
165 + // client_id == N OR customer_email == that client's email.
166 + if (!empty($client_filter)) {
167 + $client_email = '';
168 + try {
169 + $client_repo = new \EasyInvoice\Repositories\ClientRepository();
170 + $client_obj = $client_repo->find($client_filter);
171 + if ($client_obj) {
172 + // The Client model exposes the email via the magic __call → __get fallback.
173 + $client_email = (string) $client_obj->getEmail();
174 + }
175 + } catch (\Throwable $e) {
176 + $client_email = '';
177 + }
178 +
179 + $client_clauses = [
180 + 'relation' => 'OR',
181 + [
182 + 'key' => '_easy_invoice_client_id',
183 + 'value' => (string) $client_filter,
184 + 'compare' => '=',
185 + ],
186 + ];
187 + if ($client_email !== '') {
188 + $client_clauses[] = [
189 + 'key' => '_easy_invoice_customer_email',
190 + 'value' => $client_email,
191 + 'compare' => '=',
192 + ];
193 + }
194 + $meta_query[] = $client_clauses;
195 + }
196 +
135 197 // Add recurring filter if provided
136 198 if (!empty($recurring_filter)) {
137 199 if ($recurring_filter === 'recurring') {
138 200 // Show only recurring invoices
@@ -157,12 +219,37 @@
157 219 ];
158 220 }
159 221 }
160 222
223 + // Subscription filter (Pro's Subscription Invoices addon renders the
224 + // pills; the value was read above but never applied to the query).
225 + if (!empty($subscription_filter)) {
226 + if ($subscription_filter === 'subscription') {
227 + $meta_query[] = [
228 + 'key' => '_easy_invoice_subscription_enabled',
229 + 'value' => '1',
230 + 'compare' => '='
231 + ];
232 + } elseif ($subscription_filter === 'non-subscription') {
233 + $meta_query[] = [
234 + 'relation' => 'OR',
235 + [
236 + 'key' => '_easy_invoice_subscription_enabled',
237 + 'compare' => 'NOT EXISTS'
238 + ],
239 + [
240 + 'key' => '_easy_invoice_subscription_enabled',
241 + 'value' => '0',
242 + 'compare' => '='
243 + ]
244 + ];
245 + }
246 + }
247 +
161 248 // Add meta query to args if we have any filters
162 249 if (!empty($meta_query)) {
163 250 if (count($meta_query) === 1) {
164 - $args['meta_query'] = $meta_query[0];
251 + $args['meta_query'] = [ $meta_query[0] ]; // a bare clause is ignored by WP_Query; it must be a list of clauses
165 252 } else {
166 253 $args['meta_query'] = [
167 254 'relation' => 'AND',
168 255 ...$meta_query
@@ -254,8 +341,14 @@
254 341 }
255 342 }
256 343
257 344 // Remove offset as we're using paged
345 + if (null !== $overdue_ids) {
346 + $query_args['post__in'] = isset($query_args['post__in'])
347 + ? (array_values(array_intersect($query_args['post__in'], $overdue_ids)) ?: [0])
348 + : ($overdue_ids ?: [0]);
349 + }
350 +
258 351 unset($query_args['offset']);
259 352
260 353 // Allow plugins to modify the final query arguments
261 354 $query_args = apply_filters('easy_invoice_invoice_controller_final_query_args', $query_args);
@@ -279,16 +372,32 @@
279 372 $total_invoices = $wp_query->found_posts;
280 373 $total_pages = $wp_query->max_num_pages;
281 374
282 375 // Get trash count for tab display (without pagination)
283 - $trash_args = ['post_status' => 'trash'];
284 - $trash_invoices = $repository->all($trash_args);
285 - $trash_count = count($trash_invoices);
376 + // Tab counts are counts, not model loads.
377 + $trash_count = (int) $repository->count(['post_status' => 'trash']);
378 + $draft_count = (int) $repository->count(['meta_key' => '_easy_invoice_status', 'meta_value' => 'draft']); // phpcs:ignore WordPress.DB.SlowDBQuery
286 379
287 - // Get draft count for tab display (without pagination)
288 - $draft_args = ['post_status' => 'draft'];
289 - $draft_invoices = $repository->all($draft_args);
290 - $draft_count = count($draft_invoices);
380 + // Build clients list for the listing filter dropdown
381 + $clients_list = [];
382 + try {
383 + $client_repository = new \EasyInvoice\Repositories\ClientRepository();
384 + foreach ($client_repository->all() as $client) {
385 + $name = $client->getBusinessClientName() ?: trim($client->getFirstName() . ' ' . $client->getLastName());
386 + if ($name === '') {
387 + continue;
388 + }
389 + $clients_list[] = [
390 + 'id' => $client->getId(),
391 + 'name' => $name,
392 + ];
393 + }
394 + usort($clients_list, function ($a, $b) {
395 + return strcasecmp($a['name'], $b['name']);
396 + });
397 + } catch (\Throwable $e) {
398 + $clients_list = [];
399 + }
291 400
292 401 // Prepare template data
293 402 $template_data = [
294 403 'invoices' => $invoices,
@@ -294,8 +403,11 @@
294 403 'invoices' => $invoices,
295 404 'current_view' => $current_view,
296 405 'status_filter' => $status_filter,
297 406 'recurring_filter' => $recurring_filter,
407 + 'subscription_filter' => $subscription_filter,
408 + 'client_filter' => $client_filter,
409 + 'clients_list' => $clients_list,
298 410 'search_query' => $search_query,
299 411 'trash_count' => $trash_count,
300 412 'draft_count' => $draft_count,
301 413 'repository' => $repository,
@@ -344,17 +456,18 @@
344 456 * Common helper method to render an invoice preview
345 457 * Used by both preview methods to ensure consistency
346 458 */
347 459 private function renderInvoicePreview() {
348 - $check = $this->checkCapability();
460 + $check = $this->checkCapability('ei_view_invoices');
349 461 if (is_wp_error($check)) {
350 - wp_die($check->get_error_message());
462 + wp_die(esc_html($check->get_error_message()));
351 463 }
352 464
353 - $invoice_id = isset($_GET['invoice_id']) ? intval($_GET['invoice_id']) : 0;
465 + // The quote preview takes ?id=; accept both spellings here too.
466 + $invoice_id = isset($_GET['invoice_id']) ? intval($_GET['invoice_id']) : (isset($_GET['id']) ? intval($_GET['id']) : 0);
354 467
355 468 if ($invoice_id <= 0) {
356 - wp_die(__('Invalid invoice ID', 'easy-invoice'));
469 + wp_die(esc_html__('Invalid invoice ID', 'easy-invoice'));
357 470 }
358 471
359 472 // Get invoice from repository
360 473 $repository = InvoiceServiceProvider::getInvoiceRepository();
@@ -360,9 +473,9 @@
360 473 $repository = InvoiceServiceProvider::getInvoiceRepository();
361 474 $invoice = $repository->find($invoice_id);
362 475
363 476 if (!$invoice) {
364 - wp_die(__('Invalid invoice ID', 'easy-invoice'));
477 + wp_die(esc_html__('Invalid invoice ID', 'easy-invoice'));
365 478 }
366 479
367 480 // Get common template variables
368 481 $template_vars = $this->getCommonTemplateVars();
@@ -389,9 +502,9 @@
389 502 /**
390 503 * Trash an invoice (move to trash)
391 504 */
392 505 public function trashInvoice() {
393 - if (!$this->handleAjaxSecurity($_POST['nonce'])) {
506 + if (!$this->handleAjaxSecurity(($_POST['nonce'] ?? ''))) {
394 507 return;
395 508 }
396 509
397 510 // Check invoice ID
@@ -404,11 +517,9 @@
404 517 // Get the invoice object to update status
405 518 $invoice_repository = InvoiceServiceProvider::getInvoiceRepository();
406 519 $invoice = $invoice_repository->find($invoice_id);
407 520 if ($invoice) {
408 - // Set status to cancelled before moving to trash
409 - $invoice->setStatus('cancelled');
410 - $invoice->save();
521 + self::cancelForTrash($invoice);
411 522 }
412 523
413 524 // Move to trash
414 525 $result = wp_trash_post($invoice_id);
@@ -422,10 +533,43 @@
422 533
423 534 /**
424 535 * Restore an invoice from trash
425 536 */
537 + /**
538 + * Trashing cancels the invoice (the document survives and says what
539 + * happened) but remembers what it was, so restoring puts it back —
540 + * a paid invoice taken out of the list must not come back as unpaid.
541 + *
542 + * @param object $invoice Invoice model.
543 + */
544 + public static function cancelForTrash($invoice): void {
545 + $status = strtolower((string) $invoice->getStatus());
546 + if ('cancelled' !== $status) {
547 + update_post_meta((int) $invoice->getId(), '_easy_invoice_status_before_trash', $status);
548 + }
549 + $invoice->setStatus('cancelled');
550 + $invoice->save();
551 + }
552 +
553 + /**
554 + * Republish a restored invoice (WordPress restores to draft) and give it
555 + * back the status it had before it was trashed.
556 + *
557 + * @param int $invoice_id Invoice.
558 + */
559 + public static function restoreAfterTrash(int $invoice_id): void {
560 + wp_update_post(array('ID' => $invoice_id, 'post_status' => 'publish'));
561 + $previous = (string) get_post_meta($invoice_id, '_easy_invoice_status_before_trash', true);
562 + delete_post_meta($invoice_id, '_easy_invoice_status_before_trash');
563 + $invoice = InvoiceServiceProvider::getInvoiceRepository()->find($invoice_id);
564 + if ($invoice) {
565 + $invoice->setStatus('' !== $previous ? $previous : 'available');
566 + $invoice->save();
567 + }
568 + }
569 +
426 570 public function restoreInvoice() {
427 - if (!$this->handleAjaxSecurity($_POST['nonce'])) {
571 + if (!$this->handleAjaxSecurity(($_POST['nonce'] ?? ''))) {
428 572 return;
429 573 }
430 574
431 575 // Check invoice ID
@@ -438,23 +582,11 @@
438 582 // Restore from trash
439 583 $result = wp_untrash_post($invoice_id);
440 584
441 585 if ($result) {
442 - // WordPress defaults restored posts to 'draft', so we need to explicitly set it to 'publish'
443 - wp_update_post(array(
444 - 'ID' => $invoice_id,
445 - 'post_status' => 'publish'
446 - ));
586 + self::restoreAfterTrash($invoice_id);
447 587
448 - // Get the invoice object and set status to available
449 - $invoice_repository = InvoiceServiceProvider::getInvoiceRepository();
450 - $invoice = $invoice_repository->find($invoice_id);
451 - if ($invoice) {
452 - $invoice->setStatus('available');
453 - $invoice->save();
454 - }
455 -
456 - wp_send_json_success(array('message' => 'Invoice restored from trash'));
588 + wp_send_json_success(array('message' => __('Invoice restored from trash.', 'easy-invoice')));
457 589 } else {
458 590 wp_send_json_error(array('message' => 'Error restoring invoice from trash'));
459 591 }
460 592 }
@@ -462,9 +594,9 @@
462 594 /**
463 595 * Delete an invoice permanently
464 596 */
465 597 public function deleteInvoicePermanently() {
466 - if (!$this->handleAjaxSecurity($_POST['nonce'])) {
598 + if (!$this->handleAjaxSecurity(($_POST['nonce'] ?? ''))) {
467 599 return;
468 600 }
469 601
470 602 // Check invoice ID
@@ -473,8 +605,20 @@
473 605 }
474 606
475 607 $invoice_id = intval($_POST['invoice_id']);
476 608
609 + // Ask before deleting, so a refusal can say why and what to do instead.
610 + // InvoiceRetention also blocks this at the data layer, but a bare
611 + // "Error deleting invoice" would leave the user with no idea that the
612 + // refusal was deliberate.
613 + $may_delete = \EasyInvoice\Services\InvoiceRetention::mayDelete($invoice_id);
614 + if (is_wp_error($may_delete)) {
615 + wp_send_json_error(array(
616 + 'message' => $may_delete->get_error_message(),
617 + 'code' => $may_delete->get_error_code(),
618 + ));
619 + }
620 +
477 621 // Delete permanently
478 622 $result = wp_delete_post($invoice_id, true);
479 623
480 624 if ($result) {
@@ -495,9 +639,9 @@
495 639 /**
496 640 * Publish an invoice (change status from draft to publish)
497 641 */
498 642 public function publishInvoice() {
499 - if (!$this->handleAjaxSecurity($_POST['nonce'])) {
643 + if (!$this->handleAjaxSecurity(($_POST['nonce'] ?? ''))) {
500 644 return;
501 645 }
502 646
503 647 // Check invoice ID
@@ -523,9 +667,9 @@
523 667 /**
524 668 * Set an invoice to draft status
525 669 */
526 670 public function draftInvoice() {
527 - if (!$this->handleAjaxSecurity($_POST['nonce'])) {
671 + if (!$this->handleAjaxSecurity(($_POST['nonce'] ?? ''))) {
528 672 return;
529 673 }
530 674
531 675 // Check invoice ID
@@ -533,19 +677,26 @@
533 677 wp_send_json_error(array('message' => 'Invalid invoice ID'));
534 678 }
535 679
536 680 $invoice_id = intval($_POST['invoice_id']);
537 -
538 - // Update post status to draft
539 - $result = wp_update_post(array(
540 - 'ID' => $invoice_id,
541 - 'post_status' => 'draft'
542 - ));
543 -
544 - if ($result) {
545 - wp_send_json_success(array('message' => 'Invoice set to draft successfully'));
681 + $repository = InvoiceServiceProvider::getInvoiceRepository();
682 + $invoice = $repository->find($invoice_id);
683 + if (!$invoice) {
684 + wp_send_json_error(array('message' => __('Invoice not found.', 'easy-invoice')));
685 + }
686 + // Back to draft means the *invoice* status: the post stays published so
687 + // the invoice stays in the list and keeps its number and link. (It used
688 + // to set the post to draft, which made the invoice vanish from every
689 + // list and page.) Money already received cannot be un-issued.
690 + $status = strtolower((string) $invoice->getStatus());
691 + if (in_array($status, ['paid', 'partial'], true)) {
692 + wp_send_json_error(array('message' => __('A paid or part-paid invoice cannot go back to draft.', 'easy-invoice')));
693 + }
694 + $invoice->setStatus('draft');
695 + if ($invoice->save()) {
696 + wp_send_json_success(array('message' => __('Invoice set back to draft.', 'easy-invoice')));
546 697 } else {
547 - wp_send_json_error(array('message' => 'Error setting invoice to draft'));
698 + wp_send_json_error(array('message' => __('The invoice could not be updated.', 'easy-invoice')));
548 699 }
549 700 }
550 701
551 702 /**
@@ -557,16 +708,16 @@
557 708 return;
558 709 }
559 710
560 711 // Check nonce and capability
561 - $security_check = $this->securityCheck($_POST['easy_invoice_bulk_nonce'], 'easy_invoice_bulk_action');
712 + $security_check = $this->securityCheck(($_POST['easy_invoice_bulk_nonce'] ?? ''), 'easy_invoice_bulk_action');
562 713 if (is_wp_error($security_check)) {
563 - wp_die($security_check->get_error_message());
714 + wp_die(esc_html($security_check->get_error_message()));
564 715 }
565 716
566 717 // Check if we have invoice IDs
567 718 if (!isset($_POST['invoice_ids']) || !is_array($_POST['invoice_ids']) || empty($_POST['invoice_ids'])) {
568 - wp_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_error=no_selection'));
719 + wp_safe_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_error=no_selection'));
569 720 exit;
570 721 }
571 722
572 723 // Get bulk action and invoice IDs
@@ -578,49 +729,68 @@
578 729
579 730 switch ($bulk_action) {
580 731 case 'trash':
581 732 foreach ($invoice_ids as $id) {
733 + $model = InvoiceServiceProvider::getInvoiceRepository()->find((int) $id);
734 + if ($model) {
735 + self::cancelForTrash($model);
736 + }
582 737 if (wp_trash_post($id)) {
583 738 $processed++;
584 739 }
585 740 }
586 - wp_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_trashed=' . $processed));
741 + wp_safe_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_trashed=' . $processed));
587 742 break;
588 743
589 744 case 'restore':
590 745 foreach ($invoice_ids as $id) {
591 746 if (wp_untrash_post($id)) {
592 - // Also set status to publish (since WordPress sets it to draft by default)
593 - wp_update_post(array(
594 - 'ID' => $id,
595 - 'post_status' => 'publish'
596 - ));
747 + self::restoreAfterTrash((int) $id);
597 748 $processed++;
598 749 }
599 750 }
600 - wp_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_restored=' . $processed));
751 + wp_safe_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_restored=' . $processed));
601 752 break;
602 753
603 754 case 'delete':
755 + // Issued invoices are skipped rather than failing the whole
756 + // batch: selecting "all" and finding nothing happened would be
757 + // worse than deleting the drafts and reporting the rest.
758 + $protected = 0;
604 759 foreach ($invoice_ids as $id) {
760 + if (is_wp_error(\EasyInvoice\Services\InvoiceRetention::mayDelete((int) $id))) {
761 + $protected++;
762 + continue;
763 + }
605 764 if (wp_delete_post($id, true)) {
606 765 $processed++;
607 766 }
608 767 }
609 - wp_redirect(admin_url('admin.php?page=easy-invoice-all&view=trash&bulk_deleted=' . $processed));
768 + wp_safe_redirect(add_query_arg(
769 + array_filter([
770 + 'page' => 'easy-invoice-all',
771 + 'view' => 'trash',
772 + 'bulk_deleted' => $processed,
773 + 'bulk_kept' => $protected ?: null,
774 + ]),
775 + admin_url('admin.php')
776 + ));
610 777 break;
611 778
612 779 case 'draft':
780 + // Invoice status, not post status (see draftInvoice()); paid and
781 + // part-paid invoices are left alone.
613 782 foreach ($invoice_ids as $id) {
614 - // Update post status to draft
615 - if (wp_update_post(array(
616 - 'ID' => $id,
617 - 'post_status' => 'draft'
618 - ))) {
783 + $model = InvoiceServiceProvider::getInvoiceRepository()->find((int) $id);
784 + if (!$model || in_array(strtolower((string) $model->getStatus()), ['paid', 'partial'], true)) {
785 + continue;
786 + }
787 + $model->setStatus('draft');
788 + if ($model->save()) {
619 789 $processed++;
620 790 }
621 791 }
622 - wp_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_drafted=' . $processed));
792 + wp_safe_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_drafted=' . $processed));
623 793 break;
624 794
625 795 case 'publish':
626 796 foreach ($invoice_ids as $id) {
@@ -631,13 +801,17 @@
631 801 ))) {
632 802 $processed++;
633 803 }
634 804 }
635 - wp_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_published=' . $processed));
805 + wp_safe_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_published=' . $processed));
636 806 break;
637 807
638 808 default:
639 - wp_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_error=invalid_action'));
809 + // Includes the `export` action — that's a Pro-only feature handled
810 + // by the BulkExportSelected extension. When Pro is inactive, the
811 + // Free-side teaser JS intercepts the submit before the form ever
812 + // POSTs here. If somehow it does (curl, etc), we redirect cleanly.
813 + wp_safe_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_error=invalid_action'));
640 814 }
641 815
642 816 exit;
643 817 }
@@ -645,135 +819,42 @@
645 819 /**
646 820 * Get stats for dashboard
647 821 */
648 822 public function getInvoiceStats() {
649 - $repository = InvoiceServiceProvider::getInvoiceRepository();
650 - $total_invoices = count($repository->all());
651 - $pending_invoices = count($repository->findByStatus('pending'));
652 - $paid_invoices = count($repository->findByStatus('paid'));
823 + // Everything here is answered from SQL over the persisted totals
824 + // (InvoiceTotalsCache). This used to load every open and every paid
825 + // invoice as a model on each list view — minutes and hundreds of
826 + // megabytes once a store had a few thousand invoices.
827 + $repository = InvoiceServiceProvider::getInvoiceRepository();
828 + $total_invoices = (int) $repository->count();
829 + $outstanding = \EasyInvoice\Services\InvoiceTotalsCache::outstanding();
830 + $pending_invoices = (int) $outstanding['count'];
831 + $paid = \EasyInvoice\Services\InvoiceTotalsCache::paidRevenue();
832 + $paid_invoices = (int) $paid['paid_count'];
653 833
654 - // Get total revenue by currency from paid invoices
655 - $revenue_by_currency = [];
656 - $paid_invoices_list = $repository->findByStatus('paid');
657 -
658 - // First, get all currencies that exist in the system
659 - $all_invoices = $repository->all();
660 - $all_currencies = [];
661 -
662 - foreach ($all_invoices as $invoice) {
663 - $currency_code = $invoice->getCurrencyCode();
664 -
665 - // If currency is empty or "global", get the actual currency that was used
666 - if (empty($currency_code) || $currency_code === 'global') {
667 - // Get the actual currency from invoice meta
668 - $actual_currency = get_post_meta($invoice->getId(), '_easy_invoice_currency_code', true);
669 - $currency_code = !empty($actual_currency) ? $actual_currency : get_option('easy_invoice_currency_code', 'USD');
670 - }
671 -
672 - // If currency is still "global", use the global setting
673 - if ($currency_code === 'global') {
674 - $currency_code = get_option('easy_invoice_currency_code', 'USD');
675 - }
676 -
677 - // Normalize currency code to uppercase for consistent grouping
678 - $currency_code = strtoupper($currency_code);
679 -
680 - if (!empty($currency_code)) {
681 - $all_currencies[$currency_code] = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code);
682 - }
834 + $site_currency = strtoupper((string) get_option('easy_invoice_currency_code', 'USD'));
835 + $revenue_by_currency = $paid['revenue'];
836 + if (!isset($revenue_by_currency[$site_currency])) {
837 + $revenue_by_currency = [$site_currency => ['amount' => 0, 'symbol' => \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($site_currency)]] + $revenue_by_currency;
683 838 }
684 839
685 - // Initialize revenue for all currencies found
686 - foreach ($all_currencies as $currency_code => $currency_symbol) {
687 - $revenue_by_currency[$currency_code] = [
688 - 'amount' => 0,
689 - 'symbol' => $currency_symbol
690 - ];
691 - }
692 -
693 - // Now calculate revenue for paid invoices
694 - foreach ($paid_invoices_list as $invoice) {
695 - $invoice_total = $invoice->getTotal();
696 - if (!is_numeric($invoice_total)) {
697 - continue;
698 - }
699 -
700 - // Get the actual currency from the invoice
701 - $currency_code = $invoice->getCurrencyCode();
702 -
703 - // If currency is empty or "global", get the actual currency that was used
704 - if (empty($currency_code) || $currency_code === 'global') {
705 - // Get the actual currency from invoice meta
706 - $actual_currency = get_post_meta($invoice->getId(), '_easy_invoice_currency_code', true);
707 - $currency_code = !empty($actual_currency) ? $actual_currency : get_option('easy_invoice_currency_code', 'USD');
708 - }
709 -
710 - // If currency is still "global", use the global setting
711 - if ($currency_code === 'global') {
712 - $currency_code = get_option('easy_invoice_currency_code', 'USD');
713 - }
714 -
715 - // Normalize currency code to uppercase for consistent grouping
716 - $currency_code = strtoupper($currency_code);
717 -
718 - if (isset($revenue_by_currency[$currency_code])) {
719 - $revenue_by_currency[$currency_code]['amount'] += $invoice_total;
720 - }
721 - }
722 -
723 - // Calculate total value from ALL invoices (not just paid ones)
840 + // "Total value": what is still owed, by currency, with the number of open invoices.
724 841 $total_value_by_currency = [];
725 -
726 - // Initialize total value for all currencies found
727 - foreach ($all_currencies as $currency_code => $currency_symbol) {
842 + foreach ($outstanding['amount'] as $currency_code => $amount) {
728 843 $total_value_by_currency[$currency_code] = [
729 - 'amount' => 0,
730 - 'invoices' => 0,
731 - 'invoice_object' => null // Keep reference for formatting
844 + 'amount' => (float) $amount,
845 + 'invoices' => (int) ($outstanding['count_by_currency'][$currency_code] ?? 0),
846 + 'invoice_object' => null,
847 + 'currency' => $currency_code,
732 848 ];
733 849 }
734 850
735 - // Calculate total value from all invoices
736 - foreach ($all_invoices as $invoice) {
737 - $invoice_total = $invoice->getTotal();
738 - if (!is_numeric($invoice_total)) {
739 - continue;
740 - }
741 -
742 - // Get the actual currency from the invoice
743 - $currency_code = $invoice->getCurrencyCode();
744 -
745 - // If currency is empty or "global", get the actual currency that was used
746 - if (empty($currency_code) || $currency_code === 'global') {
747 - // Get the actual currency from invoice meta
748 - $actual_currency = get_post_meta($invoice->getId(), '_easy_invoice_currency_code', true);
749 - $currency_code = !empty($actual_currency) ? $actual_currency : get_option('easy_invoice_currency_code', 'USD');
750 - }
751 -
752 - // If currency is still "global", use the global setting
753 - if ($currency_code === 'global') {
754 - $currency_code = get_option('easy_invoice_currency_code', 'USD');
755 - }
756 -
757 - // Normalize currency code to uppercase for consistent grouping
758 - $currency_code = strtoupper($currency_code);
759 -
760 - if (isset($total_value_by_currency[$currency_code])) {
761 - $total_value_by_currency[$currency_code]['amount'] += $invoice_total;
762 - $total_value_by_currency[$currency_code]['invoices']++;
763 - // Keep reference to first invoice for formatting
764 - if ($total_value_by_currency[$currency_code]['invoice_object'] === null) {
765 - $total_value_by_currency[$currency_code]['invoice_object'] = $invoice;
766 - }
767 - }
768 - }
769 -
770 851 return [
771 - 'total_invoices' => $total_invoices,
852 + 'total_invoices' => $total_invoices,
772 853 'pending_invoices' => $pending_invoices,
773 - 'paid_invoices' => $paid_invoices,
774 - 'total_revenue' => $revenue_by_currency,
775 - 'total_value' => $total_value_by_currency
854 + 'paid_invoices' => $paid_invoices,
855 + 'total_revenue' => $revenue_by_currency,
856 + 'total_value' => $total_value_by_currency,
776 857 ];
777 858 }
778 859
779 860 /**
@@ -781,9 +862,15 @@
781 862 */
782 863 public function registerAjaxHandlers() {
783 864 add_action('wp_ajax_easy_invoice_load_template', array($this, 'handleLoadTemplate'));
784 865 add_action('wp_ajax_easy_invoice_create_new_invoice', array($this, 'ajax_create_new_invoice'));
785 - add_action('wp_ajax_easy_invoice_search_clients', array($this, 'handleSearchClients'));
866 + // The `easy_invoice_search_clients` AJAX is owned by EasyInvoiceAjax.
867 + // The duplicate registration that used to live here raced with
868 + // EasyInvoiceAjax::searchClients() — only the first-registered
869 + // handler ran, and which one won depended on bootstrap order. That
870 + // intermittently broke the client-search dropdown in the invoice
871 + // builder. Keep this comment as a tombstone so the registration
872 + // doesn't get added back.
786 873 }
787 874
788 875 /**
789 876 * Handle AJAX request to load invoice template
@@ -793,8 +880,14 @@
793 880 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'easy_invoice_nonce')) {
794 881 wp_send_json_error(array('message' => __('Security check failed', 'easy-invoice')));
795 882 }
796 883
884 + // The preview renders the invoice's full content: only people who
885 + // can work on invoices may ask for it.
886 + if (!current_user_can('manage_options') && !easy_invoice_user_can('ei_create_invoice') && !easy_invoice_user_can('ei_view_invoices')) {
887 + wp_send_json_error(array('message' => __('You do not have permission to preview invoices.', 'easy-invoice')));
888 + }
889 +
797 890 // Get template name and validate it securely
798 891 $template = isset($_POST['template']) ? sanitize_text_field($_POST['template']) : 'standard';
799 892 $template = $this->validateTemplateName($template, 'invoice');
800 893 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
@@ -811,12 +904,26 @@
811 904 if ($invoice_id === 0) {
812 905 // Start output buffering
813 906 ob_start();
814 907
815 - // Set up empty variables for new invoices
816 - $invoice = null;
817 - $formatter = null;
908 + // Set up empty variables for new invoices.
909 + //
910 + // $invoice used to be null here, but every invoice design template calls
911 + // $invoice->getTitle() / getNumber() / etc. unguarded — so previewing or
912 + // switching a template on an invoice that has not been saved yet was an
913 + // immediate fatal. The model's constructor accepts null and fills itself
914 + // from the field defaults, so an empty instance gives the templates the
915 + // getters they expect and renders a blank preview.
916 + $invoice = new \EasyInvoice\Models\Invoice();
917 + // What the builder currently holds, so the preview is live.
918 + $invoice = \EasyInvoice\Helpers\PreviewOverlay::apply($invoice, isset($_POST['form_data']) ? (string) wp_unslash($_POST['form_data']) : '', 'invoice');
818 919
920 + // $formatter was null here too, and the templates call
921 + // $formatter->format() for every currency value — so even with a valid
922 + // empty invoice the render still died. Build the same formatter the
923 + // saved-invoice path below uses, wrapping the empty invoice.
924 + $formatter = new \EasyInvoice\Helpers\InvoiceFormatter($invoice);
925 +
819 926 include_once $template_file;
820 927 $html = ob_get_clean();
821 928
822 929 // Send response
@@ -830,8 +937,10 @@
830 937
831 938 if (!$invoice) {
832 939 wp_send_json_error(array('message' => __('Invoice not found', 'easy-invoice')));
833 940 }
941 + // Unsaved edits from the builder take precedence over the stored values.
942 + $invoice = \EasyInvoice\Helpers\PreviewOverlay::apply($invoice, isset($_POST['form_data']) ? (string) wp_unslash($_POST['form_data']) : '', 'invoice');
834 943
835 944 // Initialize formatter for currency formatting
836 945 $formatter = new \EasyInvoice\Helpers\InvoiceFormatter($invoice);
837 946
@@ -942,210 +1051,10 @@
942 1051
943 1052 return $real_template_file;
944 1053 }
945 1054
946 - /**
947 - * Add meta box for manual payment verification to the invoice edit screen.
948 - */
949 - public function add_manual_payment_meta_box() {
950 - add_meta_box(
951 - 'easy_invoice_manual_payment_verification',
952 - __('Manual Payment Verification', 'easy-invoice'),
953 - array($this, 'render_manual_payment_meta_box'),
954 - 'easy-invoice', // Post type
955 - 'side', // Context
956 - 'high' // Priority
957 - );
958 - }
959 1055
960 1056 /**
961 - * Render the manual payment verification meta box.
962 - *
963 - * @param \WP_Post $post The current post object.
964 - */
965 - public function render_manual_payment_meta_box(\WP_Post $post) {
966 - $payment_status = get_post_meta($post->ID, '_payment_status', true);
967 - $payment_method = get_post_meta($post->ID, '_payment_method', true);
968 -
969 - if (!in_array($payment_status, ['pending-bank', 'pending-cheque'])) {
970 - echo '<p>' . __('This invoice is not pending manual payment verification.', 'easy-invoice') . '</p>';
971 - return;
972 - }
973 -
974 - wp_nonce_field('easy_invoice_mark_paid_' . $post->ID, 'easy_invoice_mark_paid_nonce');
975 -
976 - echo '<h4>' . __('Submitted Payment Proof', 'easy-invoice') . '</h4>';
977 -
978 - if ($payment_method === 'bank') {
979 - $transaction_id = get_post_meta($post->ID, '_bank_transaction_id', true);
980 - $notes = get_post_meta($post->ID, '_bank_payment_notes', true);
981 - $proof_url = get_post_meta($post->ID, '_bank_payment_proof', true);
982 -
983 - echo '<p><strong>' . __('Transaction ID:', 'easy-invoice') . '</strong> ' . esc_html($transaction_id) . '</p>';
984 - if ($notes) {
985 - echo '<p><strong>' . __('Notes:', 'easy-invoice') . '</strong></p>';
986 - echo '<div style="white-space: pre-wrap; background: #f9f9f9; padding: 5px; border: 1px solid #eee;">' . esc_html($notes) . '</div>';
987 - }
988 - if ($proof_url) {
989 - echo '<p><strong>' . __('Proof Document:', 'easy-invoice') . '</strong> <a href="' . esc_url($proof_url) . '" target="_blank" rel="noopener noreferrer">' . __('View Proof', 'easy-invoice') . '</a></p>';
990 - }
991 - } elseif ($payment_method === 'cheque') {
992 - $cheque_number = get_post_meta($post->ID, '_cheque_number', true);
993 - $bank_name = get_post_meta($post->ID, '_cheque_bank_name', true);
994 - $cheque_date = get_post_meta($post->ID, '_cheque_date', true);
995 - $notes = get_post_meta($post->ID, '_cheque_notes', true);
996 - $image_url = get_post_meta($post->ID, '_cheque_image', true);
997 -
998 - echo '<p><strong>' . __('Cheque Number:', 'easy-invoice') . '</strong> ' . esc_html($cheque_number) . '</p>';
999 - if ($bank_name) echo '<p><strong>' . __('Bank Name:', 'easy-invoice') . '</strong> ' . esc_html($bank_name) . '</p>';
1000 - if ($cheque_date) echo '<p><strong>' . __('Cheque Date:', 'easy-invoice') . '</strong> ' . esc_html($cheque_date) . '</p>';
1001 - if ($notes) {
1002 - echo '<p><strong>' . __('Notes:', 'easy-invoice') . '</strong></p>';
1003 - echo '<div style="white-space: pre-wrap; background: #f9f9f9; padding: 5px; border: 1px solid #eee;">' . esc_html($notes) . '</div>';
1004 - }
1005 - if ($image_url) {
1006 - echo '<p><strong>' . __('Cheque Image:', 'easy-invoice') . '</strong> <a href="' . esc_url($image_url) . '" target="_blank" rel="noopener noreferrer">' . __('View Image', 'easy-invoice') . '</a></p>';
1007 - }
1008 - }
1009 -
1010 - echo '<p style="margin-top: 15px;">';
1011 - echo '<button type="button" id="easy-invoice-mark-paid-btn" class="button button-primary" data-invoice-id="' . esc_attr($post->ID) . '">' . __('Mark as Paid', 'easy-invoice') . '</button>';
1012 - echo '</p>';
1013 - echo '<div id="easy-invoice-mark-paid-message" style="margin-top:10px;"></div>';
1014 -
1015 - // Add a script for the AJAX call
1016 - ?>
1017 - <script type="text/javascript">
1018 - jQuery(document).ready(function($) {
1019 - $('#easy-invoice-mark-paid-btn').on('click', function() {
1020 - var invoiceId = $(this).data('invoice-id');
1021 - var nonce = $('#easy_invoice_mark_paid_nonce').val();
1022 - var button = $(this);
1023 - var messageDiv = $('#easy-invoice-mark-paid-message');
1024 -
1025 - button.prop('disabled', true);
1026 - messageDiv.html('Processing...');
1027 -
1028 - $.ajax({
1029 - url: ajaxurl, // WordPress AJAX URL
1030 - type: 'POST',
1031 - data: {
1032 - action: 'easy_invoice_mark_paid',
1033 - invoice_id: invoiceId,
1034 - nonce: nonce
1035 - },
1036 - success: function(response) {
1037 - if (response.success) {
1038 - messageDiv.css('color', 'green').html(response.data.message);
1039 - button.hide();
1040 - // Optionally, reload the page or update UI elements to reflect paid status
1041 - // window.location.reload();
1042 - } else {
1043 - messageDiv.css('color', 'red').html(response.data.message);
1044 - button.prop('disabled', false);
1045 - }
1046 - },
1047 - error: function() {
1048 - messageDiv.css('color', 'red').html('<?php echo esc_js(__("An error occurred. Please try again.", "easy-invoice")); ?>');
1049 - button.prop('disabled', false);
1050 - }
1051 - });
1052 - });
1053 - });
1054 - </script>
1055 - <?php
1056 - }
1057 -
1058 - /**
1059 - * AJAX handler to create a sample invoice.
1060 - */
1061 - public function ajax_create_sample_invoice() {
1062 - // Security check: verify nonce
1063 - check_ajax_referer('easy_invoice_admin_nonce', 'nonce');
1064 -
1065 - // Security check: verify user capabilities
1066 - if (!current_user_can('edit_posts')) { // Or a more specific capability for your CPT
1067 - wp_send_json_error([
1068 - 'message' => __('You do not have permission to create invoices.', 'easy-invoice')
1069 - ], 403);
1070 - return;
1071 - }
1072 -
1073 - try {
1074 - $invoice_repository = InvoiceServiceProvider::getInvoiceRepository();
1075 -
1076 - // Sample Invoice Data
1077 - $sample_invoice_data = [
1078 - 'post_title' => 'Sample Invoice - ' . date('Y-m-d H:i'),
1079 - 'post_status' => 'draft', // Or 'publish' if you want it live immediately
1080 - // Add other WP_Post fields as needed (e.g., post_author)
1081 - ];
1082 -
1083 - // Sample Meta Data
1084 - $sample_meta_data = [
1085 - '_easy_invoice_number' => 'SAMPLE-' . time(),
1086 - '_easy_invoice_issue_date' => date('Y-m-d'),
1087 - '_easy_invoice_due_date' => date('Y-m-d', strtotime('+15 days')),
1088 - '_easy_invoice_status' => 'draft',
1089 - '_easy_invoice_customer_name' => 'John Doe (Sample Client)',
1090 - '_easy_invoice_customer_email' => 'customer@example.com',
1091 - '_easy_invoice_customer_address' => "123 Sample Street\nSampleville, ST 12345",
1092 - 'currency_code' => 'USD',
1093 - 'currency_position' => 'before',
1094 - // Add other meta keys as needed
1095 - ];
1096 -
1097 - // Sample Line Items
1098 - $sample_items = [];
1099 - for ($i = 1; $i <= 3; $i++) {
1100 - $sample_items[] = [
1101 - 'name' => 'Sample Service ' . $i,
1102 - 'description' => 'Detailed description of sample service ' . $i . '.',
1103 - 'quantity' => rand(1, 5),
1104 - 'price' => rand(50, 200) * 1.00,
1105 - // 'taxable' => true/false (optional)
1106 - ];
1107 - }
1108 - $sample_meta_data['_easy_invoice_items'] = $sample_items;
1109 -
1110 - // Create the invoice post
1111 - $invoice_id = wp_insert_post($sample_invoice_data, true); // true for WP_Error on failure
1112 -
1113 - if (is_wp_error($invoice_id)) {
1114 - throw new \Exception('Failed to create invoice post: ' . $invoice_id->get_error_message());
1115 - }
1116 -
1117 - // Set invoice meta data
1118 - foreach ($sample_meta_data as $key => $value) {
1119 - update_post_meta($invoice_id, $key, $value);
1120 - }
1121 -
1122 - // Recalculate totals if your Invoice model or repository has a method for it
1123 - // For example, if you have $invoice->calculateTotals()->save(); or similar.
1124 - // This step is crucial if subtotal, tax, total are not directly set but calculated.
1125 - // For now, we assume they might be calculated on load or save by other parts of your plugin.
1126 - // If not, you'd need to calculate and save them here.
1127 - // Example (conceptual):
1128 - // $invoice_object = $invoice_repository->find($invoice_id);
1129 - // if ($invoice_object) {
1130 - // $invoice_object->setItems($sample_items); // This might trigger calculations if model is designed so
1131 - // // Or call a specific method: $invoice_object->recalculateAndSaveTotals();
1132 - // }
1133 -
1134 - wp_send_json_success([
1135 - 'message' => __('Sample invoice created successfully!', 'easy-invoice'),
1136 - 'invoice_id' => $invoice_id,
1137 - 'edit_link' => admin_url('admin.php?page=easy-invoice-builder&id=' . $invoice_id)
1138 - ]);
1139 -
1140 - } catch (\Exception $e) {
1141 - wp_send_json_error([
1142 - 'message' => __('Error creating sample invoice:', 'easy-invoice') . ' ' . $e->getMessage()
1143 - ], 500);
1144 - }
1145 - }
1146 -
1147 - /**
1148 1057 * AJAX handler for creating a new invoice with title
1149 1058 */
1150 1059 public function ajax_create_new_invoice() {
1151 1060 // Security check: verify nonce
@@ -1151,9 +1060,9 @@
1151 1060 // Security check: verify nonce
1152 1061 check_ajax_referer('easy_invoice_nonce', 'nonce');
1153 1062
1154 1063 // Security check: verify user capabilities
1155 - if (!current_user_can('edit_posts')) {
1064 + if (!easy_invoice_user_can('ei_create_invoice')) {
1156 1065 wp_send_json_error([
1157 1066 'message' => __('You do not have permission to create invoices.', 'easy-invoice')
1158 1067 ], 403);
1159 1068 return;
@@ -1175,10 +1084,10 @@
1175 1084 // Prepare invoice data for repository
1176 1085 $invoice_data = [
1177 1086 'title' => $title,
1178 1087 'post_status' => 'draft',
1179 - 'issue_date' => date('Y-m-d'),
1180 - 'due_date' => date('Y-m-d', strtotime('+30 days')),
1088 + 'issue_date' => current_time('Y-m-d'),
1089 + 'due_date' => wp_date('Y-m-d', strtotime('+30 days')),
1181 1090 'status' => 'draft',
1182 1091 'invoice_template' => get_option('easy_invoice_last_invoice_template', 'standard')
1183 1092 ];
1184 1093
@@ -1212,49 +1121,141 @@
1212 1121 ], 500);
1213 1122 }
1214 1123 }
1215 1124
1125 + // ---------------------------------------------------------------------
1126 + // Per-invoice access token + authorisation helpers.
1127 + //
1128 + // Used by submitManualPayment (and any future invoice-scoped public
1129 + // action) to gate the request without relying on the global
1130 + // `easy_invoice_payment` nonce, which is rendered on every public
1131 + // invoice page and is therefore harvestable for cross-invoice abuse.
1132 + //
1133 + // Mirrors QuoteController::quoteAccessToken / canActOnQuote — see the
1134 + // CVE-2026-9021 patch for the design rationale. The shape is intentionally
1135 + // the same so future audits can verify both quote and invoice paths
1136 + // against the same mental model.
1137 + // ---------------------------------------------------------------------
1138 +
1216 1139 /**
1217 - * Handle search clients AJAX request
1140 + * Get (or lazily generate) the per-invoice access token. 32 hex chars =
1141 + * 128 bits of entropy, well above what's brute-forceable inside the
1142 + * lifetime of a published invoice. Stored in private post meta.
1143 + */
1144 + public static function invoiceAccessToken(int $invoice_id): string {
1145 + if ($invoice_id <= 0) {
1146 + return '';
1147 + }
1148 + $token = (string) get_post_meta($invoice_id, '_easy_invoice_invoice_access_token', true);
1149 + if ($token === '' || strlen($token) < 32) {
1150 + try {
1151 + $token = bin2hex(random_bytes(16));
1152 + } catch (\Throwable $e) {
1153 + // Fallback for systems without CSPRNG. wp_generate_password
1154 + // uses random_bytes internally on modern PHP — same entropy.
1155 + $token = wp_generate_password(32, false, false);
1156 + }
1157 + update_post_meta($invoice_id, '_easy_invoice_invoice_access_token', $token);
1158 + }
1159 + return $token;
1160 + }
1161 +
1162 + /**
1163 + * Read-only sibling of invoiceAccessToken(). Returns the persisted
1164 + * token if one already exists, or an empty string otherwise — never
1165 + * mints. Use this from user-controlled rendering contexts (e.g. the
1166 + * `[easy_invoice_url]` shortcode) where allowing an arbitrary caller
1167 + * to MINT a payment-authorising token for an attacker-chosen invoice
1168 + * would be a privilege-escalation vector.
1218 1169 *
1219 - * @since 1.0.0
1170 + * Trusted server contexts (the EmailManager invoice-send path) should
1171 + * keep calling invoiceAccessToken() so first-send still works.
1220 1172 */
1221 - public function handleSearchClients(): void {
1222 - // Verify nonce
1223 - if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_nonce')) {
1224 - wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
1173 + public static function invoiceAccessTokenIfExists(int $invoice_id): string {
1174 + if ($invoice_id <= 0) {
1175 + return '';
1225 1176 }
1177 + $token = (string) get_post_meta($invoice_id, '_easy_invoice_invoice_access_token', true);
1178 + return strlen($token) >= 32 ? $token : '';
1179 + }
1226 1180
1227 - // Check permissions
1228 - if (!current_user_can('manage_options')) {
1229 - wp_send_json_error(['message' => __('Insufficient permissions.', 'easy-invoice')]);
1181 + /**
1182 + * Pull the presented access token off the current request. Accepts it on
1183 + * either POST (when the JS form posts AJAX) or GET (when the invoice URL
1184 + * is opened directly from an emailed link).
1185 + */
1186 + private static function invoiceTokenFromRequest(): string {
1187 + $token = '';
1188 + if (isset($_POST['access_token'])) {
1189 + $token = sanitize_text_field(wp_unslash($_POST['access_token']));
1190 + } elseif (isset($_GET['ik'])) {
1191 + $token = sanitize_text_field(wp_unslash($_GET['ik']));
1230 1192 }
1193 + /**
1194 + * Filter the access token presented for the current request.
1195 + *
1196 + * Lets another proof of access (a signed secure link, say) stand in
1197 + * for the ?ik= token. Return the document's own token to grant.
1198 + *
1199 + * @param string $token Token from the request, may be ''.
1200 + * @param string $type 'invoice'.
1201 + */
1202 + return (string) apply_filters('easy_invoice_presented_access_token', $token, 'invoice');
1203 + }
1231 1204
1232 - $query = sanitize_text_field($_POST['query'] ?? '');
1205 + /**
1206 + * Central authorisation check for invoice-scoped public actions
1207 + * (currently only manual-payment submission). Returns true when ANY of:
1208 + *
1209 + * 1. The request carries a valid per-invoice access token (the
1210 + * legitimate email-recipient flow). Constant-time compared with
1211 + * hash_equals.
1212 + * 2. The current user is logged in AND has admin-grade capability
1213 + * (manage_options) — admin-side payment recording.
1214 + * 3. The current user is logged in AND is the invoice's bound client
1215 + * (case-insensitive email match against the invoice's client_id
1216 + * record).
1217 + *
1218 + * Returns false otherwise. Callers must reject the request when this
1219 + * returns false.
1220 + */
1221 + public static function canSubmitPaymentForInvoice(int $invoice_id, $invoice = null): bool {
1222 + if ($invoice_id <= 0) {
1223 + return false;
1224 + }
1233 1225
1234 - // Get client repository
1235 - $client_repository = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository();
1226 + // Path 1: legitimate access-token flow (email/shortcode link recipient).
1227 + $presented = self::invoiceTokenFromRequest();
1228 + if ($presented !== '') {
1229 + $stored = (string) get_post_meta($invoice_id, '_easy_invoice_invoice_access_token', true);
1230 + if ($stored !== '' && hash_equals($stored, $presented)) {
1231 + return true;
1232 + }
1233 + }
1236 1234
1237 - // If query is empty, get all clients
1238 - if (empty($query)) {
1239 - $clients = $client_repository->all();
1240 - } else {
1241 - // Search clients by name, email, or company
1242 - $clients = $client_repository->search($query);
1235 + // Path 2: admin override.
1236 + if (current_user_can('manage_options')) {
1237 + return true;
1243 1238 }
1244 1239
1245 - $results = [];
1246 - foreach ($clients as $client) {
1247 - $results[] = [
1248 - 'id' => $client->getId(),
1249 - 'name' => $client->getBusinessClientName() ?: ($client->getFirstName() . ' ' . $client->getLastName()),
1250 - 'email' => $client->getEmail(),
1251 - 'company' => $client->getBusinessClientName(),
1252 - 'phone' => $client->getExtraInfo(),
1253 - 'website' => $client->getWebsite(),
1254 - 'address' => $client->getAddress()
1255 - ];
1240 + // Path 3: authenticated owner. ONLY when the current user is the
1241 + // invoice's bound client (email match against the client_id record).
1242 + //
1243 + // Note: Invoice model resolves `getClientId()` via __call magic,
1244 + // so method_exists() returns FALSE for it (PHP's method_exists
1245 + // does not recognise __call-resolved methods). Use is_callable
1246 + // instead — it correctly returns TRUE when the receiver has a
1247 + // __call that can field the message, so this guard actually
1248 + // permits the bound-client path on real Invoice objects.
1249 + if (is_user_logged_in() && $invoice && is_callable([$invoice, 'getClientId']) && $invoice->getClientId()) {
1250 + $current_user = wp_get_current_user();
1251 + $client_repository = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository();
1252 + $client = $client_repository->find($invoice->getClientId());
1253 + if ($client && strcasecmp((string) $client->getEmail(), (string) $current_user->user_email) === 0) {
1254 + return true;
1255 + }
1256 1256 }
1257 1257
1258 - wp_send_json_success($results);
1258 + return false;
1259 1259 }
1260 +
1260 1261 }