PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.6.1
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.6.1
1.6.1 1.6.0 1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
suredonation / inc / campaigns / campaign-stats.php

campaign-stats.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management 1.6.1, at inc/campaigns/campaign-stats.php

418 lines 13.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Campaign Statistics Helper
4 *
5 * @package SureDonation
6 */
7
8 namespace SureDonation\Inc\Campaigns;
9
10 use SureDonation\Inc\Helper;
11
12 // Exit if accessed directly.
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit;
15 }
16
17 /**
18 * Campaign_Stats class.
19 *
20 * @since 0.0.1
21 */
22 class Campaign_Stats {
23 /**
24 * Rows fetched into the cached recent-donations / top-donors transients.
25 *
26 * A fixed window (rather than the caller's limit) keeps one cache entry
27 * per campaign regardless of the block's display limit; callers slice
28 * down to what they need. Matches the largest fetch any block performs.
29 */
30 private const LIST_CACHE_SIZE = 100;
31
32 /**
33 * Get campaign statistics.
34 *
35 * @param int $campaign_id Campaign post ID.
36 * @return array<string, mixed> Campaign statistics.
37 * @since 0.0.1
38 */
39 public static function get_stats( $campaign_id ) {
40 global $wpdb;
41
42 $donations_table = $wpdb->prefix . 'suredonation_donations';
43
44 // Get total raised amount (completed and partially refunded donations, minus refunds).
45 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
46 $total_raised = $wpdb->get_var(
47 $wpdb->prepare(
48 "SELECT COALESCE(SUM(amount - refunded_amount), 0)
49 FROM %i
50 WHERE campaign_id = %d
51 AND payment_status IN ('completed', 'partially_refunded')",
52 $donations_table,
53 $campaign_id
54 )
55 );
56
57 // Get total donation count (completed and partially refunded).
58 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
59 $donation_count = $wpdb->get_var(
60 $wpdb->prepare(
61 "SELECT COUNT(*)
62 FROM %i
63 WHERE campaign_id = %d
64 AND payment_status IN ('completed', 'partially_refunded')",
65 $donations_table,
66 $campaign_id
67 )
68 );
69
70 // Get unique donor count (completed and partially refunded).
71 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
72 $donor_count = $wpdb->get_var(
73 $wpdb->prepare(
74 "SELECT COUNT(DISTINCT donor_email)
75 FROM %i
76 WHERE campaign_id = %d
77 AND payment_status IN ('completed', 'partially_refunded')
78 AND donor_email IS NOT NULL
79 AND donor_email != ''",
80 $donations_table,
81 $campaign_id
82 )
83 );
84
85 // Get goal type and amount from consolidated meta.
86 $campaign_meta = Helper::get_campaign_meta( $campaign_id );
87 $goal_type = $campaign_meta['goal_type'];
88 $goal_amount = floatval( Helper::get_string_value( $campaign_meta['goal_amount'] ) );
89
90 // Calculate progress percentage based on goal type.
91 $progress_percentage = 0;
92 if ( $goal_amount > 0 ) {
93 if ( 'donation_count' === $goal_type ) {
94 $progress_percentage = intval( $donation_count ) / $goal_amount * 100;
95 } else {
96 $progress_percentage = floatval( $total_raised ) / $goal_amount * 100;
97 }
98 $progress_percentage = min( $progress_percentage, 100 ); // Cap at 100%.
99 }
100
101 // Get average donation amount.
102 $average_donation = 0;
103 if ( $donation_count > 0 ) {
104 $average_donation = floatval( $total_raised ) / intval( $donation_count );
105 }
106
107 // Get largest single donation (net of refunds).
108 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
109 $largest_donation = $wpdb->get_var(
110 $wpdb->prepare(
111 "SELECT COALESCE(MAX(amount - refunded_amount), 0)
112 FROM %i
113 WHERE campaign_id = %d
114 AND payment_status IN ('completed', 'partially_refunded')",
115 $donations_table,
116 $campaign_id
117 )
118 );
119
120 // Get campaign status from consolidated meta.
121 $campaign_status = $campaign_meta['campaign_status'];
122
123 return [
124 'total_raised' => floatval( $total_raised ),
125 'goal_amount' => $goal_amount,
126 'donation_count' => intval( $donation_count ),
127 'donor_count' => intval( $donor_count ),
128 'progress_percentage' => round( $progress_percentage, 2 ),
129 'average_donation' => round( $average_donation, 2 ),
130 'largest_donation' => floatval( $largest_donation ),
131 'campaign_status' => $campaign_status,
132 'goal_type' => $goal_type,
133 'is_goal_reached' => ( $goal_amount > 0 && ( 'donation_count' === $goal_type ? intval( $donation_count ) >= $goal_amount : floatval( $total_raised ) >= $goal_amount ) ),
134 ];
135 }
136
137 /**
138 * Get recent donations for a campaign.
139 *
140 * @param int $campaign_id Campaign post ID.
141 * @param int $limit Number of donations to retrieve.
142 * @return array<int, array<string, mixed>>|null Recent donations.
143 * @since 0.0.1
144 */
145 public static function get_recent_donations( $campaign_id, $limit = 10 ) {
146 global $wpdb;
147
148 $donations_table = $wpdb->prefix . 'suredonation_donations';
149
150 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
151 return $wpdb->get_results(
152 $wpdb->prepare(
153 "SELECT id, (amount - refunded_amount) as amount, donor_name, donor_email, is_anonymous, created_at, payment_status
154 FROM %i
155 WHERE campaign_id = %d
156 AND payment_status IN ('completed', 'partially_refunded')
157 ORDER BY created_at DESC
158 LIMIT %d",
159 $donations_table,
160 $campaign_id,
161 $limit
162 ),
163 ARRAY_A
164 );
165 }
166
167 /**
168 * Get top donors for a campaign.
169 *
170 * @param int $campaign_id Campaign post ID.
171 * @param int $limit Number of donors to retrieve.
172 * @return array<int, array<string, mixed>>|null Top donors.
173 * @since 0.0.1
174 */
175 public static function get_top_donors( $campaign_id, $limit = 10 ) {
176 global $wpdb;
177
178 $donations_table = $wpdb->prefix . 'suredonation_donations';
179
180 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
181 return $wpdb->get_results(
182 $wpdb->prepare(
183 "SELECT
184 donor_email,
185 donor_name,
186 SUM(amount - refunded_amount) as total_donated,
187 COUNT(*) as donation_count,
188 MAX(created_at) as last_donation_date
189 FROM %i
190 WHERE campaign_id = %d
191 AND payment_status IN ('completed', 'partially_refunded')
192 AND is_anonymous = 0
193 AND donor_email IS NOT NULL
194 AND donor_email != ''
195 GROUP BY donor_email, donor_name
196 ORDER BY total_donated DESC
197 LIMIT %d",
198 $donations_table,
199 $campaign_id,
200 $limit
201 ),
202 ARRAY_A
203 );
204 }
205
206 /**
207 * Get the approved donor comments for a campaign.
208 *
209 * Only completed (or partially refunded) donations qualify, matching the
210 * recent-donations list — a comment on a pending or failed payment is not a
211 * donation the campaign received. `rejected` and `pending` comments are
212 * excluded here rather than filtered in PHP so a moderated comment never
213 * reaches the render path or the cache.
214 *
215 * @param int $campaign_id Campaign post ID.
216 * @param int $limit Number of comments to retrieve.
217 * @return array<int, array<string, mixed>>|null Donor comments.
218 * @since 1.6.0
219 */
220 public static function get_donor_comments( $campaign_id, $limit = 10 ) {
221 global $wpdb;
222
223 $donations_table = $wpdb->prefix . 'suredonation_donations';
224
225 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
226 return $wpdb->get_results(
227 $wpdb->prepare(
228 "SELECT id, (amount - refunded_amount) as amount, donor_name, donor_email, is_anonymous, donor_comment, created_at
229 FROM %i
230 WHERE campaign_id = %d
231 AND payment_status IN ('completed', 'partially_refunded')
232 AND donor_comment_status = 'approved'
233 AND donor_comment IS NOT NULL
234 AND donor_comment != ''
235 ORDER BY created_at DESC
236 LIMIT %d",
237 $donations_table,
238 $campaign_id,
239 $limit
240 ),
241 ARRAY_A
242 );
243 }
244
245 /**
246 * Get donation timeline (grouped by date).
247 *
248 * @param int $campaign_id Campaign post ID.
249 * @param int $days Number of days to include.
250 * @return array<int, array<string, mixed>>|null Donation timeline.
251 * @since 0.0.1
252 */
253 public static function get_donation_timeline( $campaign_id, $days = 30 ) {
254 global $wpdb;
255
256 $donations_table = $wpdb->prefix . 'suredonation_donations';
257
258 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
259 return $wpdb->get_results(
260 $wpdb->prepare(
261 "SELECT
262 DATE(created_at) as date,
263 COUNT(*) as donation_count,
264 SUM(amount - refunded_amount) as total_amount
265 FROM %i
266 WHERE campaign_id = %d
267 AND payment_status IN ('completed', 'partially_refunded')
268 AND created_at >= DATE_SUB(NOW(), INTERVAL %d DAY)
269 GROUP BY DATE(created_at)
270 ORDER BY date ASC",
271 $donations_table,
272 $campaign_id,
273 $days
274 ),
275 ARRAY_A
276 );
277 }
278
279 /**
280 * Get cached recent donations for a campaign.
281 *
282 * Caches a fixed window per campaign (see LIST_CACHE_SIZE) and slices to
283 * the requested limit, so the public campaign-donations block doesn't hit
284 * the database on every page view.
285 *
286 * @param int $campaign_id Campaign post ID.
287 * @param int $limit Number of donations to return.
288 * @param int $cache_duration Cache duration in seconds (default: 5 minutes).
289 * @return array<int, array<string, mixed>> Recent donations.
290 * @since 1.0.0
291 */
292 public static function get_cached_recent_donations( $campaign_id, $limit = 10, $cache_duration = 300 ) {
293 $cache_key = 'suredonation_recent_donations_' . $campaign_id;
294 $donations = get_transient( $cache_key );
295
296 if ( false === $donations || ! is_array( $donations ) ) {
297 $donations = self::get_recent_donations( $campaign_id, self::LIST_CACHE_SIZE );
298 $donations = is_array( $donations ) ? $donations : [];
299 set_transient( $cache_key, $donations, $cache_duration );
300 }
301
302 return array_slice( $donations, 0, max( 0, (int) $limit ) );
303 }
304
305 /**
306 * Get cached top donors for a campaign.
307 *
308 * Caches a fixed window per campaign (see LIST_CACHE_SIZE) and slices to
309 * the requested limit, so the public campaign-donors block doesn't hit
310 * the database on every page view.
311 *
312 * @param int $campaign_id Campaign post ID.
313 * @param int $limit Number of donors to return.
314 * @param int $cache_duration Cache duration in seconds (default: 5 minutes).
315 * @return array<int, array<string, mixed>> Top donors.
316 * @since 1.0.0
317 */
318 public static function get_cached_top_donors( $campaign_id, $limit = 10, $cache_duration = 300 ) {
319 $cache_key = 'suredonation_top_donors_' . $campaign_id;
320 $donors = get_transient( $cache_key );
321
322 if ( false === $donors || ! is_array( $donors ) ) {
323 $donors = self::get_top_donors( $campaign_id, self::LIST_CACHE_SIZE );
324 $donors = is_array( $donors ) ? $donors : [];
325 set_transient( $cache_key, $donors, $cache_duration );
326 }
327
328 return array_slice( $donors, 0, max( 0, (int) $limit ) );
329 }
330
331 /**
332 * Get cached donor comments for a campaign.
333 *
334 * Caches a fixed window per campaign (see LIST_CACHE_SIZE) and slices to the
335 * requested limit, so the public donor-comments block doesn't hit the
336 * database on every page view.
337 *
338 * `$exclude_anonymous` is applied to the whole cached window before the
339 * slice, which is why it belongs here rather than in the caller. Filtering
340 * after a slice means a run of anonymous comments at the top can consume the
341 * entire slice and render an empty list while non-anonymous approved comments
342 * sit just past it. The cached window is materialised in full either way, so
343 * filtering it costs nothing extra. The transient is deliberately keyed per
344 * campaign only — it always holds the unfiltered window, so both callers
345 * share one entry.
346 *
347 * The ceiling is LIST_CACHE_SIZE: more than that many consecutive anonymous
348 * comments at the top of a campaign still yields an empty list. Far better
349 * than the caller's old `min( limit * 5, 100 )` over-fetch, which hit the
350 * same wall at 25 for the default limit, but not unbounded — stated here so
351 * the next reader does not have to re-derive it from the slice.
352 *
353 * @param int $campaign_id Campaign post ID.
354 * @param int $limit Number of comments to return.
355 * @param int $cache_duration Cache duration in seconds (default: 5 minutes).
356 * @param bool $exclude_anonymous Drop comments left on anonymous donations.
357 * @return array<int, array<string, mixed>> Donor comments.
358 * @since 1.6.0
359 */
360 public static function get_cached_donor_comments( $campaign_id, $limit = 10, $cache_duration = 300, $exclude_anonymous = false ) {
361 $cache_key = 'suredonation_donor_comments_' . $campaign_id;
362 $comments = get_transient( $cache_key );
363
364 if ( false === $comments || ! is_array( $comments ) ) {
365 $comments = self::get_donor_comments( $campaign_id, self::LIST_CACHE_SIZE );
366 $comments = is_array( $comments ) ? $comments : [];
367 set_transient( $cache_key, $comments, $cache_duration );
368 }
369
370 if ( $exclude_anonymous ) {
371 $comments = array_values(
372 array_filter(
373 $comments,
374 static function ( $comment ) {
375 return empty( $comment['is_anonymous'] );
376 }
377 )
378 );
379 }
380
381 return array_slice( $comments, 0, max( 0, (int) $limit ) );
382 }
383
384 /**
385 * Clear campaign stats cache.
386 *
387 * @param int $campaign_id Campaign post ID.
388 * @return void
389 * @since 0.0.1
390 */
391 public static function clear_cache( $campaign_id ) {
392 delete_transient( 'suredonation_stats_' . $campaign_id );
393 delete_transient( 'suredonation_recent_donations_' . $campaign_id );
394 delete_transient( 'suredonation_top_donors_' . $campaign_id );
395 delete_transient( 'suredonation_donor_comments_' . $campaign_id );
396 }
397
398 /**
399 * Get cached campaign statistics.
400 *
401 * @param int $campaign_id Campaign post ID.
402 * @param int $cache_duration Cache duration in seconds (default: 5 minutes).
403 * @return array<string, mixed> Campaign statistics.
404 * @since 0.0.1
405 */
406 public static function get_cached_stats( $campaign_id, $cache_duration = 300 ) {
407 $cache_key = 'suredonation_stats_' . $campaign_id;
408 $stats = get_transient( $cache_key );
409
410 if ( false === $stats || ! is_array( $stats ) ) {
411 $stats = self::get_stats( $campaign_id );
412 set_transient( $cache_key, $stats, $cache_duration );
413 }
414
415 return $stats;
416 }
417 }
418