PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.3.1
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.3.1
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
easy-invoice / includes / Controllers / InvoiceController.php

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

1,333 lines 51.2 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 add_action('wp_ajax_easy_invoice_search_clients', array($this, 'handleSearchClients'));
855 }
856
857 /**
858 * Handle AJAX request to load invoice template
859 */
860 public function handleLoadTemplate() {
861 // Verify nonce
862 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'easy_invoice_nonce')) {
863 wp_send_json_error(array('message' => __('Security check failed', 'easy-invoice')));
864 }
865
866 // Get template name and validate it securely
867 $template = isset($_POST['template']) ? sanitize_text_field($_POST['template']) : 'standard';
868 $template = $this->validateTemplateName($template, 'invoice');
869 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
870
871 // Get secure template file path
872 $template_file = $this->getSecureTemplatePath($template, 'invoice');
873
874
875 if (!$template_file) {
876 wp_send_json_error(array('message' => __('Invalid template', 'easy-invoice')));
877 }
878
879 // For new invoices (no ID), just return the template without invoice data
880 if ($invoice_id === 0) {
881 // Start output buffering
882 ob_start();
883
884 // Set up empty variables for new invoices
885 $invoice = null;
886 $formatter = null;
887
888 include_once $template_file;
889 $html = ob_get_clean();
890
891 // Send response
892 wp_send_json_success(array('html' => $html));
893 return;
894 }
895
896 // Get invoice data for existing invoices
897 $repository = InvoiceServiceProvider::getInvoiceRepository();
898 $invoice = $repository->find($invoice_id);
899
900 if (!$invoice) {
901 wp_send_json_error(array('message' => __('Invoice not found', 'easy-invoice')));
902 }
903
904 // Initialize formatter for currency formatting
905 $formatter = new \EasyInvoice\Helpers\InvoiceFormatter($invoice);
906
907 // Start output buffering
908 ob_start();
909 include_once $template_file;
910 $html = ob_get_clean();
911
912 // Send response
913 wp_send_json_success(array('html' => $html));
914 }
915
916 /**
917 * Validate and sanitize template name to prevent directory traversal attacks
918 *
919 * @param string $template The template name to validate
920 * @param string $type Either 'invoice' or 'quote'
921 * @return string Validated template name or 'standard' as fallback
922 */
923 private function validateTemplateName($template, $type = 'invoice') {
924 // Whitelist of allowed template names
925 $allowed_templates = array(
926 'invoice' => array('classic', 'corporate', 'creative', 'elegant', 'legacy', 'minimal', 'modern', 'professional', 'standard', 'default'),
927 'quote' => array('legacy', 'minimal', 'minimalist', 'modern', 'standard', 'default')
928 );
929
930 // Strip any directory components using basename
931 $template = basename($template);
932
933 // Remove any file extension
934 $template = preg_replace('/\.(php|html|htm)$/i', '', $template);
935
936 // Remove any non-alphanumeric characters except hyphens and underscores
937 $template = preg_replace('/[^a-z0-9_-]/i', '', $template);
938
939 // Check if template is in whitelist
940 if (isset($allowed_templates[$type]) && in_array($template, $allowed_templates[$type], true)) {
941 return $template;
942 }
943
944 // Return default template if not in whitelist
945 return 'standard';
946 }
947
948 /**
949 * Get secure template file path with directory traversal protection
950 *
951 * @param string $template The validated template name
952 * @param string $type Either 'invoice' or 'quote'
953 * @return string|false The secure template file path or false if invalid
954 */
955 private function getSecureTemplatePath($template, $type = 'invoice') {
956 // Define template directories
957 $template_dirs = array(
958 'invoice' => EASY_INVOICE_PLUGIN_DIR . 'templates/invoice-templates/',
959 'quote' => EASY_INVOICE_PLUGIN_DIR . 'templates/quote-templates/'
960 );
961
962 if (!isset($template_dirs[$type])) {
963 return false;
964 }
965
966 $template_dir = $template_dirs[$type];
967
968 // Ensure template directory exists and is a directory
969 if (!is_dir($template_dir)) {
970 return false;
971 }
972
973 // Get the real path of the template directory (resolves any symlinks)
974 $real_template_dir = realpath($template_dir);
975 if ($real_template_dir === false) {
976 return false;
977 }
978
979 // Construct the template file path
980 $template_file = $real_template_dir . DIRECTORY_SEPARATOR . $template . '.php';
981
982 // Get the real path of the template file (resolves any .. or . components)
983 $real_template_file = realpath($template_file);
984
985 // Verify that the resolved path is within the template directory
986 // This prevents directory traversal attacks
987 if ($real_template_file === false || strpos($real_template_file, $real_template_dir) !== 0) {
988 // If template doesn't exist or is outside the directory, use default
989 $default_file = $real_template_dir . DIRECTORY_SEPARATOR . 'standard.php';
990 $real_default_file = realpath($default_file);
991
992 if ($real_default_file !== false && strpos($real_default_file, $real_template_dir) === 0) {
993 return $real_default_file;
994 }
995
996 return false;
997 }
998
999 // Verify the file exists and is readable
1000 if (!is_file($real_template_file) || !is_readable($real_template_file)) {
1001 // Fallback to standard template
1002 $default_file = $real_template_dir . DIRECTORY_SEPARATOR . 'standard.php';
1003 $real_default_file = realpath($default_file);
1004
1005 if ($real_default_file !== false && strpos($real_default_file, $real_template_dir) === 0 && is_file($real_default_file) && is_readable($real_default_file)) {
1006 return $real_default_file;
1007 }
1008
1009 return false;
1010 }
1011
1012 return $real_template_file;
1013 }
1014
1015 /**
1016 * Add meta box for manual payment verification to the invoice edit screen.
1017 */
1018 public function add_manual_payment_meta_box() {
1019 add_meta_box(
1020 'easy_invoice_manual_payment_verification',
1021 __('Manual Payment Verification', 'easy-invoice'),
1022 array($this, 'render_manual_payment_meta_box'),
1023 'easy-invoice', // Post type
1024 'side', // Context
1025 'high' // Priority
1026 );
1027 }
1028
1029 /**
1030 * Render the manual payment verification meta box.
1031 *
1032 * @param \WP_Post $post The current post object.
1033 */
1034 public function render_manual_payment_meta_box(\WP_Post $post) {
1035 $payment_status = get_post_meta($post->ID, '_payment_status', true);
1036 $payment_method = get_post_meta($post->ID, '_payment_method', true);
1037
1038 if (!in_array($payment_status, ['pending-bank', 'pending-cheque'])) {
1039 echo '<p>' . __('This invoice is not pending manual payment verification.', 'easy-invoice') . '</p>';
1040 return;
1041 }
1042
1043 wp_nonce_field('easy_invoice_mark_paid_' . $post->ID, 'easy_invoice_mark_paid_nonce');
1044
1045 echo '<h4>' . __('Submitted Payment Proof', 'easy-invoice') . '</h4>';
1046
1047 if ($payment_method === 'bank') {
1048 $transaction_id = get_post_meta($post->ID, '_bank_transaction_id', true);
1049 $notes = get_post_meta($post->ID, '_bank_payment_notes', true);
1050 $proof_url = get_post_meta($post->ID, '_bank_payment_proof', true);
1051
1052 echo '<p><strong>' . __('Transaction ID:', 'easy-invoice') . '</strong> ' . esc_html($transaction_id) . '</p>';
1053 if ($notes) {
1054 echo '<p><strong>' . __('Notes:', 'easy-invoice') . '</strong></p>';
1055 echo '<div style="white-space: pre-wrap; background: #f9f9f9; padding: 5px; border: 1px solid #eee;">' . esc_html($notes) . '</div>';
1056 }
1057 if ($proof_url) {
1058 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>';
1059 }
1060 } elseif ($payment_method === 'cheque') {
1061 $cheque_number = get_post_meta($post->ID, '_cheque_number', true);
1062 $bank_name = get_post_meta($post->ID, '_cheque_bank_name', true);
1063 $cheque_date = get_post_meta($post->ID, '_cheque_date', true);
1064 $notes = get_post_meta($post->ID, '_cheque_notes', true);
1065 $image_url = get_post_meta($post->ID, '_cheque_image', true);
1066
1067 echo '<p><strong>' . __('Cheque Number:', 'easy-invoice') . '</strong> ' . esc_html($cheque_number) . '</p>';
1068 if ($bank_name) echo '<p><strong>' . __('Bank Name:', 'easy-invoice') . '</strong> ' . esc_html($bank_name) . '</p>';
1069 if ($cheque_date) echo '<p><strong>' . __('Cheque Date:', 'easy-invoice') . '</strong> ' . esc_html($cheque_date) . '</p>';
1070 if ($notes) {
1071 echo '<p><strong>' . __('Notes:', 'easy-invoice') . '</strong></p>';
1072 echo '<div style="white-space: pre-wrap; background: #f9f9f9; padding: 5px; border: 1px solid #eee;">' . esc_html($notes) . '</div>';
1073 }
1074 if ($image_url) {
1075 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>';
1076 }
1077 }
1078
1079 echo '<p style="margin-top: 15px;">';
1080 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>';
1081 echo '</p>';
1082 echo '<div id="easy-invoice-mark-paid-message" style="margin-top:10px;"></div>';
1083
1084 // Add a script for the AJAX call
1085 ?>
1086 <script type="text/javascript">
1087 jQuery(document).ready(function($) {
1088 $('#easy-invoice-mark-paid-btn').on('click', function() {
1089 var invoiceId = $(this).data('invoice-id');
1090 var nonce = $('#easy_invoice_mark_paid_nonce').val();
1091 var button = $(this);
1092 var messageDiv = $('#easy-invoice-mark-paid-message');
1093
1094 button.prop('disabled', true);
1095 messageDiv.html('Processing...');
1096
1097 $.ajax({
1098 url: ajaxurl, // WordPress AJAX URL
1099 type: 'POST',
1100 data: {
1101 action: 'easy_invoice_mark_paid',
1102 invoice_id: invoiceId,
1103 nonce: nonce
1104 },
1105 success: function(response) {
1106 if (response.success) {
1107 messageDiv.css('color', 'green').html(response.data.message);
1108 button.hide();
1109 // Optionally, reload the page or update UI elements to reflect paid status
1110 // window.location.reload();
1111 } else {
1112 messageDiv.css('color', 'red').html(response.data.message);
1113 button.prop('disabled', false);
1114 }
1115 },
1116 error: function() {
1117 messageDiv.css('color', 'red').html('<?php echo esc_js(__("An error occurred. Please try again.", "easy-invoice")); ?>');
1118 button.prop('disabled', false);
1119 }
1120 });
1121 });
1122 });
1123 </script>
1124 <?php
1125 }
1126
1127 /**
1128 * AJAX handler to create a sample invoice.
1129 */
1130 public function ajax_create_sample_invoice() {
1131 // Security check: verify nonce
1132 check_ajax_referer('easy_invoice_admin_nonce', 'nonce');
1133
1134 // Security check: verify user capabilities
1135 if (!easy_invoice_user_can('ei_create_invoice')) {
1136 wp_send_json_error([
1137 'message' => __('You do not have permission to create invoices.', 'easy-invoice')
1138 ], 403);
1139 return;
1140 }
1141
1142 try {
1143 $invoice_repository = InvoiceServiceProvider::getInvoiceRepository();
1144
1145 // Sample Invoice Data
1146 $sample_invoice_data = [
1147 'post_title' => 'Sample Invoice - ' . date('Y-m-d H:i'),
1148 'post_status' => 'draft', // Or 'publish' if you want it live immediately
1149 // Add other WP_Post fields as needed (e.g., post_author)
1150 ];
1151
1152 // Sample Meta Data
1153 $sample_meta_data = [
1154 '_easy_invoice_number' => 'SAMPLE-' . time(),
1155 '_easy_invoice_issue_date' => date('Y-m-d'),
1156 '_easy_invoice_due_date' => date('Y-m-d', strtotime('+15 days')),
1157 '_easy_invoice_status' => 'draft',
1158 '_easy_invoice_customer_name' => 'John Doe (Sample Client)',
1159 '_easy_invoice_customer_email' => 'customer@example.com',
1160 '_easy_invoice_customer_address' => "123 Sample Street\nSampleville, ST 12345",
1161 'currency_code' => 'USD',
1162 'currency_position' => 'before',
1163 // Add other meta keys as needed
1164 ];
1165
1166 // Sample Line Items
1167 $sample_items = [];
1168 for ($i = 1; $i <= 3; $i++) {
1169 $sample_items[] = [
1170 'name' => 'Sample Service ' . $i,
1171 'description' => 'Detailed description of sample service ' . $i . '.',
1172 'quantity' => rand(1, 5),
1173 'price' => rand(50, 200) * 1.00,
1174 // 'taxable' => true/false (optional)
1175 ];
1176 }
1177 $sample_meta_data['_easy_invoice_items'] = $sample_items;
1178
1179 // Create the invoice post
1180 $invoice_id = wp_insert_post($sample_invoice_data, true); // true for WP_Error on failure
1181
1182 if (is_wp_error($invoice_id)) {
1183 throw new \Exception('Failed to create invoice post: ' . $invoice_id->get_error_message());
1184 }
1185
1186 // Set invoice meta data
1187 foreach ($sample_meta_data as $key => $value) {
1188 update_post_meta($invoice_id, $key, $value);
1189 }
1190
1191 // Recalculate totals if your Invoice model or repository has a method for it
1192 // For example, if you have $invoice->calculateTotals()->save(); or similar.
1193 wp_send_json_success([
1194 'message' => __('Sample invoice created successfully!', 'easy-invoice'),
1195 'invoice_id' => $invoice_id,
1196 'edit_link' => admin_url('admin.php?page=easy-invoice-builder&id=' . $invoice_id)
1197 ]);
1198
1199 } catch (\Exception $e) {
1200 wp_send_json_error([
1201 'message' => __('Error creating sample invoice:', 'easy-invoice') . ' ' . $e->getMessage()
1202 ], 500);
1203 }
1204 }
1205
1206 /**
1207 * AJAX handler for creating a new invoice with title
1208 */
1209 public function ajax_create_new_invoice() {
1210 // Security check: verify nonce
1211 check_ajax_referer('easy_invoice_nonce', 'nonce');
1212
1213 // Security check: verify user capabilities
1214 if (!easy_invoice_user_can('ei_create_invoice')) {
1215 wp_send_json_error([
1216 'message' => __('You do not have permission to create invoices.', 'easy-invoice')
1217 ], 403);
1218 return;
1219 }
1220
1221 // Get the invoice title
1222 $title = isset($_POST['title']) ? sanitize_text_field($_POST['title']) : '';
1223
1224 if (empty($title)) {
1225 wp_send_json_error([
1226 'message' => __('Invoice title is required.', 'easy-invoice')
1227 ], 400);
1228 return;
1229 }
1230
1231 try {
1232 $invoice_repository = InvoiceServiceProvider::getInvoiceRepository();
1233
1234 // Prepare invoice data for repository
1235 $invoice_data = [
1236 'title' => $title,
1237 'post_status' => 'draft',
1238 'issue_date' => date('Y-m-d'),
1239 'due_date' => date('Y-m-d', strtotime('+30 days')),
1240 'status' => 'draft',
1241 'invoice_template' => get_option('easy_invoice_last_invoice_template', 'standard')
1242 ];
1243
1244 // Create the invoice using repository (this will auto-generate invoice number)
1245 $invoice = $invoice_repository->create($invoice_data);
1246
1247 if (!$invoice) {
1248 throw new \Exception('Failed to create invoice');
1249 }
1250
1251 // Debug: Check if invoice number was set
1252 $invoice_number = $invoice->getNumber();
1253 if (empty($invoice_number)) {
1254 // Force set the invoice number if it's empty
1255 $invoice_number_service = easy_invoice_get_invoice_number_service();
1256 $generated_number = $invoice_number_service->generateUniqueNumber();
1257 $invoice->setNumber($generated_number);
1258 $invoice->save();
1259 }
1260
1261 wp_send_json_success([
1262 'message' => __('Invoice created successfully!', 'easy-invoice'),
1263 'invoice_id' => $invoice->getId(),
1264 'invoice_number' => $invoice->getNumber(),
1265 'redirect_url' => admin_url('admin.php?page=easy-invoice-builder&invoice_id=' . $invoice->getId())
1266 ]);
1267
1268 } catch (\Exception $e) {
1269 wp_send_json_error([
1270 'message' => __('Error creating invoice:', 'easy-invoice') . ' ' . $e->getMessage()
1271 ], 500);
1272 }
1273 }
1274
1275 /**
1276 * Handle search clients AJAX request
1277 *
1278 * @since 1.0.0
1279 */
1280 public function handleSearchClients(): void {
1281 // Verify nonce
1282 if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_nonce')) {
1283 wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
1284 }
1285
1286 // Check permissions — searching client list while building an invoice.
1287 if (!easy_invoice_user_can('ei_view_clients')) {
1288 wp_send_json_error(['message' => __('Insufficient permissions.', 'easy-invoice')]);
1289 }
1290
1291 $query = sanitize_text_field($_POST['query'] ?? '');
1292
1293 // Get client repository
1294 $client_repository = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository();
1295
1296 // If query is empty, get all clients
1297 if (empty($query)) {
1298 $clients = $client_repository->all();
1299 } else {
1300 // Search clients by name, email, or company
1301 $clients = $client_repository->search($query);
1302 }
1303
1304 // Row-level security: restrict to assigned clients when the
1305 // current user is a Sales rep without `ei_view_all_clients`.
1306 // Returns null when unrestricted; we pass through in that case.
1307 if (function_exists('easy_invoice_visible_client_ids')) {
1308 $visible = easy_invoice_visible_client_ids();
1309 if (is_array($visible)) {
1310 $allowed = array_flip(array_map('intval', $visible));
1311 $clients = array_values(array_filter($clients, static function ($c) use ($allowed) {
1312 return isset($allowed[(int) $c->getId()]);
1313 }));
1314 }
1315 }
1316
1317 $results = [];
1318 foreach ($clients as $client) {
1319 $results[] = [
1320 'id' => $client->getId(),
1321 'name' => $client->getBusinessClientName() ?: ($client->getFirstName() . ' ' . $client->getLastName()),
1322 'email' => $client->getEmail(),
1323 'company' => $client->getBusinessClientName(),
1324 'phone' => $client->getExtraInfo(),
1325 'website' => $client->getWebsite(),
1326 'address' => $client->getAddress()
1327 ];
1328 }
1329
1330 wp_send_json_success($results);
1331 }
1332 }
1333