injection here is not permitted // even as a fallback path. The local copy above is the only source. // Register reports script wp_register_script( 'easy-invoice-reports', EASY_INVOICE_PLUGIN_URL . 'assets/js/reports.js', array( 'jquery', 'chartjs' ), EASY_INVOICE_VERSION, true ); // Enqueue the scripts wp_enqueue_script( 'chartjs' ); wp_enqueue_script( 'easy-invoice-reports' ); } /** * Display method implementation * * @since 1.0.0 * @param array $args Display arguments * @return void */ public function display( array $args = [] ) { // Check if this is a premium feature if ( ! easy_invoice_has_pro() ) { // Show premium popup instead of reports page $this->displayPremiumPopup(); return; } $page = $args['page'] ?? 'easy-invoice-reports'; // Get report data $report_data = $this->getReportData(); // Display the reports page $this->displayReportsPage( $report_data ); } /** * Display premium popup for reports feature */ private function displayPremiumPopup() { ?>

Detailed Reports

Unlock powerful insights into your business performance

Revenue analysis and trends
Payment statistics and status tracking
Monthly revenue charts and visualizations
Invoice status distribution analysis
Top clients and revenue analysis
Export reports to CSV, Excel, and PDF

Get comprehensive insights into your business with advanced reporting features

checkCapability('ei_view_reports'); if ( is_wp_error( $error ) ) { wp_die(esc_html($error->get_error_message())); } // Get date range filters $start_date = isset( $_GET['start_date'] ) ? sanitize_text_field( $_GET['start_date'] ) : wp_date('Y-m-d', strtotime('-30 days')); $end_date = isset( $_GET['end_date'] ) ? sanitize_text_field( $_GET['end_date'] ) : current_time('Y-m-d'); // Get repositories $invoice_repository = InvoiceServiceProvider::getInvoiceRepository(); $client_repository = ClientServiceProvider::getClientRepository(); // Get data for reports $summary_stats = $this->getSummaryStats( $invoice_repository, $client_repository, $start_date, $end_date ); $monthly_revenue = $this->getMonthlySummary( $invoice_repository, $start_date, $end_date ); $invoice_status = $this->getInvoiceStatusSummary( $invoice_repository, $start_date, $end_date ); $top_clients = $this->getTopClients( $invoice_repository, $client_repository, $start_date, $end_date ); // Generate payment and invoice reports $payment_report = $this->getPaymentReport( $start_date, $end_date ); $invoice_report = $this->getInvoiceReport( $start_date, $end_date ); // Prepare data for JavaScript $reports_data = array( 'monthly_revenue' => $monthly_revenue, 'invoice_status' => $invoice_status, 'payment_report' => $payment_report, 'invoice_report' => $invoice_report, 'start_date' => $start_date, 'end_date' => $end_date, 'ajax_url' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'easy_invoice_reports_nonce' ) ); // Localize the script with data wp_localize_script( 'easy-invoice-reports', 'easy_invoice_reports', $reports_data ); // Display the template $this->displayTemplate( EASY_INVOICE_PLUGIN_DIR . 'templates/reports-page.php', [ 'start_date' => $start_date, 'end_date' => $end_date, 'summary_stats' => $summary_stats, 'monthly_revenue' => $monthly_revenue, 'invoice_status' => $invoice_status, 'top_clients' => $top_clients, 'payment_report' => $payment_report, 'invoice_report' => $invoice_report ] ); } /** * Get report data via AJAX * * @since 1.0.0 * @return void */ public function getReportData() { // Only handle AJAX requests if ( ! wp_doing_ajax() ) { return; } // Check nonce if ( ! isset( $_POST['nonce'] ) || ! $this->handleAjaxSecurity( $_POST['nonce'] ) ) { wp_send_json_error( array( 'message' => 'Security verification failed. Please refresh the page and try again.', 'code' => 'invalid_nonce' ) ); return; } $report_type = isset( $_POST['report_type'] ) ? sanitize_text_field( $_POST['report_type'] ) : ''; $start_date = isset( $_POST['start_date'] ) ? sanitize_text_field( $_POST['start_date'] ) : wp_date('Y-m-d', strtotime('-30 days')); $end_date = isset( $_POST['end_date'] ) ? sanitize_text_field( $_POST['end_date'] ) : current_time('Y-m-d'); // Get repositories $invoice_repository = InvoiceServiceProvider::getInvoiceRepository(); $client_repository = ClientServiceProvider::getClientRepository(); $response = array(); switch ( $report_type ) { case 'monthly_revenue': $response = $this->getMonthlySummary( $invoice_repository, $start_date, $end_date ); break; case 'invoice_status': $response = $this->getInvoiceStatusSummary( $invoice_repository, $start_date, $end_date ); break; case 'top_clients': $response = $this->getTopClients( $invoice_repository, $client_repository, $start_date, $end_date ); break; case 'summary_stats': $response = $this->getSummaryStats( $invoice_repository, $client_repository, $start_date, $end_date ); break; case 'payment_report': $response = $this->getPaymentReport( $start_date, $end_date ); break; case 'invoice_report': $response = $this->getInvoiceReport( $start_date, $end_date ); break; default: $response = array( 'error' => 'Invalid report type' ); break; } wp_send_json_success( $response ); } /** * Get summary statistics * * @since 1.0.0 * @param object $invoice_repository Invoice repository * @param object $client_repository Client repository * @param string $start_date Start date * @param string $end_date End date * @return array Summary statistics */ private function getSummaryStats( $invoice_repository, $client_repository, $start_date = '', $end_date = '' ) { try { // Revenue, counts and billed clients are SQL over the persisted totals // (InvoiceTotalsCache) — no models for a 10,000-invoice range. $paid = \EasyInvoice\Services\InvoiceTotalsCache::paidRevenue( (string) $start_date, (string) $end_date ); // Average days from issue to payment over the completed payments in the range. global $wpdb; $where = ''; $args = []; if ( '' !== (string) $start_date ) { $where .= ' AND pd.meta_value >= %s'; $args[] = (string) $start_date; } if ( '' !== (string) $end_date ) { $where .= ' AND pd.meta_value <= %s'; $args[] = (string) $end_date . ' 23:59:59'; } $sql = "SELECT AVG(DATEDIFF(pd.meta_value, iss.meta_value)) AS days FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} st ON st.post_id = p.ID AND st.meta_key = '_status' AND st.meta_value = 'completed' INNER JOIN {$wpdb->postmeta} pd ON pd.post_id = p.ID AND pd.meta_key = '_payment_date' AND pd.meta_value <> '' INNER JOIN {$wpdb->postmeta} inv ON inv.post_id = p.ID AND inv.meta_key = '_invoice_id' INNER JOIN {$wpdb->postmeta} iss ON iss.post_id = inv.meta_value AND iss.meta_key = '_easy_invoice_issue_date' AND iss.meta_value <> '' WHERE p.post_type = 'easy_invoice_payment' AND p.post_status = 'publish' AND DATEDIFF(pd.meta_value, iss.meta_value) >= 0 {$where}"; $days = $wpdb->get_var( $args ? $wpdb->prepare( $sql, ...$args ) : $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared $avg_payment_time = null === $days ? null : (int) round( (float) $days ); return [ 'total_revenue' => $paid['revenue'], 'total_invoices' => (int) $paid['invoice_count'], 'active_clients' => (int) $paid['client_count'], 'avg_payment_time' => $avg_payment_time, ]; } catch ( \Exception $e ) { return [ 'total_revenue' => [], 'total_invoices' => 0, 'active_clients' => 0, 'avg_payment_time' => null, ]; } } /** * Get monthly summary data * * @since 1.0.0 * @param object $invoice_repository Invoice repository * @param string $start_date Optional start date * @param string $end_date Optional end date * @return array Monthly revenue data */ private function getMonthlySummary( $invoice_repository, $start_date = '', $end_date = '' ) { try { // Calculate date range for the last 12 months $current_date = new \DateTime(); $start_date = clone $current_date; $start_date->modify('-11 months'); $start_date->setTime(0, 0, 0); // Initialize monthly revenue array $monthly_revenue = []; // Initialize all months in the range for ($i = 0; $i < 12; $i++) { $month_date = clone $start_date; $month_date->modify("+$i months"); $month_key = $month_date->format('M Y'); $monthly_revenue[$month_key] = []; } // Query payments for the date range // One grouped query over the payments; loading them all as posts // (thousands on a busy store) cost ~0.3 s and 100 MB per report view. global $wpdb; $site_currency = strtoupper((string) get_option('easy_invoice_currency_code', 'USD')); $rows = $wpdb->get_results($wpdb->prepare( "SELECT DATE_FORMAT(p.post_date, '%%Y-%%m') AS ym, UPPER(COALESCE(NULLIF(NULLIF(cur.meta_value, ''), 'global'), %s)) AS currency, SUM(CAST(a.meta_value AS DECIMAL(18,4))) AS amount FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} st ON st.post_id = p.ID AND st.meta_key = '_status' AND st.meta_value IN ('completed', 'approved', 'paid') 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 LEFT JOIN {$wpdb->postmeta} cur ON cur.post_id = p.ID AND cur.meta_key = '_currency' WHERE p.post_type = 'easy_invoice_payment' AND p.post_status = 'publish' AND p.post_date >= %s GROUP BY ym, currency", $site_currency, $start_date->format('Y-m-d 00:00:00') ), ARRAY_A); foreach ((array) $rows as $row) { $month_key = (new \DateTime($row['ym'] . '-01'))->format('M Y'); $currency = (string) $row['currency']; if (!isset($monthly_revenue[$month_key])) { continue; // outside the 12 shown months } if (!isset($monthly_revenue[$month_key][$currency])) { $monthly_revenue[$month_key][$currency] = [ 'amount' => 0, 'symbol' => \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency) ]; } $monthly_revenue[$month_key][$currency]['amount'] += (float) $row['amount']; } return $monthly_revenue; } catch ( \Exception $e ) { return []; } } /** * Get invoice status summary * * @since 1.0.0 * @param object $invoice_repository Invoice repository * @param string $start_date Optional start date * @param string $end_date Optional end date * @return array Invoice status data */ private function getInvoiceStatusSummary( $invoice_repository, $start_date = '', $end_date = '' ) { try { $counts = \EasyInvoice\Services\InvoiceTotalsCache::statusCounts( (string) $start_date, (string) $end_date ); $status_counts = []; foreach ( [ 'paid', 'partial', 'unpaid', 'overdue', 'draft', 'canceled' ] as $key ) { if ( ! empty( $counts[ $key ] ) ) { $status_counts[ $key ] = (int) $counts[ $key ]; } } $total_invoices = array_sum( $status_counts ); $percentages = []; foreach ( $status_counts as $status => $count ) { $percentages[ $status ] = $total_invoices > 0 ? round( ( $count / $total_invoices ) * 100 ) : 0; } return [ 'percentages' => $percentages, 'counts' => $status_counts, ]; } catch ( \Exception $e ) { return [ 'percentages' => [], 'counts' => [], ]; } } /** * Get top clients by revenue * * @since 1.0.0 * @param object $invoice_repository Invoice repository * @param object $client_repository Client repository * @param string $start_date Optional start date * @param string $end_date Optional end date * @return array Top clients data */ private function getTopClients( $invoice_repository, $client_repository, $start_date = '', $end_date = '' ) { try { $ranked = \EasyInvoice\Services\InvoiceTotalsCache::topClients( (string) $start_date, (string) $end_date, 10 ); $out = []; foreach ( $ranked as $client_id => $row ) { $client = $client_repository->find( (int) $client_id ); if ( ! $client ) { continue; } $person = trim( (string) $client->getFirstName() . ' ' . (string) $client->getLastName() ); $out[ (int) $client_id ] = [ 'id' => (int) $client_id, 'name' => (string) $client->getBusinessClientName() ?: ( $person ?: (string) $client->getUsername() ), 'email' => $client->getEmail(), 'total_amount' => $row['total_amount'], 'total_invoices' => (int) $row['total_invoices'], 'last_invoice' => (string) $row['last_invoice'], ]; } return $out; } catch ( \Exception $e ) { return []; } } /** * Get payment report data * * @since 1.0.0 * @param string $start_date Start date for report * @param string $end_date End date for report * @return array Payment report data */ private function getPaymentReport( $start_date = '', $end_date = '' ) { global $wpdb; $payment_table = $wpdb->prefix . 'posts'; $payment_meta_table = $wpdb->prefix . 'postmeta'; $start = $start_date ? strtotime( $start_date ) : strtotime( '-1 year' ); $end = $end_date ? strtotime( $end_date ) : current_time( 'timestamp' ); // Format dates for SQL query $start_date_formatted = gmdate( 'Y-m-d 00:00:00', $start ); $end_date_formatted = gmdate( 'Y-m-d 23:59:59', $end ); // Query to get payments within date range $query = $wpdb->prepare( "SELECT p.ID, p.post_date, MAX(CASE WHEN pm.meta_key = '_invoice_id' THEN pm.meta_value ELSE NULL END) as invoice_id, MAX(CASE WHEN pm.meta_key = '_amount' THEN pm.meta_value ELSE NULL END) as amount, MAX(CASE WHEN pm.meta_key = '_payment_method' THEN pm.meta_value ELSE NULL END) as payment_method, MAX(CASE WHEN pm.meta_key = '_status' THEN pm.meta_value ELSE NULL END) as status, MAX(CASE WHEN pm.meta_key = '_transaction_id' THEN pm.meta_value ELSE NULL END) as transaction_id, MAX(CASE WHEN pm.meta_key = '_currency' THEN pm.meta_value ELSE NULL END) as currency, MAX(CASE WHEN pm.meta_key = '_currency_symbol' THEN pm.meta_value ELSE NULL END) as currency_symbol FROM $payment_table p LEFT JOIN $payment_meta_table pm ON p.ID = pm.post_id WHERE p.post_type = %s AND p.post_date BETWEEN %s AND %s GROUP BY p.ID ORDER BY p.post_date DESC LIMIT 100", 'easy_invoice_payment', $start_date_formatted, $end_date_formatted ); $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. $report_data = [ 'payments' => [], 'total_amount' => 0, 'count' => 0, 'by_method' => [], 'by_status' => [] ]; if ( $payments ) { foreach ( $payments as $payment ) { $payment_method = $payment['payment_method'] ?? 'unknown'; $status = $payment['status'] ?? 'unknown'; $amount = floatval( $payment['amount'] ?? 0 ); $currency = $payment['currency'] ?? 'USD'; $currency_symbol = $payment['currency_symbol'] ?? '$'; // Normalize currency code to uppercase for consistent grouping $currency = strtoupper($currency); // Handle "global" currency by getting the actual global setting if (empty($currency) || strtolower($currency) === 'global') { $currency = get_option('easy_invoice_currency_code', 'USD'); } // Additional check: if currency is still "global" after replacement, use the global setting if (strtolower($currency) === 'global') { $currency = get_option('easy_invoice_currency_code', 'USD'); } // Get proper currency symbol $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency); // Add to total (we'll handle multi-currency totals separately) $report_data['count']++; // Track by payment method with currency breakdown if ( ! isset( $report_data['by_method'][$payment_method] ) ) { $report_data['by_method'][$payment_method] = [ 'count' => 0, 'amounts_by_currency' => [] ]; } // Initialize currency for this payment method if not exists if ( ! isset( $report_data['by_method'][$payment_method]['amounts_by_currency'][$currency] ) ) { $report_data['by_method'][$payment_method]['amounts_by_currency'][$currency] = [ 'amount' => 0, 'symbol' => $currency_symbol ]; } $report_data['by_method'][$payment_method]['amounts_by_currency'][$currency]['amount'] += $amount; $report_data['by_method'][$payment_method]['count']++; // Track by status with currency breakdown if ( ! isset( $report_data['by_status'][$status] ) ) { $report_data['by_status'][$status] = [ 'count' => 0, 'amounts_by_currency' => [] ]; } // Initialize currency for this status if not exists if ( ! isset( $report_data['by_status'][$status]['amounts_by_currency'][$currency] ) ) { $report_data['by_status'][$status]['amounts_by_currency'][$currency] = [ 'amount' => 0, 'symbol' => $currency_symbol ]; } $report_data['by_status'][$status]['amounts_by_currency'][$currency]['amount'] += $amount; $report_data['by_status'][$status]['count']++; // Get invoice number if available $invoice_number = ''; if ( ! empty( $payment['invoice_id'] ) ) { $invoice_number = get_post_meta( $payment['invoice_id'], '_easy_invoice_number', true ); // Provide a fallback format if no invoice number is found if ( empty( $invoice_number ) ) { $invoice_number = 'INV-' . $payment['invoice_id']; } } // Format payment for report $report_data['payments'][] = [ 'id' => $payment['ID'], 'date' => $payment['post_date'], 'invoice_id' => $payment['invoice_id'], 'invoice_number' => $invoice_number ?: '#' . $payment['invoice_id'], 'amount' => $amount, 'payment_method' => $payment_method, 'status' => $status, 'transaction_id' => $payment['transaction_id'] ?? '', 'currency' => $currency, 'currency_symbol' => $currency_symbol ]; } } return $report_data; } /** * Invoice report for the period: every live invoice issued between the * two dates, its real total (computed from its items, the way the invoice * itself shows it) and its Easy Invoice status. * * @since 1.0.0 * @param string $start_date Y-m-d, defaults to a year ago. * @param string $end_date Y-m-d, defaults to today. * @return array */ private function getInvoiceReport( $start_date = '', $end_date = '' ) { $start = $start_date ? strtotime( $start_date ) : strtotime( '-1 year' ); $end = $end_date ? strtotime( $end_date ) : current_time( 'timestamp' ); $start_ymd = gmdate( 'Y-m-d', $start ); $end_ymd = gmdate( 'Y-m-d', $end ); $today = gmdate( 'Y-m-d', current_time( 'timestamp' ) ); $report_data = [ 'invoices' => [], 'count' => 0, 'total_amounts_by_currency' => [], 'by_status' => [ 'paid' => ['count' => 0, 'amounts_by_currency' => []], 'partial' => ['count' => 0, 'amounts_by_currency' => []], 'unpaid' => ['count' => 0, 'amounts_by_currency' => []], 'overdue' => ['count' => 0, 'amounts_by_currency' => []], 'draft' => ['count' => 0, 'amounts_by_currency' => []], 'canceled' => ['count' => 0, 'amounts_by_currency' => []], 'other' => ['count' => 0, 'amounts_by_currency' => []], ], ]; // One query over the persisted totals for the 500 most recent invoices // issued in the range; a row without a cached total (not yet backfilled) // falls back to its model. global $wpdb; \EasyInvoice\Services\InvoiceTotalsCache::ensure(); $site_currency = strtoupper( (string) get_option( 'easy_invoice_currency_code', 'USD' ) ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT p.ID, p.post_date, p.post_title, num.meta_value AS number, iss.meta_value AS issue_date, due.meta_value AS due_date, st.meta_value AS status, tot.meta_value AS total, cur.meta_value AS currency, cl.meta_value AS client_id, cn.meta_value AS customer_name FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} num ON num.post_id = p.ID AND num.meta_key = '_easy_invoice_number' LEFT JOIN {$wpdb->postmeta} iss ON iss.post_id = p.ID AND iss.meta_key = '_easy_invoice_issue_date' LEFT JOIN {$wpdb->postmeta} due ON due.post_id = p.ID AND due.meta_key = '_easy_invoice_due_date' LEFT JOIN {$wpdb->postmeta} st ON st.post_id = p.ID AND st.meta_key = '_easy_invoice_status' LEFT JOIN {$wpdb->postmeta} tot ON tot.post_id = p.ID AND tot.meta_key = %s LEFT JOIN {$wpdb->postmeta} cur ON cur.post_id = p.ID AND cur.meta_key = '_easy_invoice_currency_code' LEFT JOIN {$wpdb->postmeta} cl ON cl.post_id = p.ID AND cl.meta_key = '_easy_invoice_client_id' LEFT JOIN {$wpdb->postmeta} cn ON cn.post_id = p.ID AND cn.meta_key = '_easy_invoice_customer_name' WHERE p.post_type = 'easy_invoice' AND p.post_status = 'publish' AND COALESCE(NULLIF(iss.meta_value, ''), DATE(p.post_date)) BETWEEN %s AND %s ORDER BY p.post_date DESC, p.ID DESC LIMIT 500", \EasyInvoice\Services\InvoiceTotalsCache::META_TOTAL, $start_ymd, $end_ymd ), ARRAY_A ); $repository = InvoiceServiceProvider::getInvoiceRepository(); foreach ( (array) $rows as $row ) { $id = (int) $row['ID']; $issue_ymd = $row['issue_date'] ? gmdate( 'Y-m-d', strtotime( $row['issue_date'] ) ) : substr( (string) $row['post_date'], 0, 10 ); $due_ymd = $row['due_date'] ? gmdate( 'Y-m-d', strtotime( $row['due_date'] ) ) : ''; $status = strtolower( (string) $row['status'] ); switch ( $status ) { case 'paid': case 'completed': $status_key = 'paid'; break; case 'partial': case 'partially_paid': $status_key = 'partial'; break; case 'draft': $status_key = 'draft'; break; case 'cancelled': case 'canceled': $status_key = 'canceled'; break; case 'overdue': $status_key = 'overdue'; break; case 'available': case 'unpaid': case 'sent': case 'pending': $status_key = ( $due_ymd && $due_ymd < $today ) ? 'overdue' : 'unpaid'; break; default: $status_key = 'other'; } if ( null === $row['total'] || '' === $row['total'] ) { $invoice = $repository->find( $id ); $total = $invoice ? (float) $invoice->getTotal() : 0.0; } else { $total = (float) $row['total']; } $currency_code = strtoupper( (string) $row['currency'] ); if ( '' === $currency_code || 'GLOBAL' === $currency_code ) { $currency_code = $site_currency; } $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol( $currency_code ); $add = static function ( array &$bucket ) use ( $currency_code, $currency_symbol, $total ) { if ( ! isset( $bucket[ $currency_code ] ) ) { $bucket[ $currency_code ] = [ 'amount' => 0, 'symbol' => $currency_symbol ]; } $bucket[ $currency_code ]['amount'] += $total; }; $add( $report_data['by_status'][ $status_key ]['amounts_by_currency'] ); $add( $report_data['total_amounts_by_currency'] ); $report_data['by_status'][ $status_key ]['count']++; $report_data['count']++; $client_name = trim( (string) $row['customer_name'] ); if ( '' === $client_name ) { $client_name = (string) $row['post_title']; } $number = (string) $row['number']; $report_data['invoices'][] = [ 'id' => $id, 'date' => (string) $row['post_date'], 'issue_date' => $issue_ymd, 'due_date' => $due_ymd, 'invoice_number' => '' !== $number ? $number : 'INV-' . $id, 'total' => $total, 'currency' => $currency_code, 'currency_symbol' => $currency_symbol, 'status' => $status_key, 'client_id' => (string) ( (int) $row['client_id'] ?: '' ), 'client_name' => $client_name, ]; } return $report_data; } }