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 +368 -388 2.3.22.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
@@ -117,16 +115,38 @@
117 115 // Set post status based on view
118 116 if ($current_view === 'trash') {
119 117 $args['post_status'] = 'trash';
120 118 } elseif ($current_view === 'draft') {
121 - $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';
122 123 }
123 124
124 125 // Build meta query array
125 126 $meta_query = [];
126 127
127 - // Add status filter if provided
128 - 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)) {
129 149 $meta_query[] = [
130 150 'key' => '_easy_invoice_status',
131 151 'value' => $status_filter,
132 152 'compare' => '='
@@ -199,12 +219,37 @@
199 219 ];
200 220 }
201 221 }
202 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 +
203 248 // Add meta query to args if we have any filters
204 249 if (!empty($meta_query)) {
205 250 if (count($meta_query) === 1) {
206 - $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
207 252 } else {
208 253 $args['meta_query'] = [
209 254 'relation' => 'AND',
210 255 ...$meta_query
@@ -296,8 +341,14 @@
296 341 }
297 342 }
298 343
299 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 +
300 351 unset($query_args['offset']);
301 352
302 353 // Allow plugins to modify the final query arguments
303 354 $query_args = apply_filters('easy_invoice_invoice_controller_final_query_args', $query_args);
@@ -321,17 +372,12 @@
321 372 $total_invoices = $wp_query->found_posts;
322 373 $total_pages = $wp_query->max_num_pages;
323 374
324 375 // Get trash count for tab display (without pagination)
325 - $trash_args = ['post_status' => 'trash'];
326 - $trash_invoices = $repository->all($trash_args);
327 - $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
328 379
329 - // Get draft count for tab display (without pagination)
330 - $draft_args = ['post_status' => 'draft'];
331 - $draft_invoices = $repository->all($draft_args);
332 - $draft_count = count($draft_invoices);
333 -
334 380 // Build clients list for the listing filter dropdown
335 381 $clients_list = [];
336 382 try {
337 383 $client_repository = new \EasyInvoice\Repositories\ClientRepository();
@@ -357,8 +403,9 @@
357 403 'invoices' => $invoices,
358 404 'current_view' => $current_view,
359 405 'status_filter' => $status_filter,
360 406 'recurring_filter' => $recurring_filter,
407 + 'subscription_filter' => $subscription_filter,
361 408 'client_filter' => $client_filter,
362 409 'clients_list' => $clients_list,
363 410 'search_query' => $search_query,
364 411 'trash_count' => $trash_count,
@@ -409,17 +456,18 @@
409 456 * Common helper method to render an invoice preview
410 457 * Used by both preview methods to ensure consistency
411 458 */
412 459 private function renderInvoicePreview() {
413 - $check = $this->checkCapability();
460 + $check = $this->checkCapability('ei_view_invoices');
414 461 if (is_wp_error($check)) {
415 - wp_die($check->get_error_message());
462 + wp_die(esc_html($check->get_error_message()));
416 463 }
417 464
418 - $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);
419 467
420 468 if ($invoice_id <= 0) {
421 - wp_die(__('Invalid invoice ID', 'easy-invoice'));
469 + wp_die(esc_html__('Invalid invoice ID', 'easy-invoice'));
422 470 }
423 471
424 472 // Get invoice from repository
425 473 $repository = InvoiceServiceProvider::getInvoiceRepository();
@@ -425,9 +473,9 @@
425 473 $repository = InvoiceServiceProvider::getInvoiceRepository();
426 474 $invoice = $repository->find($invoice_id);
427 475
428 476 if (!$invoice) {
429 - wp_die(__('Invalid invoice ID', 'easy-invoice'));
477 + wp_die(esc_html__('Invalid invoice ID', 'easy-invoice'));
430 478 }
431 479
432 480 // Get common template variables
433 481 $template_vars = $this->getCommonTemplateVars();
@@ -454,9 +502,9 @@
454 502 /**
455 503 * Trash an invoice (move to trash)
456 504 */
457 505 public function trashInvoice() {
458 - if (!$this->handleAjaxSecurity($_POST['nonce'])) {
506 + if (!$this->handleAjaxSecurity(($_POST['nonce'] ?? ''))) {
459 507 return;
460 508 }
461 509
462 510 // Check invoice ID
@@ -469,11 +517,9 @@
469 517 // Get the invoice object to update status
470 518 $invoice_repository = InvoiceServiceProvider::getInvoiceRepository();
471 519 $invoice = $invoice_repository->find($invoice_id);
472 520 if ($invoice) {
473 - // Set status to cancelled before moving to trash
474 - $invoice->setStatus('cancelled');
475 - $invoice->save();
521 + self::cancelForTrash($invoice);
476 522 }
477 523
478 524 // Move to trash
479 525 $result = wp_trash_post($invoice_id);
@@ -487,10 +533,43 @@
487 533
488 534 /**
489 535 * Restore an invoice from trash
490 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 +
491 570 public function restoreInvoice() {
492 - if (!$this->handleAjaxSecurity($_POST['nonce'])) {
571 + if (!$this->handleAjaxSecurity(($_POST['nonce'] ?? ''))) {
493 572 return;
494 573 }
495 574
496 575 // Check invoice ID
@@ -503,23 +582,11 @@
503 582 // Restore from trash
504 583 $result = wp_untrash_post($invoice_id);
505 584
506 585 if ($result) {
507 - // WordPress defaults restored posts to 'draft', so we need to explicitly set it to 'publish'
508 - wp_update_post(array(
509 - 'ID' => $invoice_id,
510 - 'post_status' => 'publish'
511 - ));
586 + self::restoreAfterTrash($invoice_id);
512 587
513 - // Get the invoice object and set status to available
514 - $invoice_repository = InvoiceServiceProvider::getInvoiceRepository();
515 - $invoice = $invoice_repository->find($invoice_id);
516 - if ($invoice) {
517 - $invoice->setStatus('available');
518 - $invoice->save();
519 - }
520 -
521 - wp_send_json_success(array('message' => 'Invoice restored from trash'));
588 + wp_send_json_success(array('message' => __('Invoice restored from trash.', 'easy-invoice')));
522 589 } else {
523 590 wp_send_json_error(array('message' => 'Error restoring invoice from trash'));
524 591 }
525 592 }
@@ -527,9 +594,9 @@
527 594 /**
528 595 * Delete an invoice permanently
529 596 */
530 597 public function deleteInvoicePermanently() {
531 - if (!$this->handleAjaxSecurity($_POST['nonce'])) {
598 + if (!$this->handleAjaxSecurity(($_POST['nonce'] ?? ''))) {
532 599 return;
533 600 }
534 601
535 602 // Check invoice ID
@@ -538,8 +605,20 @@
538 605 }
539 606
540 607 $invoice_id = intval($_POST['invoice_id']);
541 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 +
542 621 // Delete permanently
543 622 $result = wp_delete_post($invoice_id, true);
544 623
545 624 if ($result) {
@@ -560,9 +639,9 @@
560 639 /**
561 640 * Publish an invoice (change status from draft to publish)
562 641 */
563 642 public function publishInvoice() {
564 - if (!$this->handleAjaxSecurity($_POST['nonce'])) {
643 + if (!$this->handleAjaxSecurity(($_POST['nonce'] ?? ''))) {
565 644 return;
566 645 }
567 646
568 647 // Check invoice ID
@@ -588,9 +667,9 @@
588 667 /**
589 668 * Set an invoice to draft status
590 669 */
591 670 public function draftInvoice() {
592 - if (!$this->handleAjaxSecurity($_POST['nonce'])) {
671 + if (!$this->handleAjaxSecurity(($_POST['nonce'] ?? ''))) {
593 672 return;
594 673 }
595 674
596 675 // Check invoice ID
@@ -598,19 +677,26 @@
598 677 wp_send_json_error(array('message' => 'Invalid invoice ID'));
599 678 }
600 679
601 680 $invoice_id = intval($_POST['invoice_id']);
602 -
603 - // Update post status to draft
604 - $result = wp_update_post(array(
605 - 'ID' => $invoice_id,
606 - 'post_status' => 'draft'
607 - ));
608 -
609 - if ($result) {
610 - 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')));
611 697 } else {
612 - 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')));
613 699 }
614 700 }
615 701
616 702 /**
@@ -622,16 +708,16 @@
622 708 return;
623 709 }
624 710
625 711 // Check nonce and capability
626 - $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');
627 713 if (is_wp_error($security_check)) {
628 - wp_die($security_check->get_error_message());
714 + wp_die(esc_html($security_check->get_error_message()));
629 715 }
630 716
631 717 // Check if we have invoice IDs
632 718 if (!isset($_POST['invoice_ids']) || !is_array($_POST['invoice_ids']) || empty($_POST['invoice_ids'])) {
633 - 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'));
634 720 exit;
635 721 }
636 722
637 723 // Get bulk action and invoice IDs
@@ -643,49 +729,68 @@
643 729
644 730 switch ($bulk_action) {
645 731 case 'trash':
646 732 foreach ($invoice_ids as $id) {
733 + $model = InvoiceServiceProvider::getInvoiceRepository()->find((int) $id);
734 + if ($model) {
735 + self::cancelForTrash($model);
736 + }
647 737 if (wp_trash_post($id)) {
648 738 $processed++;
649 739 }
650 740 }
651 - 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));
652 742 break;
653 743
654 744 case 'restore':
655 745 foreach ($invoice_ids as $id) {
656 746 if (wp_untrash_post($id)) {
657 - // Also set status to publish (since WordPress sets it to draft by default)
658 - wp_update_post(array(
659 - 'ID' => $id,
660 - 'post_status' => 'publish'
661 - ));
747 + self::restoreAfterTrash((int) $id);
662 748 $processed++;
663 749 }
664 750 }
665 - 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));
666 752 break;
667 753
668 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;
669 759 foreach ($invoice_ids as $id) {
760 + if (is_wp_error(\EasyInvoice\Services\InvoiceRetention::mayDelete((int) $id))) {
761 + $protected++;
762 + continue;
763 + }
670 764 if (wp_delete_post($id, true)) {
671 765 $processed++;
672 766 }
673 767 }
674 - 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 + ));
675 777 break;
676 778
677 779 case 'draft':
780 + // Invoice status, not post status (see draftInvoice()); paid and
781 + // part-paid invoices are left alone.
678 782 foreach ($invoice_ids as $id) {
679 - // Update post status to draft
680 - if (wp_update_post(array(
681 - 'ID' => $id,
682 - 'post_status' => 'draft'
683 - ))) {
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()) {
684 789 $processed++;
685 790 }
686 791 }
687 - 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));
688 793 break;
689 794
690 795 case 'publish':
691 796 foreach ($invoice_ids as $id) {
@@ -696,9 +801,9 @@
696 801 ))) {
697 802 $processed++;
698 803 }
699 804 }
700 - 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));
701 806 break;
702 807
703 808 default:
704 809 // Includes the `export` action — that's a Pro-only feature handled
@@ -704,9 +809,9 @@
704 809 // Includes the `export` action — that's a Pro-only feature handled
705 810 // by the BulkExportSelected extension. When Pro is inactive, the
706 811 // Free-side teaser JS intercepts the submit before the form ever
707 812 // POSTs here. If somehow it does (curl, etc), we redirect cleanly.
708 - wp_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_error=invalid_action'));
813 + wp_safe_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_error=invalid_action'));
709 814 }
710 815
711 816 exit;
712 817 }
@@ -714,135 +819,42 @@
714 819 /**
715 820 * Get stats for dashboard
716 821 */
717 822 public function getInvoiceStats() {
718 - $repository = InvoiceServiceProvider::getInvoiceRepository();
719 - $total_invoices = count($repository->all());
720 - $pending_invoices = count($repository->findByStatus('pending'));
721 - $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'];
722 833
723 - // Get total revenue by currency from paid invoices
724 - $revenue_by_currency = [];
725 - $paid_invoices_list = $repository->findByStatus('paid');
726 -
727 - // First, get all currencies that exist in the system
728 - $all_invoices = $repository->all();
729 - $all_currencies = [];
730 -
731 - foreach ($all_invoices as $invoice) {
732 - $currency_code = $invoice->getCurrencyCode();
733 -
734 - // If currency is empty or "global", get the actual currency that was used
735 - if (empty($currency_code) || $currency_code === 'global') {
736 - // Get the actual currency from invoice meta
737 - $actual_currency = get_post_meta($invoice->getId(), '_easy_invoice_currency_code', true);
738 - $currency_code = !empty($actual_currency) ? $actual_currency : get_option('easy_invoice_currency_code', 'USD');
739 - }
740 -
741 - // If currency is still "global", use the global setting
742 - if ($currency_code === 'global') {
743 - $currency_code = get_option('easy_invoice_currency_code', 'USD');
744 - }
745 -
746 - // Normalize currency code to uppercase for consistent grouping
747 - $currency_code = strtoupper($currency_code);
748 -
749 - if (!empty($currency_code)) {
750 - $all_currencies[$currency_code] = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code);
751 - }
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;
752 838 }
753 839
754 - // Initialize revenue for all currencies found
755 - foreach ($all_currencies as $currency_code => $currency_symbol) {
756 - $revenue_by_currency[$currency_code] = [
757 - 'amount' => 0,
758 - 'symbol' => $currency_symbol
759 - ];
760 - }
761 -
762 - // Now calculate revenue for paid invoices
763 - foreach ($paid_invoices_list as $invoice) {
764 - $invoice_total = $invoice->getTotal();
765 - if (!is_numeric($invoice_total)) {
766 - continue;
767 - }
768 -
769 - // Get the actual currency from the invoice
770 - $currency_code = $invoice->getCurrencyCode();
771 -
772 - // If currency is empty or "global", get the actual currency that was used
773 - if (empty($currency_code) || $currency_code === 'global') {
774 - // Get the actual currency from invoice meta
775 - $actual_currency = get_post_meta($invoice->getId(), '_easy_invoice_currency_code', true);
776 - $currency_code = !empty($actual_currency) ? $actual_currency : get_option('easy_invoice_currency_code', 'USD');
777 - }
778 -
779 - // If currency is still "global", use the global setting
780 - if ($currency_code === 'global') {
781 - $currency_code = get_option('easy_invoice_currency_code', 'USD');
782 - }
783 -
784 - // Normalize currency code to uppercase for consistent grouping
785 - $currency_code = strtoupper($currency_code);
786 -
787 - if (isset($revenue_by_currency[$currency_code])) {
788 - $revenue_by_currency[$currency_code]['amount'] += $invoice_total;
789 - }
790 - }
791 -
792 - // 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.
793 841 $total_value_by_currency = [];
794 -
795 - // Initialize total value for all currencies found
796 - foreach ($all_currencies as $currency_code => $currency_symbol) {
842 + foreach ($outstanding['amount'] as $currency_code => $amount) {
797 843 $total_value_by_currency[$currency_code] = [
798 - 'amount' => 0,
799 - 'invoices' => 0,
800 - '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,
801 848 ];
802 849 }
803 850
804 - // Calculate total value from all invoices
805 - foreach ($all_invoices as $invoice) {
806 - $invoice_total = $invoice->getTotal();
807 - if (!is_numeric($invoice_total)) {
808 - continue;
809 - }
810 -
811 - // Get the actual currency from the invoice
812 - $currency_code = $invoice->getCurrencyCode();
813 -
814 - // If currency is empty or "global", get the actual currency that was used
815 - if (empty($currency_code) || $currency_code === 'global') {
816 - // Get the actual currency from invoice meta
817 - $actual_currency = get_post_meta($invoice->getId(), '_easy_invoice_currency_code', true);
818 - $currency_code = !empty($actual_currency) ? $actual_currency : get_option('easy_invoice_currency_code', 'USD');
819 - }
820 -
821 - // If currency is still "global", use the global setting
822 - if ($currency_code === 'global') {
823 - $currency_code = get_option('easy_invoice_currency_code', 'USD');
824 - }
825 -
826 - // Normalize currency code to uppercase for consistent grouping
827 - $currency_code = strtoupper($currency_code);
828 -
829 - if (isset($total_value_by_currency[$currency_code])) {
830 - $total_value_by_currency[$currency_code]['amount'] += $invoice_total;
831 - $total_value_by_currency[$currency_code]['invoices']++;
832 - // Keep reference to first invoice for formatting
833 - if ($total_value_by_currency[$currency_code]['invoice_object'] === null) {
834 - $total_value_by_currency[$currency_code]['invoice_object'] = $invoice;
835 - }
836 - }
837 - }
838 -
839 851 return [
840 - 'total_invoices' => $total_invoices,
852 + 'total_invoices' => $total_invoices,
841 853 'pending_invoices' => $pending_invoices,
842 - 'paid_invoices' => $paid_invoices,
843 - 'total_revenue' => $revenue_by_currency,
844 - 'total_value' => $total_value_by_currency
854 + 'paid_invoices' => $paid_invoices,
855 + 'total_revenue' => $revenue_by_currency,
856 + 'total_value' => $total_value_by_currency,
845 857 ];
846 858 }
847 859
848 860 /**
@@ -868,8 +880,14 @@
868 880 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'easy_invoice_nonce')) {
869 881 wp_send_json_error(array('message' => __('Security check failed', 'easy-invoice')));
870 882 }
871 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 +
872 890 // Get template name and validate it securely
873 891 $template = isset($_POST['template']) ? sanitize_text_field($_POST['template']) : 'standard';
874 892 $template = $this->validateTemplateName($template, 'invoice');
875 893 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
@@ -886,12 +904,26 @@
886 904 if ($invoice_id === 0) {
887 905 // Start output buffering
888 906 ob_start();
889 907
890 - // Set up empty variables for new invoices
891 - $invoice = null;
892 - $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');
893 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 +
894 926 include_once $template_file;
895 927 $html = ob_get_clean();
896 928
897 929 // Send response
@@ -905,8 +937,10 @@
905 937
906 938 if (!$invoice) {
907 939 wp_send_json_error(array('message' => __('Invoice not found', 'easy-invoice')));
908 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');
909 943
910 944 // Initialize formatter for currency formatting
911 945 $formatter = new \EasyInvoice\Helpers\InvoiceFormatter($invoice);
912 946
@@ -1017,200 +1051,10 @@
1017 1051
1018 1052 return $real_template_file;
1019 1053 }
1020 1054
1021 - /**
1022 - * Add meta box for manual payment verification to the invoice edit screen.
1023 - */
1024 - public function add_manual_payment_meta_box() {
1025 - add_meta_box(
1026 - 'easy_invoice_manual_payment_verification',
1027 - __('Manual Payment Verification', 'easy-invoice'),
1028 - array($this, 'render_manual_payment_meta_box'),
1029 - 'easy-invoice', // Post type
1030 - 'side', // Context
1031 - 'high' // Priority
1032 - );
1033 - }
1034 1055
1035 1056 /**
1036 - * Render the manual payment verification meta box.
1037 - *
1038 - * @param \WP_Post $post The current post object.
1039 - */
1040 - public function render_manual_payment_meta_box(\WP_Post $post) {
1041 - $payment_status = get_post_meta($post->ID, '_payment_status', true);
1042 - $payment_method = get_post_meta($post->ID, '_payment_method', true);
1043 -
1044 - if (!in_array($payment_status, ['pending-bank', 'pending-cheque'])) {
1045 - echo '<p>' . __('This invoice is not pending manual payment verification.', 'easy-invoice') . '</p>';
1046 - return;
1047 - }
1048 -
1049 - wp_nonce_field('easy_invoice_mark_paid_' . $post->ID, 'easy_invoice_mark_paid_nonce');
1050 -
1051 - echo '<h4>' . __('Submitted Payment Proof', 'easy-invoice') . '</h4>';
1052 -
1053 - if ($payment_method === 'bank') {
1054 - $transaction_id = get_post_meta($post->ID, '_bank_transaction_id', true);
1055 - $notes = get_post_meta($post->ID, '_bank_payment_notes', true);
1056 - $proof_url = get_post_meta($post->ID, '_bank_payment_proof', true);
1057 -
1058 - echo '<p><strong>' . __('Transaction ID:', 'easy-invoice') . '</strong> ' . esc_html($transaction_id) . '</p>';
1059 - if ($notes) {
1060 - echo '<p><strong>' . __('Notes:', 'easy-invoice') . '</strong></p>';
1061 - echo '<div style="white-space: pre-wrap; background: #f9f9f9; padding: 5px; border: 1px solid #eee;">' . esc_html($notes) . '</div>';
1062 - }
1063 - if ($proof_url) {
1064 - 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>';
1065 - }
1066 - } elseif ($payment_method === 'cheque') {
1067 - $cheque_number = get_post_meta($post->ID, '_cheque_number', true);
1068 - $bank_name = get_post_meta($post->ID, '_cheque_bank_name', true);
1069 - $cheque_date = get_post_meta($post->ID, '_cheque_date', true);
1070 - $notes = get_post_meta($post->ID, '_cheque_notes', true);
1071 - $image_url = get_post_meta($post->ID, '_cheque_image', true);
1072 -
1073 - echo '<p><strong>' . __('Cheque Number:', 'easy-invoice') . '</strong> ' . esc_html($cheque_number) . '</p>';
1074 - if ($bank_name) echo '<p><strong>' . __('Bank Name:', 'easy-invoice') . '</strong> ' . esc_html($bank_name) . '</p>';
1075 - if ($cheque_date) echo '<p><strong>' . __('Cheque Date:', 'easy-invoice') . '</strong> ' . esc_html($cheque_date) . '</p>';
1076 - if ($notes) {
1077 - echo '<p><strong>' . __('Notes:', 'easy-invoice') . '</strong></p>';
1078 - echo '<div style="white-space: pre-wrap; background: #f9f9f9; padding: 5px; border: 1px solid #eee;">' . esc_html($notes) . '</div>';
1079 - }
1080 - if ($image_url) {
1081 - 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>';
1082 - }
1083 - }
1084 -
1085 - echo '<p style="margin-top: 15px;">';
1086 - 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>';
1087 - echo '</p>';
1088 - echo '<div id="easy-invoice-mark-paid-message" style="margin-top:10px;"></div>';
1089 -
1090 - // Add a script for the AJAX call
1091 - ?>
1092 - <script type="text/javascript">
1093 - jQuery(document).ready(function($) {
1094 - $('#easy-invoice-mark-paid-btn').on('click', function() {
1095 - var invoiceId = $(this).data('invoice-id');
1096 - var nonce = $('#easy_invoice_mark_paid_nonce').val();
1097 - var button = $(this);
1098 - var messageDiv = $('#easy-invoice-mark-paid-message');
1099 -
1100 - button.prop('disabled', true);
1101 - messageDiv.html('Processing...');
1102 -
1103 - $.ajax({
1104 - url: ajaxurl, // WordPress AJAX URL
1105 - type: 'POST',
1106 - data: {
1107 - action: 'easy_invoice_mark_paid',
1108 - invoice_id: invoiceId,
1109 - nonce: nonce
1110 - },
1111 - success: function(response) {
1112 - if (response.success) {
1113 - messageDiv.css('color', 'green').html(response.data.message);
1114 - button.hide();
1115 - // Optionally, reload the page or update UI elements to reflect paid status
1116 - // window.location.reload();
1117 - } else {
1118 - messageDiv.css('color', 'red').html(response.data.message);
1119 - button.prop('disabled', false);
1120 - }
1121 - },
1122 - error: function() {
1123 - messageDiv.css('color', 'red').html('<?php echo esc_js(__("An error occurred. Please try again.", "easy-invoice")); ?>');
1124 - button.prop('disabled', false);
1125 - }
1126 - });
1127 - });
1128 - });
1129 - </script>
1130 - <?php
1131 - }
1132 -
1133 - /**
1134 - * AJAX handler to create a sample invoice.
1135 - */
1136 - public function ajax_create_sample_invoice() {
1137 - // Security check: verify nonce
1138 - check_ajax_referer('easy_invoice_admin_nonce', 'nonce');
1139 -
1140 - // Security check: verify user capabilities
1141 - if (!easy_invoice_user_can('ei_create_invoice')) {
1142 - wp_send_json_error([
1143 - 'message' => __('You do not have permission to create invoices.', 'easy-invoice')
1144 - ], 403);
1145 - return;
1146 - }
1147 -
1148 - try {
1149 - $invoice_repository = InvoiceServiceProvider::getInvoiceRepository();
1150 -
1151 - // Sample Invoice Data
1152 - $sample_invoice_data = [
1153 - 'post_title' => 'Sample Invoice - ' . date('Y-m-d H:i'),
1154 - 'post_status' => 'draft', // Or 'publish' if you want it live immediately
1155 - // Add other WP_Post fields as needed (e.g., post_author)
1156 - ];
1157 -
1158 - // Sample Meta Data
1159 - $sample_meta_data = [
1160 - '_easy_invoice_number' => 'SAMPLE-' . time(),
1161 - '_easy_invoice_issue_date' => date('Y-m-d'),
1162 - '_easy_invoice_due_date' => date('Y-m-d', strtotime('+15 days')),
1163 - '_easy_invoice_status' => 'draft',
1164 - '_easy_invoice_customer_name' => 'John Doe (Sample Client)',
1165 - '_easy_invoice_customer_email' => 'customer@example.com',
1166 - '_easy_invoice_customer_address' => "123 Sample Street\nSampleville, ST 12345",
1167 - 'currency_code' => 'USD',
1168 - 'currency_position' => 'before',
1169 - // Add other meta keys as needed
1170 - ];
1171 -
1172 - // Sample Line Items
1173 - $sample_items = [];
1174 - for ($i = 1; $i <= 3; $i++) {
1175 - $sample_items[] = [
1176 - 'name' => 'Sample Service ' . $i,
1177 - 'description' => 'Detailed description of sample service ' . $i . '.',
1178 - 'quantity' => rand(1, 5),
1179 - 'price' => rand(50, 200) * 1.00,
1180 - // 'taxable' => true/false (optional)
1181 - ];
1182 - }
1183 - $sample_meta_data['_easy_invoice_items'] = $sample_items;
1184 -
1185 - // Create the invoice post
1186 - $invoice_id = wp_insert_post($sample_invoice_data, true); // true for WP_Error on failure
1187 -
1188 - if (is_wp_error($invoice_id)) {
1189 - throw new \Exception('Failed to create invoice post: ' . $invoice_id->get_error_message());
1190 - }
1191 -
1192 - // Set invoice meta data
1193 - foreach ($sample_meta_data as $key => $value) {
1194 - update_post_meta($invoice_id, $key, $value);
1195 - }
1196 -
1197 - // Recalculate totals if your Invoice model or repository has a method for it
1198 - // For example, if you have $invoice->calculateTotals()->save(); or similar.
1199 - wp_send_json_success([
1200 - 'message' => __('Sample invoice created successfully!', 'easy-invoice'),
1201 - 'invoice_id' => $invoice_id,
1202 - 'edit_link' => admin_url('admin.php?page=easy-invoice-builder&id=' . $invoice_id)
1203 - ]);
1204 -
1205 - } catch (\Exception $e) {
1206 - wp_send_json_error([
1207 - 'message' => __('Error creating sample invoice:', 'easy-invoice') . ' ' . $e->getMessage()
1208 - ], 500);
1209 - }
1210 - }
1211 -
1212 - /**
1213 1057 * AJAX handler for creating a new invoice with title
1214 1058 */
1215 1059 public function ajax_create_new_invoice() {
1216 1060 // Security check: verify nonce
@@ -1240,10 +1084,10 @@
1240 1084 // Prepare invoice data for repository
1241 1085 $invoice_data = [
1242 1086 'title' => $title,
1243 1087 'post_status' => 'draft',
1244 - 'issue_date' => date('Y-m-d'),
1245 - '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')),
1246 1090 'status' => 'draft',
1247 1091 'invoice_template' => get_option('easy_invoice_last_invoice_template', 'standard')
1248 1092 ];
1249 1093
@@ -1275,7 +1119,143 @@
1275 1119 wp_send_json_error([
1276 1120 'message' => __('Error creating invoice:', 'easy-invoice') . ' ' . $e->getMessage()
1277 1121 ], 500);
1278 1122 }
1123 + }
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 +
1139 + /**
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.
1169 + *
1170 + * Trusted server contexts (the EmailManager invoice-send path) should
1171 + * keep calling invoiceAccessToken() so first-send still works.
1172 + */
1173 + public static function invoiceAccessTokenIfExists(int $invoice_id): string {
1174 + if ($invoice_id <= 0) {
1175 + return '';
1176 + }
1177 + $token = (string) get_post_meta($invoice_id, '_easy_invoice_invoice_access_token', true);
1178 + return strlen($token) >= 32 ? $token : '';
1179 + }
1180 +
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']));
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 + }
1204 +
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 + }
1225 +
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 + }
1234 +
1235 + // Path 2: admin override.
1236 + if (current_user_can('manage_options')) {
1237 + return true;
1238 + }
1239 +
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 + }
1257 +
1258 + return false;
1279 1259 }
1280 1260
1281 1261 }