PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.0
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.0
2.4.0 2.4.1 2.3.8 2.3.7 2.3.6 2.3.5 2.3.4 2.3.3 2.3.2 2.3.1 2.2.0 2.1.21 2.1.20 2.1.19 2.1.18 2.1.0 2.1.1 2.1.10 2.1.11 2.1.12 2.1.13 2.1.14 2.1.15 2.1.16 2.1.2 All 57 releases
easy-invoice / includes / Controllers / InvoiceController.php

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

1,262 lines 50.5 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 // NOTE: `easy_invoice_create_new_invoice` is registered by
42 // registerAjaxHandlers() (called above), not here. It used to be registered
43 // in both places, so WordPress held two handlers for the same action and
44 // ajax_create_new_invoice() was hooked twice on a single request — harmless
45 // only because the handler exits on reply. Registered once now; keep this
46 // tombstone so it doesn't get added back.
47
48 // Allow plugins to extend the controller initialization
49 do_action('easy_invoice_invoice_controller_after_init', $this);
50 }
51
52 /**
53 * Display method implementation
54 *
55 * @param array $args Display arguments
56 */
57 public function display(array $args = []) {
58 // Allow plugins to modify display arguments
59 $args = apply_filters('easy_invoice_invoice_controller_display_args', $args);
60
61 $page = isset($args['page']) ? $args['page'] : '';
62
63 // Allow plugins to modify the page before processing
64 $page = apply_filters('easy_invoice_invoice_controller_display_page', $page, $args);
65
66 switch ($page) {
67 case PagesSlugs::ALL_INVOICES:
68 $this->displayInvoicesPage();
69 break;
70
71 case PagesSlugs::INVOICE_NEW:
72 // For a new invoice, ensure no ID is passed
73 $_GET['id'] = isset($_GET['id']) ? $_GET['id'] : 0;
74 $this->displayInvoiceBuilderPage();
75 break;
76
77 case PagesSlugs::INVOICE_PREVIEW:
78 $this->displayPreviewPage();
79 break;
80
81 default:
82 $this->displayInvoicesPage();
83 break;
84 }
85
86 // Allow plugins to perform actions after display
87 do_action('easy_invoice_invoice_controller_after_display', $page, $args);
88 }
89
90 /**
91 * Display all invoices page
92 *
93 * Uses WordPress's built-in WP_Query and paginate_links() for optimal performance
94 * with large datasets (10,000+ invoices). The pagination is handled efficiently
95 * by WordPress core functions which are optimized for scalability.
96 */
97 protected function displayInvoicesPage() {
98 // Allow plugins to perform actions before displaying invoices page
99 do_action('easy_invoice_invoice_controller_before_display_invoices_page');
100
101 // Get filter parameters
102 $status_filter = isset($_GET['status']) ? sanitize_text_field($_GET['status']) : '';
103 $recurring_filter = isset($_GET['recurring']) ? sanitize_text_field($_GET['recurring']) : '';
104 $subscription_filter = isset($_GET['subscription']) ? sanitize_text_field($_GET['subscription']) : '';
105 $client_filter = isset($_GET['client_id']) ? absint($_GET['client_id']) : 0;
106 $search_query = isset($_GET['search']) ? sanitize_text_field(wp_unslash($_GET['search'])) : '';
107 $current_view = isset($_GET['view']) ? sanitize_text_field($_GET['view']) : 'all';
108 $current_page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1;
109 $per_page = 20;
110 $offset = ($current_page - 1) * $per_page;
111
112 // Build repository query arguments
113 $args = [];
114
115 // Set post status based on view
116 if ($current_view === 'trash') {
117 $args['post_status'] = 'trash';
118 } elseif ($current_view === 'draft') {
119 // A draft is an invoice whose *status* is draft (every invoice is a
120 // published post); the tab used to look for draft posts and always
121 // read 0 while the Draft filter pill listed several.
122 $status_filter = 'draft';
123 }
124
125 // Build meta query array
126 $meta_query = [];
127
128 // Add status filter if provided. "Overdue" is a state an invoice is in
129 // (owed and past its due date), not a status anything writes, so the
130 // filter derives it: any invoice still awaiting money whose due date
131 // has passed, plus the few that carry the literal status.
132 $overdue_ids = null;
133 if ('overdue' === $status_filter) {
134 // One direct lookup. As a nested OR/AND meta_query (with a DATE cast)
135 // WordPress joined postmeta three times and scanned it — 17 s at
136 // 10,000 invoices. Due dates are stored Y-m-d, so a string compare
137 // is a date compare.
138 global $wpdb;
139 $overdue_ids = array_map('intval', (array) $wpdb->get_col($wpdb->prepare(
140 "SELECT s.post_id FROM {$wpdb->postmeta} s
141 LEFT JOIN {$wpdb->postmeta} d ON d.post_id = s.post_id AND d.meta_key = '_easy_invoice_due_date'
142 WHERE s.meta_key = '_easy_invoice_status'
143 AND (s.meta_value = 'overdue'
144 OR (s.meta_value IN ('available', 'unpaid', 'partial', 'sent', 'pending')
145 AND d.meta_value IS NOT NULL AND d.meta_value <> '' AND d.meta_value < %s))",
146 gmdate('Y-m-d', current_time('timestamp'))
147 )));
148 } elseif (!empty($status_filter)) {
149 $meta_query[] = [
150 'key' => '_easy_invoice_status',
151 'value' => $status_filter,
152 'compare' => '='
153 ];
154 }
155
156 // Add client filter if provided.
157 //
158 // Easy Invoice stores either:
159 // • `_easy_invoice_client_id` — populated when the user picks a
160 // client from the dropdown in the Invoice Builder, OR
161 // • `_easy_invoice_customer_email` (+ customer_name) — populated when
162 // the biller types ad-hoc customer info inline.
163 //
164 // To make the filter useful for both flows we match on
165 // client_id == N OR customer_email == that client's email.
166 if (!empty($client_filter)) {
167 $client_email = '';
168 try {
169 $client_repo = new \EasyInvoice\Repositories\ClientRepository();
170 $client_obj = $client_repo->find($client_filter);
171 if ($client_obj) {
172 // The Client model exposes the email via the magic __call → __get fallback.
173 $client_email = (string) $client_obj->getEmail();
174 }
175 } catch (\Throwable $e) {
176 $client_email = '';
177 }
178
179 $client_clauses = [
180 'relation' => 'OR',
181 [
182 'key' => '_easy_invoice_client_id',
183 'value' => (string) $client_filter,
184 'compare' => '=',
185 ],
186 ];
187 if ($client_email !== '') {
188 $client_clauses[] = [
189 'key' => '_easy_invoice_customer_email',
190 'value' => $client_email,
191 'compare' => '=',
192 ];
193 }
194 $meta_query[] = $client_clauses;
195 }
196
197 // Add recurring filter if provided
198 if (!empty($recurring_filter)) {
199 if ($recurring_filter === 'recurring') {
200 // Show only recurring invoices
201 $meta_query[] = [
202 'key' => '_easy_invoice_recurring_enabled',
203 'value' => '1',
204 'compare' => '='
205 ];
206 } elseif ($recurring_filter === 'non-recurring') {
207 // Show only non-recurring invoices
208 $meta_query[] = [
209 'relation' => 'OR',
210 [
211 'key' => '_easy_invoice_recurring_enabled',
212 'compare' => 'NOT EXISTS'
213 ],
214 [
215 'key' => '_easy_invoice_recurring_enabled',
216 'value' => '0',
217 'compare' => '='
218 ]
219 ];
220 }
221 }
222
223 // Subscription filter (Pro's Subscription Invoices addon renders the
224 // pills; the value was read above but never applied to the query).
225 if (!empty($subscription_filter)) {
226 if ($subscription_filter === 'subscription') {
227 $meta_query[] = [
228 'key' => '_easy_invoice_subscription_enabled',
229 'value' => '1',
230 'compare' => '='
231 ];
232 } elseif ($subscription_filter === 'non-subscription') {
233 $meta_query[] = [
234 'relation' => 'OR',
235 [
236 'key' => '_easy_invoice_subscription_enabled',
237 'compare' => 'NOT EXISTS'
238 ],
239 [
240 'key' => '_easy_invoice_subscription_enabled',
241 'value' => '0',
242 'compare' => '='
243 ]
244 ];
245 }
246 }
247
248 // Add meta query to args if we have any filters
249 if (!empty($meta_query)) {
250 if (count($meta_query) === 1) {
251 $args['meta_query'] = [ $meta_query[0] ]; // a bare clause is ignored by WP_Query; it must be a list of clauses
252 } else {
253 $args['meta_query'] = [
254 'relation' => 'AND',
255 ...$meta_query
256 ];
257 }
258 }
259
260 // Add pagination parameters to args
261 $args['posts_per_page'] = $per_page;
262 $args['offset'] = $offset;
263 $args['orderby'] = 'date';
264 $args['order'] = 'DESC';
265
266 // Allow plugins to modify query arguments
267 $args = apply_filters('easy_invoice_invoice_controller_query_args', $args, $current_view, $status_filter);
268
269 // Get paginated invoices using WordPress query
270 $repository = InvoiceServiceProvider::getInvoiceRepository();
271
272 // Use WordPress WP_Query directly for better pagination handling
273 $query_args = array_merge([
274 'post_type' => \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE,
275 'post_status' => $args['post_status'] ?? 'publish',
276 'posts_per_page' => $per_page,
277 'paged' => $current_page,
278 'orderby' => 'date',
279 'order' => 'DESC',
280 'no_found_rows' => false, // We need this for pagination
281 'update_post_term_cache' => false, // Disable term cache for better performance
282 'update_post_meta_cache' => false, // Disable meta cache for better performance
283 ], $args);
284
285 // Add search functionality
286 if (!empty($search_query)) {
287 // For search, we'll use a simpler approach that works better with WordPress
288 // First, get all invoices that match the search criteria
289 $search_ids = [];
290
291 // Search in post title and content
292 $title_search = new WP_Query([
293 'post_type' => \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE,
294 'post_status' => $args['post_status'] ?? 'publish',
295 'posts_per_page' => -1,
296 's' => $search_query
297 ]);
298
299 if ($title_search->have_posts()) {
300 $search_ids = array_merge($search_ids, wp_list_pluck($title_search->posts, 'ID'));
301 }
302
303 // Search in meta fields
304 $meta_search = new WP_Query([
305 'post_type' => \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE,
306 'post_status' => $args['post_status'] ?? 'publish',
307 'posts_per_page' => -1,
308 'meta_query' => [
309 'relation' => 'OR',
310 [
311 'key' => '_easy_invoice_number',
312 'value' => $search_query,
313 'compare' => 'LIKE'
314 ],
315 [
316 'key' => '_easy_invoice_customer_name',
317 'value' => $search_query,
318 'compare' => 'LIKE'
319 ],
320 [
321 'key' => '_easy_invoice_customer_email',
322 'value' => $search_query,
323 'compare' => 'LIKE'
324 ]
325 ]
326 ]);
327
328 if ($meta_search->have_posts()) {
329 $search_ids = array_merge($search_ids, wp_list_pluck($meta_search->posts, 'ID'));
330 }
331
332 // Remove duplicates
333 $search_ids = array_unique($search_ids);
334
335 if (!empty($search_ids)) {
336 // Use post__in to filter by the found IDs
337 $query_args['post__in'] = $search_ids;
338 } else {
339 // If no results found, set post__in to empty array to show no results
340 $query_args['post__in'] = [0];
341 }
342 }
343
344 // Remove offset as we're using paged
345 if (null !== $overdue_ids) {
346 $query_args['post__in'] = isset($query_args['post__in'])
347 ? (array_values(array_intersect($query_args['post__in'], $overdue_ids)) ?: [0])
348 : ($overdue_ids ?: [0]);
349 }
350
351 unset($query_args['offset']);
352
353 // Allow plugins to modify the final query arguments
354 $query_args = apply_filters('easy_invoice_invoice_controller_final_query_args', $query_args);
355
356 $wp_query = new WP_Query($query_args);
357 $invoices = [];
358
359 if ($wp_query->have_posts()) {
360 foreach ($wp_query->posts as $post) {
361 $invoice = $repository->find($post->ID);
362 if ($invoice) {
363 $invoices[] = $invoice;
364 }
365 }
366 }
367
368 // Allow plugins to modify the invoices array
369 $invoices = apply_filters('easy_invoice_invoice_controller_invoices_list', $invoices, $wp_query);
370
371 // Get pagination info from WordPress query
372 $total_invoices = $wp_query->found_posts;
373 $total_pages = $wp_query->max_num_pages;
374
375 // Get trash count for tab display (without pagination)
376 // Tab counts are counts, not model loads.
377 $trash_count = (int) $repository->count(['post_status' => 'trash']);
378 $draft_count = (int) $repository->count(['meta_key' => '_easy_invoice_status', 'meta_value' => 'draft']); // phpcs:ignore WordPress.DB.SlowDBQuery
379
380 // Build clients list for the listing filter dropdown
381 $clients_list = [];
382 try {
383 $client_repository = new \EasyInvoice\Repositories\ClientRepository();
384 foreach ($client_repository->all() as $client) {
385 $name = $client->getBusinessClientName() ?: trim($client->getFirstName() . ' ' . $client->getLastName());
386 if ($name === '') {
387 continue;
388 }
389 $clients_list[] = [
390 'id' => $client->getId(),
391 'name' => $name,
392 ];
393 }
394 usort($clients_list, function ($a, $b) {
395 return strcasecmp($a['name'], $b['name']);
396 });
397 } catch (\Throwable $e) {
398 $clients_list = [];
399 }
400
401 // Prepare template data
402 $template_data = [
403 'invoices' => $invoices,
404 'current_view' => $current_view,
405 'status_filter' => $status_filter,
406 'recurring_filter' => $recurring_filter,
407 'subscription_filter' => $subscription_filter,
408 'client_filter' => $client_filter,
409 'clients_list' => $clients_list,
410 'search_query' => $search_query,
411 'trash_count' => $trash_count,
412 'draft_count' => $draft_count,
413 'repository' => $repository,
414 'current_page' => $current_page,
415 'per_page' => $per_page,
416 'total_invoices' => $total_invoices,
417 'total_pages' => $total_pages,
418 'wp_query' => $wp_query
419 ];
420
421 // Allow plugins to modify template data
422 $template_data = apply_filters('easy_invoice_invoice_controller_template_data', $template_data);
423
424 // Display the template
425 $this->displayTemplate(
426 EASY_INVOICE_PLUGIN_DIR . 'templates/invoices/listing.php',
427 $template_data
428 );
429
430 // Allow plugins to perform actions after displaying invoices page
431 do_action('easy_invoice_invoice_controller_after_display_invoices_page', $template_data);
432 }
433
434 /**
435 * Display invoice builder page
436 */
437 protected function displayInvoiceBuilderPage() {
438 $invoice_id = isset($_GET['id']) ? intval($_GET['id']) : 0;
439 $repository = InvoiceServiceProvider::getInvoiceRepository();
440
441 // Display the template
442 $this->displayTemplate(
443 EASY_INVOICE_PLUGIN_DIR . 'templates/invoices/builder.php',
444 ['invoice_id' => $invoice_id, 'repository' => $repository]
445 );
446 }
447
448 /**
449 * Display invoice preview page
450 */
451 protected function displayPreviewPage() {
452 $this->renderInvoicePreview();
453 }
454
455 /**
456 * Common helper method to render an invoice preview
457 * Used by both preview methods to ensure consistency
458 */
459 private function renderInvoicePreview() {
460 $check = $this->checkCapability('ei_view_invoices');
461 if (is_wp_error($check)) {
462 wp_die(esc_html($check->get_error_message()));
463 }
464
465 // The quote preview takes ?id=; accept both spellings here too.
466 $invoice_id = isset($_GET['invoice_id']) ? intval($_GET['invoice_id']) : (isset($_GET['id']) ? intval($_GET['id']) : 0);
467
468 if ($invoice_id <= 0) {
469 wp_die(esc_html__('Invalid invoice ID', 'easy-invoice'));
470 }
471
472 // Get invoice from repository
473 $repository = InvoiceServiceProvider::getInvoiceRepository();
474 $invoice = $repository->find($invoice_id);
475
476 if (!$invoice) {
477 wp_die(esc_html__('Invalid invoice ID', 'easy-invoice'));
478 }
479
480 // Get common template variables
481 $template_vars = $this->getCommonTemplateVars();
482 $currency_symbol = $template_vars['currency_symbol'];
483
484 // Enqueue preview styles
485 wp_enqueue_style(
486 'easy-invoice-preview',
487 EASY_INVOICE_PLUGIN_URL . 'assets/css/preview.css',
488 array(),
489 EASY_INVOICE_VERSION
490 );
491
492 // Display the template
493 $this->displayTemplate(
494 EASY_INVOICE_PLUGIN_DIR . 'templates/invoices/preview.php',
495 [
496 'invoice' => $invoice,
497 'currency_symbol' => $currency_symbol
498 ]
499 );
500 }
501
502 /**
503 * Trash an invoice (move to trash)
504 */
505 public function trashInvoice() {
506 if (!$this->handleAjaxSecurity(($_POST['nonce'] ?? ''))) {
507 return;
508 }
509
510 // Check invoice ID
511 if (!isset($_POST['invoice_id']) || empty($_POST['invoice_id'])) {
512 wp_send_json_error(array('message' => 'Invalid invoice ID'));
513 }
514
515 $invoice_id = intval($_POST['invoice_id']);
516
517 // Get the invoice object to update status
518 $invoice_repository = InvoiceServiceProvider::getInvoiceRepository();
519 $invoice = $invoice_repository->find($invoice_id);
520 if ($invoice) {
521 self::cancelForTrash($invoice);
522 }
523
524 // Move to trash
525 $result = wp_trash_post($invoice_id);
526
527 if ($result) {
528 wp_send_json_success(array('message' => 'Invoice moved to trash'));
529 } else {
530 wp_send_json_error(array('message' => 'Error moving invoice to trash'));
531 }
532 }
533
534 /**
535 * Restore an invoice from trash
536 */
537 /**
538 * Trashing cancels the invoice (the document survives and says what
539 * happened) but remembers what it was, so restoring puts it back —
540 * a paid invoice taken out of the list must not come back as unpaid.
541 *
542 * @param object $invoice Invoice model.
543 */
544 public static function cancelForTrash($invoice): void {
545 $status = strtolower((string) $invoice->getStatus());
546 if ('cancelled' !== $status) {
547 update_post_meta((int) $invoice->getId(), '_easy_invoice_status_before_trash', $status);
548 }
549 $invoice->setStatus('cancelled');
550 $invoice->save();
551 }
552
553 /**
554 * Republish a restored invoice (WordPress restores to draft) and give it
555 * back the status it had before it was trashed.
556 *
557 * @param int $invoice_id Invoice.
558 */
559 public static function restoreAfterTrash(int $invoice_id): void {
560 wp_update_post(array('ID' => $invoice_id, 'post_status' => 'publish'));
561 $previous = (string) get_post_meta($invoice_id, '_easy_invoice_status_before_trash', true);
562 delete_post_meta($invoice_id, '_easy_invoice_status_before_trash');
563 $invoice = InvoiceServiceProvider::getInvoiceRepository()->find($invoice_id);
564 if ($invoice) {
565 $invoice->setStatus('' !== $previous ? $previous : 'available');
566 $invoice->save();
567 }
568 }
569
570 public function restoreInvoice() {
571 if (!$this->handleAjaxSecurity(($_POST['nonce'] ?? ''))) {
572 return;
573 }
574
575 // Check invoice ID
576 if (!isset($_POST['invoice_id']) || empty($_POST['invoice_id'])) {
577 wp_send_json_error(array('message' => 'Invalid invoice ID'));
578 }
579
580 $invoice_id = intval($_POST['invoice_id']);
581
582 // Restore from trash
583 $result = wp_untrash_post($invoice_id);
584
585 if ($result) {
586 self::restoreAfterTrash($invoice_id);
587
588 wp_send_json_success(array('message' => __('Invoice restored from trash.', 'easy-invoice')));
589 } else {
590 wp_send_json_error(array('message' => 'Error restoring invoice from trash'));
591 }
592 }
593
594 /**
595 * Delete an invoice permanently
596 */
597 public function deleteInvoicePermanently() {
598 if (!$this->handleAjaxSecurity(($_POST['nonce'] ?? ''))) {
599 return;
600 }
601
602 // Check invoice ID
603 if (!isset($_POST['invoice_id']) || empty($_POST['invoice_id'])) {
604 wp_send_json_error(array('message' => 'Invalid invoice ID'));
605 }
606
607 $invoice_id = intval($_POST['invoice_id']);
608
609 // Ask before deleting, so a refusal can say why and what to do instead.
610 // InvoiceRetention also blocks this at the data layer, but a bare
611 // "Error deleting invoice" would leave the user with no idea that the
612 // refusal was deliberate.
613 $may_delete = \EasyInvoice\Services\InvoiceRetention::mayDelete($invoice_id);
614 if (is_wp_error($may_delete)) {
615 wp_send_json_error(array(
616 'message' => $may_delete->get_error_message(),
617 'code' => $may_delete->get_error_code(),
618 ));
619 }
620
621 // Delete permanently
622 $result = wp_delete_post($invoice_id, true);
623
624 if ($result) {
625 wp_send_json_success(array('message' => 'Invoice deleted permanently'));
626 } else {
627 wp_send_json_error(array('message' => 'Error deleting invoice'));
628 }
629 }
630
631 /**
632 * Legacy delete invoice handler (now redirects to trash)
633 */
634 public function deleteInvoice() {
635 // Redirect to trash function for backward compatibility
636 $this->trashInvoice();
637 }
638
639 /**
640 * Publish an invoice (change status from draft to publish)
641 */
642 public function publishInvoice() {
643 if (!$this->handleAjaxSecurity(($_POST['nonce'] ?? ''))) {
644 return;
645 }
646
647 // Check invoice ID
648 if (!isset($_POST['invoice_id']) || empty($_POST['invoice_id'])) {
649 wp_send_json_error(array('message' => 'Invalid invoice ID'));
650 }
651
652 $invoice_id = intval($_POST['invoice_id']);
653
654 // Update post status to published
655 $result = wp_update_post(array(
656 'ID' => $invoice_id,
657 'post_status' => 'publish'
658 ));
659
660 if ($result) {
661 wp_send_json_success(array('message' => 'Invoice published successfully'));
662 } else {
663 wp_send_json_error(array('message' => 'Error publishing invoice'));
664 }
665 }
666
667 /**
668 * Set an invoice to draft status
669 */
670 public function draftInvoice() {
671 if (!$this->handleAjaxSecurity(($_POST['nonce'] ?? ''))) {
672 return;
673 }
674
675 // Check invoice ID
676 if (!isset($_POST['invoice_id']) || empty($_POST['invoice_id'])) {
677 wp_send_json_error(array('message' => 'Invalid invoice ID'));
678 }
679
680 $invoice_id = intval($_POST['invoice_id']);
681 $repository = InvoiceServiceProvider::getInvoiceRepository();
682 $invoice = $repository->find($invoice_id);
683 if (!$invoice) {
684 wp_send_json_error(array('message' => __('Invoice not found.', 'easy-invoice')));
685 }
686 // Back to draft means the *invoice* status: the post stays published so
687 // the invoice stays in the list and keeps its number and link. (It used
688 // to set the post to draft, which made the invoice vanish from every
689 // list and page.) Money already received cannot be un-issued.
690 $status = strtolower((string) $invoice->getStatus());
691 if (in_array($status, ['paid', 'partial'], true)) {
692 wp_send_json_error(array('message' => __('A paid or part-paid invoice cannot go back to draft.', 'easy-invoice')));
693 }
694 $invoice->setStatus('draft');
695 if ($invoice->save()) {
696 wp_send_json_success(array('message' => __('Invoice set back to draft.', 'easy-invoice')));
697 } else {
698 wp_send_json_error(array('message' => __('The invoice could not be updated.', 'easy-invoice')));
699 }
700 }
701
702 /**
703 * Handle bulk actions
704 */
705 public function handleBulkActions() {
706 // Check if we're processing a bulk action
707 if (!isset($_POST['action']) || $_POST['action'] !== 'easy_invoice_bulk_action') {
708 return;
709 }
710
711 // Check nonce and capability
712 $security_check = $this->securityCheck(($_POST['easy_invoice_bulk_nonce'] ?? ''), 'easy_invoice_bulk_action');
713 if (is_wp_error($security_check)) {
714 wp_die(esc_html($security_check->get_error_message()));
715 }
716
717 // Check if we have invoice IDs
718 if (!isset($_POST['invoice_ids']) || !is_array($_POST['invoice_ids']) || empty($_POST['invoice_ids'])) {
719 wp_safe_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_error=no_selection'));
720 exit;
721 }
722
723 // Get bulk action and invoice IDs
724 $bulk_action = isset($_POST['bulk_action']) ? sanitize_text_field($_POST['bulk_action']) : '';
725 $invoice_ids = array_map('intval', $_POST['invoice_ids']);
726
727 // Process based on action
728 $processed = 0;
729
730 switch ($bulk_action) {
731 case 'trash':
732 foreach ($invoice_ids as $id) {
733 $model = InvoiceServiceProvider::getInvoiceRepository()->find((int) $id);
734 if ($model) {
735 self::cancelForTrash($model);
736 }
737 if (wp_trash_post($id)) {
738 $processed++;
739 }
740 }
741 wp_safe_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_trashed=' . $processed));
742 break;
743
744 case 'restore':
745 foreach ($invoice_ids as $id) {
746 if (wp_untrash_post($id)) {
747 self::restoreAfterTrash((int) $id);
748 $processed++;
749 }
750 }
751 wp_safe_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_restored=' . $processed));
752 break;
753
754 case 'delete':
755 // Issued invoices are skipped rather than failing the whole
756 // batch: selecting "all" and finding nothing happened would be
757 // worse than deleting the drafts and reporting the rest.
758 $protected = 0;
759 foreach ($invoice_ids as $id) {
760 if (is_wp_error(\EasyInvoice\Services\InvoiceRetention::mayDelete((int) $id))) {
761 $protected++;
762 continue;
763 }
764 if (wp_delete_post($id, true)) {
765 $processed++;
766 }
767 }
768 wp_safe_redirect(add_query_arg(
769 array_filter([
770 'page' => 'easy-invoice-all',
771 'view' => 'trash',
772 'bulk_deleted' => $processed,
773 'bulk_kept' => $protected ?: null,
774 ]),
775 admin_url('admin.php')
776 ));
777 break;
778
779 case 'draft':
780 // Invoice status, not post status (see draftInvoice()); paid and
781 // part-paid invoices are left alone.
782 foreach ($invoice_ids as $id) {
783 $model = InvoiceServiceProvider::getInvoiceRepository()->find((int) $id);
784 if (!$model || in_array(strtolower((string) $model->getStatus()), ['paid', 'partial'], true)) {
785 continue;
786 }
787 $model->setStatus('draft');
788 if ($model->save()) {
789 $processed++;
790 }
791 }
792 wp_safe_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_drafted=' . $processed));
793 break;
794
795 case 'publish':
796 foreach ($invoice_ids as $id) {
797 // Update post status to publish
798 if (wp_update_post(array(
799 'ID' => $id,
800 'post_status' => 'publish'
801 ))) {
802 $processed++;
803 }
804 }
805 wp_safe_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_published=' . $processed));
806 break;
807
808 default:
809 // Includes the `export` action — that's a Pro-only feature handled
810 // by the BulkExportSelected extension. When Pro is inactive, the
811 // Free-side teaser JS intercepts the submit before the form ever
812 // POSTs here. If somehow it does (curl, etc), we redirect cleanly.
813 wp_safe_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_error=invalid_action'));
814 }
815
816 exit;
817 }
818
819 /**
820 * Get stats for dashboard
821 */
822 public function getInvoiceStats() {
823 // Everything here is answered from SQL over the persisted totals
824 // (InvoiceTotalsCache). This used to load every open and every paid
825 // invoice as a model on each list view — minutes and hundreds of
826 // megabytes once a store had a few thousand invoices.
827 $repository = InvoiceServiceProvider::getInvoiceRepository();
828 $total_invoices = (int) $repository->count();
829 $outstanding = \EasyInvoice\Services\InvoiceTotalsCache::outstanding();
830 $pending_invoices = (int) $outstanding['count'];
831 $paid = \EasyInvoice\Services\InvoiceTotalsCache::paidRevenue();
832 $paid_invoices = (int) $paid['paid_count'];
833
834 $site_currency = strtoupper((string) get_option('easy_invoice_currency_code', 'USD'));
835 $revenue_by_currency = $paid['revenue'];
836 if (!isset($revenue_by_currency[$site_currency])) {
837 $revenue_by_currency = [$site_currency => ['amount' => 0, 'symbol' => \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($site_currency)]] + $revenue_by_currency;
838 }
839
840 // "Total value": what is still owed, by currency, with the number of open invoices.
841 $total_value_by_currency = [];
842 foreach ($outstanding['amount'] as $currency_code => $amount) {
843 $total_value_by_currency[$currency_code] = [
844 'amount' => (float) $amount,
845 'invoices' => (int) ($outstanding['count_by_currency'][$currency_code] ?? 0),
846 'invoice_object' => null,
847 'currency' => $currency_code,
848 ];
849 }
850
851 return [
852 'total_invoices' => $total_invoices,
853 'pending_invoices' => $pending_invoices,
854 'paid_invoices' => $paid_invoices,
855 'total_revenue' => $revenue_by_currency,
856 'total_value' => $total_value_by_currency,
857 ];
858 }
859
860 /**
861 * Register additional AJAX handlers
862 */
863 public function registerAjaxHandlers() {
864 add_action('wp_ajax_easy_invoice_load_template', array($this, 'handleLoadTemplate'));
865 add_action('wp_ajax_easy_invoice_create_new_invoice', array($this, 'ajax_create_new_invoice'));
866 // The `easy_invoice_search_clients` AJAX is owned by EasyInvoiceAjax.
867 // The duplicate registration that used to live here raced with
868 // EasyInvoiceAjax::searchClients() — only the first-registered
869 // handler ran, and which one won depended on bootstrap order. That
870 // intermittently broke the client-search dropdown in the invoice
871 // builder. Keep this comment as a tombstone so the registration
872 // doesn't get added back.
873 }
874
875 /**
876 * Handle AJAX request to load invoice template
877 */
878 public function handleLoadTemplate() {
879 // Verify nonce
880 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'easy_invoice_nonce')) {
881 wp_send_json_error(array('message' => __('Security check failed', 'easy-invoice')));
882 }
883
884 // The preview renders the invoice's full content: only people who
885 // can work on invoices may ask for it.
886 if (!current_user_can('manage_options') && !easy_invoice_user_can('ei_create_invoice') && !easy_invoice_user_can('ei_view_invoices')) {
887 wp_send_json_error(array('message' => __('You do not have permission to preview invoices.', 'easy-invoice')));
888 }
889
890 // Get template name and validate it securely
891 $template = isset($_POST['template']) ? sanitize_text_field($_POST['template']) : 'standard';
892 $template = $this->validateTemplateName($template, 'invoice');
893 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
894
895 // Get secure template file path
896 $template_file = $this->getSecureTemplatePath($template, 'invoice');
897
898
899 if (!$template_file) {
900 wp_send_json_error(array('message' => __('Invalid template', 'easy-invoice')));
901 }
902
903 // For new invoices (no ID), just return the template without invoice data
904 if ($invoice_id === 0) {
905 // Start output buffering
906 ob_start();
907
908 // Set up empty variables for new invoices.
909 //
910 // $invoice used to be null here, but every invoice design template calls
911 // $invoice->getTitle() / getNumber() / etc. unguarded — so previewing or
912 // switching a template on an invoice that has not been saved yet was an
913 // immediate fatal. The model's constructor accepts null and fills itself
914 // from the field defaults, so an empty instance gives the templates the
915 // getters they expect and renders a blank preview.
916 $invoice = new \EasyInvoice\Models\Invoice();
917 // What the builder currently holds, so the preview is live.
918 $invoice = \EasyInvoice\Helpers\PreviewOverlay::apply($invoice, isset($_POST['form_data']) ? (string) wp_unslash($_POST['form_data']) : '', 'invoice');
919
920 // $formatter was null here too, and the templates call
921 // $formatter->format() for every currency value — so even with a valid
922 // empty invoice the render still died. Build the same formatter the
923 // saved-invoice path below uses, wrapping the empty invoice.
924 $formatter = new \EasyInvoice\Helpers\InvoiceFormatter($invoice);
925
926 include_once $template_file;
927 $html = ob_get_clean();
928
929 // Send response
930 wp_send_json_success(array('html' => $html));
931 return;
932 }
933
934 // Get invoice data for existing invoices
935 $repository = InvoiceServiceProvider::getInvoiceRepository();
936 $invoice = $repository->find($invoice_id);
937
938 if (!$invoice) {
939 wp_send_json_error(array('message' => __('Invoice not found', 'easy-invoice')));
940 }
941 // Unsaved edits from the builder take precedence over the stored values.
942 $invoice = \EasyInvoice\Helpers\PreviewOverlay::apply($invoice, isset($_POST['form_data']) ? (string) wp_unslash($_POST['form_data']) : '', 'invoice');
943
944 // Initialize formatter for currency formatting
945 $formatter = new \EasyInvoice\Helpers\InvoiceFormatter($invoice);
946
947 // Start output buffering
948 ob_start();
949 include_once $template_file;
950 $html = ob_get_clean();
951
952 // Send response
953 wp_send_json_success(array('html' => $html));
954 }
955
956 /**
957 * Validate and sanitize template name to prevent directory traversal attacks
958 *
959 * @param string $template The template name to validate
960 * @param string $type Either 'invoice' or 'quote'
961 * @return string Validated template name or 'standard' as fallback
962 */
963 private function validateTemplateName($template, $type = 'invoice') {
964 // Whitelist of allowed template names
965 $allowed_templates = array(
966 'invoice' => array('classic', 'corporate', 'creative', 'elegant', 'legacy', 'minimal', 'modern', 'professional', 'standard', 'default'),
967 'quote' => array('legacy', 'minimal', 'minimalist', 'modern', 'standard', 'default')
968 );
969
970 // Strip any directory components using basename
971 $template = basename($template);
972
973 // Remove any file extension
974 $template = preg_replace('/\.(php|html|htm)$/i', '', $template);
975
976 // Remove any non-alphanumeric characters except hyphens and underscores
977 $template = preg_replace('/[^a-z0-9_-]/i', '', $template);
978
979 // Check if template is in whitelist
980 if (isset($allowed_templates[$type]) && in_array($template, $allowed_templates[$type], true)) {
981 return $template;
982 }
983
984 // Return default template if not in whitelist
985 return 'standard';
986 }
987
988 /**
989 * Get secure template file path with directory traversal protection
990 *
991 * @param string $template The validated template name
992 * @param string $type Either 'invoice' or 'quote'
993 * @return string|false The secure template file path or false if invalid
994 */
995 private function getSecureTemplatePath($template, $type = 'invoice') {
996 // Define template directories
997 $template_dirs = array(
998 'invoice' => EASY_INVOICE_PLUGIN_DIR . 'templates/invoice-templates/',
999 'quote' => EASY_INVOICE_PLUGIN_DIR . 'templates/quote-templates/'
1000 );
1001
1002 if (!isset($template_dirs[$type])) {
1003 return false;
1004 }
1005
1006 $template_dir = $template_dirs[$type];
1007
1008 // Ensure template directory exists and is a directory
1009 if (!is_dir($template_dir)) {
1010 return false;
1011 }
1012
1013 // Get the real path of the template directory (resolves any symlinks)
1014 $real_template_dir = realpath($template_dir);
1015 if ($real_template_dir === false) {
1016 return false;
1017 }
1018
1019 // Construct the template file path
1020 $template_file = $real_template_dir . DIRECTORY_SEPARATOR . $template . '.php';
1021
1022 // Get the real path of the template file (resolves any .. or . components)
1023 $real_template_file = realpath($template_file);
1024
1025 // Verify that the resolved path is within the template directory
1026 // This prevents directory traversal attacks
1027 if ($real_template_file === false || strpos($real_template_file, $real_template_dir) !== 0) {
1028 // If template doesn't exist or is outside the directory, use default
1029 $default_file = $real_template_dir . DIRECTORY_SEPARATOR . 'standard.php';
1030 $real_default_file = realpath($default_file);
1031
1032 if ($real_default_file !== false && strpos($real_default_file, $real_template_dir) === 0) {
1033 return $real_default_file;
1034 }
1035
1036 return false;
1037 }
1038
1039 // Verify the file exists and is readable
1040 if (!is_file($real_template_file) || !is_readable($real_template_file)) {
1041 // Fallback to standard template
1042 $default_file = $real_template_dir . DIRECTORY_SEPARATOR . 'standard.php';
1043 $real_default_file = realpath($default_file);
1044
1045 if ($real_default_file !== false && strpos($real_default_file, $real_template_dir) === 0 && is_file($real_default_file) && is_readable($real_default_file)) {
1046 return $real_default_file;
1047 }
1048
1049 return false;
1050 }
1051
1052 return $real_template_file;
1053 }
1054
1055
1056 /**
1057 * AJAX handler for creating a new invoice with title
1058 */
1059 public function ajax_create_new_invoice() {
1060 // Security check: verify nonce
1061 check_ajax_referer('easy_invoice_nonce', 'nonce');
1062
1063 // Security check: verify user capabilities
1064 if (!easy_invoice_user_can('ei_create_invoice')) {
1065 wp_send_json_error([
1066 'message' => __('You do not have permission to create invoices.', 'easy-invoice')
1067 ], 403);
1068 return;
1069 }
1070
1071 // Get the invoice title
1072 $title = isset($_POST['title']) ? sanitize_text_field($_POST['title']) : '';
1073
1074 if (empty($title)) {
1075 wp_send_json_error([
1076 'message' => __('Invoice title is required.', 'easy-invoice')
1077 ], 400);
1078 return;
1079 }
1080
1081 try {
1082 $invoice_repository = InvoiceServiceProvider::getInvoiceRepository();
1083
1084 // Prepare invoice data for repository
1085 $invoice_data = [
1086 'title' => $title,
1087 'post_status' => 'draft',
1088 'issue_date' => current_time('Y-m-d'),
1089 'due_date' => wp_date('Y-m-d', strtotime('+30 days')),
1090 'status' => 'draft',
1091 'invoice_template' => get_option('easy_invoice_last_invoice_template', 'standard')
1092 ];
1093
1094 // Create the invoice using repository (this will auto-generate invoice number)
1095 $invoice = $invoice_repository->create($invoice_data);
1096
1097 if (!$invoice) {
1098 throw new \Exception('Failed to create invoice');
1099 }
1100
1101 // Debug: Check if invoice number was set
1102 $invoice_number = $invoice->getNumber();
1103 if (empty($invoice_number)) {
1104 // Force set the invoice number if it's empty
1105 $invoice_number_service = easy_invoice_get_invoice_number_service();
1106 $generated_number = $invoice_number_service->generateUniqueNumber();
1107 $invoice->setNumber($generated_number);
1108 $invoice->save();
1109 }
1110
1111 wp_send_json_success([
1112 'message' => __('Invoice created successfully!', 'easy-invoice'),
1113 'invoice_id' => $invoice->getId(),
1114 'invoice_number' => $invoice->getNumber(),
1115 'redirect_url' => admin_url('admin.php?page=easy-invoice-builder&invoice_id=' . $invoice->getId())
1116 ]);
1117
1118 } catch (\Exception $e) {
1119 wp_send_json_error([
1120 'message' => __('Error creating invoice:', 'easy-invoice') . ' ' . $e->getMessage()
1121 ], 500);
1122 }
1123 }
1124
1125 // ---------------------------------------------------------------------
1126 // Per-invoice access token + authorisation helpers.
1127 //
1128 // Used by submitManualPayment (and any future invoice-scoped public
1129 // action) to gate the request without relying on the global
1130 // `easy_invoice_payment` nonce, which is rendered on every public
1131 // invoice page and is therefore harvestable for cross-invoice abuse.
1132 //
1133 // Mirrors QuoteController::quoteAccessToken / canActOnQuote — see the
1134 // CVE-2026-9021 patch for the design rationale. The shape is intentionally
1135 // the same so future audits can verify both quote and invoice paths
1136 // against the same mental model.
1137 // ---------------------------------------------------------------------
1138
1139 /**
1140 * Get (or lazily generate) the per-invoice access token. 32 hex chars =
1141 * 128 bits of entropy, well above what's brute-forceable inside the
1142 * lifetime of a published invoice. Stored in private post meta.
1143 */
1144 public static function invoiceAccessToken(int $invoice_id): string {
1145 if ($invoice_id <= 0) {
1146 return '';
1147 }
1148 $token = (string) get_post_meta($invoice_id, '_easy_invoice_invoice_access_token', true);
1149 if ($token === '' || strlen($token) < 32) {
1150 try {
1151 $token = bin2hex(random_bytes(16));
1152 } catch (\Throwable $e) {
1153 // Fallback for systems without CSPRNG. wp_generate_password
1154 // uses random_bytes internally on modern PHP — same entropy.
1155 $token = wp_generate_password(32, false, false);
1156 }
1157 update_post_meta($invoice_id, '_easy_invoice_invoice_access_token', $token);
1158 }
1159 return $token;
1160 }
1161
1162 /**
1163 * Read-only sibling of invoiceAccessToken(). Returns the persisted
1164 * token if one already exists, or an empty string otherwise — never
1165 * mints. Use this from user-controlled rendering contexts (e.g. the
1166 * `[easy_invoice_url]` shortcode) where allowing an arbitrary caller
1167 * to MINT a payment-authorising token for an attacker-chosen invoice
1168 * would be a privilege-escalation vector.
1169 *
1170 * Trusted server contexts (the EmailManager invoice-send path) should
1171 * keep calling invoiceAccessToken() so first-send still works.
1172 */
1173 public static function invoiceAccessTokenIfExists(int $invoice_id): string {
1174 if ($invoice_id <= 0) {
1175 return '';
1176 }
1177 $token = (string) get_post_meta($invoice_id, '_easy_invoice_invoice_access_token', true);
1178 return strlen($token) >= 32 ? $token : '';
1179 }
1180
1181 /**
1182 * Pull the presented access token off the current request. Accepts it on
1183 * either POST (when the JS form posts AJAX) or GET (when the invoice URL
1184 * is opened directly from an emailed link).
1185 */
1186 private static function invoiceTokenFromRequest(): string {
1187 $token = '';
1188 if (isset($_POST['access_token'])) {
1189 $token = sanitize_text_field(wp_unslash($_POST['access_token']));
1190 } elseif (isset($_GET['ik'])) {
1191 $token = sanitize_text_field(wp_unslash($_GET['ik']));
1192 }
1193 /**
1194 * Filter the access token presented for the current request.
1195 *
1196 * Lets another proof of access (a signed secure link, say) stand in
1197 * for the ?ik= token. Return the document's own token to grant.
1198 *
1199 * @param string $token Token from the request, may be ''.
1200 * @param string $type 'invoice'.
1201 */
1202 return (string) apply_filters('easy_invoice_presented_access_token', $token, 'invoice');
1203 }
1204
1205 /**
1206 * Central authorisation check for invoice-scoped public actions
1207 * (currently only manual-payment submission). Returns true when ANY of:
1208 *
1209 * 1. The request carries a valid per-invoice access token (the
1210 * legitimate email-recipient flow). Constant-time compared with
1211 * hash_equals.
1212 * 2. The current user is logged in AND has admin-grade capability
1213 * (manage_options) — admin-side payment recording.
1214 * 3. The current user is logged in AND is the invoice's bound client
1215 * (case-insensitive email match against the invoice's client_id
1216 * record).
1217 *
1218 * Returns false otherwise. Callers must reject the request when this
1219 * returns false.
1220 */
1221 public static function canSubmitPaymentForInvoice(int $invoice_id, $invoice = null): bool {
1222 if ($invoice_id <= 0) {
1223 return false;
1224 }
1225
1226 // Path 1: legitimate access-token flow (email/shortcode link recipient).
1227 $presented = self::invoiceTokenFromRequest();
1228 if ($presented !== '') {
1229 $stored = (string) get_post_meta($invoice_id, '_easy_invoice_invoice_access_token', true);
1230 if ($stored !== '' && hash_equals($stored, $presented)) {
1231 return true;
1232 }
1233 }
1234
1235 // Path 2: admin override.
1236 if (current_user_can('manage_options')) {
1237 return true;
1238 }
1239
1240 // Path 3: authenticated owner. ONLY when the current user is the
1241 // invoice's bound client (email match against the client_id record).
1242 //
1243 // Note: Invoice model resolves `getClientId()` via __call magic,
1244 // so method_exists() returns FALSE for it (PHP's method_exists
1245 // does not recognise __call-resolved methods). Use is_callable
1246 // instead — it correctly returns TRUE when the receiver has a
1247 // __call that can field the message, so this guard actually
1248 // permits the bound-client path on real Invoice objects.
1249 if (is_user_logged_in() && $invoice && is_callable([$invoice, 'getClientId']) && $invoice->getClientId()) {
1250 $current_user = wp_get_current_user();
1251 $client_repository = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository();
1252 $client = $client_repository->find($invoice->getClientId());
1253 if ($client && strcasecmp((string) $client->getEmail(), (string) $current_user->user_email) === 0) {
1254 return true;
1255 }
1256 }
1257
1258 return false;
1259 }
1260
1261 }
1262