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

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

779 lines 35.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Report Controller Class
4 *
5 * @package EasyInvoice
6 * @subpackage Controllers
7 * @since 1.0.0
8 */
9
10 namespace EasyInvoice\Controllers;
11
12 use EasyInvoice\Constants\PagesSlugs;
13 use EasyInvoice\Providers\InvoiceServiceProvider;
14 use EasyInvoice\Providers\ClientServiceProvider;
15
16 /**
17 * ReportController handles all reporting functionality
18 *
19 * @since 1.0.0
20 */
21 class ReportController extends BaseController {
22
23 /**
24 * Initialize the controller
25 *
26 * @since 1.0.0
27 * @return void
28 */
29 public function init() {
30 // Initialize report functionality
31 add_action( 'wp_ajax_easy_invoice_get_report_data', array( $this, 'getReportData' ) );
32
33 // Register scripts for the reports page
34 add_action( 'admin_enqueue_scripts', array( $this, 'enqueueReportScripts' ) );
35 }
36
37 /**
38 * Enqueue scripts and styles for the reports page
39 *
40 * @since 1.0.0
41 * @param string $hook The current admin page
42 * @return void
43 */
44 public function enqueueReportScripts( $hook ) {
45 if ( empty( $hook ) || strpos( $hook, PagesSlugs::REPORTS ) === false ) {
46 return;
47 }
48
49 // Register Chart.js from local copy rather than CDN
50 wp_register_script(
51 'chartjs',
52 EASY_INVOICE_PLUGIN_URL . 'assets/js/vendors/chart.min.js',
53 array(),
54 '3.9.1',
55 true
56 );
57
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
62 // Register reports script
63 wp_register_script(
64 'easy-invoice-reports',
65 EASY_INVOICE_PLUGIN_URL . 'assets/js/reports.js',
66 array( 'jquery', 'chartjs' ),
67 EASY_INVOICE_VERSION,
68 true
69 );
70
71 // Enqueue the scripts
72 wp_enqueue_script( 'chartjs' );
73 wp_enqueue_script( 'easy-invoice-reports' );
74 }
75
76 /**
77 * Display method implementation
78 *
79 * @since 1.0.0
80 * @param array $args Display arguments
81 * @return void
82 */
83 public function display( array $args = [] ) {
84 // Check if this is a premium feature
85 if ( ! easy_invoice_has_pro() ) {
86 // Show premium popup instead of reports page
87 $this->displayPremiumPopup();
88 return;
89 }
90
91 $page = $args['page'] ?? 'easy-invoice-reports';
92
93 // Get report data
94 $report_data = $this->getReportData();
95
96 // Display the reports page
97 $this->displayReportsPage( $report_data );
98 }
99
100 /**
101 * Display premium popup for reports feature
102 */
103 private function displayPremiumPopup() {
104 ?>
105 <div class="min-h-screen bg-gray-50 flex items-center justify-center p-4">
106 <div class="max-w-lg w-full bg-white rounded-lg shadow-xl overflow-hidden">
107 <!-- Header -->
108 <div class="bg-gradient-to-r from-indigo-600 to-purple-600 px-6 py-8 text-center">
109 <div class="mx-auto flex items-center justify-center h-16 w-16 rounded-full bg-white bg-opacity-20 mb-4">
110 <i class="fas fa-chart-bar text-white text-2xl"></i>
111 </div>
112 <h3 class="text-2xl font-bold text-white mb-2">Detailed Reports</h3>
113 <p class="text-indigo-100 text-sm">
114 Unlock powerful insights into your business performance
115 </p>
116 </div>
117
118 <!-- Content -->
119 <div class="px-6 py-8">
120 <div class="space-y-4 mb-8">
121 <div class="flex items-center text-sm text-gray-700">
122 <div class="flex-shrink-0 w-8 h-8 bg-green-100 rounded-full flex items-center justify-center mr-3">
123 <i class="fas fa-check text-green-600 text-xs"></i>
124 </div>
125 <span>Revenue analysis and trends</span>
126 </div>
127 <div class="flex items-center text-sm text-gray-700">
128 <div class="flex-shrink-0 w-8 h-8 bg-green-100 rounded-full flex items-center justify-center mr-3">
129 <i class="fas fa-check text-green-600 text-xs"></i>
130 </div>
131 <span>Payment statistics and status tracking</span>
132 </div>
133 <div class="flex items-center text-sm text-gray-700">
134 <div class="flex-shrink-0 w-8 h-8 bg-green-100 rounded-full flex items-center justify-center mr-3">
135 <i class="fas fa-check text-green-600 text-xs"></i>
136 </div>
137 <span>Monthly revenue charts and visualizations</span>
138 </div>
139 <div class="flex items-center text-sm text-gray-700">
140 <div class="flex-shrink-0 w-8 h-8 bg-green-100 rounded-full flex items-center justify-center mr-3">
141 <i class="fas fa-check text-green-600 text-xs"></i>
142 </div>
143 <span>Invoice status distribution analysis</span>
144 </div>
145 <div class="flex items-center text-sm text-gray-700">
146 <div class="flex-shrink-0 w-8 h-8 bg-green-100 rounded-full flex items-center justify-center mr-3">
147 <i class="fas fa-check text-green-600 text-xs"></i>
148 </div>
149 <span>Top clients and revenue analysis</span>
150 </div>
151 <div class="flex items-center text-sm text-gray-700">
152 <div class="flex-shrink-0 w-8 h-8 bg-green-100 rounded-full flex items-center justify-center mr-3">
153 <i class="fas fa-check text-green-600 text-xs"></i>
154 </div>
155 <span>Export reports to CSV, Excel, and PDF</span>
156 </div>
157 </div>
158
159 <!-- Action Buttons -->
160 <div class="space-y-3">
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">
162 <i class="fas fa-crown mr-2"></i>
163 Upgrade to Easy Invoice Pro
164 </a>
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">
166 <i class="fas fa-arrow-left mr-2"></i>
167 Back to Dashboard
168 </a>
169 </div>
170
171 <!-- Footer -->
172 <div class="mt-6 text-center">
173 <p class="text-xs text-gray-500">
174 Get comprehensive insights into your business with advanced reporting features
175 </p>
176 </div>
177 </div>
178 </div>
179 </div>
180 <?php
181 }
182
183 /**
184 * Display reports page
185 *
186 * @since 1.0.0
187 * @return void
188 */
189 protected function displayReportsPage( $report_data = [] ) {
190 // Check user capability
191 $error = $this->checkCapability('ei_view_reports');
192 if ( is_wp_error( $error ) ) {
193 wp_die(esc_html($error->get_error_message()));
194 }
195
196 // Get date range filters
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');
199
200 // Get repositories
201 $invoice_repository = InvoiceServiceProvider::getInvoiceRepository();
202 $client_repository = ClientServiceProvider::getClientRepository();
203
204 // Get data for reports
205 $summary_stats = $this->getSummaryStats( $invoice_repository, $client_repository, $start_date, $end_date );
206 $monthly_revenue = $this->getMonthlySummary( $invoice_repository, $start_date, $end_date );
207 $invoice_status = $this->getInvoiceStatusSummary( $invoice_repository, $start_date, $end_date );
208 $top_clients = $this->getTopClients( $invoice_repository, $client_repository, $start_date, $end_date );
209
210 // Generate payment and invoice reports
211 $payment_report = $this->getPaymentReport( $start_date, $end_date );
212 $invoice_report = $this->getInvoiceReport( $start_date, $end_date );
213
214 // Prepare data for JavaScript
215 $reports_data = array(
216 'monthly_revenue' => $monthly_revenue,
217 'invoice_status' => $invoice_status,
218 'payment_report' => $payment_report,
219 'invoice_report' => $invoice_report,
220 'start_date' => $start_date,
221 'end_date' => $end_date,
222 'ajax_url' => admin_url( 'admin-ajax.php' ),
223 'nonce' => wp_create_nonce( 'easy_invoice_reports_nonce' )
224 );
225
226 // Localize the script with data
227 wp_localize_script( 'easy-invoice-reports', 'easy_invoice_reports', $reports_data );
228
229 // Display the template
230 $this->displayTemplate(
231 EASY_INVOICE_PLUGIN_DIR . 'templates/reports-page.php',
232 [
233 'start_date' => $start_date,
234 'end_date' => $end_date,
235 'summary_stats' => $summary_stats,
236 'monthly_revenue' => $monthly_revenue,
237 'invoice_status' => $invoice_status,
238 'top_clients' => $top_clients,
239 'payment_report' => $payment_report,
240 'invoice_report' => $invoice_report
241 ]
242 );
243 }
244
245 /**
246 * Get report data via AJAX
247 *
248 * @since 1.0.0
249 * @return void
250 */
251 public function getReportData() {
252 // Only handle AJAX requests
253 if ( ! wp_doing_ajax() ) {
254 return;
255 }
256
257 // Check nonce
258 if ( ! isset( $_POST['nonce'] ) || ! $this->handleAjaxSecurity( $_POST['nonce'] ) ) {
259 wp_send_json_error( array( 'message' => 'Security verification failed. Please refresh the page and try again.', 'code' => 'invalid_nonce' ) );
260 return;
261 }
262
263 $report_type = isset( $_POST['report_type'] ) ? sanitize_text_field( $_POST['report_type'] ) : '';
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');
266
267 // Get repositories
268 $invoice_repository = InvoiceServiceProvider::getInvoiceRepository();
269 $client_repository = ClientServiceProvider::getClientRepository();
270
271 $response = array();
272
273 switch ( $report_type ) {
274 case 'monthly_revenue':
275 $response = $this->getMonthlySummary( $invoice_repository, $start_date, $end_date );
276 break;
277
278 case 'invoice_status':
279 $response = $this->getInvoiceStatusSummary( $invoice_repository, $start_date, $end_date );
280 break;
281
282 case 'top_clients':
283 $response = $this->getTopClients( $invoice_repository, $client_repository, $start_date, $end_date );
284 break;
285
286 case 'summary_stats':
287 $response = $this->getSummaryStats( $invoice_repository, $client_repository, $start_date, $end_date );
288 break;
289
290 case 'payment_report':
291 $response = $this->getPaymentReport( $start_date, $end_date );
292 break;
293
294 case 'invoice_report':
295 $response = $this->getInvoiceReport( $start_date, $end_date );
296 break;
297
298 default:
299 $response = array( 'error' => 'Invalid report type' );
300 break;
301 }
302
303 wp_send_json_success( $response );
304 }
305
306 /**
307 * Get summary statistics
308 *
309 * @since 1.0.0
310 * @param object $invoice_repository Invoice repository
311 * @param object $client_repository Client repository
312 * @param string $start_date Start date
313 * @param string $end_date End date
314 * @return array Summary statistics
315 */
316 private function getSummaryStats( $invoice_repository, $client_repository, $start_date = '', $end_date = '' ) {
317 try {
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
338 return [
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,
343 ];
344 } catch ( \Exception $e ) {
345 return [
346 'total_revenue' => [],
347 'total_invoices' => 0,
348 'active_clients' => 0,
349 'avg_payment_time' => null,
350 ];
351 }
352 }
353
354 /**
355 * Get monthly summary data
356 *
357 * @since 1.0.0
358 * @param object $invoice_repository Invoice repository
359 * @param string $start_date Optional start date
360 * @param string $end_date Optional end date
361 * @return array Monthly revenue data
362 */
363 private function getMonthlySummary( $invoice_repository, $start_date = '', $end_date = '' ) {
364 try {
365 // Calculate date range for the last 12 months
366 $current_date = new \DateTime();
367 $start_date = clone $current_date;
368 $start_date->modify('-11 months');
369 $start_date->setTime(0, 0, 0);
370
371 // Initialize monthly revenue array
372 $monthly_revenue = [];
373
374 // Initialize all months in the range
375 for ($i = 0; $i < 12; $i++) {
376 $month_date = clone $start_date;
377 $month_date->modify("+$i months");
378 $month_key = $month_date->format('M Y');
379 $monthly_revenue[$month_key] = [];
380 }
381
382 // Query payments for the date range
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
405 }
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'];
413 }
414
415 return $monthly_revenue;
416 } catch ( \Exception $e ) {
417 return [];
418 }
419 }
420
421 /**
422 * Get invoice status summary
423 *
424 * @since 1.0.0
425 * @param object $invoice_repository Invoice repository
426 * @param string $start_date Optional start date
427 * @param string $end_date Optional end date
428 * @return array Invoice status data
429 */
430 private function getInvoiceStatusSummary( $invoice_repository, $start_date = '', $end_date = '' ) {
431 try {
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 ];
437 }
438 }
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;
443 }
444 return [
445 'percentages' => $percentages,
446 'counts' => $status_counts,
447 ];
448 } catch ( \Exception $e ) {
449 return [
450 'percentages' => [],
451 'counts' => [],
452 ];
453 }
454 }
455
456 /**
457 * Get top clients by revenue
458 *
459 * @since 1.0.0
460 * @param object $invoice_repository Invoice repository
461 * @param object $client_repository Client repository
462 * @param string $start_date Optional start date
463 * @param string $end_date Optional end date
464 * @return array Top clients data
465 */
466 private function getTopClients( $invoice_repository, $client_repository, $start_date = '', $end_date = '' ) {
467 try {
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'],
483 ];
484 }
485 return $out;
486 } catch ( \Exception $e ) {
487 return [];
488 }
489 }
490
491 /**
492 * Get payment report data
493 *
494 * @since 1.0.0
495 * @param string $start_date Start date for report
496 * @param string $end_date End date for report
497 * @return array Payment report data
498 */
499 private function getPaymentReport( $start_date = '', $end_date = '' ) {
500 global $wpdb;
501
502 $payment_table = $wpdb->prefix . 'posts';
503 $payment_meta_table = $wpdb->prefix . 'postmeta';
504
505 $start = $start_date ? strtotime( $start_date ) : strtotime( '-1 year' );
506 $end = $end_date ? strtotime( $end_date ) : current_time( 'timestamp' );
507
508 // Format dates for SQL query
509 $start_date_formatted = gmdate( 'Y-m-d 00:00:00', $start );
510 $end_date_formatted = gmdate( 'Y-m-d 23:59:59', $end );
511
512 // Query to get payments within date range
513 $query = $wpdb->prepare(
514 "SELECT p.ID, p.post_date,
515 MAX(CASE WHEN pm.meta_key = '_invoice_id' THEN pm.meta_value ELSE NULL END) as invoice_id,
516 MAX(CASE WHEN pm.meta_key = '_amount' THEN pm.meta_value ELSE NULL END) as amount,
517 MAX(CASE WHEN pm.meta_key = '_payment_method' THEN pm.meta_value ELSE NULL END) as payment_method,
518 MAX(CASE WHEN pm.meta_key = '_status' THEN pm.meta_value ELSE NULL END) as status,
519 MAX(CASE WHEN pm.meta_key = '_transaction_id' THEN pm.meta_value ELSE NULL END) as transaction_id,
520 MAX(CASE WHEN pm.meta_key = '_currency' THEN pm.meta_value ELSE NULL END) as currency,
521 MAX(CASE WHEN pm.meta_key = '_currency_symbol' THEN pm.meta_value ELSE NULL END) as currency_symbol
522 FROM $payment_table p
523 LEFT JOIN $payment_meta_table pm ON p.ID = pm.post_id
524 WHERE p.post_type = %s
525 AND p.post_date BETWEEN %s AND %s
526 GROUP BY p.ID
527 ORDER BY p.post_date DESC
528 LIMIT 100",
529 'easy_invoice_payment',
530 $start_date_formatted,
531 $end_date_formatted
532 );
533
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.
535
536 $report_data = [
537 'payments' => [],
538 'total_amount' => 0,
539 'count' => 0,
540 'by_method' => [],
541 'by_status' => []
542 ];
543
544 if ( $payments ) {
545 foreach ( $payments as $payment ) {
546 $payment_method = $payment['payment_method'] ?? 'unknown';
547 $status = $payment['status'] ?? 'unknown';
548 $amount = floatval( $payment['amount'] ?? 0 );
549 $currency = $payment['currency'] ?? 'USD';
550 $currency_symbol = $payment['currency_symbol'] ?? '$';
551
552 // Normalize currency code to uppercase for consistent grouping
553 $currency = strtoupper($currency);
554
555 // Handle "global" currency by getting the actual global setting
556 if (empty($currency) || strtolower($currency) === 'global') {
557 $currency = get_option('easy_invoice_currency_code', 'USD');
558 }
559
560 // Additional check: if currency is still "global" after replacement, use the global setting
561 if (strtolower($currency) === 'global') {
562 $currency = get_option('easy_invoice_currency_code', 'USD');
563 }
564
565 // Get proper currency symbol
566 $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency);
567
568 // Add to total (we'll handle multi-currency totals separately)
569 $report_data['count']++;
570
571 // Track by payment method with currency breakdown
572 if ( ! isset( $report_data['by_method'][$payment_method] ) ) {
573 $report_data['by_method'][$payment_method] = [
574 'count' => 0,
575 'amounts_by_currency' => []
576 ];
577 }
578
579 // Initialize currency for this payment method if not exists
580 if ( ! isset( $report_data['by_method'][$payment_method]['amounts_by_currency'][$currency] ) ) {
581 $report_data['by_method'][$payment_method]['amounts_by_currency'][$currency] = [
582 'amount' => 0,
583 'symbol' => $currency_symbol
584 ];
585 }
586
587 $report_data['by_method'][$payment_method]['amounts_by_currency'][$currency]['amount'] += $amount;
588 $report_data['by_method'][$payment_method]['count']++;
589
590 // Track by status with currency breakdown
591 if ( ! isset( $report_data['by_status'][$status] ) ) {
592 $report_data['by_status'][$status] = [
593 'count' => 0,
594 'amounts_by_currency' => []
595 ];
596 }
597
598 // Initialize currency for this status if not exists
599 if ( ! isset( $report_data['by_status'][$status]['amounts_by_currency'][$currency] ) ) {
600 $report_data['by_status'][$status]['amounts_by_currency'][$currency] = [
601 'amount' => 0,
602 'symbol' => $currency_symbol
603 ];
604 }
605
606 $report_data['by_status'][$status]['amounts_by_currency'][$currency]['amount'] += $amount;
607 $report_data['by_status'][$status]['count']++;
608
609 // Get invoice number if available
610 $invoice_number = '';
611 if ( ! empty( $payment['invoice_id'] ) ) {
612 $invoice_number = get_post_meta( $payment['invoice_id'], '_easy_invoice_number', true );
613
614 // Provide a fallback format if no invoice number is found
615 if ( empty( $invoice_number ) ) {
616 $invoice_number = 'INV-' . $payment['invoice_id'];
617 }
618 }
619
620 // Format payment for report
621 $report_data['payments'][] = [
622 'id' => $payment['ID'],
623 'date' => $payment['post_date'],
624 'invoice_id' => $payment['invoice_id'],
625 'invoice_number' => $invoice_number ?: '#' . $payment['invoice_id'],
626 'amount' => $amount,
627 'payment_method' => $payment_method,
628 'status' => $status,
629 'transaction_id' => $payment['transaction_id'] ?? '',
630 'currency' => $currency,
631 'currency_symbol' => $currency_symbol
632 ];
633 }
634 }
635
636 return $report_data;
637 }
638
639 /**
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.
643 *
644 * @since 1.0.0
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
648 */
649 private function getInvoiceReport( $start_date = '', $end_date = '' ) {
650 $start = $start_date ? strtotime( $start_date ) : strtotime( '-1 year' );
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
656 $report_data = [
657 'invoices' => [],
658 'count' => 0,
659 'total_amounts_by_currency' => [],
660 'by_status' => [
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' => []],
666 'canceled' => ['count' => 0, 'amounts_by_currency' => []],
667 'other' => ['count' => 0, 'amounts_by_currency' => []],
668 ],
669 ];
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':
709 $status_key = 'paid';
710 break;
711 case 'partial':
712 case 'partially_paid':
713 $status_key = 'partial';
714 break;
715 case 'draft':
716 $status_key = 'draft';
717 break;
718 case 'cancelled':
719 case 'canceled':
720 $status_key = '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 ];
749 }
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'];
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 ];
775 }
776
777 return $report_data;
778 }
779 }