| @@ -47,11 +47,11 @@ | ||
| 47 | 47 | * Display dashboard page |
| 48 | 48 | */ |
| 49 | 49 | protected function displayDashboardPage() { |
| 50 | 50 | // Check user capability |
| 51 | - $error = $this->checkCapability(); | |
| 51 | + $error = $this->checkCapability('ei_view_dashboard'); | |
| 52 | 52 | if (is_wp_error($error)) { |
| 53 | - wp_die($error); | |
| 53 | + wp_die(esc_html($error->get_error_message())); | |
| 54 | 54 | } |
| 55 | 55 | |
| 56 | 56 | // Get data for dashboard |
| 57 | 57 | $invoice_repository = InvoiceServiceProvider::getInvoiceRepository(); |
| @@ -57,14 +57,23 @@ | ||
| 57 | 57 | $invoice_repository = InvoiceServiceProvider::getInvoiceRepository(); |
| 58 | 58 | $client_repository = ClientServiceProvider::getClientRepository(); |
| 59 | 59 | |
| 60 | 60 | // Get counts |
| 61 | - $total_invoices = count($invoice_repository->all()); | |
| 62 | - $paid_invoices = count($invoice_repository->findByStatus('paid')); | |
| 63 | - $unpaid_invoices = count($invoice_repository->findByStatus('unpaid')); | |
| 64 | - $overdue_invoices = count($invoice_repository->findByStatus('overdue')); | |
| 61 | + // Counts are SQL counts; only the invoices that can still carry a | |
| 62 | + // balance are loaded as models. Loading every invoice (three times) | |
| 63 | + // put a 3,000-invoice site at ~40 seconds per dashboard view. | |
| 64 | + $total_invoices = (int) $invoice_repository->count(); | |
| 65 | + $paid_invoices = (int) $invoice_repository->count(['meta_key' => '_easy_invoice_status', 'meta_value' => 'paid']); // phpcs:ignore WordPress.DB.SlowDBQuery | |
| 66 | + // "Unpaid" is every issued invoice still carrying a balance — a sent | |
| 67 | + // invoice is 'available' (or 'partial') until paid, so counting the | |
| 68 | + // literal 'unpaid' / 'overdue' statuses showed 0 on nearly every site. | |
| 69 | + // Outstanding balances come from SQL over the persisted totals; loading | |
| 70 | + // every open invoice as a model does not scale past a few thousand. | |
| 71 | + $outstanding = \EasyInvoice\Services\InvoiceTotalsCache::outstanding(); | |
| 72 | + $unpaid_invoices = $outstanding['count']; | |
| 73 | + $overdue_invoices = $outstanding['overdue_count']; | |
| 65 | 74 | |
| 66 | - $total_clients = count($client_repository->all()); | |
| 75 | + $total_clients = (int) (new \WP_User_Query(['role__not_in' => ['Administrator'], 'fields' => 'ID', 'number' => 1, 'count_total' => true]))->get_total(); | |
| 67 | 76 | $active_clients = $this->getActiveClientCount($client_repository, $invoice_repository); |
| 68 | 77 | |
| 69 | 78 | // Get recent invoices |
| 70 | 79 | $recent_invoices = $this->getRecentInvoices($invoice_repository); |
| @@ -80,8 +89,10 @@ | ||
| 80 | 89 | 'total_invoices' => $total_invoices, |
| 81 | 90 | 'paid_invoices' => $paid_invoices, |
| 82 | 91 | 'unpaid_invoices' => $unpaid_invoices, |
| 83 | 92 | 'overdue_invoices' => $overdue_invoices, |
| 93 | + 'unpaid_amount' => $outstanding['amount'], | |
| 94 | + 'overdue_amount' => $outstanding['overdue_amount'], | |
| 84 | 95 | 'total_clients' => $total_clients, |
| 85 | 96 | 'active_clients' => $active_clients, |
| 86 | 97 | 'recent_invoices' => $recent_invoices, |
| 87 | 98 | 'total_revenue' => $total_revenue, |
| @@ -97,44 +108,25 @@ | ||
| 97 | 108 | * @param object $invoice_repository |
| 98 | 109 | * @return int Count of active clients |
| 99 | 110 | */ |
| 100 | 111 | private function getActiveClientCount($client_repository, $invoice_repository) { |
| 101 | - $clients = $client_repository->all(); | |
| 102 | - $active_count = 0; | |
| 103 | - | |
| 104 | - foreach ($clients as $client) { | |
| 105 | - try { | |
| 106 | - $client_id = $client->getId(); | |
| 107 | - if (!$client_id) { | |
| 108 | - continue; | |
| 109 | - } | |
| 110 | - | |
| 111 | - $client_invoices = $invoice_repository->findByCustomer($client_id); | |
| 112 | - | |
| 113 | - // Consider a client active if they have an invoice in the last 90 days | |
| 114 | - $has_recent_invoice = false; | |
| 115 | - $ninety_days_ago = strtotime('-90 days'); | |
| 116 | - | |
| 117 | - foreach ($client_invoices as $invoice) { | |
| 118 | - $invoice_date = strtotime($invoice->getIssueDate()); | |
| 119 | - if ($invoice_date && $invoice_date >= $ninety_days_ago) { | |
| 120 | - $has_recent_invoice = true; | |
| 121 | - break; | |
| 122 | - } | |
| 123 | - } | |
| 124 | - | |
| 125 | - if ($has_recent_invoice) { | |
| 126 | - $active_count++; | |
| 127 | - } | |
| 128 | - } catch (\Exception $e) { | |
| 129 | - // Log the error and continue with the next client | |
| 130 | - continue; | |
| 131 | - } | |
| 132 | - } | |
| 133 | - | |
| 134 | - return $active_count; | |
| 112 | + // Distinct clients billed in the last 90 days — one query instead of | |
| 113 | + // one full invoice load per client. | |
| 114 | + global $wpdb; | |
| 115 | + $since = wp_date('Y-m-d', strtotime('-90 days')); | |
| 116 | + $count = $wpdb->get_var($wpdb->prepare( | |
| 117 | + "SELECT COUNT(DISTINCT c.meta_value) | |
| 118 | + FROM {$wpdb->posts} p | |
| 119 | + INNER JOIN {$wpdb->postmeta} c ON c.post_id = p.ID AND c.meta_key = '_easy_invoice_client_id' | |
| 120 | + INNER JOIN {$wpdb->postmeta} d ON d.post_id = p.ID AND d.meta_key = '_easy_invoice_issue_date' | |
| 121 | + WHERE p.post_type = %s AND p.post_status = 'publish' | |
| 122 | + AND c.meta_value <> '' AND c.meta_value <> '0' AND d.meta_value >= %s", | |
| 123 | + \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE, | |
| 124 | + $since | |
| 125 | + )); | |
| 126 | + return (int) $count; | |
| 135 | 127 | } |
| 136 | - | |
| 128 | + | |
| 137 | 129 | /** |
| 138 | 130 | * Get recent invoices |
| 139 | 131 | * |
| 140 | 132 | * @param object $invoice_repository |
| @@ -141,19 +133,14 @@ | ||
| 141 | 133 | * @return array Recent invoices |
| 142 | 134 | */ |
| 143 | 135 | private function getRecentInvoices($invoice_repository) { |
| 144 | 136 | try { |
| 145 | - $invoices = $invoice_repository->all(); | |
| 146 | - | |
| 147 | - // Sort invoices by date (newest first) | |
| 148 | - usort($invoices, function($a, $b) { | |
| 149 | - $date_a = $a->getIssueDate() ? strtotime($a->getIssueDate()) : 0; | |
| 150 | - $date_b = $b->getIssueDate() ? strtotime($b->getIssueDate()) : 0; | |
| 151 | - return $date_b - $date_a; | |
| 152 | - }); | |
| 153 | - | |
| 154 | - // Return the 5 most recent invoices | |
| 155 | - return array_slice($invoices, 0, 5); | |
| 137 | + // Five most recently issued: let the database sort and limit. | |
| 138 | + return $invoice_repository->all([ | |
| 139 | + 'posts_per_page' => 5, | |
| 140 | + 'meta_key' => '_easy_invoice_issue_date', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key | |
| 141 | + 'orderby' => ['meta_value' => 'DESC', 'ID' => 'DESC'], | |
| 142 | + ]); | |
| 156 | 143 | } catch (\Exception $e) { |
| 157 | 144 | // Log the error and return an empty array |
| 158 | 145 | return []; |
| 159 | 146 | } |
| @@ -220,9 +207,9 @@ | ||
| 220 | 207 | AND CAST(m_amt.meta_value AS DECIMAL(20,4)) > 0 |
| 221 | 208 | GROUP BY currency_code", |
| 222 | 209 | $global_currency |
| 223 | 210 | ); |
| 224 | - $rows = $wpdb->get_results($sql); | |
| 211 | + $rows = $wpdb->get_results($sql); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $sql is built with $wpdb->prepare() above. | |
| 225 | 212 | if ($rows === null) { |
| 226 | 213 | return null; // hard DB error → caller falls back to PHP path |
| 227 | 214 | } |
| 228 | 215 | |
| @@ -461,9 +448,9 @@ | ||
| 461 | 448 | $global_currency, |
| 462 | 449 | $start_iso, |
| 463 | 450 | $end_iso |
| 464 | 451 | ); |
| 465 | - $rows = $wpdb->get_results($sql); | |
| 452 | + $rows = $wpdb->get_results($sql); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $sql is built with $wpdb->prepare() above. | |
| 466 | 453 | if ($rows === null) { |
| 467 | 454 | return null; // hard DB error → caller falls back to PHP path |
| 468 | 455 | } |
| 469 | 456 | |