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/ReportController.php +226 -488 2.3.32.4.0 View file →
@@ -54,23 +54,12 @@
54 54 '3.9.1',
55 55 true
56 56 );
57 57
58 - // Add fallback to CDN if local file fails
59 - wp_add_inline_script('chartjs', '
60 - if (typeof Chart === "undefined") {
61 - var script = document.createElement("script");
62 - script.src = "https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.js";
63 - script.onload = function() {
64 - // Chart.js loaded from CDN
65 - };
66 - script.onerror = function() {
67 - // Failed to load Chart.js from CDN
68 - };
69 - document.head.appendChild(script);
70 - }
71 - ');
72 -
58 + // No CDN fallback. WordPress.org requires every asset to ship inside
59 + // the plugin, so a remote <script> injection here is not permitted
60 + // even as a fallback path. The local copy above is the only source.
61 +
73 62 // Register reports script
74 63 wp_register_script(
75 64 'easy-invoice-reports',
76 65 EASY_INVOICE_PLUGIN_URL . 'assets/js/reports.js',
@@ -168,13 +157,13 @@
168 157 </div>
169 158
170 159 <!-- Action Buttons -->
171 160 <div class="space-y-3">
172 - <a href="<?php echo admin_url('admin.php?page=easy-invoice-settings&tab=premium'); ?>" class="w-full inline-flex items-center justify-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors duration-200">
161 + <a href="<?php echo esc_url(admin_url('admin.php?page=easy-invoice-settings&tab=premium')); ?>" class="w-full inline-flex items-center justify-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors duration-200">
173 162 <i class="fas fa-crown mr-2"></i>
174 163 Upgrade to Easy Invoice Pro
175 164 </a>
176 - <a href="<?php echo admin_url('admin.php?page=easy-invoice'); ?>" class="w-full inline-flex items-center justify-center px-6 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors duration-200">
165 + <a href="<?php echo esc_url(admin_url('admin.php?page=easy-invoice')); ?>" class="w-full inline-flex items-center justify-center px-6 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors duration-200">
177 166 <i class="fas fa-arrow-left mr-2"></i>
178 167 Back to Dashboard
179 168 </a>
180 169 </div>
@@ -198,16 +187,16 @@
198 187 * @return void
199 188 */
200 189 protected function displayReportsPage( $report_data = [] ) {
201 190 // Check user capability
202 - $error = $this->checkCapability();
191 + $error = $this->checkCapability('ei_view_reports');
203 192 if ( is_wp_error( $error ) ) {
204 - wp_die( $error );
193 + wp_die(esc_html($error->get_error_message()));
205 194 }
206 195
207 196 // Get date range filters
208 - $start_date = isset( $_GET['start_date'] ) ? sanitize_text_field( $_GET['start_date'] ) : date( 'Y-m-d', strtotime( '-30 days' ) );
209 - $end_date = isset( $_GET['end_date'] ) ? sanitize_text_field( $_GET['end_date'] ) : date( 'Y-m-d' );
197 + $start_date = isset( $_GET['start_date'] ) ? sanitize_text_field( $_GET['start_date'] ) : wp_date('Y-m-d', strtotime('-30 days'));
198 + $end_date = isset( $_GET['end_date'] ) ? sanitize_text_field( $_GET['end_date'] ) : current_time('Y-m-d');
210 199
211 200 // Get repositories
212 201 $invoice_repository = InvoiceServiceProvider::getInvoiceRepository();
213 202 $client_repository = ClientServiceProvider::getClientRepository();
@@ -215,9 +204,9 @@
215 204 // Get data for reports
216 205 $summary_stats = $this->getSummaryStats( $invoice_repository, $client_repository, $start_date, $end_date );
217 206 $monthly_revenue = $this->getMonthlySummary( $invoice_repository, $start_date, $end_date );
218 207 $invoice_status = $this->getInvoiceStatusSummary( $invoice_repository, $start_date, $end_date );
219 - $top_clients = $this->getTopClients( $invoice_repository, $client_repository );
208 + $top_clients = $this->getTopClients( $invoice_repository, $client_repository, $start_date, $end_date );
220 209
221 210 // Generate payment and invoice reports
222 211 $payment_report = $this->getPaymentReport( $start_date, $end_date );
223 212 $invoice_report = $this->getInvoiceReport( $start_date, $end_date );
@@ -271,10 +260,10 @@
271 260 return;
272 261 }
273 262
274 263 $report_type = isset( $_POST['report_type'] ) ? sanitize_text_field( $_POST['report_type'] ) : '';
275 - $start_date = isset( $_POST['start_date'] ) ? sanitize_text_field( $_POST['start_date'] ) : date( 'Y-m-d', strtotime( '-30 days' ) );
276 - $end_date = isset( $_POST['end_date'] ) ? sanitize_text_field( $_POST['end_date'] ) : date( 'Y-m-d' );
264 + $start_date = isset( $_POST['start_date'] ) ? sanitize_text_field( $_POST['start_date'] ) : wp_date('Y-m-d', strtotime('-30 days'));
265 + $end_date = isset( $_POST['end_date'] ) ? sanitize_text_field( $_POST['end_date'] ) : current_time('Y-m-d');
277 266
278 267 // Get repositories
279 268 $invoice_repository = InvoiceServiceProvider::getInvoiceRepository();
280 269 $client_repository = ClientServiceProvider::getClientRepository();
@@ -290,9 +279,9 @@
290 279 $response = $this->getInvoiceStatusSummary( $invoice_repository, $start_date, $end_date );
291 280 break;
292 281
293 282 case 'top_clients':
294 - $response = $this->getTopClients( $invoice_repository, $client_repository );
283 + $response = $this->getTopClients( $invoice_repository, $client_repository, $start_date, $end_date );
295 284 break;
296 285
297 286 case 'summary_stats':
298 287 $response = $this->getSummaryStats( $invoice_repository, $client_repository, $start_date, $end_date );
@@ -325,101 +314,44 @@
325 314 * @return array Summary statistics
326 315 */
327 316 private function getSummaryStats( $invoice_repository, $client_repository, $start_date = '', $end_date = '' ) {
328 317 try {
329 - // Get all invoices
330 - $all_invoices = $invoice_repository->all();
331 - $paid_invoices = $invoice_repository->findByStatus( 'paid' );
332 -
333 - // Calculate total revenue by currency
334 - $revenue_by_currency = [];
335 -
336 - // First, get all currencies that exist in the system
337 - $all_currencies = [];
338 -
339 - foreach ($all_invoices as $invoice) {
340 - $currency_code = $invoice->getCurrencyCode();
341 -
342 - // If currency is empty or "global", get the actual currency that was used
343 - if (empty($currency_code) || $currency_code === 'global') {
344 - // Get the actual currency from invoice meta
345 - $actual_currency = get_post_meta($invoice->getId(), '_easy_invoice_currency_code', true);
346 - $currency_code = !empty($actual_currency) ? $actual_currency : get_option('easy_invoice_currency_code', 'USD');
347 - }
348 -
349 - // If currency is still "global", use the global setting
350 - if ($currency_code === 'global') {
351 - $currency_code = get_option('easy_invoice_currency_code', 'USD');
352 - }
353 -
354 - // Normalize currency code to uppercase for consistent grouping
355 - $currency_code = strtoupper($currency_code);
356 -
357 - if (!empty($currency_code)) {
358 - $all_currencies[$currency_code] = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code);
359 - }
360 - }
361 -
362 - // Initialize revenue for all currencies found
363 - foreach ($all_currencies as $currency_code => $currency_symbol) {
364 - $revenue_by_currency[$currency_code] = [
365 - 'amount' => 0,
366 - 'symbol' => $currency_symbol
367 - ];
368 - }
369 -
370 - // Sum revenue from paid invoices
371 - foreach ($paid_invoices as $invoice) {
372 - $currency_code = $invoice->getCurrencyCode();
373 -
374 - // If currency is empty or "global", get the actual currency that was used
375 - if (empty($currency_code) || $currency_code === 'global') {
376 - // Get the actual currency from invoice meta
377 - $actual_currency = get_post_meta($invoice->getId(), '_easy_invoice_currency_code', true);
378 - $currency_code = !empty($actual_currency) ? $actual_currency : get_option('easy_invoice_currency_code', 'USD');
379 - }
380 -
381 - // If currency is still "global", use the global setting
382 - if ($currency_code === 'global') {
383 - $currency_code = get_option('easy_invoice_currency_code', 'USD');
384 - }
385 -
386 - // Normalize currency code to uppercase for consistent grouping
387 - $currency_code = strtoupper($currency_code);
388 -
389 - if (!empty($currency_code)) {
390 - $amount = $invoice->getTotal();
391 - if (!isset($revenue_by_currency[$currency_code])) {
392 - $revenue_by_currency[$currency_code] = [
393 - 'amount' => 0,
394 - 'symbol' => \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code)
395 - ];
396 - }
397 - $revenue_by_currency[$currency_code]['amount'] += $amount;
398 - }
399 - }
400 -
401 - // Get other statistics
402 - $total_invoices = count($all_invoices);
403 - $active_clients = count($client_repository->all());
404 - $avg_payment_time = 30; // Default value, could be calculated from actual payment data
405 -
318 + // Revenue, counts and billed clients are SQL over the persisted totals
319 + // (InvoiceTotalsCache) — no models for a 10,000-invoice range.
320 + $paid = \EasyInvoice\Services\InvoiceTotalsCache::paidRevenue( (string) $start_date, (string) $end_date );
321 +
322 + // Average days from issue to payment over the completed payments in the range.
323 + global $wpdb;
324 + $where = ''; $args = [];
325 + if ( '' !== (string) $start_date ) { $where .= ' AND pd.meta_value >= %s'; $args[] = (string) $start_date; }
326 + if ( '' !== (string) $end_date ) { $where .= ' AND pd.meta_value <= %s'; $args[] = (string) $end_date . ' 23:59:59'; }
327 + $sql = "SELECT AVG(DATEDIFF(pd.meta_value, iss.meta_value)) AS days
328 + FROM {$wpdb->posts} p
329 + INNER JOIN {$wpdb->postmeta} st ON st.post_id = p.ID AND st.meta_key = '_status' AND st.meta_value = 'completed'
330 + INNER JOIN {$wpdb->postmeta} pd ON pd.post_id = p.ID AND pd.meta_key = '_payment_date' AND pd.meta_value <> ''
331 + INNER JOIN {$wpdb->postmeta} inv ON inv.post_id = p.ID AND inv.meta_key = '_invoice_id'
332 + INNER JOIN {$wpdb->postmeta} iss ON iss.post_id = inv.meta_value AND iss.meta_key = '_easy_invoice_issue_date' AND iss.meta_value <> ''
333 + WHERE p.post_type = 'easy_invoice_payment' AND p.post_status = 'publish'
334 + AND DATEDIFF(pd.meta_value, iss.meta_value) >= 0 {$where}";
335 + $days = $wpdb->get_var( $args ? $wpdb->prepare( $sql, ...$args ) : $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
336 + $avg_payment_time = null === $days ? null : (int) round( (float) $days );
337 +
406 338 return [
407 - 'total_revenue' => $revenue_by_currency,
408 - 'total_invoices' => $total_invoices,
409 - 'active_clients' => $active_clients,
410 - 'avg_payment_time' => $avg_payment_time
339 + 'total_revenue' => $paid['revenue'],
340 + 'total_invoices' => (int) $paid['invoice_count'],
341 + 'active_clients' => (int) $paid['client_count'],
342 + 'avg_payment_time' => $avg_payment_time,
411 343 ];
412 344 } catch ( \Exception $e ) {
413 345 return [
414 - 'total_revenue' => [],
415 - 'total_invoices' => 0,
416 - 'active_clients' => 0,
417 - 'avg_payment_time' => 0
346 + 'total_revenue' => [],
347 + 'total_invoices' => 0,
348 + 'active_clients' => 0,
349 + 'avg_payment_time' => null,
418 350 ];
419 351 }
420 352 }
421 -
353 +
422 354 /**
423 355 * Get monthly summary data
424 356 *
425 357 * @since 1.0.0
@@ -447,66 +379,40 @@
447 379 $monthly_revenue[$month_key] = [];
448 380 }
449 381
450 382 // Query payments for the date range
451 - $payment_query = new \WP_Query([
452 - 'post_type' => 'easy_invoice_payment',
453 - 'post_status' => 'publish',
454 - 'meta_query' => [
455 - [
456 - 'key' => '_status',
457 - 'value' => ['completed', 'approved', 'paid'],
458 - 'compare' => 'IN'
459 - ],
460 - [
461 - 'key' => '_amount',
462 - 'value' => '0',
463 - 'compare' => '>'
464 - ]
465 - ],
466 - 'date_query' => [
467 - [
468 - 'after' => $start_date->format('Y-m-d'),
469 - 'inclusive' => true
470 - ]
471 - ],
472 - 'posts_per_page' => -1
473 - ]);
474 -
475 - if ($payment_query->have_posts()) {
476 - while ($payment_query->have_posts()) {
477 - $payment_query->the_post();
478 - $payment_id = get_the_ID();
479 -
480 - $amount = floatval(get_post_meta($payment_id, '_amount', true));
481 - $currency = get_post_meta($payment_id, '_currency', true);
482 - $payment_date = get_the_date('Y-m-d', $payment_id);
483 -
484 - // Handle "global" currency
485 - if (empty($currency) || $currency === 'global') {
486 - $currency = get_option('easy_invoice_currency_code', 'USD');
487 - }
488 -
489 - // Normalize currency to uppercase
490 - $currency = strtoupper($currency);
491 -
492 - if ($amount > 0 && !empty($currency)) {
493 - $date_obj = new \DateTime($payment_date);
494 - $month_key = $date_obj->format('M Y');
495 -
496 - if (!isset($monthly_revenue[$month_key][$currency])) {
497 - $monthly_revenue[$month_key][$currency] = [
498 - 'amount' => 0,
499 - 'symbol' => \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency)
500 - ];
501 - }
502 -
503 - $monthly_revenue[$month_key][$currency]['amount'] += $amount;
504 - }
383 + // One grouped query over the payments; loading them all as posts
384 + // (thousands on a busy store) cost ~0.3 s and 100 MB per report view.
385 + global $wpdb;
386 + $site_currency = strtoupper((string) get_option('easy_invoice_currency_code', 'USD'));
387 + $rows = $wpdb->get_results($wpdb->prepare(
388 + "SELECT DATE_FORMAT(p.post_date, '%%Y-%%m') AS ym,
389 + UPPER(COALESCE(NULLIF(NULLIF(cur.meta_value, ''), 'global'), %s)) AS currency,
390 + SUM(CAST(a.meta_value AS DECIMAL(18,4))) AS amount
391 + FROM {$wpdb->posts} p
392 + INNER JOIN {$wpdb->postmeta} st ON st.post_id = p.ID AND st.meta_key = '_status' AND st.meta_value IN ('completed', 'approved', 'paid')
393 + INNER JOIN {$wpdb->postmeta} a ON a.post_id = p.ID AND a.meta_key = '_amount' AND CAST(a.meta_value AS DECIMAL(18,4)) > 0
394 + LEFT JOIN {$wpdb->postmeta} cur ON cur.post_id = p.ID AND cur.meta_key = '_currency'
395 + WHERE p.post_type = 'easy_invoice_payment' AND p.post_status = 'publish' AND p.post_date >= %s
396 + GROUP BY ym, currency",
397 + $site_currency,
398 + $start_date->format('Y-m-d 00:00:00')
399 + ), ARRAY_A);
400 + foreach ((array) $rows as $row) {
401 + $month_key = (new \DateTime($row['ym'] . '-01'))->format('M Y');
402 + $currency = (string) $row['currency'];
403 + if (!isset($monthly_revenue[$month_key])) {
404 + continue; // outside the 12 shown months
505 405 }
506 - wp_reset_postdata();
406 + if (!isset($monthly_revenue[$month_key][$currency])) {
407 + $monthly_revenue[$month_key][$currency] = [
408 + 'amount' => 0,
409 + 'symbol' => \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency)
410 + ];
411 + }
412 + $monthly_revenue[$month_key][$currency]['amount'] += (float) $row['amount'];
507 413 }
508 -
414 +
509 415 return $monthly_revenue;
510 416 } catch ( \Exception $e ) {
511 417 return [];
512 418 }
@@ -522,81 +428,32 @@
522 428 * @return array Invoice status data
523 429 */
524 430 private function getInvoiceStatusSummary( $invoice_repository, $start_date = '', $end_date = '' ) {
525 431 try {
526 - // Get all invoices first
527 - $invoices = $invoice_repository->all();
528 -
529 - // Filter by date range if provided
530 - if (!empty($start_date) || !empty($end_date)) {
531 - $filtered_invoices = [];
532 - foreach ($invoices as $invoice) {
533 - $invoice_date = $invoice->getInvoiceDate();
534 -
535 - // If invoice has a date, check if it's within the range
536 - if ($invoice_date) {
537 - $invoice_timestamp = strtotime($invoice_date);
538 - $start_timestamp = !empty($start_date) ? strtotime($start_date) : 0;
539 - $end_timestamp = !empty($end_date) ? strtotime($end_date . ' 23:59:59') : PHP_INT_MAX;
540 -
541 - if ($invoice_timestamp >= $start_timestamp && $invoice_timestamp <= $end_timestamp) {
542 - $filtered_invoices[] = $invoice;
543 - }
544 - } else {
545 - // If invoice has no date, include it in the results (don't filter out)
546 - $filtered_invoices[] = $invoice;
547 - }
432 + $counts = \EasyInvoice\Services\InvoiceTotalsCache::statusCounts( (string) $start_date, (string) $end_date );
433 + $status_counts = [];
434 + foreach ( [ 'paid', 'partial', 'unpaid', 'overdue', 'draft', 'canceled' ] as $key ) {
435 + if ( ! empty( $counts[ $key ] ) ) {
436 + $status_counts[ $key ] = (int) $counts[ $key ];
548 437 }
549 - $invoices = $filtered_invoices;
550 438 }
551 - $status_counts = [
552 - 'paid' => 0,
553 - 'unpaid' => 0,
554 - 'overdue' => 0,
555 - 'draft' => 0,
556 - 'canceled' => 0
557 - ];
558 -
559 - foreach ($invoices as $invoice) {
560 - $status = $invoice->getStatus();
561 -
562 - if (in_array($status, ['paid', 'completed'])) {
563 - $status_counts['paid']++;
564 - } elseif ($status === 'unpaid') {
565 - // Check if overdue
566 - $due_date = $invoice->getDueDate();
567 - if ($due_date && strtotime($due_date) < current_time('timestamp')) {
568 - $status_counts['overdue']++;
569 - } else {
570 - $status_counts['unpaid']++;
571 - }
572 - } elseif ($status === 'draft') {
573 - $status_counts['draft']++;
574 - } elseif (in_array($status, ['canceled', 'cancelled'])) {
575 - $status_counts['canceled']++;
576 - }
439 + $total_invoices = array_sum( $status_counts );
440 + $percentages = [];
441 + foreach ( $status_counts as $status => $count ) {
442 + $percentages[ $status ] = $total_invoices > 0 ? round( ( $count / $total_invoices ) * 100 ) : 0;
577 443 }
578 -
579 -
580 - $total_invoices = count($invoices);
581 - $percentages = [];
582 -
583 - foreach ($status_counts as $status => $count) {
584 - $percentages[$status] = $total_invoices > 0 ? round(($count / $total_invoices) * 100) : 0;
585 - }
586 -
587 444 return [
588 445 'percentages' => $percentages,
589 - 'counts' => $status_counts
446 + 'counts' => $status_counts,
590 447 ];
591 448 } catch ( \Exception $e ) {
592 449 return [
593 450 'percentages' => [],
594 - 'counts' => []
451 + 'counts' => [],
595 452 ];
596 453 }
597 454 }
598 -
455 +
599 456 /**
600 457 * Get top clients by revenue
601 458 *
602 459 * @since 1.0.0
@@ -607,71 +464,31 @@
607 464 * @return array Top clients data
608 465 */
609 466 private function getTopClients( $invoice_repository, $client_repository, $start_date = '', $end_date = '' ) {
610 467 try {
611 - $invoices = $invoice_repository->all();
612 - $clients = $client_repository->all();
613 -
614 - $client_revenue = [];
615 -
616 - foreach ($clients as $client) {
617 - $client_id = $client->getId();
618 - $client_revenue[$client_id] = [
619 - 'id' => $client_id,
620 - 'name' => $client->getName(),
621 - 'email' => $client->getEmail(),
622 - 'total_amount' => [],
623 - 'total_invoices' => 0,
624 - 'last_invoice' => ''
468 + $ranked = \EasyInvoice\Services\InvoiceTotalsCache::topClients( (string) $start_date, (string) $end_date, 10 );
469 + $out = [];
470 + foreach ( $ranked as $client_id => $row ) {
471 + $client = $client_repository->find( (int) $client_id );
472 + if ( ! $client ) {
473 + continue;
474 + }
475 + $person = trim( (string) $client->getFirstName() . ' ' . (string) $client->getLastName() );
476 + $out[ (int) $client_id ] = [
477 + 'id' => (int) $client_id,
478 + 'name' => (string) $client->getBusinessClientName() ?: ( $person ?: (string) $client->getUsername() ),
479 + 'email' => $client->getEmail(),
480 + 'total_amount' => $row['total_amount'],
481 + 'total_invoices' => (int) $row['total_invoices'],
482 + 'last_invoice' => (string) $row['last_invoice'],
625 483 ];
626 484 }
627 -
628 - foreach ($invoices as $invoice) {
629 - $client_id = $invoice->getClientId();
630 - if (isset($client_revenue[$client_id])) {
631 - $amount = $invoice->getTotal();
632 - $currency = $invoice->getCurrencyCode();
633 -
634 - // Handle "global" currency
635 - if (empty($currency) || $currency === 'global') {
636 - $currency = get_option('easy_invoice_currency_code', 'USD');
637 - }
638 -
639 - // Normalize currency to uppercase
640 - $currency = strtoupper($currency);
641 -
642 - if (!isset($client_revenue[$client_id]['total_amount'][$currency])) {
643 - $client_revenue[$client_id]['total_amount'][$currency] = [
644 - 'amount' => 0,
645 - 'symbol' => \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency)
646 - ];
647 - }
648 -
649 - $client_revenue[$client_id]['total_amount'][$currency]['amount'] += $amount;
650 - $client_revenue[$client_id]['total_invoices']++;
651 -
652 - // Track last invoice date
653 - $issue_date = $invoice->getIssueDate();
654 - if ($issue_date && (empty($client_revenue[$client_id]['last_invoice']) || $issue_date > $client_revenue[$client_id]['last_invoice'])) {
655 - $client_revenue[$client_id]['last_invoice'] = $issue_date;
656 - }
657 - }
658 - }
659 -
660 - // Sort by total revenue (USD first, then other currencies)
661 - uasort($client_revenue, function($a, $b) {
662 - $a_total = isset($a['total_amount']['USD']) ? $a['total_amount']['USD']['amount'] : 0;
663 - $b_total = isset($b['total_amount']['USD']) ? $b['total_amount']['USD']['amount'] : 0;
664 - return $b_total <=> $a_total;
665 - });
666 -
667 - // Return top 10 clients
668 - return array_slice($client_revenue, 0, 10, true);
485 + return $out;
669 486 } catch ( \Exception $e ) {
670 487 return [];
671 488 }
672 489 }
673 -
490 +
674 491 /**
675 492 * Get payment report data
676 493 *
677 494 * @since 1.0.0
@@ -688,10 +505,10 @@
688 505 $start = $start_date ? strtotime( $start_date ) : strtotime( '-1 year' );
689 506 $end = $end_date ? strtotime( $end_date ) : current_time( 'timestamp' );
690 507
691 508 // Format dates for SQL query
692 - $start_date_formatted = date( 'Y-m-d 00:00:00', $start );
693 - $end_date_formatted = date( 'Y-m-d 23:59:59', $end );
509 + $start_date_formatted = gmdate( 'Y-m-d 00:00:00', $start );
510 + $end_date_formatted = gmdate( 'Y-m-d 23:59:59', $end );
694 511
695 512 // Query to get payments within date range
696 513 $query = $wpdb->prepare(
697 514 "SELECT p.ID, p.post_date,
@@ -713,9 +530,9 @@
713 530 $start_date_formatted,
714 531 $end_date_formatted
715 532 );
716 533
717 - $payments = $wpdb->get_results( $query, ARRAY_A );
534 + $payments = $wpdb->get_results( $query, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- $query is built with $wpdb->prepare() above; only table names are interpolated.
718 535
719 536 $report_data = [
720 537 'payments' => [],
721 538 'total_amount' => 0,
@@ -791,9 +608,9 @@
791 608
792 609 // Get invoice number if available
793 610 $invoice_number = '';
794 611 if ( ! empty( $payment['invoice_id'] ) ) {
795 - $invoice_number = get_post_meta( $payment['invoice_id'], '_invoice_number', true );
612 + $invoice_number = get_post_meta( $payment['invoice_id'], '_easy_invoice_number', true );
796 613
797 614 // Provide a fallback format if no invoice number is found
798 615 if ( empty( $invoice_number ) ) {
799 616 $invoice_number = 'INV-' . $payment['invoice_id'];
@@ -819,223 +636,144 @@
819 636 return $report_data;
820 637 }
821 638
822 639 /**
823 - * Get invoice report data
640 + * Invoice report for the period: every live invoice issued between the
641 + * two dates, its real total (computed from its items, the way the invoice
642 + * itself shows it) and its Easy Invoice status.
824 643 *
825 644 * @since 1.0.0
826 - * @param string $start_date Start date for report
827 - * @param string $end_date End date for report
828 - * @return array Invoice report data
645 + * @param string $start_date Y-m-d, defaults to a year ago.
646 + * @param string $end_date Y-m-d, defaults to today.
647 + * @return array
829 648 */
830 649 private function getInvoiceReport( $start_date = '', $end_date = '' ) {
831 - global $wpdb;
832 -
833 - $invoice_table = $wpdb->prefix . 'posts';
834 - $invoice_meta_table = $wpdb->prefix . 'postmeta';
835 -
836 650 $start = $start_date ? strtotime( $start_date ) : strtotime( '-1 year' );
837 - $end = $end_date ? strtotime( $end_date ) : current_time( 'timestamp' );
838 -
839 - // Format dates for SQL query
840 - $start_date_formatted = date( 'Y-m-d 00:00:00', $start );
841 - $end_date_formatted = date( 'Y-m-d 23:59:59', $end );
842 -
843 - try {
844 - // Query to get invoices within date range - using the correct post type "easy_invoice"
845 - // First, let's get all invoices without date filtering to see if there are any
846 - $query = $wpdb->prepare(
847 - "SELECT p.ID, p.post_date, p.post_status, p.post_title,
848 - MAX(CASE WHEN pm.meta_key = '_easy_invoice_number' THEN pm.meta_value ELSE NULL END) as invoice_number,
849 - MAX(CASE WHEN pm.meta_key = '_easy_invoice_total' THEN pm.meta_value ELSE NULL END) as total,
850 - MAX(CASE WHEN pm.meta_key = '_invoice_total' THEN pm.meta_value ELSE NULL END) as invoice_total,
851 - MAX(CASE WHEN pm.meta_key = '_payment_status' THEN pm.meta_value ELSE NULL END) as payment_status,
852 - MAX(CASE WHEN pm.meta_key = '_easy_invoice_client_id' THEN pm.meta_value ELSE NULL END) as client_id,
853 - MAX(CASE WHEN pm.meta_key = '_easy_invoice_issue_date' THEN pm.meta_value ELSE NULL END) as issue_date,
854 - MAX(CASE WHEN pm.meta_key = '_easy_invoice_due_date' THEN pm.meta_value ELSE NULL END) as due_date,
855 - MAX(CASE WHEN pm.meta_key = '_easy_invoice_currency_code' THEN pm.meta_value ELSE NULL END) as currency_code,
856 - MAX(CASE WHEN pm.meta_key = '_currency_code' THEN pm.meta_value ELSE NULL END) as currency_code_alt,
857 - MAX(CASE WHEN pm.meta_key = '_easy_invoice_customer_name' THEN pm.meta_value ELSE NULL END) as customer_name,
858 - MAX(CASE WHEN pm.meta_key = 'customer_name' THEN pm.meta_value ELSE NULL END) as customer_name_alt,
859 - MAX(CASE WHEN pm.meta_key = '_easy_invoice_client_name' THEN pm.meta_value ELSE NULL END) as client_name,
860 - MAX(CASE WHEN pm.meta_key = 'client_name' THEN pm.meta_value ELSE NULL END) as client_name_alt
861 - FROM $invoice_table p
862 - LEFT JOIN $invoice_meta_table pm ON p.ID = pm.post_id
863 - WHERE p.post_type = %s
864 - GROUP BY p.ID
865 - ORDER BY p.post_date DESC
866 - LIMIT 100",
867 - 'easy_invoice'
868 - );
869 -
870 - $invoices = $wpdb->get_results( $query, ARRAY_A );
871 - } catch ( \Exception $e ) {
872 - $invoices = array();
873 - }
874 -
651 + $end = $end_date ? strtotime( $end_date ) : current_time( 'timestamp' );
652 + $start_ymd = gmdate( 'Y-m-d', $start );
653 + $end_ymd = gmdate( 'Y-m-d', $end );
654 + $today = gmdate( 'Y-m-d', current_time( 'timestamp' ) );
655 +
875 656 $report_data = [
876 657 'invoices' => [],
877 658 'count' => 0,
878 - 'total_amounts_by_currency' => [], // Track totals by currency
659 + 'total_amounts_by_currency' => [],
879 660 'by_status' => [
880 - 'paid' => ['count' => 0, 'amounts_by_currency' => []],
881 - 'unpaid' => ['count' => 0, 'amounts_by_currency' => []],
882 - 'overdue' => ['count' => 0, 'amounts_by_currency' => []],
883 - 'draft' => ['count' => 0, 'amounts_by_currency' => []],
661 + 'paid' => ['count' => 0, 'amounts_by_currency' => []],
662 + 'partial' => ['count' => 0, 'amounts_by_currency' => []],
663 + 'unpaid' => ['count' => 0, 'amounts_by_currency' => []],
664 + 'overdue' => ['count' => 0, 'amounts_by_currency' => []],
665 + 'draft' => ['count' => 0, 'amounts_by_currency' => []],
884 666 'canceled' => ['count' => 0, 'amounts_by_currency' => []],
885 - 'other' => ['count' => 0, 'amounts_by_currency' => []]
886 - ]
667 + 'other' => ['count' => 0, 'amounts_by_currency' => []],
668 + ],
887 669 ];
888 -
889 - if ( $invoices ) {
890 - foreach ( $invoices as $invoice ) {
891 - $payment_status = $invoice['payment_status'] ?? 'unpaid';
892 -
893 - // Check multiple possible total fields
894 - $total = 0;
895 - if ( ! empty( $invoice['total'] ) ) {
896 - $total = floatval( $invoice['total'] );
897 - } elseif ( ! empty( $invoice['invoice_total'] ) ) {
898 - $total = floatval( $invoice['invoice_total'] );
899 - }
900 -
901 - // Get currency information
902 - $currency_code = $invoice['currency_code'] ?? $invoice['currency_code_alt'] ?? 'USD';
903 - $currency_code = strtoupper($currency_code);
904 -
905 - // Handle "global" currency by getting the actual global setting
906 - if (empty($currency_code) || strtolower($currency_code) === 'global') {
907 - $currency_code = get_option('easy_invoice_currency_code', 'USD');
908 - }
909 -
910 - // Additional check: if currency is still "global" after replacement, use the global setting
911 - if (strtolower($currency_code) === 'global') {
912 - $currency_code = get_option('easy_invoice_currency_code', 'USD');
913 - }
914 -
915 - $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code);
916 -
917 - // Add to count
918 - $report_data['count']++;
919 -
920 - // Map payment statuses to standard categories for report
921 - $status_key = 'other';
922 - if ( in_array( $payment_status, ['paid', 'completed'] ) ) {
670 +
671 + // One query over the persisted totals for the 500 most recent invoices
672 + // issued in the range; a row without a cached total (not yet backfilled)
673 + // falls back to its model.
674 + global $wpdb;
675 + \EasyInvoice\Services\InvoiceTotalsCache::ensure();
676 + $site_currency = strtoupper( (string) get_option( 'easy_invoice_currency_code', 'USD' ) );
677 + $rows = $wpdb->get_results( $wpdb->prepare(
678 + "SELECT p.ID, p.post_date, p.post_title,
679 + num.meta_value AS number, iss.meta_value AS issue_date, due.meta_value AS due_date,
680 + st.meta_value AS status, tot.meta_value AS total, cur.meta_value AS currency,
681 + cl.meta_value AS client_id, cn.meta_value AS customer_name
682 + FROM {$wpdb->posts} p
683 + LEFT JOIN {$wpdb->postmeta} num ON num.post_id = p.ID AND num.meta_key = '_easy_invoice_number'
684 + LEFT JOIN {$wpdb->postmeta} iss ON iss.post_id = p.ID AND iss.meta_key = '_easy_invoice_issue_date'
685 + LEFT JOIN {$wpdb->postmeta} due ON due.post_id = p.ID AND due.meta_key = '_easy_invoice_due_date'
686 + LEFT JOIN {$wpdb->postmeta} st ON st.post_id = p.ID AND st.meta_key = '_easy_invoice_status'
687 + LEFT JOIN {$wpdb->postmeta} tot ON tot.post_id = p.ID AND tot.meta_key = %s
688 + LEFT JOIN {$wpdb->postmeta} cur ON cur.post_id = p.ID AND cur.meta_key = '_easy_invoice_currency_code'
689 + LEFT JOIN {$wpdb->postmeta} cl ON cl.post_id = p.ID AND cl.meta_key = '_easy_invoice_client_id'
690 + LEFT JOIN {$wpdb->postmeta} cn ON cn.post_id = p.ID AND cn.meta_key = '_easy_invoice_customer_name'
691 + WHERE p.post_type = 'easy_invoice' AND p.post_status = 'publish'
692 + AND COALESCE(NULLIF(iss.meta_value, ''), DATE(p.post_date)) BETWEEN %s AND %s
693 + ORDER BY p.post_date DESC, p.ID DESC
694 + LIMIT 500",
695 + \EasyInvoice\Services\InvoiceTotalsCache::META_TOTAL,
696 + $start_ymd,
697 + $end_ymd
698 + ), ARRAY_A );
699 +
700 + $repository = InvoiceServiceProvider::getInvoiceRepository();
701 + foreach ( (array) $rows as $row ) {
702 + $id = (int) $row['ID'];
703 + $issue_ymd = $row['issue_date'] ? gmdate( 'Y-m-d', strtotime( $row['issue_date'] ) ) : substr( (string) $row['post_date'], 0, 10 );
704 + $due_ymd = $row['due_date'] ? gmdate( 'Y-m-d', strtotime( $row['due_date'] ) ) : '';
705 + $status = strtolower( (string) $row['status'] );
706 + switch ( $status ) {
707 + case 'paid':
708 + case 'completed':
923 709 $status_key = 'paid';
924 - } elseif ( $payment_status === 'unpaid' ) {
925 - $status_key = 'unpaid';
926 -
927 - // Check if overdue
928 - if ( ! empty( $invoice['due_date'] ) ) {
929 - $due_date = strtotime( $invoice['due_date'] );
930 - if ( $due_date && $due_date < current_time( 'timestamp' ) ) {
931 - $status_key = 'overdue';
932 - $payment_status = 'overdue';
933 - }
934 - }
935 - } elseif ( $invoice['post_status'] === 'draft' ) {
710 + break;
711 + case 'partial':
712 + case 'partially_paid':
713 + $status_key = 'partial';
714 + break;
715 + case 'draft':
936 716 $status_key = 'draft';
937 - $payment_status = 'draft';
938 - } elseif ( $payment_status === 'canceled' || $invoice['post_status'] === 'trash' ) {
717 + break;
718 + case 'cancelled':
719 + case 'canceled':
939 720 $status_key = 'canceled';
940 - $payment_status = 'canceled';
721 + break;
722 + case 'overdue':
723 + $status_key = 'overdue';
724 + break;
725 + case 'available':
726 + case 'unpaid':
727 + case 'sent':
728 + case 'pending':
729 + $status_key = ( $due_ymd && $due_ymd < $today ) ? 'overdue' : 'unpaid';
730 + break;
731 + default:
732 + $status_key = 'other';
733 + }
734 +
735 + if ( null === $row['total'] || '' === $row['total'] ) {
736 + $invoice = $repository->find( $id );
737 + $total = $invoice ? (float) $invoice->getTotal() : 0.0;
738 + } else {
739 + $total = (float) $row['total'];
740 + }
741 + $currency_code = strtoupper( (string) $row['currency'] );
742 + if ( '' === $currency_code || 'GLOBAL' === $currency_code ) {
743 + $currency_code = $site_currency;
744 + }
745 + $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol( $currency_code );
746 + $add = static function ( array &$bucket ) use ( $currency_code, $currency_symbol, $total ) {
747 + if ( ! isset( $bucket[ $currency_code ] ) ) {
748 + $bucket[ $currency_code ] = [ 'amount' => 0, 'symbol' => $currency_symbol ];
941 749 }
942 -
943 - // Track by status with currency breakdown
944 - if ( ! isset( $report_data['by_status'][$status_key]['amounts_by_currency'][$currency_code] ) ) {
945 - $report_data['by_status'][$status_key]['amounts_by_currency'][$currency_code] = [
946 - 'amount' => 0,
947 - 'symbol' => $currency_symbol
948 - ];
949 - }
950 -
951 - $report_data['by_status'][$status_key]['amounts_by_currency'][$currency_code]['amount'] += $total;
952 - $report_data['by_status'][$status_key]['count']++;
953 -
954 - // Track overall totals by currency
955 - if (!isset($report_data['total_amounts_by_currency'][$currency_code])) {
956 - $report_data['total_amounts_by_currency'][$currency_code] = [
957 - 'amount' => 0,
958 - 'symbol' => $currency_symbol
959 - ];
960 - }
961 - $report_data['total_amounts_by_currency'][$currency_code]['amount'] += $total;
962 -
963 - // Get client name if available - try multiple sources
964 - $client_name = '';
965 -
966 - // First try the customer_name fields from the query
967 - if ( ! empty( $invoice['customer_name'] ) ) {
968 - $client_name = $invoice['customer_name'];
969 - } elseif ( ! empty( $invoice['customer_name_alt'] ) ) {
970 - $client_name = $invoice['customer_name_alt'];
971 - } elseif ( ! empty( $invoice['client_name'] ) ) {
972 - $client_name = $invoice['client_name'];
973 - } elseif ( ! empty( $invoice['client_name_alt'] ) ) {
974 - $client_name = $invoice['client_name_alt'];
975 - } elseif ( ! empty( $invoice['client_id'] ) ) {
976 - // Try to get client from post
977 - $client = get_post( $invoice['client_id'] );
978 - if ( $client && $client->post_type === 'easy_invoice_client' ) {
979 - $client_name = $client->post_title;
980 - } else {
981 - // Try to get client name from meta if direct post lookup fails
982 - $client_name = get_post_meta( $invoice['client_id'], '_client_name', true );
983 - if ( empty( $client_name ) ) {
984 - // Try alternative meta key
985 - $client_name = get_post_meta( $invoice['client_id'], 'client_name', true );
986 - }
987 - if ( empty( $client_name ) ) {
988 - // Try to get client name from invoice meta
989 - $client_name = get_post_meta( $invoice['ID'], '_client_name', true );
990 - }
991 - if ( empty( $client_name ) ) {
992 - // Try customer_name from invoice meta
993 - $client_name = get_post_meta( $invoice['ID'], '_easy_invoice_customer_name', true );
994 - }
995 - if ( empty( $client_name ) ) {
996 - // Try alternative customer name meta keys
997 - $client_name = get_post_meta( $invoice['ID'], 'customer_name', true );
998 - }
999 - if ( empty( $client_name ) ) {
1000 - // Try client name from invoice meta
1001 - $client_name = get_post_meta( $invoice['ID'], '_easy_invoice_client_name', true );
1002 - }
1003 - if ( empty( $client_name ) ) {
1004 - // Try client name from invoice meta (alternative)
1005 - $client_name = get_post_meta( $invoice['ID'], 'client_name', true );
1006 - }
1007 - }
1008 - }
1009 -
1010 - // If still no client name, try to get it from the invoice post title or other sources
1011 - if ( empty( $client_name ) ) {
1012 - // Try to get from invoice post title if it contains client info
1013 - $invoice_post = get_post( $invoice['ID'] );
1014 - if ( $invoice_post && ! empty( $invoice_post->post_title ) ) {
1015 - $client_name = $invoice_post->post_title;
1016 - }
1017 - }
1018 -
1019 - // Format invoice number with fallback
1020 - $invoice_number = ! empty( $invoice['invoice_number'] ) ? $invoice['invoice_number'] : 'INV-' . $invoice['ID'];
1021 -
1022 - // Format invoice for report
1023 - $report_data['invoices'][] = [
1024 - 'id' => $invoice['ID'],
1025 - 'date' => $invoice['post_date'],
1026 - 'issue_date' => $invoice['issue_date'] ?? $invoice['post_date'],
1027 - 'due_date' => $invoice['due_date'] ?? '',
1028 - 'invoice_number' => $invoice_number,
1029 - 'total' => $total,
1030 - 'currency' => $currency_code,
1031 - 'currency_symbol' => $currency_symbol,
1032 - 'status' => $payment_status,
1033 - 'client_id' => $invoice['client_id'] ?? '',
1034 - 'client_name' => $client_name
1035 - ];
750 + $bucket[ $currency_code ]['amount'] += $total;
751 + };
752 + $add( $report_data['by_status'][ $status_key ]['amounts_by_currency'] );
753 + $add( $report_data['total_amounts_by_currency'] );
754 + $report_data['by_status'][ $status_key ]['count']++;
755 + $report_data['count']++;
756 +
757 + $client_name = trim( (string) $row['customer_name'] );
758 + if ( '' === $client_name ) {
759 + $client_name = (string) $row['post_title'];
1036 760 }
761 + $number = (string) $row['number'];
762 + $report_data['invoices'][] = [
763 + 'id' => $id,
764 + 'date' => (string) $row['post_date'],
765 + 'issue_date' => $issue_ymd,
766 + 'due_date' => $due_ymd,
767 + 'invoice_number' => '' !== $number ? $number : 'INV-' . $id,
768 + 'total' => $total,
769 + 'currency' => $currency_code,
770 + 'currency_symbol' => $currency_symbol,
771 + 'status' => $status_key,
772 + 'client_id' => (string) ( (int) $row['client_id'] ?: '' ),
773 + 'client_name' => $client_name,
774 + ];
1037 775 }
1038 -
776 +
1039 777 return $report_data;
1040 778 }
1041 779 }