PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.0
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.0
2.4.0 2.4.1 2.3.8 2.3.7 2.3.6 2.3.5 2.3.4 2.3.3 2.3.2 2.3.1 2.2.0 2.1.21 2.1.20 2.1.19 2.1.18 2.1.0 2.1.1 2.1.10 2.1.11 2.1.12 2.1.13 2.1.14 2.1.15 2.1.16 2.1.2 All 57 releases
← All changes | includes/Controllers/InvoiceController.php +622 -528 2.1.22.4.0 View file →
@@ -22,9 +22,9 @@
22 22 */
23 23 public function init() {
24 24 // Allow plugins to extend the controller initialization
25 25 do_action('easy_invoice_invoice_controller_before_init', $this);
26 -
26 +
27 27 // Register AJAX endpoints for invoice management
28 28 add_action('wp_ajax_easy_invoice_trash_invoice', array($this, 'trashInvoice'));
29 29 add_action('wp_ajax_easy_invoice_restore_invoice', array($this, 'restoreInvoice'));
30 30 add_action('wp_ajax_easy_invoice_delete_invoice_permanently', array($this, 'deleteInvoicePermanently'));
@@ -30,9 +30,9 @@
30 30 add_action('wp_ajax_easy_invoice_delete_invoice_permanently', array($this, 'deleteInvoicePermanently'));
31 31 add_action('wp_ajax_easy_invoice_delete_invoice', array($this, 'deleteInvoice')); // Legacy support
32 32 add_action('wp_ajax_easy_invoice_publish_invoice', array($this, 'publishInvoice'));
33 33 add_action('wp_ajax_easy_invoice_draft_invoice', array($this, 'draftInvoice'));
34 -
34 +
35 35 // Handle bulk actions
36 36 add_action('admin_init', array($this, 'handleBulkActions'));
37 37
38 38 // Register additional AJAX handlers
@@ -37,62 +37,60 @@
37 37
38 38 // Register additional AJAX handlers
39 39 $this->registerAjaxHandlers();
40 40
41 - // Add meta box for manual payment verification
42 - add_action('add_meta_boxes_easy-invoice', array($this, 'add_manual_payment_meta_box'));
41 + // NOTE: `easy_invoice_create_new_invoice` is registered by
42 + // registerAjaxHandlers() (called above), not here. It used to be registered
43 + // in both places, so WordPress held two handlers for the same action and
44 + // ajax_create_new_invoice() was hooked twice on a single request — harmless
45 + // only because the handler exits on reply. Registered once now; keep this
46 + // tombstone so it doesn't get added back.
43 47
44 - // AJAX handler for creating a sample invoice
45 - add_action('wp_ajax_easy_invoice_create_sample_invoice', array($this, 'ajax_create_sample_invoice'));
46 -
47 - // AJAX handler for creating a new invoice with title
48 - add_action('wp_ajax_easy_invoice_create_new_invoice', array($this, 'ajax_create_new_invoice'));
49 -
50 48 // Allow plugins to extend the controller initialization
51 49 do_action('easy_invoice_invoice_controller_after_init', $this);
52 50 }
53 -
51 +
54 52 /**
55 53 * Display method implementation
56 - *
54 + *
57 55 * @param array $args Display arguments
58 56 */
59 57 public function display(array $args = []) {
60 58 // Allow plugins to modify display arguments
61 59 $args = apply_filters('easy_invoice_invoice_controller_display_args', $args);
62 -
60 +
63 61 $page = isset($args['page']) ? $args['page'] : '';
64 -
62 +
65 63 // Allow plugins to modify the page before processing
66 64 $page = apply_filters('easy_invoice_invoice_controller_display_page', $page, $args);
67 -
65 +
68 66 switch ($page) {
69 67 case PagesSlugs::ALL_INVOICES:
70 68 $this->displayInvoicesPage();
71 69 break;
72 -
70 +
73 71 case PagesSlugs::INVOICE_NEW:
74 72 // For a new invoice, ensure no ID is passed
75 73 $_GET['id'] = isset($_GET['id']) ? $_GET['id'] : 0;
76 74 $this->displayInvoiceBuilderPage();
77 75 break;
78 -
76 +
79 77 case PagesSlugs::INVOICE_PREVIEW:
80 78 $this->displayPreviewPage();
81 79 break;
82 -
80 +
83 81 default:
84 82 $this->displayInvoicesPage();
85 83 break;
86 84 }
87 -
85 +
88 86 // Allow plugins to perform actions after display
89 87 do_action('easy_invoice_invoice_controller_after_display', $page, $args);
90 88 }
91 -
89 +
92 90 /**
93 91 * Display all invoices page
94 - *
92 + *
95 93 * Uses WordPress's built-in WP_Query and paginate_links() for optimal performance
96 94 * with large datasets (10,000+ invoices). The pagination is handled efficiently
97 95 * by WordPress core functions which are optimized for scalability.
98 96 */
@@ -98,34 +96,57 @@
98 96 */
99 97 protected function displayInvoicesPage() {
100 98 // Allow plugins to perform actions before displaying invoices page
101 99 do_action('easy_invoice_invoice_controller_before_display_invoices_page');
102 -
100 +
103 101 // Get filter parameters
104 102 $status_filter = isset($_GET['status']) ? sanitize_text_field($_GET['status']) : '';
105 103 $recurring_filter = isset($_GET['recurring']) ? sanitize_text_field($_GET['recurring']) : '';
106 104 $subscription_filter = isset($_GET['subscription']) ? sanitize_text_field($_GET['subscription']) : '';
105 + $client_filter = isset($_GET['client_id']) ? absint($_GET['client_id']) : 0;
107 106 $search_query = isset($_GET['search']) ? sanitize_text_field(wp_unslash($_GET['search'])) : '';
108 107 $current_view = isset($_GET['view']) ? sanitize_text_field($_GET['view']) : 'all';
109 108 $current_page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1;
110 109 $per_page = 20;
111 110 $offset = ($current_page - 1) * $per_page;
112 -
111 +
113 112 // Build repository query arguments
114 113 $args = [];
115 -
114 +
116 115 // Set post status based on view
117 116 if ($current_view === 'trash') {
118 117 $args['post_status'] = 'trash';
119 118 } elseif ($current_view === 'draft') {
120 - $args['post_status'] = 'draft';
119 + // A draft is an invoice whose *status* is draft (every invoice is a
120 + // published post); the tab used to look for draft posts and always
121 + // read 0 while the Draft filter pill listed several.
122 + $status_filter = 'draft';
121 123 }
122 -
124 +
123 125 // Build meta query array
124 126 $meta_query = [];
125 -
126 - // Add status filter if provided
127 - if (!empty($status_filter)) {
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)) {
128 149 $meta_query[] = [
129 150 'key' => '_easy_invoice_status',
130 151 'value' => $status_filter,
131 152 'compare' => '='
@@ -130,9 +151,50 @@
130 151 'value' => $status_filter,
131 152 'compare' => '='
132 153 ];
133 154 }
134 -
155 +
156 + // Add client filter if provided.
157 + //
158 + // Easy Invoice stores either:
159 + // • `_easy_invoice_client_id` — populated when the user picks a
160 + // client from the dropdown in the Invoice Builder, OR
161 + // • `_easy_invoice_customer_email` (+ customer_name) — populated when
162 + // the biller types ad-hoc customer info inline.
163 + //
164 + // To make the filter useful for both flows we match on
165 + // client_id == N OR customer_email == that client's email.
166 + if (!empty($client_filter)) {
167 + $client_email = '';
168 + try {
169 + $client_repo = new \EasyInvoice\Repositories\ClientRepository();
170 + $client_obj = $client_repo->find($client_filter);
171 + if ($client_obj) {
172 + // The Client model exposes the email via the magic __call → __get fallback.
173 + $client_email = (string) $client_obj->getEmail();
174 + }
175 + } catch (\Throwable $e) {
176 + $client_email = '';
177 + }
178 +
179 + $client_clauses = [
180 + 'relation' => 'OR',
181 + [
182 + 'key' => '_easy_invoice_client_id',
183 + 'value' => (string) $client_filter,
184 + 'compare' => '=',
185 + ],
186 + ];
187 + if ($client_email !== '') {
188 + $client_clauses[] = [
189 + 'key' => '_easy_invoice_customer_email',
190 + 'value' => $client_email,
191 + 'compare' => '=',
192 + ];
193 + }
194 + $meta_query[] = $client_clauses;
195 + }
196 +
135 197 // Add recurring filter if provided
136 198 if (!empty($recurring_filter)) {
137 199 if ($recurring_filter === 'recurring') {
138 200 // Show only recurring invoices
@@ -156,13 +218,38 @@
156 218 ]
157 219 ];
158 220 }
159 221 }
160 -
222 +
223 + // Subscription filter (Pro's Subscription Invoices addon renders the
224 + // pills; the value was read above but never applied to the query).
225 + if (!empty($subscription_filter)) {
226 + if ($subscription_filter === 'subscription') {
227 + $meta_query[] = [
228 + 'key' => '_easy_invoice_subscription_enabled',
229 + 'value' => '1',
230 + 'compare' => '='
231 + ];
232 + } elseif ($subscription_filter === 'non-subscription') {
233 + $meta_query[] = [
234 + 'relation' => 'OR',
235 + [
236 + 'key' => '_easy_invoice_subscription_enabled',
237 + 'compare' => 'NOT EXISTS'
238 + ],
239 + [
240 + 'key' => '_easy_invoice_subscription_enabled',
241 + 'value' => '0',
242 + 'compare' => '='
243 + ]
244 + ];
245 + }
246 + }
247 +
161 248 // Add meta query to args if we have any filters
162 249 if (!empty($meta_query)) {
163 250 if (count($meta_query) === 1) {
164 - $args['meta_query'] = $meta_query[0];
251 + $args['meta_query'] = [ $meta_query[0] ]; // a bare clause is ignored by WP_Query; it must be a list of clauses
165 252 } else {
166 253 $args['meta_query'] = [
167 254 'relation' => 'AND',
168 255 ...$meta_query
@@ -168,21 +255,21 @@
168 255 ...$meta_query
169 256 ];
170 257 }
171 258 }
172 -
259 +
173 260 // Add pagination parameters to args
174 261 $args['posts_per_page'] = $per_page;
175 262 $args['offset'] = $offset;
176 263 $args['orderby'] = 'date';
177 264 $args['order'] = 'DESC';
178 -
265 +
179 266 // Allow plugins to modify query arguments
180 267 $args = apply_filters('easy_invoice_invoice_controller_query_args', $args, $current_view, $status_filter);
181 -
268 +
182 269 // Get paginated invoices using WordPress query
183 270 $repository = InvoiceServiceProvider::getInvoiceRepository();
184 -
271 +
185 272 // Use WordPress WP_Query directly for better pagination handling
186 273 $query_args = array_merge([
187 274 'post_type' => \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE,
188 275 'post_status' => $args['post_status'] ?? 'publish',
@@ -193,15 +280,15 @@
193 280 'no_found_rows' => false, // We need this for pagination
194 281 'update_post_term_cache' => false, // Disable term cache for better performance
195 282 'update_post_meta_cache' => false, // Disable meta cache for better performance
196 283 ], $args);
197 -
284 +
198 285 // Add search functionality
199 286 if (!empty($search_query)) {
200 287 // For search, we'll use a simpler approach that works better with WordPress
201 288 // First, get all invoices that match the search criteria
202 289 $search_ids = [];
203 -
290 +
204 291 // Search in post title and content
205 292 $title_search = new WP_Query([
206 293 'post_type' => \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE,
207 294 'post_status' => $args['post_status'] ?? 'publish',
@@ -207,13 +294,13 @@
207 294 'post_status' => $args['post_status'] ?? 'publish',
208 295 'posts_per_page' => -1,
209 296 's' => $search_query
210 297 ]);
211 -
298 +
212 299 if ($title_search->have_posts()) {
213 300 $search_ids = array_merge($search_ids, wp_list_pluck($title_search->posts, 'ID'));
214 301 }
215 -
302 +
216 303 // Search in meta fields
217 304 $meta_search = new WP_Query([
218 305 'post_type' => \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE,
219 306 'post_status' => $args['post_status'] ?? 'publish',
@@ -236,16 +323,16 @@
236 323 'compare' => 'LIKE'
237 324 ]
238 325 ]
239 326 ]);
240 -
327 +
241 328 if ($meta_search->have_posts()) {
242 329 $search_ids = array_merge($search_ids, wp_list_pluck($meta_search->posts, 'ID'));
243 330 }
244 -
331 +
245 332 // Remove duplicates
246 333 $search_ids = array_unique($search_ids);
247 -
334 +
248 335 if (!empty($search_ids)) {
249 336 // Use post__in to filter by the found IDs
250 337 $query_args['post__in'] = $search_ids;
251 338 } else {
@@ -252,18 +339,24 @@
252 339 // If no results found, set post__in to empty array to show no results
253 340 $query_args['post__in'] = [0];
254 341 }
255 342 }
256 -
343 +
257 344 // Remove offset as we're using paged
345 + if (null !== $overdue_ids) {
346 + $query_args['post__in'] = isset($query_args['post__in'])
347 + ? (array_values(array_intersect($query_args['post__in'], $overdue_ids)) ?: [0])
348 + : ($overdue_ids ?: [0]);
349 + }
350 +
258 351 unset($query_args['offset']);
259 -
352 +
260 353 // Allow plugins to modify the final query arguments
261 354 $query_args = apply_filters('easy_invoice_invoice_controller_final_query_args', $query_args);
262 -
355 +
263 356 $wp_query = new WP_Query($query_args);
264 357 $invoices = [];
265 -
358 +
266 359 if ($wp_query->have_posts()) {
267 360 foreach ($wp_query->posts as $post) {
268 361 $invoice = $repository->find($post->ID);
269 362 if ($invoice) {
@@ -270,26 +363,42 @@
270 363 $invoices[] = $invoice;
271 364 }
272 365 }
273 366 }
274 -
367 +
275 368 // Allow plugins to modify the invoices array
276 369 $invoices = apply_filters('easy_invoice_invoice_controller_invoices_list', $invoices, $wp_query);
277 -
370 +
278 371 // Get pagination info from WordPress query
279 372 $total_invoices = $wp_query->found_posts;
280 373 $total_pages = $wp_query->max_num_pages;
281 -
374 +
282 375 // Get trash count for tab display (without pagination)
283 - $trash_args = ['post_status' => 'trash'];
284 - $trash_invoices = $repository->all($trash_args);
285 - $trash_count = count($trash_invoices);
286 -
287 - // Get draft count for tab display (without pagination)
288 - $draft_args = ['post_status' => 'draft'];
289 - $draft_invoices = $repository->all($draft_args);
290 - $draft_count = count($draft_invoices);
291 -
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 +
292 401 // Prepare template data
293 402 $template_data = [
294 403 'invoices' => $invoices,
295 404 'current_view' => $current_view,
@@ -294,8 +403,11 @@
294 403 'invoices' => $invoices,
295 404 'current_view' => $current_view,
296 405 'status_filter' => $status_filter,
297 406 'recurring_filter' => $recurring_filter,
407 + 'subscription_filter' => $subscription_filter,
408 + 'client_filter' => $client_filter,
409 + 'clients_list' => $clients_list,
298 410 'search_query' => $search_query,
299 411 'trash_count' => $trash_count,
300 412 'draft_count' => $draft_count,
301 413 'repository' => $repository,
@@ -304,22 +416,22 @@
304 416 'total_invoices' => $total_invoices,
305 417 'total_pages' => $total_pages,
306 418 'wp_query' => $wp_query
307 419 ];
308 -
420 +
309 421 // Allow plugins to modify template data
310 422 $template_data = apply_filters('easy_invoice_invoice_controller_template_data', $template_data);
311 -
423 +
312 424 // Display the template
313 425 $this->displayTemplate(
314 426 EASY_INVOICE_PLUGIN_DIR . 'templates/invoices/listing.php',
315 427 $template_data
316 428 );
317 -
429 +
318 430 // Allow plugins to perform actions after displaying invoices page
319 431 do_action('easy_invoice_invoice_controller_after_display_invoices_page', $template_data);
320 432 }
321 -
433 +
322 434 /**
323 435 * Display invoice builder page
324 436 */
325 437 protected function displayInvoiceBuilderPage() {
@@ -324,9 +436,9 @@
324 436 */
325 437 protected function displayInvoiceBuilderPage() {
326 438 $invoice_id = isset($_GET['id']) ? intval($_GET['id']) : 0;
327 439 $repository = InvoiceServiceProvider::getInvoiceRepository();
328 -
440 +
329 441 // Display the template
330 442 $this->displayTemplate(
331 443 EASY_INVOICE_PLUGIN_DIR . 'templates/invoices/builder.php',
332 444 ['invoice_id' => $invoice_id, 'repository' => $repository]
@@ -331,9 +443,9 @@
331 443 EASY_INVOICE_PLUGIN_DIR . 'templates/invoices/builder.php',
332 444 ['invoice_id' => $invoice_id, 'repository' => $repository]
333 445 );
334 446 }
335 -
447 +
336 448 /**
337 449 * Display invoice preview page
338 450 */
339 451 protected function displayPreviewPage() {
@@ -338,37 +450,38 @@
338 450 */
339 451 protected function displayPreviewPage() {
340 452 $this->renderInvoicePreview();
341 453 }
342 -
454 +
343 455 /**
344 456 * Common helper method to render an invoice preview
345 457 * Used by both preview methods to ensure consistency
346 458 */
347 459 private function renderInvoicePreview() {
348 - $check = $this->checkCapability();
460 + $check = $this->checkCapability('ei_view_invoices');
349 461 if (is_wp_error($check)) {
350 - wp_die($check->get_error_message());
462 + wp_die(esc_html($check->get_error_message()));
351 463 }
352 -
353 - $invoice_id = isset($_GET['invoice_id']) ? intval($_GET['invoice_id']) : 0;
354 -
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 +
355 468 if ($invoice_id <= 0) {
356 - wp_die(__('Invalid invoice ID', 'easy-invoice'));
469 + wp_die(esc_html__('Invalid invoice ID', 'easy-invoice'));
357 470 }
358 -
471 +
359 472 // Get invoice from repository
360 473 $repository = InvoiceServiceProvider::getInvoiceRepository();
361 474 $invoice = $repository->find($invoice_id);
362 -
475 +
363 476 if (!$invoice) {
364 - wp_die(__('Invalid invoice ID', 'easy-invoice'));
477 + wp_die(esc_html__('Invalid invoice ID', 'easy-invoice'));
365 478 }
366 -
479 +
367 480 // Get common template variables
368 481 $template_vars = $this->getCommonTemplateVars();
369 482 $currency_symbol = $template_vars['currency_symbol'];
370 -
483 +
371 484 // Enqueue preview styles
372 485 wp_enqueue_style(
373 486 'easy-invoice-preview',
374 487 EASY_INVOICE_PLUGIN_URL . 'assets/css/preview.css',
@@ -374,9 +487,9 @@
374 487 EASY_INVOICE_PLUGIN_URL . 'assets/css/preview.css',
375 488 array(),
376 489 EASY_INVOICE_VERSION
377 490 );
378 -
491 +
379 492 // Display the template
380 493 $this->displayTemplate(
381 494 EASY_INVOICE_PLUGIN_DIR . 'templates/invoices/preview.php',
382 495 [
@@ -384,36 +497,34 @@
384 497 'currency_symbol' => $currency_symbol
385 498 ]
386 499 );
387 500 }
388 -
501 +
389 502 /**
390 503 * Trash an invoice (move to trash)
391 504 */
392 505 public function trashInvoice() {
393 - if (!$this->handleAjaxSecurity($_POST['nonce'])) {
506 + if (!$this->handleAjaxSecurity(($_POST['nonce'] ?? ''))) {
394 507 return;
395 508 }
396 -
509 +
397 510 // Check invoice ID
398 511 if (!isset($_POST['invoice_id']) || empty($_POST['invoice_id'])) {
399 512 wp_send_json_error(array('message' => 'Invalid invoice ID'));
400 513 }
401 -
514 +
402 515 $invoice_id = intval($_POST['invoice_id']);
403 -
516 +
404 517 // Get the invoice object to update status
405 518 $invoice_repository = InvoiceServiceProvider::getInvoiceRepository();
406 519 $invoice = $invoice_repository->find($invoice_id);
407 520 if ($invoice) {
408 - // Set status to cancelled before moving to trash
409 - $invoice->setStatus('cancelled');
410 - $invoice->save();
521 + self::cancelForTrash($invoice);
411 522 }
412 -
523 +
413 524 // Move to trash
414 525 $result = wp_trash_post($invoice_id);
415 -
526 +
416 527 if ($result) {
417 528 wp_send_json_success(array('message' => 'Invoice moved to trash'));
418 529 } else {
419 530 wp_send_json_error(array('message' => 'Error moving invoice to trash'));
@@ -418,66 +529,99 @@
418 529 } else {
419 530 wp_send_json_error(array('message' => 'Error moving invoice to trash'));
420 531 }
421 532 }
422 -
533 +
423 534 /**
424 535 * Restore an invoice from trash
425 536 */
537 + /**
538 + * Trashing cancels the invoice (the document survives and says what
539 + * happened) but remembers what it was, so restoring puts it back —
540 + * a paid invoice taken out of the list must not come back as unpaid.
541 + *
542 + * @param object $invoice Invoice model.
543 + */
544 + public static function cancelForTrash($invoice): void {
545 + $status = strtolower((string) $invoice->getStatus());
546 + if ('cancelled' !== $status) {
547 + update_post_meta((int) $invoice->getId(), '_easy_invoice_status_before_trash', $status);
548 + }
549 + $invoice->setStatus('cancelled');
550 + $invoice->save();
551 + }
552 +
553 + /**
554 + * Republish a restored invoice (WordPress restores to draft) and give it
555 + * back the status it had before it was trashed.
556 + *
557 + * @param int $invoice_id Invoice.
558 + */
559 + public static function restoreAfterTrash(int $invoice_id): void {
560 + wp_update_post(array('ID' => $invoice_id, 'post_status' => 'publish'));
561 + $previous = (string) get_post_meta($invoice_id, '_easy_invoice_status_before_trash', true);
562 + delete_post_meta($invoice_id, '_easy_invoice_status_before_trash');
563 + $invoice = InvoiceServiceProvider::getInvoiceRepository()->find($invoice_id);
564 + if ($invoice) {
565 + $invoice->setStatus('' !== $previous ? $previous : 'available');
566 + $invoice->save();
567 + }
568 + }
569 +
426 570 public function restoreInvoice() {
427 - if (!$this->handleAjaxSecurity($_POST['nonce'])) {
571 + if (!$this->handleAjaxSecurity(($_POST['nonce'] ?? ''))) {
428 572 return;
429 573 }
430 -
574 +
431 575 // Check invoice ID
432 576 if (!isset($_POST['invoice_id']) || empty($_POST['invoice_id'])) {
433 577 wp_send_json_error(array('message' => 'Invalid invoice ID'));
434 578 }
435 -
579 +
436 580 $invoice_id = intval($_POST['invoice_id']);
437 -
581 +
438 582 // Restore from trash
439 583 $result = wp_untrash_post($invoice_id);
440 -
584 +
441 585 if ($result) {
442 - // WordPress defaults restored posts to 'draft', so we need to explicitly set it to 'publish'
443 - wp_update_post(array(
444 - 'ID' => $invoice_id,
445 - 'post_status' => 'publish'
446 - ));
447 -
448 - // Get the invoice object and set status to available
449 - $invoice_repository = InvoiceServiceProvider::getInvoiceRepository();
450 - $invoice = $invoice_repository->find($invoice_id);
451 - if ($invoice) {
452 - $invoice->setStatus('available');
453 - $invoice->save();
454 - }
455 -
456 - wp_send_json_success(array('message' => 'Invoice restored from trash'));
586 + self::restoreAfterTrash($invoice_id);
587 +
588 + wp_send_json_success(array('message' => __('Invoice restored from trash.', 'easy-invoice')));
457 589 } else {
458 590 wp_send_json_error(array('message' => 'Error restoring invoice from trash'));
459 591 }
460 592 }
461 -
593 +
462 594 /**
463 595 * Delete an invoice permanently
464 596 */
465 597 public function deleteInvoicePermanently() {
466 - if (!$this->handleAjaxSecurity($_POST['nonce'])) {
598 + if (!$this->handleAjaxSecurity(($_POST['nonce'] ?? ''))) {
467 599 return;
468 600 }
469 -
601 +
470 602 // Check invoice ID
471 603 if (!isset($_POST['invoice_id']) || empty($_POST['invoice_id'])) {
472 604 wp_send_json_error(array('message' => 'Invalid invoice ID'));
473 605 }
474 -
606 +
475 607 $invoice_id = intval($_POST['invoice_id']);
476 -
608 +
609 + // Ask before deleting, so a refusal can say why and what to do instead.
610 + // InvoiceRetention also blocks this at the data layer, but a bare
611 + // "Error deleting invoice" would leave the user with no idea that the
612 + // refusal was deliberate.
613 + $may_delete = \EasyInvoice\Services\InvoiceRetention::mayDelete($invoice_id);
614 + if (is_wp_error($may_delete)) {
615 + wp_send_json_error(array(
616 + 'message' => $may_delete->get_error_message(),
617 + 'code' => $may_delete->get_error_code(),
618 + ));
619 + }
620 +
477 621 // Delete permanently
478 622 $result = wp_delete_post($invoice_id, true);
479 -
623 +
480 624 if ($result) {
481 625 wp_send_json_success(array('message' => 'Invoice deleted permanently'));
482 626 } else {
483 627 wp_send_json_error(array('message' => 'Error deleting invoice'));
@@ -482,9 +626,9 @@
482 626 } else {
483 627 wp_send_json_error(array('message' => 'Error deleting invoice'));
484 628 }
485 629 }
486 -
630 +
487 631 /**
488 632 * Legacy delete invoice handler (now redirects to trash)
489 633 */
490 634 public function deleteInvoice() {
@@ -490,30 +634,30 @@
490 634 public function deleteInvoice() {
491 635 // Redirect to trash function for backward compatibility
492 636 $this->trashInvoice();
493 637 }
494 -
638 +
495 639 /**
496 640 * Publish an invoice (change status from draft to publish)
497 641 */
498 642 public function publishInvoice() {
499 - if (!$this->handleAjaxSecurity($_POST['nonce'])) {
643 + if (!$this->handleAjaxSecurity(($_POST['nonce'] ?? ''))) {
500 644 return;
501 645 }
502 -
646 +
503 647 // Check invoice ID
504 648 if (!isset($_POST['invoice_id']) || empty($_POST['invoice_id'])) {
505 649 wp_send_json_error(array('message' => 'Invalid invoice ID'));
506 650 }
507 -
651 +
508 652 $invoice_id = intval($_POST['invoice_id']);
509 -
653 +
510 654 // Update post status to published
511 655 $result = wp_update_post(array(
512 656 'ID' => $invoice_id,
513 657 'post_status' => 'publish'
514 658 ));
515 -
659 +
516 660 if ($result) {
517 661 wp_send_json_success(array('message' => 'Invoice published successfully'));
518 662 } else {
519 663 wp_send_json_error(array('message' => 'Error publishing invoice'));
@@ -518,37 +662,44 @@
518 662 } else {
519 663 wp_send_json_error(array('message' => 'Error publishing invoice'));
520 664 }
521 665 }
522 -
666 +
523 667 /**
524 668 * Set an invoice to draft status
525 669 */
526 670 public function draftInvoice() {
527 - if (!$this->handleAjaxSecurity($_POST['nonce'])) {
671 + if (!$this->handleAjaxSecurity(($_POST['nonce'] ?? ''))) {
528 672 return;
529 673 }
530 -
674 +
531 675 // Check invoice ID
532 676 if (!isset($_POST['invoice_id']) || empty($_POST['invoice_id'])) {
533 677 wp_send_json_error(array('message' => 'Invalid invoice ID'));
534 678 }
535 -
679 +
536 680 $invoice_id = intval($_POST['invoice_id']);
537 -
538 - // Update post status to draft
539 - $result = wp_update_post(array(
540 - 'ID' => $invoice_id,
541 - 'post_status' => 'draft'
542 - ));
543 -
544 - if ($result) {
545 - wp_send_json_success(array('message' => 'Invoice set to draft successfully'));
681 + $repository = InvoiceServiceProvider::getInvoiceRepository();
682 + $invoice = $repository->find($invoice_id);
683 + if (!$invoice) {
684 + wp_send_json_error(array('message' => __('Invoice not found.', 'easy-invoice')));
685 + }
686 + // Back to draft means the *invoice* status: the post stays published so
687 + // the invoice stays in the list and keeps its number and link. (It used
688 + // to set the post to draft, which made the invoice vanish from every
689 + // list and page.) Money already received cannot be un-issued.
690 + $status = strtolower((string) $invoice->getStatus());
691 + if (in_array($status, ['paid', 'partial'], true)) {
692 + wp_send_json_error(array('message' => __('A paid or part-paid invoice cannot go back to draft.', 'easy-invoice')));
693 + }
694 + $invoice->setStatus('draft');
695 + if ($invoice->save()) {
696 + wp_send_json_success(array('message' => __('Invoice set back to draft.', 'easy-invoice')));
546 697 } else {
547 - wp_send_json_error(array('message' => 'Error setting invoice to draft'));
698 + wp_send_json_error(array('message' => __('The invoice could not be updated.', 'easy-invoice')));
548 699 }
549 700 }
550 -
701 +
551 702 /**
552 703 * Handle bulk actions
553 704 */
554 705 public function handleBulkActions() {
@@ -555,74 +706,93 @@
555 706 // Check if we're processing a bulk action
556 707 if (!isset($_POST['action']) || $_POST['action'] !== 'easy_invoice_bulk_action') {
557 708 return;
558 709 }
559 -
710 +
560 711 // Check nonce and capability
561 - $security_check = $this->securityCheck($_POST['easy_invoice_bulk_nonce'], 'easy_invoice_bulk_action');
712 + $security_check = $this->securityCheck(($_POST['easy_invoice_bulk_nonce'] ?? ''), 'easy_invoice_bulk_action');
562 713 if (is_wp_error($security_check)) {
563 - wp_die($security_check->get_error_message());
714 + wp_die(esc_html($security_check->get_error_message()));
564 715 }
565 -
716 +
566 717 // Check if we have invoice IDs
567 718 if (!isset($_POST['invoice_ids']) || !is_array($_POST['invoice_ids']) || empty($_POST['invoice_ids'])) {
568 - wp_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_error=no_selection'));
719 + wp_safe_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_error=no_selection'));
569 720 exit;
570 721 }
571 -
722 +
572 723 // Get bulk action and invoice IDs
573 724 $bulk_action = isset($_POST['bulk_action']) ? sanitize_text_field($_POST['bulk_action']) : '';
574 725 $invoice_ids = array_map('intval', $_POST['invoice_ids']);
575 -
726 +
576 727 // Process based on action
577 728 $processed = 0;
578 -
729 +
579 730 switch ($bulk_action) {
580 731 case 'trash':
581 732 foreach ($invoice_ids as $id) {
733 + $model = InvoiceServiceProvider::getInvoiceRepository()->find((int) $id);
734 + if ($model) {
735 + self::cancelForTrash($model);
736 + }
582 737 if (wp_trash_post($id)) {
583 738 $processed++;
584 739 }
585 740 }
586 - wp_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_trashed=' . $processed));
741 + wp_safe_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_trashed=' . $processed));
587 742 break;
588 -
743 +
589 744 case 'restore':
590 745 foreach ($invoice_ids as $id) {
591 746 if (wp_untrash_post($id)) {
592 - // Also set status to publish (since WordPress sets it to draft by default)
593 - wp_update_post(array(
594 - 'ID' => $id,
595 - 'post_status' => 'publish'
596 - ));
747 + self::restoreAfterTrash((int) $id);
597 748 $processed++;
598 749 }
599 750 }
600 - wp_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_restored=' . $processed));
751 + wp_safe_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_restored=' . $processed));
601 752 break;
602 -
753 +
603 754 case 'delete':
755 + // Issued invoices are skipped rather than failing the whole
756 + // batch: selecting "all" and finding nothing happened would be
757 + // worse than deleting the drafts and reporting the rest.
758 + $protected = 0;
604 759 foreach ($invoice_ids as $id) {
760 + if (is_wp_error(\EasyInvoice\Services\InvoiceRetention::mayDelete((int) $id))) {
761 + $protected++;
762 + continue;
763 + }
605 764 if (wp_delete_post($id, true)) {
606 765 $processed++;
607 766 }
608 767 }
609 - wp_redirect(admin_url('admin.php?page=easy-invoice-all&view=trash&bulk_deleted=' . $processed));
768 + wp_safe_redirect(add_query_arg(
769 + array_filter([
770 + 'page' => 'easy-invoice-all',
771 + 'view' => 'trash',
772 + 'bulk_deleted' => $processed,
773 + 'bulk_kept' => $protected ?: null,
774 + ]),
775 + admin_url('admin.php')
776 + ));
610 777 break;
611 -
778 +
612 779 case 'draft':
780 + // Invoice status, not post status (see draftInvoice()); paid and
781 + // part-paid invoices are left alone.
613 782 foreach ($invoice_ids as $id) {
614 - // Update post status to draft
615 - if (wp_update_post(array(
616 - 'ID' => $id,
617 - 'post_status' => 'draft'
618 - ))) {
783 + $model = InvoiceServiceProvider::getInvoiceRepository()->find((int) $id);
784 + if (!$model || in_array(strtolower((string) $model->getStatus()), ['paid', 'partial'], true)) {
785 + continue;
786 + }
787 + $model->setStatus('draft');
788 + if ($model->save()) {
619 789 $processed++;
620 790 }
621 791 }
622 - wp_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_drafted=' . $processed));
792 + wp_safe_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_drafted=' . $processed));
623 793 break;
624 -
794 +
625 795 case 'publish':
626 796 foreach ($invoice_ids as $id) {
627 797 // Update post status to publish
628 798 if (wp_update_post(array(
@@ -631,149 +801,60 @@
631 801 ))) {
632 802 $processed++;
633 803 }
634 804 }
635 - wp_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_published=' . $processed));
805 + wp_safe_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_published=' . $processed));
636 806 break;
637 -
807 +
638 808 default:
639 - wp_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_error=invalid_action'));
809 + // Includes the `export` action — that's a Pro-only feature handled
810 + // by the BulkExportSelected extension. When Pro is inactive, the
811 + // Free-side teaser JS intercepts the submit before the form ever
812 + // POSTs here. If somehow it does (curl, etc), we redirect cleanly.
813 + wp_safe_redirect(admin_url('admin.php?page=easy-invoice-all&bulk_error=invalid_action'));
640 814 }
641 -
815 +
642 816 exit;
643 817 }
644 -
818 +
645 819 /**
646 - * Get stats for dashboard
820 + * Get stats for dashboard
647 821 */
648 822 public function getInvoiceStats() {
649 - $repository = InvoiceServiceProvider::getInvoiceRepository();
650 - $total_invoices = count($repository->all());
651 - $pending_invoices = count($repository->findByStatus('pending'));
652 - $paid_invoices = count($repository->findByStatus('paid'));
653 -
654 - // Get total revenue by currency from paid invoices
655 - $revenue_by_currency = [];
656 - $paid_invoices_list = $repository->findByStatus('paid');
657 -
658 - // First, get all currencies that exist in the system
659 - $all_invoices = $repository->all();
660 - $all_currencies = [];
661 -
662 - foreach ($all_invoices as $invoice) {
663 - $currency_code = $invoice->getCurrencyCode();
664 -
665 - // If currency is empty or "global", get the actual currency that was used
666 - if (empty($currency_code) || $currency_code === 'global') {
667 - // Get the actual currency from invoice meta
668 - $actual_currency = get_post_meta($invoice->getId(), '_easy_invoice_currency_code', true);
669 - $currency_code = !empty($actual_currency) ? $actual_currency : get_option('easy_invoice_currency_code', 'USD');
670 - }
671 -
672 - // If currency is still "global", use the global setting
673 - if ($currency_code === 'global') {
674 - $currency_code = get_option('easy_invoice_currency_code', 'USD');
675 - }
676 -
677 - // Normalize currency code to uppercase for consistent grouping
678 - $currency_code = strtoupper($currency_code);
679 -
680 - if (!empty($currency_code)) {
681 - $all_currencies[$currency_code] = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code);
682 - }
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;
683 838 }
684 -
685 - // Initialize revenue for all currencies found
686 - foreach ($all_currencies as $currency_code => $currency_symbol) {
687 - $revenue_by_currency[$currency_code] = [
688 - 'amount' => 0,
689 - 'symbol' => $currency_symbol
690 - ];
691 - }
692 -
693 - // Now calculate revenue for paid invoices
694 - foreach ($paid_invoices_list as $invoice) {
695 - $invoice_total = $invoice->getTotal();
696 - if (!is_numeric($invoice_total)) {
697 - continue;
698 - }
699 -
700 - // Get the actual currency from the invoice
701 - $currency_code = $invoice->getCurrencyCode();
702 -
703 - // If currency is empty or "global", get the actual currency that was used
704 - if (empty($currency_code) || $currency_code === 'global') {
705 - // Get the actual currency from invoice meta
706 - $actual_currency = get_post_meta($invoice->getId(), '_easy_invoice_currency_code', true);
707 - $currency_code = !empty($actual_currency) ? $actual_currency : get_option('easy_invoice_currency_code', 'USD');
708 - }
709 -
710 - // If currency is still "global", use the global setting
711 - if ($currency_code === 'global') {
712 - $currency_code = get_option('easy_invoice_currency_code', 'USD');
713 - }
714 -
715 - // Normalize currency code to uppercase for consistent grouping
716 - $currency_code = strtoupper($currency_code);
717 -
718 - if (isset($revenue_by_currency[$currency_code])) {
719 - $revenue_by_currency[$currency_code]['amount'] += $invoice_total;
720 - }
721 - }
722 -
723 - // Calculate total value from ALL invoices (not just paid ones)
839 +
840 + // "Total value": what is still owed, by currency, with the number of open invoices.
724 841 $total_value_by_currency = [];
725 -
726 - // Initialize total value for all currencies found
727 - foreach ($all_currencies as $currency_code => $currency_symbol) {
842 + foreach ($outstanding['amount'] as $currency_code => $amount) {
728 843 $total_value_by_currency[$currency_code] = [
729 - 'amount' => 0,
730 - 'invoices' => 0,
731 - 'invoice_object' => null // Keep reference for formatting
844 + 'amount' => (float) $amount,
845 + 'invoices' => (int) ($outstanding['count_by_currency'][$currency_code] ?? 0),
846 + 'invoice_object' => null,
847 + 'currency' => $currency_code,
732 848 ];
733 849 }
734 -
735 - // Calculate total value from all invoices
736 - foreach ($all_invoices as $invoice) {
737 - $invoice_total = $invoice->getTotal();
738 - if (!is_numeric($invoice_total)) {
739 - continue;
740 - }
741 -
742 - // Get the actual currency from the invoice
743 - $currency_code = $invoice->getCurrencyCode();
744 -
745 - // If currency is empty or "global", get the actual currency that was used
746 - if (empty($currency_code) || $currency_code === 'global') {
747 - // Get the actual currency from invoice meta
748 - $actual_currency = get_post_meta($invoice->getId(), '_easy_invoice_currency_code', true);
749 - $currency_code = !empty($actual_currency) ? $actual_currency : get_option('easy_invoice_currency_code', 'USD');
750 - }
751 -
752 - // If currency is still "global", use the global setting
753 - if ($currency_code === 'global') {
754 - $currency_code = get_option('easy_invoice_currency_code', 'USD');
755 - }
756 -
757 - // Normalize currency code to uppercase for consistent grouping
758 - $currency_code = strtoupper($currency_code);
759 -
760 - if (isset($total_value_by_currency[$currency_code])) {
761 - $total_value_by_currency[$currency_code]['amount'] += $invoice_total;
762 - $total_value_by_currency[$currency_code]['invoices']++;
763 - // Keep reference to first invoice for formatting
764 - if ($total_value_by_currency[$currency_code]['invoice_object'] === null) {
765 - $total_value_by_currency[$currency_code]['invoice_object'] = $invoice;
766 - }
767 - }
768 - }
769 -
850 +
770 851 return [
771 - 'total_invoices' => $total_invoices,
852 + 'total_invoices' => $total_invoices,
772 853 'pending_invoices' => $pending_invoices,
773 - 'paid_invoices' => $paid_invoices,
774 - 'total_revenue' => $revenue_by_currency,
775 - 'total_value' => $total_value_by_currency
854 + 'paid_invoices' => $paid_invoices,
855 + 'total_revenue' => $revenue_by_currency,
856 + 'total_value' => $total_value_by_currency,
776 857 ];
777 858 }
778 859
779 860 /**
@@ -781,9 +862,15 @@
781 862 */
782 863 public function registerAjaxHandlers() {
783 864 add_action('wp_ajax_easy_invoice_load_template', array($this, 'handleLoadTemplate'));
784 865 add_action('wp_ajax_easy_invoice_create_new_invoice', array($this, 'ajax_create_new_invoice'));
785 - add_action('wp_ajax_easy_invoice_search_clients', array($this, 'handleSearchClients'));
866 + // The `easy_invoice_search_clients` AJAX is owned by EasyInvoiceAjax.
867 + // The duplicate registration that used to live here raced with
868 + // EasyInvoiceAjax::searchClients() — only the first-registered
869 + // handler ran, and which one won depended on bootstrap order. That
870 + // intermittently broke the client-search dropdown in the invoice
871 + // builder. Keep this comment as a tombstone so the registration
872 + // doesn't get added back.
786 873 }
787 874
788 875 /**
789 876 * Handle AJAX request to load invoice template
@@ -793,29 +880,50 @@
793 880 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'easy_invoice_nonce')) {
794 881 wp_send_json_error(array('message' => __('Security check failed', 'easy-invoice')));
795 882 }
796 883
797 - // Get template name
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
798 891 $template = isset($_POST['template']) ? sanitize_text_field($_POST['template']) : 'standard';
799 - $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
892 + $template = $this->validateTemplateName($template, 'invoice');
893 + $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
800 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 +
801 903 // For new invoices (no ID), just return the template without invoice data
802 904 if ($invoice_id === 0) {
803 - // Get template file path
804 - $template_file = EASY_INVOICE_PLUGIN_DIR . 'templates/invoice-templates/' . $template . '.php';
805 -
806 - if (!file_exists($template_file)) {
807 - $template_file = EASY_INVOICE_PLUGIN_DIR . 'templates/invoice-templates/standard.php';
808 - }
809 -
810 905 // Start output buffering
811 906 ob_start();
812 -
813 - // Set up empty variables for new invoices
814 - $invoice = null;
815 - $formatter = null;
816 -
817 -
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 +
818 926 include_once $template_file;
819 927 $html = ob_get_clean();
820 928
821 929 // Send response
@@ -829,19 +937,14 @@
829 937
830 938 if (!$invoice) {
831 939 wp_send_json_error(array('message' => __('Invoice not found', 'easy-invoice')));
832 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');
833 943
834 944 // Initialize formatter for currency formatting
835 945 $formatter = new \EasyInvoice\Helpers\InvoiceFormatter($invoice);
836 946
837 - // Get template file path
838 - $template_file = EASY_INVOICE_PLUGIN_DIR . 'templates/invoice-templates/' . $template . '.php';
839 -
840 - if (!file_exists($template_file)) {
841 - $template_file = EASY_INVOICE_PLUGIN_DIR . 'templates/invoice-templates/standard.php';
842 - }
843 -
844 947 // Start output buffering
845 948 ob_start();
846 949 include_once $template_file;
847 950 $html = ob_get_clean();
@@ -850,208 +953,107 @@
850 953 wp_send_json_success(array('html' => $html));
851 954 }
852 955
853 956 /**
854 - * Add meta box for manual payment verification to the invoice edit screen.
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
855 962 */
856 - public function add_manual_payment_meta_box() {
857 - add_meta_box(
858 - 'easy_invoice_manual_payment_verification',
859 - __('Manual Payment Verification', 'easy-invoice'),
860 - array($this, 'render_manual_payment_meta_box'),
861 - 'easy-invoice', // Post type
862 - 'side', // Context
863 - 'high' // Priority
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')
864 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';
865 986 }
866 987
867 988 /**
868 - * Render the manual payment verification meta box.
989 + * Get secure template file path with directory traversal protection
869 990 *
870 - * @param \WP_Post $post The current post object.
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
871 994 */
872 - public function render_manual_payment_meta_box(\WP_Post $post) {
873 - $payment_status = get_post_meta($post->ID, '_payment_status', true);
874 - $payment_method = get_post_meta($post->ID, '_payment_method', true);
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 + );
875 1001
876 - if (!in_array($payment_status, ['pending-bank', 'pending-cheque'])) {
877 - echo '<p>' . __('This invoice is not pending manual payment verification.', 'easy-invoice') . '</p>';
878 - return;
1002 + if (!isset($template_dirs[$type])) {
1003 + return false;
879 1004 }
880 1005
881 - wp_nonce_field('easy_invoice_mark_paid_' . $post->ID, 'easy_invoice_mark_paid_nonce');
1006 + $template_dir = $template_dirs[$type];
882 1007
883 - echo '<h4>' . __('Submitted Payment Proof', 'easy-invoice') . '</h4>';
884 -
885 - if ($payment_method === 'bank') {
886 - $transaction_id = get_post_meta($post->ID, '_bank_transaction_id', true);
887 - $notes = get_post_meta($post->ID, '_bank_payment_notes', true);
888 - $proof_url = get_post_meta($post->ID, '_bank_payment_proof', true);
889 -
890 - echo '<p><strong>' . __('Transaction ID:', 'easy-invoice') . '</strong> ' . esc_html($transaction_id) . '</p>';
891 - if ($notes) {
892 - echo '<p><strong>' . __('Notes:', 'easy-invoice') . '</strong></p>';
893 - echo '<div style="white-space: pre-wrap; background: #f9f9f9; padding: 5px; border: 1px solid #eee;">' . esc_html($notes) . '</div>';
894 - }
895 - if ($proof_url) {
896 - 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>';
897 - }
898 - } elseif ($payment_method === 'cheque') {
899 - $cheque_number = get_post_meta($post->ID, '_cheque_number', true);
900 - $bank_name = get_post_meta($post->ID, '_cheque_bank_name', true);
901 - $cheque_date = get_post_meta($post->ID, '_cheque_date', true);
902 - $notes = get_post_meta($post->ID, '_cheque_notes', true);
903 - $image_url = get_post_meta($post->ID, '_cheque_image', true);
904 -
905 - echo '<p><strong>' . __('Cheque Number:', 'easy-invoice') . '</strong> ' . esc_html($cheque_number) . '</p>';
906 - if ($bank_name) echo '<p><strong>' . __('Bank Name:', 'easy-invoice') . '</strong> ' . esc_html($bank_name) . '</p>';
907 - if ($cheque_date) echo '<p><strong>' . __('Cheque Date:', 'easy-invoice') . '</strong> ' . esc_html($cheque_date) . '</p>';
908 - if ($notes) {
909 - echo '<p><strong>' . __('Notes:', 'easy-invoice') . '</strong></p>';
910 - echo '<div style="white-space: pre-wrap; background: #f9f9f9; padding: 5px; border: 1px solid #eee;">' . esc_html($notes) . '</div>';
911 - }
912 - if ($image_url) {
913 - 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>';
914 - }
1008 + // Ensure template directory exists and is a directory
1009 + if (!is_dir($template_dir)) {
1010 + return false;
915 1011 }
916 1012
917 - echo '<p style="margin-top: 15px;">';
918 - 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>';
919 - echo '</p>';
920 - echo '<div id="easy-invoice-mark-paid-message" style="margin-top:10px;"></div>';
921 -
922 - // Add a script for the AJAX call
923 - ?>
924 - <script type="text/javascript">
925 - jQuery(document).ready(function($) {
926 - $('#easy-invoice-mark-paid-btn').on('click', function() {
927 - var invoiceId = $(this).data('invoice-id');
928 - var nonce = $('#easy_invoice_mark_paid_nonce').val();
929 - var button = $(this);
930 - var messageDiv = $('#easy-invoice-mark-paid-message');
931 -
932 - button.prop('disabled', true);
933 - messageDiv.html('Processing...');
934 -
935 - $.ajax({
936 - url: ajaxurl, // WordPress AJAX URL
937 - type: 'POST',
938 - data: {
939 - action: 'easy_invoice_mark_paid',
940 - invoice_id: invoiceId,
941 - nonce: nonce
942 - },
943 - success: function(response) {
944 - if (response.success) {
945 - messageDiv.css('color', 'green').html(response.data.message);
946 - button.hide();
947 - // Optionally, reload the page or update UI elements to reflect paid status
948 - // window.location.reload();
949 - } else {
950 - messageDiv.css('color', 'red').html(response.data.message);
951 - button.prop('disabled', false);
952 - }
953 - },
954 - error: function() {
955 - messageDiv.css('color', 'red').html('<?php echo esc_js(__("An error occurred. Please try again.", "easy-invoice")); ?>');
956 - button.prop('disabled', false);
957 - }
958 - });
959 - });
960 - });
961 - </script>
962 - <?php
963 - }
964 -
965 - /**
966 - * AJAX handler to create a sample invoice.
967 - */
968 - public function ajax_create_sample_invoice() {
969 - // Security check: verify nonce
970 - check_ajax_referer('easy_invoice_admin_nonce', 'nonce');
971 -
972 - // Security check: verify user capabilities
973 - if (!current_user_can('edit_posts')) { // Or a more specific capability for your CPT
974 - wp_send_json_error([
975 - 'message' => __('You do not have permission to create invoices.', 'easy-invoice')
976 - ], 403);
977 - return;
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;
978 1017 }
979 1018
980 - try {
981 - $invoice_repository = InvoiceServiceProvider::getInvoiceRepository();
1019 + // Construct the template file path
1020 + $template_file = $real_template_dir . DIRECTORY_SEPARATOR . $template . '.php';
982 1021
983 - // Sample Invoice Data
984 - $sample_invoice_data = [
985 - 'post_title' => 'Sample Invoice - ' . date('Y-m-d H:i'),
986 - 'post_status' => 'draft', // Or 'publish' if you want it live immediately
987 - // Add other WP_Post fields as needed (e.g., post_author)
988 - ];
1022 + // Get the real path of the template file (resolves any .. or . components)
1023 + $real_template_file = realpath($template_file);
989 1024
990 - // Sample Meta Data
991 - $sample_meta_data = [
992 - '_easy_invoice_number' => 'SAMPLE-' . time(),
993 - '_easy_invoice_issue_date' => date('Y-m-d'),
994 - '_easy_invoice_due_date' => date('Y-m-d', strtotime('+15 days')),
995 - '_easy_invoice_status' => 'draft',
996 - '_easy_invoice_customer_name' => 'John Doe (Sample Client)',
997 - '_easy_invoice_customer_email' => 'customer@example.com',
998 - '_easy_invoice_customer_address' => "123 Sample Street\nSampleville, ST 12345",
999 - 'currency_code' => 'USD',
1000 - 'currency_position' => 'before',
1001 - // Add other meta keys as needed
1002 - ];
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);
1003 1031
1004 - // Sample Line Items
1005 - $sample_items = [];
1006 - for ($i = 1; $i <= 3; $i++) {
1007 - $sample_items[] = [
1008 - 'name' => 'Sample Service ' . $i,
1009 - 'description' => 'Detailed description of sample service ' . $i . '.',
1010 - 'quantity' => rand(1, 5),
1011 - 'price' => rand(50, 200) * 1.00,
1012 - // 'taxable' => true/false (optional)
1013 - ];
1032 + if ($real_default_file !== false && strpos($real_default_file, $real_template_dir) === 0) {
1033 + return $real_default_file;
1014 1034 }
1015 - $sample_meta_data['_easy_invoice_items'] = $sample_items;
1016 1035
1017 - // Create the invoice post
1018 - $invoice_id = wp_insert_post($sample_invoice_data, true); // true for WP_Error on failure
1036 + return false;
1037 + }
1019 1038
1020 - if (is_wp_error($invoice_id)) {
1021 - throw new \Exception('Failed to create invoice post: ' . $invoice_id->get_error_message());
1022 - }
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);
1023 1044
1024 - // Set invoice meta data
1025 - foreach ($sample_meta_data as $key => $value) {
1026 - update_post_meta($invoice_id, $key, $value);
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;
1027 1047 }
1028 -
1029 - // Recalculate totals if your Invoice model or repository has a method for it
1030 - // For example, if you have $invoice->calculateTotals()->save(); or similar.
1031 - // This step is crucial if subtotal, tax, total are not directly set but calculated.
1032 - // For now, we assume they might be calculated on load or save by other parts of your plugin.
1033 - // If not, you'd need to calculate and save them here.
1034 - // Example (conceptual):
1035 - // $invoice_object = $invoice_repository->find($invoice_id);
1036 - // if ($invoice_object) {
1037 - // $invoice_object->setItems($sample_items); // This might trigger calculations if model is designed so
1038 - // // Or call a specific method: $invoice_object->recalculateAndSaveTotals();
1039 - // }
1040 1048
1041 - wp_send_json_success([
1042 - 'message' => __('Sample invoice created successfully!', 'easy-invoice'),
1043 - 'invoice_id' => $invoice_id,
1044 - 'edit_link' => admin_url('admin.php?page=easy-invoice-builder&id=' . $invoice_id)
1045 - ]);
1049 + return false;
1050 + }
1046 1051
1047 - } catch (\Exception $e) {
1048 - wp_send_json_error([
1049 - 'message' => __('Error creating sample invoice:', 'easy-invoice') . ' ' . $e->getMessage()
1050 - ], 500);
1051 - }
1052 + return $real_template_file;
1052 1053 }
1053 1054
1055 +
1054 1056 /**
1055 1057 * AJAX handler for creating a new invoice with title
1056 1058 */
1057 1059 public function ajax_create_new_invoice() {
@@ -1058,9 +1060,9 @@
1058 1060 // Security check: verify nonce
1059 1061 check_ajax_referer('easy_invoice_nonce', 'nonce');
1060 1062
1061 1063 // Security check: verify user capabilities
1062 - if (!current_user_can('edit_posts')) {
1064 + if (!easy_invoice_user_can('ei_create_invoice')) {
1063 1065 wp_send_json_error([
1064 1066 'message' => __('You do not have permission to create invoices.', 'easy-invoice')
1065 1067 ], 403);
1066 1068 return;
@@ -1067,9 +1069,9 @@
1067 1069 }
1068 1070
1069 1071 // Get the invoice title
1070 1072 $title = isset($_POST['title']) ? sanitize_text_field($_POST['title']) : '';
1071 -
1073 +
1072 1074 if (empty($title)) {
1073 1075 wp_send_json_error([
1074 1076 'message' => __('Invoice title is required.', 'easy-invoice')
1075 1077 ], 400);
@@ -1082,15 +1084,15 @@
1082 1084 // Prepare invoice data for repository
1083 1085 $invoice_data = [
1084 1086 'title' => $title,
1085 1087 'post_status' => 'draft',
1086 - 'issue_date' => date('Y-m-d'),
1087 - 'due_date' => date('Y-m-d', strtotime('+30 days')),
1088 + 'issue_date' => current_time('Y-m-d'),
1089 + 'due_date' => wp_date('Y-m-d', strtotime('+30 days')),
1088 1090 'status' => 'draft',
1089 - 'invoice_template' => 'standard'
1091 + 'invoice_template' => get_option('easy_invoice_last_invoice_template', 'standard')
1090 1092 ];
1091 1093
1092 - // Create the invoice using repository (this will auto-generate invoice number)
1094 + // Create the invoice using repository (this will auto-generate invoice number)
1093 1095 $invoice = $invoice_repository->create($invoice_data);
1094 1096
1095 1097 if (!$invoice) {
1096 1098 throw new \Exception('Failed to create invoice');
@@ -1119,49 +1121,141 @@
1119 1121 ], 500);
1120 1122 }
1121 1123 }
1122 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 +
1123 1139 /**
1124 - * Handle search clients AJAX request
1140 + * Get (or lazily generate) the per-invoice access token. 32 hex chars =
1141 + * 128 bits of entropy, well above what's brute-forceable inside the
1142 + * lifetime of a published invoice. Stored in private post meta.
1143 + */
1144 + public static function invoiceAccessToken(int $invoice_id): string {
1145 + if ($invoice_id <= 0) {
1146 + return '';
1147 + }
1148 + $token = (string) get_post_meta($invoice_id, '_easy_invoice_invoice_access_token', true);
1149 + if ($token === '' || strlen($token) < 32) {
1150 + try {
1151 + $token = bin2hex(random_bytes(16));
1152 + } catch (\Throwable $e) {
1153 + // Fallback for systems without CSPRNG. wp_generate_password
1154 + // uses random_bytes internally on modern PHP — same entropy.
1155 + $token = wp_generate_password(32, false, false);
1156 + }
1157 + update_post_meta($invoice_id, '_easy_invoice_invoice_access_token', $token);
1158 + }
1159 + return $token;
1160 + }
1161 +
1162 + /**
1163 + * Read-only sibling of invoiceAccessToken(). Returns the persisted
1164 + * token if one already exists, or an empty string otherwise — never
1165 + * mints. Use this from user-controlled rendering contexts (e.g. the
1166 + * `[easy_invoice_url]` shortcode) where allowing an arbitrary caller
1167 + * to MINT a payment-authorising token for an attacker-chosen invoice
1168 + * would be a privilege-escalation vector.
1125 1169 *
1126 - * @since 1.0.0
1170 + * Trusted server contexts (the EmailManager invoice-send path) should
1171 + * keep calling invoiceAccessToken() so first-send still works.
1127 1172 */
1128 - public function handleSearchClients(): void {
1129 - // Verify nonce
1130 - if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_nonce')) {
1131 - wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
1173 + public static function invoiceAccessTokenIfExists(int $invoice_id): string {
1174 + if ($invoice_id <= 0) {
1175 + return '';
1132 1176 }
1133 -
1134 - // Check permissions
1135 - if (!current_user_can('manage_options')) {
1136 - wp_send_json_error(['message' => __('Insufficient permissions.', 'easy-invoice')]);
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']));
1137 1192 }
1138 -
1139 - $query = sanitize_text_field($_POST['query'] ?? '');
1140 -
1141 - // Get client repository
1142 - $client_repository = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository();
1143 -
1144 - // If query is empty, get all clients
1145 - if (empty($query)) {
1146 - $clients = $client_repository->all();
1147 - } else {
1148 - // Search clients by name, email, or company
1149 - $clients = $client_repository->search($query);
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;
1150 1224 }
1151 -
1152 - $results = [];
1153 - foreach ($clients as $client) {
1154 - $results[] = [
1155 - 'id' => $client->getId(),
1156 - 'name' => $client->getBusinessClientName() ?: ($client->getFirstName() . ' ' . $client->getLastName()),
1157 - 'email' => $client->getEmail(),
1158 - 'company' => $client->getBusinessClientName(),
1159 - 'phone' => $client->getExtraInfo(),
1160 - 'website' => $client->getWebsite(),
1161 - 'address' => $client->getAddress()
1162 - ];
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 + }
1163 1233 }
1164 -
1165 - wp_send_json_success($results);
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;
1166 1259 }
1167 -}
1260 +
1261 +}