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/DashboardController.php +214 -72 2.2.02.4.0 View file →
@@ -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 }
@@ -165,10 +152,88 @@
165 152 * @param object $invoice_repository
166 153 * @return array Total revenue by currency
167 154 */
168 155 private function getTotalRevenue($invoice_repository) {
156 + // SQL-aggregate path. Replaces the previous "load every payment post +
157 + // call get_post_meta() 2× per row in PHP" approach with a single
158 + // GROUP BY query. On a site with 50K payments this cuts ~150K
159 + // postmeta queries to 1 SQL aggregate.
160 + //
161 + // Returns the SAME data structure as the legacy path:
162 + // [ 'USD' => ['amount' => 12345.67, 'symbol' => '$'], ... ]
163 + //
164 + // Filter `easy_invoice_dashboard_use_sql_aggregates` lets admins
165 + // revert to the legacy PHP path if any production-data edge case
166 + // surfaces unexpected numbers.
167 + $use_sql = (bool) apply_filters('easy_invoice_dashboard_use_sql_aggregates', true);
168 + if ($use_sql) {
169 + $sql_result = $this->getTotalRevenueViaSql();
170 + if ($sql_result !== null) {
171 + return $sql_result;
172 + }
173 + }
174 + return $this->getTotalRevenueViaPhpFallback();
175 + }
176 +
177 + /**
178 + * Single-query revenue aggregation. Returns null on hard DB failure so
179 + * the caller can fall back to the PHP path; returns an empty array
180 + * legitimately when there are zero matching payments.
181 + *
182 + * @return array<string,array{amount:float,symbol:string}>|null
183 + */
184 + private function getTotalRevenueViaSql(): ?array {
185 + global $wpdb;
186 + $global_currency = strtoupper((string) get_option('easy_invoice_currency_code', 'USD'));
187 +
188 + // SQL inputs:
189 + // m_stat → payment status (must be completed / approved / paid)
190 + // m_amt → payment amount (CAST to DECIMAL so the SUM ignores junk)
191 + // m_cur → payment currency (LEFT JOIN — older payments may not have it)
192 + //
193 + // The IFNULL/NULLIF chain collapses empty-string and the literal
194 + // 'global' sentinel to the site default, matching the PHP path's
195 + // `empty($currency_code) || $currency_code === 'global'` check.
196 + $sql = $wpdb->prepare(
197 + "SELECT
198 + UPPER(IFNULL(NULLIF(NULLIF(m_cur.meta_value, ''), 'global'), %s)) AS currency_code,
199 + SUM(CAST(m_amt.meta_value AS DECIMAL(20,4))) AS total_amount
200 + FROM {$wpdb->posts} p
201 + INNER JOIN {$wpdb->postmeta} m_stat ON m_stat.post_id = p.ID AND m_stat.meta_key = '_status'
202 + INNER JOIN {$wpdb->postmeta} m_amt ON m_amt.post_id = p.ID AND m_amt.meta_key = '_amount'
203 + LEFT JOIN {$wpdb->postmeta} m_cur ON m_cur.post_id = p.ID AND m_cur.meta_key = '_currency'
204 + WHERE p.post_type = 'easy_invoice_payment'
205 + AND p.post_status = 'publish'
206 + AND m_stat.meta_value IN ('completed','approved','paid')
207 + AND CAST(m_amt.meta_value AS DECIMAL(20,4)) > 0
208 + GROUP BY currency_code",
209 + $global_currency
210 + );
211 + $rows = $wpdb->get_results($sql); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $sql is built with $wpdb->prepare() above.
212 + if ($rows === null) {
213 + return null; // hard DB error → caller falls back to PHP path
214 + }
215 +
216 + $out = [];
217 + foreach ($rows as $row) {
218 + $code = (string) $row->currency_code;
219 + $out[$code] = [
220 + 'amount' => (float) $row->total_amount,
221 + 'symbol' => \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($code),
222 + ];
223 + }
224 + return $out;
225 + }
226 +
227 + /**
228 + * Legacy PHP aggregation path. Kept verbatim from the original
229 + * implementation so the filter-off fallback returns identical numbers
230 + * to what the dashboard always rendered before the SQL refactor.
231 + *
232 + * @return array<string,array{amount:float,symbol:string}>
233 + */
234 + private function getTotalRevenueViaPhpFallback() {
169 235 try {
170 - // Get all completed payments instead of using invoice data
171 236 $payments = get_posts([
172 237 'post_type' => 'easy_invoice_payment',
173 238 'post_status' => 'publish',
174 239 'meta_query' => [
@@ -179,13 +244,12 @@
179 244 ]
180 245 ],
181 246 'numberposts' => -1
182 247 ]);
183 -
248 +
184 249 $revenue_by_currency = [];
185 250 $global_currency = get_option('easy_invoice_currency_code', 'USD');
186 -
187 - // Calculate revenue from actual payments
251 +
188 252 foreach ($payments as $payment) {
189 253 try {
190 254 $payment_amount = get_post_meta($payment->ID, '_amount', true);
191 255 if (!is_numeric($payment_amount) || $payment_amount <= 0) {
@@ -190,19 +254,17 @@
190 254 $payment_amount = get_post_meta($payment->ID, '_amount', true);
191 255 if (!is_numeric($payment_amount) || $payment_amount <= 0) {
192 256 continue;
193 257 }
194 -
195 - // Get currency from payment
258 +
196 259 $currency_code = get_post_meta($payment->ID, '_currency', true);
197 260 if (empty($currency_code) || $currency_code === 'global') {
198 261 $currency_code = $global_currency;
199 262 }
200 263 $currency_code = strtoupper($currency_code);
201 -
264 +
202 265 $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code);
203 -
204 - // Initialize currency if not exists
266 +
205 267 if (!isset($revenue_by_currency[$currency_code])) {
206 268 $revenue_by_currency[$currency_code] = [
207 269 'amount' => 0,
208 270 'symbol' => $currency_symbol
@@ -207,19 +269,17 @@
207 269 'amount' => 0,
208 270 'symbol' => $currency_symbol
209 271 ];
210 272 }
211 -
273 +
212 274 $revenue_by_currency[$currency_code]['amount'] += $payment_amount;
213 275 } catch (\Exception $e) {
214 - // Log the error and continue with the next payment
215 276 continue;
216 277 }
217 278 }
218 -
279 +
219 280 return $revenue_by_currency;
220 281 } catch (\Exception $e) {
221 - // Log the error and return empty array
222 282 return [];
223 283 }
224 284 }
225 285
@@ -231,14 +291,14 @@
231 291 */
232 292 private function getMonthlyRevenue($invoice_repository) {
233 293 // Initialize months for the last 12 months (rolling period)
234 294 $monthly_revenue = array();
235 -
295 +
236 296 // Get the current date and go back 11 months to create a 12-month period
237 297 $current_date = new \DateTime();
238 298 $start_date = clone $current_date;
239 299 $start_date->modify('-11 months');
240 -
300 +
241 301 // Initialize all 12 months
242 302 for ($i = 0; $i < 12; $i++) {
243 303 $month_date = clone $start_date;
244 304 $month_date->modify("+{$i} months");
@@ -244,9 +304,27 @@
244 304 $month_date->modify("+{$i} months");
245 305 $month_name = $month_date->format('M Y');
246 306 $monthly_revenue[$month_name] = [];
247 307 }
248 -
308 +
309 + // SQL-aggregate path. Same logic as the chart's PHP loop but executed
310 + // as a single GROUP BY in MySQL. Filterable via
311 + // `easy_invoice_dashboard_use_sql_aggregates` (shared with
312 + // getTotalRevenue — flip both at once).
313 + if (apply_filters('easy_invoice_dashboard_use_sql_aggregates', true)) {
314 + $sql_buckets = $this->getMonthlyRevenueViaSql($start_date, $current_date);
315 + if ($sql_buckets !== null) {
316 + // Merge SQL aggregates into the pre-initialized 12-month skeleton
317 + // so empty months stay empty (instead of disappearing from the chart).
318 + foreach ($sql_buckets as $month_label => $by_currency) {
319 + if (isset($monthly_revenue[$month_label])) {
320 + $monthly_revenue[$month_label] = $by_currency;
321 + }
322 + }
323 + return $monthly_revenue;
324 + }
325 + }
326 +
249 327 try {
250 328 // Get all completed payments (including different statuses that might be considered completed)
251 329 $payments = get_posts([
252 330 'post_type' => 'easy_invoice_payment',
@@ -259,9 +337,9 @@
259 337 ]
260 338 ],
261 339 'numberposts' => -1
262 340 ]);
263 -
341 +
264 342 // If no completed payments found, try to get any payments with amounts
265 343 if (empty($payments)) {
266 344 $payments = get_posts([
267 345 'post_type' => 'easy_invoice_payment',
@@ -275,9 +353,9 @@
275 353 ],
276 354 'numberposts' => -1
277 355 ]);
278 356 }
279 -
357 +
280 358 $global_currency = get_option('easy_invoice_currency_code', 'USD');
281 359
282 360 // Calculate revenue for each month by currency
283 361 foreach ($payments as $payment) {
@@ -325,5 +403,69 @@
325 403 // Log the error and return empty monthly revenue
326 404 return $monthly_revenue;
327 405 }
328 406 }
329 -}
407 +
408 + /**
409 + * Single-query monthly revenue aggregation. Returns null on hard DB
410 + * failure so the caller falls back to the PHP path; returns an array
411 + * keyed by 'Mon YYYY' month label (matching the PHP path's format).
412 + *
413 + * The query mirrors getTotalRevenueViaSql but adds DATE_FORMAT
414 + * grouping on the _payment_date meta. Date range is enforced in SQL
415 + * via lexicographic comparison on the ISO date string, which works
416 + * because _payment_date is stored as 'YYYY-MM-DD' (sortable).
417 + *
418 + * @param \DateTime $start_date
419 + * @param \DateTime $current_date
420 + * @return array<string,array<string,array{amount:float,symbol:string}>>|null
421 + */
422 + private function getMonthlyRevenueViaSql(\DateTime $start_date, \DateTime $current_date): ?array {
423 + global $wpdb;
424 + $global_currency = strtoupper((string) get_option('easy_invoice_currency_code', 'USD'));
425 + $start_iso = $start_date->format('Y-m-01');
426 + $end_iso = $current_date->format('Y-m-d');
427 +
428 + $sql = $wpdb->prepare(
429 + "SELECT
430 + DATE_FORMAT(m_date.meta_value, '%%b %%Y') AS month_label,
431 + MIN(m_date.meta_value) AS month_sort,
432 + UPPER(IFNULL(NULLIF(NULLIF(m_cur.meta_value, ''), 'global'), %s)) AS currency_code,
433 + SUM(CAST(m_amt.meta_value AS DECIMAL(20,4))) AS total_amount
434 + FROM {$wpdb->posts} p
435 + INNER JOIN {$wpdb->postmeta} m_stat ON m_stat.post_id = p.ID AND m_stat.meta_key = '_status'
436 + INNER JOIN {$wpdb->postmeta} m_amt ON m_amt.post_id = p.ID AND m_amt.meta_key = '_amount'
437 + INNER JOIN {$wpdb->postmeta} m_date ON m_date.post_id = p.ID AND m_date.meta_key = '_payment_date'
438 + LEFT JOIN {$wpdb->postmeta} m_cur ON m_cur.post_id = p.ID AND m_cur.meta_key = '_currency'
439 + WHERE p.post_type = 'easy_invoice_payment'
440 + AND p.post_status = 'publish'
441 + AND m_stat.meta_value IN ('completed','approved','paid')
442 + AND CAST(m_amt.meta_value AS DECIMAL(20,4)) > 0
443 + AND m_date.meta_value <> ''
444 + AND m_date.meta_value >= %s
445 + AND m_date.meta_value <= %s
446 + GROUP BY month_label, currency_code
447 + ORDER BY month_sort ASC",
448 + $global_currency,
449 + $start_iso,
450 + $end_iso
451 + );
452 + $rows = $wpdb->get_results($sql); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $sql is built with $wpdb->prepare() above.
453 + if ($rows === null) {
454 + return null; // hard DB error → caller falls back to PHP path
455 + }
456 +
457 + $buckets = [];
458 + foreach ($rows as $row) {
459 + $month = (string) $row->month_label;
460 + $code = (string) $row->currency_code;
461 + if (!isset($buckets[$month])) {
462 + $buckets[$month] = [];
463 + }
464 + $buckets[$month][$code] = [
465 + 'amount' => (float) $row->total_amount,
466 + 'symbol' => \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($code),
467 + ];
468 + }
469 + return $buckets;
470 + }
471 +}