PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.3.8
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.3.8
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 2.1.3 2.1.4 All 55 releases
easy-invoice / includes / Controllers / InvoiceController.php

InvoiceController.php in Easy Invoice – Invoice Generator, PDF Quotes & Payments 2.3.8, at includes/Controllers/InvoiceController.php

1,409 lines 55.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Invoice Controller Class
4 *
5 * @package Easy_Invoice
6 * @subpackage Controllers
7 */
8
9 namespace EasyInvoice\Controllers;
10
11 use EasyInvoice\Models\Invoice;
12 use EasyInvoice\Providers\InvoiceServiceProvider;
13 use EasyInvoice\Constants\PagesSlugs;
14 use WP_Query;
15
16 /**
17 * InvoiceController handles all invoice-related functionality
18 */
19 class InvoiceController extends BaseController {
20 /**
21 * Initialize the controller
22 */
23 public function init() {
24 // Allow plugins to extend the controller initialization
25 do_action('easy_invoice_invoice_controller_before_init', $this);
26
27 // Register AJAX endpoints for invoice management
28 add_action('wp_ajax_easy_invoice_trash_invoice', array($this, 'trashInvoice'));
29 add_action('wp_ajax_easy_invoice_restore_invoice', array($this, 'restoreInvoice'));
30 add_action('wp_ajax_easy_invoice_delete_invoice_permanently', array($this, 'deleteInvoicePermanently'));
31 add_action('wp_ajax_easy_invoice_delete_invoice', array($this, 'deleteInvoice')); // Legacy support
32 add_action('wp_ajax_easy_invoice_publish_invoice', array($this, 'publishInvoice'));
33 add_action('wp_ajax_easy_invoice_draft_invoice', array($this, 'draftInvoice'));
34
35 // Handle bulk actions
36 add_action('admin_init', array($this, 'handleBulkActions'));
37
38 // Register additional AJAX handlers
39 $this->registerAjaxHandlers();
40
41 // Add meta box for manual payment verification
42 add_action('add_meta_boxes_easy-invoice', array($this, 'add_manual_payment_meta_box'));
43
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 // Allow plugins to extend the controller initialization
51 do_action('easy_invoice_invoice_controller_after_init', $this);
52 }
53
54 /**
55 * Display method implementation
56 *
57 * @param array $args Display arguments
58 */
59 public function display(array $args = []) {
60 // Allow plugins to modify display arguments
61 $args = apply_filters('easy_invoice_invoice_controller_display_args', $args);
62
63 $page = isset($args['page']) ? $args['page'] : '';
64
65 // Allow plugins to modify the page before processing
66 $page = apply_filters('easy_invoice_invoice_controller_display_page', $page, $args);
67
68 switch ($page) {
69 case PagesSlugs::ALL_INVOICES:
70 $this->displayInvoicesPage();
71 break;
72
73 case PagesSlugs::INVOICE_NEW:
74 // For a new invoice, ensure no ID is passed
75 $_GET['id'] = isset($_GET['id']) ? $_GET['id'] : 0;
76 $this->displayInvoiceBuilderPage();
77 break;
78
79 case PagesSlugs::INVOICE_PREVIEW:
80 $this->displayPreviewPage();
81 break;
82
83 default:
84 $this->displayInvoicesPage();
85 break;
86 }
87
88 // Allow plugins to perform actions after display
89 do_action('easy_invoice_invoice_controller_after_display', $page, $args);
90 }
91
92 /**
93 * Display all invoices page
94 *
95 * Uses WordPress's built-in WP_Query and paginate_links() for optimal performance
96 * with large datasets (10,000+ invoices). The pagination is handled efficiently
97 * by WordPress core functions which are optimized for scalability.
98 */
99 protected function displayInvoicesPage() {
100 // Allow plugins to perform actions before displaying invoices page
101 do_action('easy_invoice_invoice_controller_before_display_invoices_page');
102
103 // Get filter parameters
104 $status_filter = isset($_GET['status']) ? sanitize_text_field($_GET['status']) : '';
105 $recurring_filter = isset($_GET['recurring']) ? sanitize_text_field($_GET['recurring']) : '';
106 $subscription_filter = isset($_GET['subscription']) ? sanitize_text_field($_GET['subscription']) : '';
107 $client_filter = isset($_GET['client_id']) ? absint($_GET['client_id']) : 0;
108 $search_query = isset($_GET['search']) ? sanitize_text_field(wp_unslash($_GET['search'])) : '';
109 $current_view = isset($_GET['view']) ? sanitize_text_field($_GET['view']) : 'all';
110 $current_page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1;
111 $per_page = 20;
112 $offset = ($current_page - 1) * $per_page;
113
114 // Build repository query arguments
115 $args = [];
116
117 // Set post status based on view
118 if ($current_view === 'trash') {
119 $args['post_status'] = 'trash';
120 } elseif ($current_view === 'draft') {
121 $args['post_status'] = 'draft';
122 }
123
124 // Build meta query array
125 $meta_query = [];
126
127 // Add status filter if provided
128 if (!empty($status_filter)) {
129 $meta_query[] = [
130 'key' => '_easy_invoice_status',
131 'value' => $status_filter,
132 'compare' => '='
133 ];
134 }
135
136 // Add client filter if provided.
137 //
138 // Easy Invoice stores either:
139 // • `_easy_invoice_client_id` — populated when the user picks a
140 // client from the dropdown in the Invoice Builder, OR
141 // • `_easy_invoice_customer_email` (+ customer_name) — populated when
142 // the biller types ad-hoc customer info inline.
143 //
144 // To make the filter useful for both flows we match on
145 // client_id == N OR customer_email == that client's email.
146 if (!empty($client_filter)) {
147 $client_email = '';
148 try {
149 $client_repo = new \EasyInvoice\Repositories\ClientRepository();
150 $client_obj = $client_repo->find($client_filter);
151 if ($client_obj) {
152 // The Client model exposes the email via the magic __call → __get fallback.
153 $client_email = (string) $client_obj->getEmail();
154 }
155 } catch (\Throwable $e) {
156 $client_email = '';
157 }
158
159 $client_clauses = [
160 'relation' => 'OR',
161 [
162 'key' => '_easy_invoice_client_id',
163 'value' => (string) $client_filter,
164 'compare' => '=',
165 ],
166 ];
167 if ($client_email !== '') {
168 $client_clauses[] = [
169 'key' => '_easy_invoice_customer_email',
170 'value' => $client_email,
171 'compare' => '=',
172 ];
173 }
174 $meta_query[] = $client_clauses;
175 }
176
177 // Add recurring filter if provided
178 if (!empty($recurring_filter)) {
179 if ($recurring_filter === 'recurring') {
180 // Show only recurring invoices
181 $meta_query[] = [
182 'key' => '_easy_invoice_recurring_enabled',
183 'value' => '1',
184 'compare' => '='
185 ];
186 } elseif ($recurring_filter === 'non-recurring') {
187 // Show only non-recurring invoices
188 $meta_query[] = [
189 'relation' => 'OR',
190 [
191 'key' => '_easy_invoice_recurring_enabled',
192 'compare' => 'NOT EXISTS'
193 ],
194 [
195 'key' => '_easy_invoice_recurring_enabled',
196 'value' => '0',
197 'compare' => '='
198 ]
199 ];
200 }
201 }
202
203 // Add meta query to args if we have any filters
204 if (!empty($meta_query)) {
205 if (count($meta_query) === 1) {
206 $args['meta_query'] = $meta_query[0];
207 } else {
208 $args['meta_query'] = [
209 'relation' => 'AND',
210 ...$meta_query
211 ];
212 }
213 }
214
215 // Add pagination parameters to args
216 $args['posts_per_page'] = $per_page;
217 $args['offset'] = $offset;
218 $args['orderby'] = 'date';
219 $args['order'] = 'DESC';
220
221 // Allow plugins to modify query arguments
222 $args = apply_filters('easy_invoice_invoice_controller_query_args', $args, $current_view, $status_filter);
223
224 // Get paginated invoices using WordPress query
225 $repository = InvoiceServiceProvider::getInvoiceRepository();
226
227 // Use WordPress WP_Query directly for better pagination handling
228 $query_args = array_merge([
229 'post_type' => \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE,
230 'post_status' => $args['post_status'] ?? 'publish',
231 'posts_per_page' => $per_page,
232 'paged' => $current_page,
233 'orderby' => 'date',
234 'order' => 'DESC',
235 'no_found_rows' => false, // We need this for pagination
236 'update_post_term_cache' => false, // Disable term cache for better performance
237 'update_post_meta_cache' => false, // Disable meta cache for better performance
238 ], $args);
239
240 // Add search functionality
241 if (!empty($search_query)) {
242 // For search, we'll use a simpler approach that works better with WordPress
243 // First, get all invoices that match the search criteria
244 $search_ids = [];
245
246 // Search in post title and content
247 $title_search = new WP_Query([
248 'post_type' => \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE,
249 'post_status' => $args['post_status'] ?? 'publish',
250 'posts_per_page' => -1,
251 's' => $search_query
252 ]);
253
254 if ($title_search->have_posts()) {
255 $search_ids = array_merge($search_ids, wp_list_pluck($title_search->posts, 'ID'));
256 }
257
258 // Search in meta fields
259 $meta_search = new WP_Query([
260 'post_type' => \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE,
261 'post_status' => $args['post_status'] ?? 'publish',
262 'posts_per_page' => -1,
263 'meta_query' => [
264 'relation' => 'OR',
265 [
266 'key' => '_easy_invoice_number',
267 'value' => $search_query,
268 'compare' => 'LIKE'
269 ],
270 [
271 'key' => '_easy_invoice_customer_name',
272 'value' => $search_query,
273 'compare' => 'LIKE'
274 ],
275 [
276 'key' => '_easy_invoice_customer_email',
277 'value' => $search_query,
278 'compare' => 'LIKE'
279 ]
280 ]
281 ]);
282
283 if ($meta_search->have_posts()) {
284 $search_ids = array_merge($search_ids, wp_list_pluck($meta_search->posts, 'ID'));
285 }
286
287 // Remove duplicates
288 $search_ids = array_unique($search_ids);
289
290 if (!empty($search_ids)) {
291 // Use post__in to filter by the found IDs
292 $query_args['post__in'] = $search_ids;
293 } else {
294 // If no results found, set post__in to empty array to show no results
295 $query_args['post__in'] = [0];
296 }
297 }
298
299 // Remove offset as we're using paged
300 unset($query_args['offset']);
301
302 // Allow plugins to modify the final query arguments
303 $query_args = apply_filters('easy_invoice_invoice_controller_final_query_args', $query_args);
304
305 $wp_query = new WP_Query($query_args);
306 $invoices = [];
307
308 if ($wp_query->have_posts()) {
309 foreach ($wp_query->posts as $post) {
310 $invoice = $repository->find($post->ID);
311 if ($invoice) {
312 $invoices[] = $invoice;
313 }
314 }
315 }
316
317 // Allow plugins to modify the invoices array
318 $invoices = apply_filters('easy_invoice_invoice_controller_invoices_list', $invoices, $wp_query);
319
320 // Get pagination info from WordPress query
321 $total_invoices = $wp_query->found_posts;
322 $total_pages = $wp_query->max_num_pages;
323
324 // 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);
328
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 // Build clients list for the listing filter dropdown
335 $clients_list = [];
336 try {
337 $client_repository = new \EasyInvoice\Repositories\ClientRepository();
338 foreach ($client_repository->all() as $client) {
339 $name = $client->getBusinessClientName() ?: trim($client->getFirstName() . ' ' . $client->getLastName());
340 if ($name === '') {
341 continue;
342 }
343 $clients_list[] = [
344 'id' => $client->getId(),
345 'name' => $name,
346 ];
347 }
348 usort($clients_list, function ($a, $b) {
349 return strcasecmp($a['name'], $b['name']);
350 });
351 } catch (\Throwable $e) {
352 $clients_list = [];
353 }
354
355 // Prepare template data
356 $template_data = [
357 'invoices' => $invoices,
358 'current_view' => $current_view,
359 'status_filter' => $status_filter,
360 'recurring_filter' => $recurring_filter,
361 'client_filter' => $client_filter,
362 'clients_list' => $clients_list,
363 'search_query' => $search_query,
364 'trash_count' => $trash_count,
365 'draft_count' => $draft_count,
366 'repository' => $repository,
367 'current_page' => $current_page,
368 'per_page' => $per_page,
369 'total_invoices' => $total_invoices,
370 'total_pages' => $total_pages,
371 'wp_query' => $wp_query
372 ];
373
374 // Allow plugins to modify template data
375 $template_data = apply_filters('easy_invoice_invoice_controller_template_data', $template_data);
376
377 // Display the template
378 $this->displayTemplate(
379 EASY_INVOICE_PLUGIN_DIR . 'templates/invoices/listing.php',
380 $template_data
381 );
382
383 // Allow plugins to perform actions after displaying invoices page
384 do_action('easy_invoice_invoice_controller_after_display_invoices_page', $template_data);
385 }
386
387 /**
388 * Display invoice builder page
389 */
390 protected function displayInvoiceBuilderPage() {
391 $invoice_id = isset($_GET['id']) ? intval($_GET['id']) : 0;
392 $repository = InvoiceServiceProvider::getInvoiceRepository();
393
394 // Display the template
395 $this->displayTemplate(
396 EASY_INVOICE_PLUGIN_DIR . 'templates/invoices/builder.php',
397 ['invoice_id' => $invoice_id, 'repository' => $repository]
398 );
399 }
400
401 /**
402 * Display invoice preview page
403 */
404 protected function displayPreviewPage() {
405 $this->renderInvoicePreview();
406 }
407
408 /**
409 * Common helper method to render an invoice preview
410 * Used by both preview methods to ensure consistency
411 */
412 private function renderInvoicePreview() {
413 $check = $this->checkCapability();
414 if (is_wp_error($check)) {
415 wp_die($check->get_error_message());
416 }
417
418 $invoice_id = isset($_GET['invoice_id']) ? intval($_GET['invoice_id']) : 0;
419
420 if ($invoice_id <= 0) {
421 wp_die(__('Invalid invoice ID', 'easy-invoice'));
422 }
423
424 // Get invoice from repository
425 $repository = InvoiceServiceProvider::getInvoiceRepository();
426 $invoice = $repository->find($invoice_id);
427
428 if (!$invoice) {
429 wp_die(__('Invalid invoice ID', 'easy-invoice'));
430 }
431
432 // Get common template variables
433 $template_vars = $this->getCommonTemplateVars();
434 $currency_symbol = $template_vars['currency_symbol'];
435
436 // Enqueue preview styles
437 wp_enqueue_style(
438 'easy-invoice-preview',
439 EASY_INVOICE_PLUGIN_URL . 'assets/css/preview.css',
440 array(),
441 EASY_INVOICE_VERSION
442 );
443
444 // Display the template
445 $this->displayTemplate(
446 EASY_INVOICE_PLUGIN_DIR . 'templates/invoices/preview.php',
447 [
448 'invoice' => $invoice,
449 'currency_symbol' => $currency_symbol
450 ]
451 );
452 }
453
454 /**
455 * Trash an invoice (move to trash)
456 */
457 public function trashInvoice() {
458 if (!$this->handleAjaxSecurity($_POST['nonce'])) {
459 return;
460 }
461
462 // Check invoice ID
463 if (!isset($_POST['invoice_id']) || empty($_POST['invoice_id'])) {
464 wp_send_json_error(array('message' => 'Invalid invoice ID'));
465 }
466
467 $invoice_id = intval($_POST['invoice_id']);
468
469 // Get the invoice object to update status
470 $invoice_repository = InvoiceServiceProvider::getInvoiceRepository();
471 $invoice = $invoice_repository->find($invoice_id);
472 if ($invoice) {
473 // Set status to cancelled before moving to trash
474 $invoice->setStatus('cancelled');
475 $invoice->save();
476 }
477
478 // Move to trash
479 $result = wp_trash_post($invoice_id);
480
481 if ($result) {
482 wp_send_json_success(array('message' => 'Invoice moved to trash'));
483 } else {
484 wp_send_json_error(array('message' => 'Error moving invoice to trash'));
485 }
486 }
487
488 /**
489 * Restore an invoice from trash
490 */
491 public function restoreInvoice() {
492 if (!$this->handleAjaxSecurity($_POST['nonce'])) {
493 return;
494 }
495
496 // Check invoice ID
497 if (!isset($_POST['invoice_id']) || empty($_POST['invoice_id'])) {
498 wp_send_json_error(array('message' => 'Invalid invoice ID'));
499 }
500
501 $invoice_id = intval($_POST['invoice_id']);
502
503 // Restore from trash
504 $result = wp_untrash_post($invoice_id);
505
506 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 ));
512
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'));
522 } else {
523 wp_send_json_error(array('message' => 'Error restoring invoice from trash'));
524 }
525 }
526
527 /**
528 * Delete an invoice permanently
529 */
530 public function deleteInvoicePermanently() {
531 if (!$this->handleAjaxSecurity($_POST['nonce'])) {
532 return;
533 }
534
535 // Check invoice ID
536 if (!isset($_POST['invoice_id']) || empty($_POST['invoice_id'])) {
537 wp_send_json_error(array('message' => 'Invalid invoice ID'));
538 }
539
540 $invoice_id = intval($_POST['invoice_id']);
541
542 // Delete permanently
543 $result = wp_delete_post($invoice_id, true);
544
545 if ($result) {
546 wp_send_json_success(array('message' => 'Invoice deleted permanently'));
547 } else {
548 wp_send_json_error(array('message' => 'Error deleting invoice'));
549 }
550 }
551
552 /**
553 * Legacy delete invoice handler (now redirects to trash)
554 */
555 public function deleteInvoice() {
556 // Redirect to trash function for backward compatibility
557 $this->trashInvoice();
558 }
559
560 /**
561 * Publish an invoice (change status from draft to publish)
562 */
563 public function publishInvoice() {
564 if (!$this->handleAjaxSecurity($_POST['nonce'])) {
565 return;
566 }
567
568 // Check invoice ID
569 if (!isset($_POST['invoice_id']) || empty($_POST['invoice_id'])) {
570 wp_send_json_error(array('message' => 'Invalid invoice ID'));
571 }
572
573 $invoice_id = intval($_POST['invoice_id']);
574
575 // Update post status to published
576 $result = wp_update_post(array(
577 'ID' => $invoice_id,
578 'post_status' => 'publish'
579 ));
580
581 if ($result) {
582 wp_send_json_success(array('message' => 'Invoice published successfully'));
583 } else {
584 wp_send_json_error(array('message' => 'Error publishing invoice'));
585 }
586 }
587
588 /**
589 * Set an invoice to draft status
590 */
591 public function draftInvoice() {
592 if (!$this->handleAjaxSecurity($_POST['nonce'])) {
593 return;
594 }
595
596 // Check invoice ID
597 if (!isset($_POST['invoice_id']) || empty($_POST['invoice_id'])) {
598 wp_send_json_error(array('message' => 'Invalid invoice ID'));
599 }
600
601 $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'));
611 } else {
612 wp_send_json_error(array('message' => 'Error setting invoice to draft'));
613 }
614 }
615
616 /**
617 * Handle bulk actions
618 */
619 public function handleBulkActions() {
620 // Check if we're processing a bulk action
621 if (!isset($_POST['action']) || $_POST['action'] !== 'easy_invoice_bulk_action') {
622 return;
623 }
624
625 // Check nonce and capability
626 $security_check = $this->securityCheck($_POST['easy_invoice_bulk_nonce'], 'easy_invoice_bulk_action');
627 if (is_wp_error($security_check)) {
628 wp_die($security_check->get_error_message());
629 }
630
631 // Check if we have invoice IDs
632 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'));
634 exit;
635 }
636
637 // Get bulk action and invoice IDs
638 $bulk_action = isset($_POST['bulk_action']) ? sanitize_text_field($_POST['bulk_action']) : '';
639 $invoice_ids = array_map('intval', $_POST['invoice_ids']);
640
641 // Process based on action
642 $processed = 0;
643
644 switch ($bulk_action) {
645 case 'trash':
646 foreach ($invoice_ids as $id) {
647 if (wp_trash_post($id)) {
648 $processed++;
649 }
650 }
651 wp_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_trashed=' . $processed));
652 break;
653
654 case 'restore':
655 foreach ($invoice_ids as $id) {
656 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 ));
662 $processed++;
663 }
664 }
665 wp_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_restored=' . $processed));
666 break;
667
668 case 'delete':
669 foreach ($invoice_ids as $id) {
670 if (wp_delete_post($id, true)) {
671 $processed++;
672 }
673 }
674 wp_redirect(admin_url('admin.php?page=easy-invoice-all&view=trash&bulk_deleted=' . $processed));
675 break;
676
677 case 'draft':
678 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 ))) {
684 $processed++;
685 }
686 }
687 wp_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_drafted=' . $processed));
688 break;
689
690 case 'publish':
691 foreach ($invoice_ids as $id) {
692 // Update post status to publish
693 if (wp_update_post(array(
694 'ID' => $id,
695 'post_status' => 'publish'
696 ))) {
697 $processed++;
698 }
699 }
700 wp_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_published=' . $processed));
701 break;
702
703 default:
704 // Includes the `export` action — that's a Pro-only feature handled
705 // by the BulkExportSelected extension. When Pro is inactive, the
706 // Free-side teaser JS intercepts the submit before the form ever
707 // 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'));
709 }
710
711 exit;
712 }
713
714 /**
715 * Get stats for dashboard
716 */
717 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'));
722
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 }
752 }
753
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)
793 $total_value_by_currency = [];
794
795 // Initialize total value for all currencies found
796 foreach ($all_currencies as $currency_code => $currency_symbol) {
797 $total_value_by_currency[$currency_code] = [
798 'amount' => 0,
799 'invoices' => 0,
800 'invoice_object' => null // Keep reference for formatting
801 ];
802 }
803
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 return [
840 'total_invoices' => $total_invoices,
841 'pending_invoices' => $pending_invoices,
842 'paid_invoices' => $paid_invoices,
843 'total_revenue' => $revenue_by_currency,
844 'total_value' => $total_value_by_currency
845 ];
846 }
847
848 /**
849 * Register additional AJAX handlers
850 */
851 public function registerAjaxHandlers() {
852 add_action('wp_ajax_easy_invoice_load_template', array($this, 'handleLoadTemplate'));
853 add_action('wp_ajax_easy_invoice_create_new_invoice', array($this, 'ajax_create_new_invoice'));
854 // The `easy_invoice_search_clients` AJAX is owned by EasyInvoiceAjax.
855 // The duplicate registration that used to live here raced with
856 // EasyInvoiceAjax::searchClients() — only the first-registered
857 // handler ran, and which one won depended on bootstrap order. That
858 // intermittently broke the client-search dropdown in the invoice
859 // builder. Keep this comment as a tombstone so the registration
860 // doesn't get added back.
861 }
862
863 /**
864 * Handle AJAX request to load invoice template
865 */
866 public function handleLoadTemplate() {
867 // Verify nonce
868 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'easy_invoice_nonce')) {
869 wp_send_json_error(array('message' => __('Security check failed', 'easy-invoice')));
870 }
871
872 // Get template name and validate it securely
873 $template = isset($_POST['template']) ? sanitize_text_field($_POST['template']) : 'standard';
874 $template = $this->validateTemplateName($template, 'invoice');
875 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
876
877 // Get secure template file path
878 $template_file = $this->getSecureTemplatePath($template, 'invoice');
879
880
881 if (!$template_file) {
882 wp_send_json_error(array('message' => __('Invalid template', 'easy-invoice')));
883 }
884
885 // For new invoices (no ID), just return the template without invoice data
886 if ($invoice_id === 0) {
887 // Start output buffering
888 ob_start();
889
890 // Set up empty variables for new invoices
891 $invoice = null;
892 $formatter = null;
893
894 include_once $template_file;
895 $html = ob_get_clean();
896
897 // Send response
898 wp_send_json_success(array('html' => $html));
899 return;
900 }
901
902 // Get invoice data for existing invoices
903 $repository = InvoiceServiceProvider::getInvoiceRepository();
904 $invoice = $repository->find($invoice_id);
905
906 if (!$invoice) {
907 wp_send_json_error(array('message' => __('Invoice not found', 'easy-invoice')));
908 }
909
910 // Initialize formatter for currency formatting
911 $formatter = new \EasyInvoice\Helpers\InvoiceFormatter($invoice);
912
913 // Start output buffering
914 ob_start();
915 include_once $template_file;
916 $html = ob_get_clean();
917
918 // Send response
919 wp_send_json_success(array('html' => $html));
920 }
921
922 /**
923 * Validate and sanitize template name to prevent directory traversal attacks
924 *
925 * @param string $template The template name to validate
926 * @param string $type Either 'invoice' or 'quote'
927 * @return string Validated template name or 'standard' as fallback
928 */
929 private function validateTemplateName($template, $type = 'invoice') {
930 // Whitelist of allowed template names
931 $allowed_templates = array(
932 'invoice' => array('classic', 'corporate', 'creative', 'elegant', 'legacy', 'minimal', 'modern', 'professional', 'standard', 'default'),
933 'quote' => array('legacy', 'minimal', 'minimalist', 'modern', 'standard', 'default')
934 );
935
936 // Strip any directory components using basename
937 $template = basename($template);
938
939 // Remove any file extension
940 $template = preg_replace('/\.(php|html|htm)$/i', '', $template);
941
942 // Remove any non-alphanumeric characters except hyphens and underscores
943 $template = preg_replace('/[^a-z0-9_-]/i', '', $template);
944
945 // Check if template is in whitelist
946 if (isset($allowed_templates[$type]) && in_array($template, $allowed_templates[$type], true)) {
947 return $template;
948 }
949
950 // Return default template if not in whitelist
951 return 'standard';
952 }
953
954 /**
955 * Get secure template file path with directory traversal protection
956 *
957 * @param string $template The validated template name
958 * @param string $type Either 'invoice' or 'quote'
959 * @return string|false The secure template file path or false if invalid
960 */
961 private function getSecureTemplatePath($template, $type = 'invoice') {
962 // Define template directories
963 $template_dirs = array(
964 'invoice' => EASY_INVOICE_PLUGIN_DIR . 'templates/invoice-templates/',
965 'quote' => EASY_INVOICE_PLUGIN_DIR . 'templates/quote-templates/'
966 );
967
968 if (!isset($template_dirs[$type])) {
969 return false;
970 }
971
972 $template_dir = $template_dirs[$type];
973
974 // Ensure template directory exists and is a directory
975 if (!is_dir($template_dir)) {
976 return false;
977 }
978
979 // Get the real path of the template directory (resolves any symlinks)
980 $real_template_dir = realpath($template_dir);
981 if ($real_template_dir === false) {
982 return false;
983 }
984
985 // Construct the template file path
986 $template_file = $real_template_dir . DIRECTORY_SEPARATOR . $template . '.php';
987
988 // Get the real path of the template file (resolves any .. or . components)
989 $real_template_file = realpath($template_file);
990
991 // Verify that the resolved path is within the template directory
992 // This prevents directory traversal attacks
993 if ($real_template_file === false || strpos($real_template_file, $real_template_dir) !== 0) {
994 // If template doesn't exist or is outside the directory, use default
995 $default_file = $real_template_dir . DIRECTORY_SEPARATOR . 'standard.php';
996 $real_default_file = realpath($default_file);
997
998 if ($real_default_file !== false && strpos($real_default_file, $real_template_dir) === 0) {
999 return $real_default_file;
1000 }
1001
1002 return false;
1003 }
1004
1005 // Verify the file exists and is readable
1006 if (!is_file($real_template_file) || !is_readable($real_template_file)) {
1007 // Fallback to standard template
1008 $default_file = $real_template_dir . DIRECTORY_SEPARATOR . 'standard.php';
1009 $real_default_file = realpath($default_file);
1010
1011 if ($real_default_file !== false && strpos($real_default_file, $real_template_dir) === 0 && is_file($real_default_file) && is_readable($real_default_file)) {
1012 return $real_default_file;
1013 }
1014
1015 return false;
1016 }
1017
1018 return $real_template_file;
1019 }
1020
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
1035 /**
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 * AJAX handler for creating a new invoice with title
1214 */
1215 public function ajax_create_new_invoice() {
1216 // Security check: verify nonce
1217 check_ajax_referer('easy_invoice_nonce', 'nonce');
1218
1219 // Security check: verify user capabilities
1220 if (!easy_invoice_user_can('ei_create_invoice')) {
1221 wp_send_json_error([
1222 'message' => __('You do not have permission to create invoices.', 'easy-invoice')
1223 ], 403);
1224 return;
1225 }
1226
1227 // Get the invoice title
1228 $title = isset($_POST['title']) ? sanitize_text_field($_POST['title']) : '';
1229
1230 if (empty($title)) {
1231 wp_send_json_error([
1232 'message' => __('Invoice title is required.', 'easy-invoice')
1233 ], 400);
1234 return;
1235 }
1236
1237 try {
1238 $invoice_repository = InvoiceServiceProvider::getInvoiceRepository();
1239
1240 // Prepare invoice data for repository
1241 $invoice_data = [
1242 'title' => $title,
1243 'post_status' => 'draft',
1244 'issue_date' => date('Y-m-d'),
1245 'due_date' => date('Y-m-d', strtotime('+30 days')),
1246 'status' => 'draft',
1247 'invoice_template' => get_option('easy_invoice_last_invoice_template', 'standard')
1248 ];
1249
1250 // Create the invoice using repository (this will auto-generate invoice number)
1251 $invoice = $invoice_repository->create($invoice_data);
1252
1253 if (!$invoice) {
1254 throw new \Exception('Failed to create invoice');
1255 }
1256
1257 // Debug: Check if invoice number was set
1258 $invoice_number = $invoice->getNumber();
1259 if (empty($invoice_number)) {
1260 // Force set the invoice number if it's empty
1261 $invoice_number_service = easy_invoice_get_invoice_number_service();
1262 $generated_number = $invoice_number_service->generateUniqueNumber();
1263 $invoice->setNumber($generated_number);
1264 $invoice->save();
1265 }
1266
1267 wp_send_json_success([
1268 'message' => __('Invoice created successfully!', 'easy-invoice'),
1269 'invoice_id' => $invoice->getId(),
1270 'invoice_number' => $invoice->getNumber(),
1271 'redirect_url' => admin_url('admin.php?page=easy-invoice-builder&invoice_id=' . $invoice->getId())
1272 ]);
1273
1274 } catch (\Exception $e) {
1275 wp_send_json_error([
1276 'message' => __('Error creating invoice:', 'easy-invoice') . ' ' . $e->getMessage()
1277 ], 500);
1278 }
1279 }
1280
1281 // ---------------------------------------------------------------------
1282 // Per-invoice access token + authorisation helpers.
1283 //
1284 // Used by submitManualPayment (and any future invoice-scoped public
1285 // action) to gate the request without relying on the global
1286 // `easy_invoice_payment` nonce, which is rendered on every public
1287 // invoice page and is therefore harvestable for cross-invoice abuse.
1288 //
1289 // Mirrors QuoteController::quoteAccessToken / canActOnQuote — see the
1290 // CVE-2026-9021 patch for the design rationale. The shape is intentionally
1291 // the same so future audits can verify both quote and invoice paths
1292 // against the same mental model.
1293 // ---------------------------------------------------------------------
1294
1295 /**
1296 * Get (or lazily generate) the per-invoice access token. 32 hex chars =
1297 * 128 bits of entropy, well above what's brute-forceable inside the
1298 * lifetime of a published invoice. Stored in private post meta.
1299 */
1300 public static function invoiceAccessToken(int $invoice_id): string {
1301 if ($invoice_id <= 0) {
1302 return '';
1303 }
1304 $token = (string) get_post_meta($invoice_id, '_easy_invoice_invoice_access_token', true);
1305 if ($token === '' || strlen($token) < 32) {
1306 try {
1307 $token = bin2hex(random_bytes(16));
1308 } catch (\Throwable $e) {
1309 // Fallback for systems without CSPRNG. wp_generate_password
1310 // uses random_bytes internally on modern PHP — same entropy.
1311 $token = wp_generate_password(32, false, false);
1312 }
1313 update_post_meta($invoice_id, '_easy_invoice_invoice_access_token', $token);
1314 }
1315 return $token;
1316 }
1317
1318 /**
1319 * Read-only sibling of invoiceAccessToken(). Returns the persisted
1320 * token if one already exists, or an empty string otherwise — never
1321 * mints. Use this from user-controlled rendering contexts (e.g. the
1322 * `[easy_invoice_url]` shortcode) where allowing an arbitrary caller
1323 * to MINT a payment-authorising token for an attacker-chosen invoice
1324 * would be a privilege-escalation vector.
1325 *
1326 * Trusted server contexts (the EmailManager invoice-send path) should
1327 * keep calling invoiceAccessToken() so first-send still works.
1328 */
1329 public static function invoiceAccessTokenIfExists(int $invoice_id): string {
1330 if ($invoice_id <= 0) {
1331 return '';
1332 }
1333 $token = (string) get_post_meta($invoice_id, '_easy_invoice_invoice_access_token', true);
1334 return strlen($token) >= 32 ? $token : '';
1335 }
1336
1337 /**
1338 * Pull the presented access token off the current request. Accepts it on
1339 * either POST (when the JS form posts AJAX) or GET (when the invoice URL
1340 * is opened directly from an emailed link).
1341 */
1342 private static function invoiceTokenFromRequest(): string {
1343 $token = '';
1344 if (isset($_POST['access_token'])) {
1345 $token = sanitize_text_field(wp_unslash($_POST['access_token']));
1346 } elseif (isset($_GET['ik'])) {
1347 $token = sanitize_text_field(wp_unslash($_GET['ik']));
1348 }
1349 return $token;
1350 }
1351
1352 /**
1353 * Central authorisation check for invoice-scoped public actions
1354 * (currently only manual-payment submission). Returns true when ANY of:
1355 *
1356 * 1. The request carries a valid per-invoice access token (the
1357 * legitimate email-recipient flow). Constant-time compared with
1358 * hash_equals.
1359 * 2. The current user is logged in AND has admin-grade capability
1360 * (manage_options) — admin-side payment recording.
1361 * 3. The current user is logged in AND is the invoice's bound client
1362 * (case-insensitive email match against the invoice's client_id
1363 * record).
1364 *
1365 * Returns false otherwise. Callers must reject the request when this
1366 * returns false.
1367 */
1368 public static function canSubmitPaymentForInvoice(int $invoice_id, $invoice = null): bool {
1369 if ($invoice_id <= 0) {
1370 return false;
1371 }
1372
1373 // Path 1: legitimate access-token flow (email/shortcode link recipient).
1374 $presented = self::invoiceTokenFromRequest();
1375 if ($presented !== '') {
1376 $stored = (string) get_post_meta($invoice_id, '_easy_invoice_invoice_access_token', true);
1377 if ($stored !== '' && hash_equals($stored, $presented)) {
1378 return true;
1379 }
1380 }
1381
1382 // Path 2: admin override.
1383 if (current_user_can('manage_options')) {
1384 return true;
1385 }
1386
1387 // Path 3: authenticated owner. ONLY when the current user is the
1388 // invoice's bound client (email match against the client_id record).
1389 //
1390 // Note: Invoice model resolves `getClientId()` via __call magic,
1391 // so method_exists() returns FALSE for it (PHP's method_exists
1392 // does not recognise __call-resolved methods). Use is_callable
1393 // instead — it correctly returns TRUE when the receiver has a
1394 // __call that can field the message, so this guard actually
1395 // permits the bound-client path on real Invoice objects.
1396 if (is_user_logged_in() && $invoice && is_callable([$invoice, 'getClientId']) && $invoice->getClientId()) {
1397 $current_user = wp_get_current_user();
1398 $client_repository = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository();
1399 $client = $client_repository->find($invoice->getClientId());
1400 if ($client && strcasecmp((string) $client->getEmail(), (string) $current_user->user_email) === 0) {
1401 return true;
1402 }
1403 }
1404
1405 return false;
1406 }
1407
1408 }
1409