PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.2.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.2.0
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 / database / tables / donations.php

donations.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management 1.2.0, at inc/database/tables/donations.php

2,050 lines 59.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * SureDonation Database Donations Table Class.
4 *
5 * @package SureDonation
6 */
7
8 namespace SureDonation\Inc\Database\Tables;
9
10 use SureDonation\Inc\Campaigns\Campaign_Stats;
11 use SureDonation\Inc\Database\Base;
12 use SureDonation\Inc\Helper;
13 use SureDonation\Inc\Traits\Get_Instance;
14
15 // Exit if accessed directly.
16 defined( 'ABSPATH' ) || exit;
17
18 /**
19 * SureDonation Database Donations Table Class.
20 *
21 * @since 0.0.1
22 */
23 class Donations extends Base {
24 use Get_Instance;
25
26 /**
27 * Table suffix.
28 *
29 * @var string
30 * @since 0.0.1
31 */
32 protected $table_suffix = 'donations';
33
34 /**
35 * Table version.
36 *
37 * @var int
38 * @since 0.0.1
39 */
40 protected $table_version = 4;
41
42 /**
43 * Valid payment statuses.
44 *
45 * @var array<string>
46 * @since 0.0.1
47 */
48 private static $valid_statuses = [
49 'pending',
50 'processing',
51 'completed',
52 'failed',
53 'refunded',
54 'partially_refunded',
55 'cancelled',
56 'suspicious',
57 ];
58
59 /**
60 * Valid order columns.
61 *
62 * @var array<string>
63 * @since 0.0.1
64 */
65 private static $valid_order_columns = [
66 'id',
67 'campaign_id',
68 'amount',
69 'created_at',
70 'updated_at',
71 'payment_status',
72 'donor_name',
73 'donor_email',
74 'subscription_status',
75 'subscription_id',
76 ];
77
78 /**
79 * {@inheritDoc}
80 */
81 public function get_schema() {
82 return [
83 'id' => [
84 'type' => 'number',
85 ],
86 'campaign_id' => [
87 'type' => 'number',
88 ],
89 'donor_id' => [
90 'type' => 'number',
91 'default' => 0,
92 ],
93 'form_id' => [
94 'type' => 'number',
95 'default' => 0,
96 ],
97 'amount' => [
98 'type' => 'string',
99 'default' => '0.00000000',
100 ],
101 'fees_covered' => [
102 'type' => 'string',
103 'default' => '0.00000000',
104 ],
105 'refunded_amount' => [
106 'type' => 'string',
107 'default' => '0.00000000',
108 ],
109 'currency' => [
110 'type' => 'string',
111 'default' => 'USD',
112 ],
113 'transaction_id' => [
114 'type' => 'string',
115 'default' => '',
116 ],
117 'customer_id' => [
118 'type' => 'string',
119 'default' => '',
120 ],
121 'gateway' => [
122 'type' => 'string',
123 'default' => 'stripe',
124 ],
125 'payment_status' => [
126 'type' => 'string',
127 'default' => 'pending',
128 ],
129 'payment_mode' => [
130 'type' => 'string',
131 'default' => 'test',
132 ],
133 'donor_name' => [
134 'type' => 'string',
135 'default' => '',
136 ],
137 'donor_email' => [
138 'type' => 'string',
139 'default' => '',
140 ],
141 'donor_phone' => [
142 'type' => 'string',
143 'default' => '',
144 ],
145 'is_anonymous' => [
146 'type' => 'boolean',
147 'default' => false,
148 ],
149 'donation_type' => [
150 'type' => 'string',
151 'default' => 'one-time',
152 ],
153 'subscription_id' => [
154 'type' => 'string',
155 'default' => '',
156 ],
157 'subscription_status' => [
158 'type' => 'string',
159 'default' => '',
160 ],
161 'parent_subscription_id' => [
162 'type' => 'number',
163 'default' => 0,
164 ],
165 'donor_comment' => [
166 'type' => 'string',
167 'default' => '',
168 ],
169 'receipt_sent' => [
170 'type' => 'boolean',
171 'default' => false,
172 ],
173 'receipt_pdf_url' => [
174 'type' => 'string',
175 'default' => '',
176 ],
177 'donation_data' => [
178 'type' => 'array',
179 'default' => [],
180 ],
181 'log' => [
182 'type' => 'array',
183 'default' => [],
184 ],
185 'ip_address' => [
186 'type' => 'string',
187 'default' => '',
188 ],
189 'user_agent' => [
190 'type' => 'string',
191 'default' => '',
192 ],
193 'referer_url' => [
194 'type' => 'string',
195 'default' => '',
196 ],
197 'import_source_id' => [
198 'type' => 'number',
199 'default' => 0,
200 ],
201 'import_source' => [
202 'type' => 'string',
203 'default' => '',
204 ],
205 'created_at' => [
206 'type' => 'datetime',
207 ],
208 'updated_at' => [
209 'type' => 'datetime',
210 ],
211 ];
212 }
213
214 /**
215 * {@inheritDoc}
216 */
217 public function get_columns_definition() {
218 return [
219 'id BIGINT(20) UNSIGNED AUTO_INCREMENT PRIMARY KEY',
220 'campaign_id BIGINT(20) UNSIGNED NOT NULL',
221 'donor_id BIGINT(20) UNSIGNED NULL',
222 'form_id BIGINT(20) UNSIGNED NULL',
223 'amount DECIMAL(26,8) NOT NULL',
224 'fees_covered DECIMAL(26,8) NOT NULL DEFAULT 0',
225 'refunded_amount DECIMAL(26,8) NOT NULL DEFAULT 0',
226 'currency VARCHAR(10) NOT NULL',
227 'transaction_id VARCHAR(255) NOT NULL',
228 'customer_id VARCHAR(50) NOT NULL',
229 'gateway VARCHAR(20) NOT NULL',
230 'payment_status VARCHAR(50) NOT NULL',
231 'payment_mode VARCHAR(20) NOT NULL',
232 'donor_name VARCHAR(255) NOT NULL',
233 'donor_email VARCHAR(255) NOT NULL',
234 'donor_phone VARCHAR(50) NOT NULL',
235 'is_anonymous TINYINT(1) NOT NULL DEFAULT 0',
236 'donation_type VARCHAR(30) NOT NULL',
237 'subscription_id VARCHAR(255) NOT NULL',
238 'subscription_status VARCHAR(30) NOT NULL',
239 'parent_subscription_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0',
240 'donor_comment TEXT',
241 'receipt_sent TINYINT(1) NOT NULL DEFAULT 0',
242 'receipt_pdf_url VARCHAR(255) NOT NULL',
243 'donation_data LONGTEXT',
244 'log LONGTEXT',
245 'ip_address VARCHAR(45) NOT NULL',
246 'user_agent TEXT',
247 'referer_url TEXT',
248 'import_source_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0',
249 'import_source VARCHAR(20) NOT NULL DEFAULT ""',
250 'created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP',
251 'updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
252 'INDEX idx_campaign (campaign_id)',
253 'INDEX idx_donor (donor_id)',
254 'INDEX idx_status (payment_status)',
255 'INDEX idx_email (donor_email)',
256 'INDEX idx_created (created_at)',
257 'INDEX idx_form (form_id)',
258 'INDEX idx_subscription (subscription_id)',
259 'INDEX idx_subscription_status (subscription_status)',
260 'INDEX idx_parent_subscription (parent_subscription_id)',
261 'INDEX idx_import_source (import_source_id, import_source)',
262 ];
263 }
264
265 /**
266 * New columns added across versions.
267 *
268 * Version 2 added subscription support; version 4 added the
269 * source-agnostic pair `import_source_id` + `import_source` used by
270 * the migration tool for duplicate detection and rollback.
271 *
272 * {@inheritDoc}
273 *
274 * @since 1.0.0
275 */
276 public function get_new_columns_definition() {
277 return [
278 'subscription_id VARCHAR(255) NOT NULL AFTER donation_type',
279 'subscription_status VARCHAR(30) NOT NULL AFTER subscription_id',
280 'parent_subscription_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 AFTER subscription_status',
281 'import_source_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 AFTER referer_url',
282 'import_source VARCHAR(20) NOT NULL DEFAULT "" AFTER import_source_id',
283 'INDEX idx_subscription (subscription_id)',
284 'INDEX idx_subscription_status (subscription_status)',
285 'INDEX idx_parent_subscription (parent_subscription_id)',
286 'INDEX idx_import_source (import_source_id, import_source)',
287 ];
288 }
289
290 /**
291 * Add a new donation record.
292 *
293 * @param array<mixed> $data Donation data to insert.
294 * @return int|false The donation ID on success, false on error.
295 * @since 0.0.1
296 */
297 public static function add( $data ) {
298 // Use isset check — empty() would reject campaign_id=0 which is valid for standalone forms.
299 if ( ! isset( $data['campaign_id'] ) ) {
300 return false;
301 }
302
303 $instance = self::get_instance();
304
305 // Set created_at if not provided (use GMT for consistency with TIMESTAMP column default).
306 if ( ! isset( $data['created_at'] ) ) {
307 $data['created_at'] = current_time( 'mysql', true );
308 }
309
310 $result = $instance->use_insert( $data );
311
312 if ( $result ) {
313 Campaign_Stats::clear_cache( absint( Helper::get_string_value( $data['campaign_id'] ) ) );
314
315 // Notify integration hooks (e.g. OttoKit) about the new donation.
316 // Imported rows carry an import_source and are skipped: migrating
317 // historical donations must not replay automations.
318 if ( empty( $data['import_source'] ) ) {
319 $donation_id = absint( $result );
320 $donation = self::get( $donation_id );
321 $donation = is_array( $donation ) ? $donation : [];
322
323 // Curated, integration-safe payload (no PII/internal columns)
324 // shared by every hook below. See self::get_integration_payload().
325 $payload = self::get_integration_payload( $donation );
326
327 /**
328 * Fires when a new donation record is created.
329 *
330 * @param int $donation_id Newly created donation ID.
331 * @param array<mixed> $donation Curated donation payload.
332 * @since 1.1.0
333 */
334 do_action( 'suredonation_donation_created', $donation_id, $payload );
335
336 /**
337 * Fires when a new donation record is created.
338 *
339 * Mirrors `suredonation_donation_created`; the OttoKit (formerly
340 * SureTriggers) "New Donation" trigger listens on this hook name.
341 *
342 * @param int $donation_id Newly created donation ID.
343 * @param array<mixed> $donation Curated donation payload.
344 * @since 1.2.0
345 */
346 do_action( 'suredonation_new_donation', $donation_id, $payload );
347
348 // Some donations are created already-completed rather than
349 // transitioning through update() — recurring renewals and
350 // admin-recorded paid donations. Fire the completion event here
351 // too so integration hooks still see them.
352 if ( 'completed' === ( $data['payment_status'] ?? '' ) ) {
353 /**
354 * Fires when a donation payment is completed.
355 *
356 * @param int $donation_id Donation ID.
357 * @param array<mixed> $donation Curated donation payload after insertion.
358 * @since 1.2.0
359 */
360 do_action( 'suredonation_donation_completed', $donation_id, $payload );
361 }
362 }
363 }
364
365 return $result;
366 }
367
368 /**
369 * Update a donation record.
370 *
371 * @param int $donation_id Donation ID to update.
372 * @param array<string,mixed> $data Data to update.
373 * @return int|false Number of rows updated or false on error.
374 * @since 0.0.1
375 */
376 public static function update( $donation_id, $data = [] ) {
377 if ( empty( $donation_id ) ) {
378 return false;
379 }
380
381 // Capture the current status and refunded amount before the write so
382 // integration hooks (e.g. OttoKit) can react to the transition and to
383 // refund events, not just the resulting values.
384 $old_status = '';
385 $old_refunded = 0.0;
386 if ( isset( $data['payment_status'] ) || isset( $data['refunded_amount'] ) ) {
387 $existing = self::get( absint( $donation_id ) );
388 $old_status = is_array( $existing ) ? Helper::get_string_value( $existing['payment_status'] ?? '' ) : '';
389 $old_refunded = is_array( $existing ) ? Helper::get_float_value( $existing['refunded_amount'] ?? 0 ) : 0.0;
390 }
391
392 // Set updated_at.
393 $data['updated_at'] = current_time( 'mysql' );
394
395 $updated = self::get_instance()->use_update( $data, [ 'id' => absint( $donation_id ) ] );
396
397 // Status/amount changes (e.g. a webhook completing a pending donation)
398 // affect the cached stats and donor lists.
399 if ( $updated ) {
400 $donation = self::get( absint( $donation_id ) );
401 $donation = is_array( $donation ) ? $donation : [];
402 if ( ! empty( $donation['campaign_id'] ) ) {
403 Campaign_Stats::clear_cache( absint( Helper::get_string_value( $donation['campaign_id'] ) ) );
404 }
405
406 // Curated, integration-safe payload (no PII/internal columns) shared
407 // by every hook below. See self::get_integration_payload().
408 $payload = self::get_integration_payload( $donation );
409
410 if ( isset( $data['payment_status'] ) ) {
411 $new_status = Helper::get_string_value( $data['payment_status'] );
412
413 if ( $new_status !== $old_status ) {
414 /**
415 * Fires when a donation's payment status changes.
416 *
417 * @param int $donation_id Donation ID.
418 * @param string $new_status New payment status.
419 * @param string $old_status Previous payment status (empty string if unknown).
420 * @param array<mixed> $donation Curated donation payload after the update.
421 * @since 1.1.0
422 */
423 do_action( 'suredonation_donation_status_changed', absint( $donation_id ), $new_status, $old_status, $payload );
424
425 // Fire the completion event for any genuine transition into
426 // 'completed' — including admin review states (suspicious,
427 // cancelled) — but never for refund reversals that restore
428 // the 'completed' status (refunded/partially_refunded ->
429 // completed), which would replay the completion automation.
430 if ( 'completed' === $new_status && ! in_array( $old_status, [ 'completed', 'refunded', 'partially_refunded' ], true ) ) {
431 /**
432 * Fires when a donation payment is completed.
433 *
434 * @param int $donation_id Donation ID.
435 * @param array<mixed> $donation Curated donation payload after the update.
436 * @since 1.2.0
437 */
438 do_action( 'suredonation_donation_completed', absint( $donation_id ), $payload );
439 }
440 }
441 }
442
443 // A rise in refunded_amount means a refund was processed. Keying off
444 // the amount (not the status string) catches repeat partial refunds
445 // that leave the status as partially_refunded, and excludes refund
446 // reversals where the amount drops.
447 if ( isset( $data['refunded_amount'] ) ) {
448 $new_refunded = Helper::get_float_value( $data['refunded_amount'] );
449
450 if ( $new_refunded - $old_refunded > 0.0001 ) {
451 /**
452 * Fires when a donation is refunded, fully or partially.
453 *
454 * @param int $donation_id Donation ID.
455 * @param float $refund_amount Amount refunded in this event.
456 * @param float $total_refunded Cumulative amount refunded to date.
457 * @param array<mixed> $donation Curated donation payload after the update.
458 * @since 1.2.0
459 */
460 do_action( 'suredonation_donation_refunded', absint( $donation_id ), $new_refunded - $old_refunded, $new_refunded, $payload );
461 }
462 }
463 }
464
465 return $updated;
466 }
467
468 /**
469 * Build a curated donation payload for integration hooks.
470 *
471 * Trims the raw database row to the fields advertised in the OttoKit embed
472 * `sample_response`, omitting internal and PII columns that must not leave
473 * the site (ip_address, user_agent, referer_url, the admin `log`, the
474 * gateway `customer_id`, and the full `donation_data` submission). Donor
475 * identity is blanked for anonymous donations, and monetary values are cast
476 * to float to match the sample the automation builder maps against (the raw
477 * column is a DECIMAL string). Shared by every `do_action` in add()/update()
478 * so no listener — OttoKit or otherwise — receives the raw row.
479 *
480 * @param array<string,mixed> $donation Raw donation record from self::get().
481 * @return array<string,mixed> Curated, integration-safe payload.
482 * @since 1.2.0
483 */
484 public static function get_integration_payload( $donation ) {
485 if ( ! is_array( $donation ) ) {
486 return [];
487 }
488
489 $is_anonymous = ! empty( $donation['is_anonymous'] );
490
491 return [
492 'id' => isset( $donation['id'] ) ? absint( Helper::get_string_value( $donation['id'] ) ) : 0,
493 'campaign_id' => isset( $donation['campaign_id'] ) ? absint( Helper::get_string_value( $donation['campaign_id'] ) ) : 0,
494 'form_id' => isset( $donation['form_id'] ) ? absint( Helper::get_string_value( $donation['form_id'] ) ) : 0,
495 'donor_id' => isset( $donation['donor_id'] ) ? absint( Helper::get_string_value( $donation['donor_id'] ) ) : 0,
496 'donor_name' => $is_anonymous ? '' : Helper::get_string_value( $donation['donor_name'] ?? '' ),
497 'donor_email' => $is_anonymous ? '' : Helper::get_string_value( $donation['donor_email'] ?? '' ),
498 'donor_phone' => $is_anonymous ? '' : Helper::get_string_value( $donation['donor_phone'] ?? '' ),
499 'amount' => Helper::get_float_value( $donation['amount'] ?? 0 ),
500 'fees_covered' => Helper::get_float_value( $donation['fees_covered'] ?? 0 ),
501 'refunded_amount' => Helper::get_float_value( $donation['refunded_amount'] ?? 0 ),
502 'currency' => Helper::get_string_value( $donation['currency'] ?? '' ),
503 'gateway' => Helper::get_string_value( $donation['gateway'] ?? '' ),
504 'payment_status' => Helper::get_string_value( $donation['payment_status'] ?? '' ),
505 'payment_mode' => Helper::get_string_value( $donation['payment_mode'] ?? '' ),
506 'donation_type' => Helper::get_string_value( $donation['donation_type'] ?? '' ),
507 'transaction_id' => Helper::get_string_value( $donation['transaction_id'] ?? '' ),
508 'subscription_id' => Helper::get_string_value( $donation['subscription_id'] ?? '' ),
509 'subscription_status' => Helper::get_string_value( $donation['subscription_status'] ?? '' ),
510 'donor_comment' => $is_anonymous ? '' : Helper::get_string_value( $donation['donor_comment'] ?? '' ),
511 'is_anonymous' => $is_anonymous,
512 'created_at' => Helper::get_string_value( $donation['created_at'] ?? '' ),
513 'updated_at' => Helper::get_string_value( $donation['updated_at'] ?? '' ),
514 ];
515 }
516
517 /**
518 * Get a single donation by ID.
519 *
520 * @param int $donation_id Donation ID.
521 * @return array<mixed>|null Donation data or null if not found.
522 * @since 0.0.1
523 */
524 public static function get( $donation_id ) {
525 if ( empty( $donation_id ) ) {
526 return null;
527 }
528
529 $instance = self::get_instance();
530 global $wpdb;
531
532 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
533 $result = $wpdb->get_row(
534 $wpdb->prepare(
535 'SELECT * FROM %i WHERE id = %d',
536 $instance->get_tablename(),
537 absint( $donation_id )
538 ),
539 ARRAY_A
540 );
541
542 if ( ! $result ) {
543 return null;
544 }
545
546 return $instance->decode_by_datatype( $result );
547 }
548
549 /**
550 * Get all donations with pagination.
551 *
552 * @param int $limit Number of records to return.
553 * @param int $offset Offset for pagination.
554 * @param string $orderby Column to order by.
555 * @param string $order Order direction (ASC or DESC).
556 * @return array<mixed> Array of donations.
557 * @since 0.0.1
558 */
559 public static function get_all( $limit = 10, $offset = 0, $orderby = 'created_at', $order = 'DESC' ) {
560 $instance = self::get_instance();
561 global $wpdb;
562 $table = $instance->get_tablename();
563
564 // Validate orderby column.
565 if ( ! in_array( $orderby, self::$valid_order_columns, true ) ) {
566 $orderby = 'created_at';
567 }
568
569 // Validate order direction.
570 $order = strtoupper( $order );
571 if ( ! in_array( $order, [ 'ASC', 'DESC' ], true ) ) {
572 $order = 'DESC';
573 }
574
575 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Data changes frequently, caching would show stale results.
576 $results = 'ASC' === $order
577 ? $wpdb->get_results(
578 $wpdb->prepare(
579 'SELECT * FROM %i ORDER BY %i ASC LIMIT %d, %d',
580 $table,
581 $orderby,
582 absint( $offset ),
583 absint( $limit )
584 ),
585 ARRAY_A
586 )
587 : $wpdb->get_results(
588 $wpdb->prepare(
589 'SELECT * FROM %i ORDER BY %i DESC LIMIT %d, %d',
590 $table,
591 $orderby,
592 absint( $offset ),
593 absint( $limit )
594 ),
595 ARRAY_A
596 );
597 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
598
599 if ( ! $results || ! is_array( $results ) ) {
600 return [];
601 }
602
603 return array_map( [ $instance, 'decode_by_datatype' ], $results );
604 }
605
606 /**
607 * Get donations for admin listing with optional filters.
608 *
609 * @param string $status Payment status filter ('all' for no filter).
610 * @param int $campaign_id Campaign ID filter (0 for no filter).
611 * @param string $search Search term for donor_name, donor_email, or transaction_id.
612 * @param int $limit Number of records to return.
613 * @param int $offset Offset for pagination.
614 * @param string $orderby Column to order by.
615 * @param string $order Order direction (ASC or DESC).
616 * @return array<mixed> Array of donations.
617 * @since 0.0.1
618 */
619 public static function get_admin_list( $status = 'all', $campaign_id = 0, $search = '', $limit = 10, $offset = 0, $orderby = 'created_at', $order = 'DESC' ) {
620 $instance = self::get_instance();
621 global $wpdb;
622 $table = $instance->get_tablename();
623
624 // Validate orderby column.
625 if ( ! in_array( $orderby, self::$valid_order_columns, true ) ) {
626 $orderby = 'created_at';
627 }
628
629 // Validate order direction.
630 $order = strtoupper( $order );
631 if ( ! in_array( $order, [ 'ASC', 'DESC' ], true ) ) {
632 $order = 'DESC';
633 }
634
635 // Build query based on filters.
636 // Note: Renewal records (donation_type = 'renewal') are intentionally included in the listing.
637 // They are shown alongside parent subscriptions so admins can see all transaction activity.
638 // Renewals are also accessible from the parent donation's subscription detail billing history.
639 $has_status = 'all' !== $status;
640 $has_campaign = $campaign_id > 0;
641 $has_search = ! empty( $search );
642 $is_asc = 'ASC' === $order;
643
644 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Data changes frequently, caching would show stale results.
645
646 // All three filters.
647 if ( $has_status && $has_campaign && $has_search ) {
648 $search_term = '%' . $wpdb->esc_like( sanitize_text_field( $search ) ) . '%';
649 $results = $is_asc
650 ? $wpdb->get_results(
651 $wpdb->prepare(
652 'SELECT * FROM %i WHERE payment_status = %s AND campaign_id = %d AND (donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s) ORDER BY %i ASC LIMIT %d, %d',
653 $table,
654 sanitize_text_field( $status ),
655 absint( $campaign_id ),
656 $search_term,
657 $search_term,
658 $search_term,
659 $orderby,
660 absint( $offset ),
661 absint( $limit )
662 ),
663 ARRAY_A
664 )
665 : $wpdb->get_results(
666 $wpdb->prepare(
667 'SELECT * FROM %i WHERE payment_status = %s AND campaign_id = %d AND (donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s) ORDER BY %i DESC LIMIT %d, %d',
668 $table,
669 sanitize_text_field( $status ),
670 absint( $campaign_id ),
671 $search_term,
672 $search_term,
673 $search_term,
674 $orderby,
675 absint( $offset ),
676 absint( $limit )
677 ),
678 ARRAY_A
679 );
680 } elseif ( $has_status && $has_campaign ) {
681 $results = $is_asc
682 ? $wpdb->get_results(
683 $wpdb->prepare(
684 'SELECT * FROM %i WHERE payment_status = %s AND campaign_id = %d ORDER BY %i ASC LIMIT %d, %d',
685 $table,
686 sanitize_text_field( $status ),
687 absint( $campaign_id ),
688 $orderby,
689 absint( $offset ),
690 absint( $limit )
691 ),
692 ARRAY_A
693 )
694 : $wpdb->get_results(
695 $wpdb->prepare(
696 'SELECT * FROM %i WHERE payment_status = %s AND campaign_id = %d ORDER BY %i DESC LIMIT %d, %d',
697 $table,
698 sanitize_text_field( $status ),
699 absint( $campaign_id ),
700 $orderby,
701 absint( $offset ),
702 absint( $limit )
703 ),
704 ARRAY_A
705 );
706 } elseif ( $has_status && $has_search ) {
707 $search_term = '%' . $wpdb->esc_like( sanitize_text_field( $search ) ) . '%';
708 $results = $is_asc
709 ? $wpdb->get_results(
710 $wpdb->prepare(
711 'SELECT * FROM %i WHERE payment_status = %s AND (donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s) ORDER BY %i ASC LIMIT %d, %d',
712 $table,
713 sanitize_text_field( $status ),
714 $search_term,
715 $search_term,
716 $search_term,
717 $orderby,
718 absint( $offset ),
719 absint( $limit )
720 ),
721 ARRAY_A
722 )
723 : $wpdb->get_results(
724 $wpdb->prepare(
725 'SELECT * FROM %i WHERE payment_status = %s AND (donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s) ORDER BY %i DESC LIMIT %d, %d',
726 $table,
727 sanitize_text_field( $status ),
728 $search_term,
729 $search_term,
730 $search_term,
731 $orderby,
732 absint( $offset ),
733 absint( $limit )
734 ),
735 ARRAY_A
736 );
737 } elseif ( $has_campaign && $has_search ) {
738 $search_term = '%' . $wpdb->esc_like( sanitize_text_field( $search ) ) . '%';
739 $results = $is_asc
740 ? $wpdb->get_results(
741 $wpdb->prepare(
742 'SELECT * FROM %i WHERE campaign_id = %d AND (donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s) ORDER BY %i ASC LIMIT %d, %d',
743 $table,
744 absint( $campaign_id ),
745 $search_term,
746 $search_term,
747 $search_term,
748 $orderby,
749 absint( $offset ),
750 absint( $limit )
751 ),
752 ARRAY_A
753 )
754 : $wpdb->get_results(
755 $wpdb->prepare(
756 'SELECT * FROM %i WHERE campaign_id = %d AND (donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s) ORDER BY %i DESC LIMIT %d, %d',
757 $table,
758 absint( $campaign_id ),
759 $search_term,
760 $search_term,
761 $search_term,
762 $orderby,
763 absint( $offset ),
764 absint( $limit )
765 ),
766 ARRAY_A
767 );
768 } elseif ( $has_status ) {
769 $results = $is_asc
770 ? $wpdb->get_results(
771 $wpdb->prepare(
772 'SELECT * FROM %i WHERE payment_status = %s ORDER BY %i ASC LIMIT %d, %d',
773 $table,
774 sanitize_text_field( $status ),
775 $orderby,
776 absint( $offset ),
777 absint( $limit )
778 ),
779 ARRAY_A
780 )
781 : $wpdb->get_results(
782 $wpdb->prepare(
783 'SELECT * FROM %i WHERE payment_status = %s ORDER BY %i DESC LIMIT %d, %d',
784 $table,
785 sanitize_text_field( $status ),
786 $orderby,
787 absint( $offset ),
788 absint( $limit )
789 ),
790 ARRAY_A
791 );
792 } elseif ( $has_campaign ) {
793 $results = $is_asc
794 ? $wpdb->get_results(
795 $wpdb->prepare(
796 'SELECT * FROM %i WHERE campaign_id = %d ORDER BY %i ASC LIMIT %d, %d',
797 $table,
798 absint( $campaign_id ),
799 $orderby,
800 absint( $offset ),
801 absint( $limit )
802 ),
803 ARRAY_A
804 )
805 : $wpdb->get_results(
806 $wpdb->prepare(
807 'SELECT * FROM %i WHERE campaign_id = %d ORDER BY %i DESC LIMIT %d, %d',
808 $table,
809 absint( $campaign_id ),
810 $orderby,
811 absint( $offset ),
812 absint( $limit )
813 ),
814 ARRAY_A
815 );
816 } elseif ( $has_search ) {
817 $search_term = '%' . $wpdb->esc_like( sanitize_text_field( $search ) ) . '%';
818 $results = $is_asc
819 ? $wpdb->get_results(
820 $wpdb->prepare(
821 'SELECT * FROM %i WHERE (donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s) ORDER BY %i ASC LIMIT %d, %d',
822 $table,
823 $search_term,
824 $search_term,
825 $search_term,
826 $orderby,
827 absint( $offset ),
828 absint( $limit )
829 ),
830 ARRAY_A
831 )
832 : $wpdb->get_results(
833 $wpdb->prepare(
834 'SELECT * FROM %i WHERE (donor_name LIKE %s OR donor_email LIKE %s OR transaction_id LIKE %s) ORDER BY %i DESC LIMIT %d, %d',
835 $table,
836 $search_term,
837 $search_term,
838 $search_term,
839 $orderby,
840 absint( $offset ),
841 absint( $limit )
842 ),
843 ARRAY_A
844 );
845 } else {
846 $results = $is_asc
847 ? $wpdb->get_results(
848 $wpdb->prepare(
849 'SELECT * FROM %i ORDER BY %i ASC LIMIT %d, %d',
850 $table,
851 $orderby,
852 absint( $offset ),
853 absint( $limit )
854 ),
855 ARRAY_A
856 )
857 : $wpdb->get_results(
858 $wpdb->prepare(
859 'SELECT * FROM %i ORDER BY %i DESC LIMIT %d, %d',
860 $table,
861 $orderby,
862 absint( $offset ),
863 absint( $limit )
864 ),
865 ARRAY_A
866 );
867 }
868
869 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
870
871 if ( ! $results || ! is_array( $results ) ) {
872 return [];
873 }
874
875 return array_map( [ $instance, 'decode_by_datatype' ], $results );
876 }
877
878 /**
879 * Get donations by status with pagination.
880 *
881 * @param string $status Payment status.
882 * @param int $limit Number of records to return.
883 * @param int $offset Offset for pagination.
884 * @param string $orderby Column to order by.
885 * @param string $order Order direction (ASC or DESC).
886 * @return array<mixed> Array of donations.
887 * @since 0.0.1
888 */
889 public static function get_by_status( $status, $limit = 10, $offset = 0, $orderby = 'created_at', $order = 'DESC' ) {
890 $instance = self::get_instance();
891 global $wpdb;
892 $table = $instance->get_tablename();
893
894 // Validate orderby column.
895 if ( ! in_array( $orderby, self::$valid_order_columns, true ) ) {
896 $orderby = 'created_at';
897 }
898
899 // Validate order direction.
900 $order = strtoupper( $order );
901 if ( ! in_array( $order, [ 'ASC', 'DESC' ], true ) ) {
902 $order = 'DESC';
903 }
904
905 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Data changes frequently, caching would show stale results.
906 $results = 'ASC' === $order
907 ? $wpdb->get_results(
908 $wpdb->prepare(
909 'SELECT * FROM %i WHERE payment_status = %s ORDER BY %i ASC LIMIT %d, %d',
910 $table,
911 sanitize_text_field( $status ),
912 $orderby,
913 absint( $offset ),
914 absint( $limit )
915 ),
916 ARRAY_A
917 )
918 : $wpdb->get_results(
919 $wpdb->prepare(
920 'SELECT * FROM %i WHERE payment_status = %s ORDER BY %i DESC LIMIT %d, %d',
921 $table,
922 sanitize_text_field( $status ),
923 $orderby,
924 absint( $offset ),
925 absint( $limit )
926 ),
927 ARRAY_A
928 );
929 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
930
931 if ( ! $results || ! is_array( $results ) ) {
932 return [];
933 }
934
935 return array_map( [ $instance, 'decode_by_datatype' ], $results );
936 }
937
938 /**
939 * Get donations by campaign ID with pagination.
940 *
941 * @param int $campaign_id Campaign ID.
942 * @param int $limit Number of records to return.
943 * @param int $offset Offset for pagination.
944 * @param string $orderby Column to order by.
945 * @param string $order Order direction (ASC or DESC).
946 * @return array<mixed> Array of donations.
947 * @since 0.0.1
948 */
949 public static function get_by_campaign_id( $campaign_id, $limit = 100, $offset = 0, $orderby = 'created_at', $order = 'DESC' ) {
950 if ( empty( $campaign_id ) ) {
951 return [];
952 }
953
954 $instance = self::get_instance();
955 global $wpdb;
956 $table = $instance->get_tablename();
957
958 // Validate orderby column.
959 if ( ! in_array( $orderby, self::$valid_order_columns, true ) ) {
960 $orderby = 'created_at';
961 }
962
963 // Validate order direction.
964 $order = strtoupper( $order );
965 if ( ! in_array( $order, [ 'ASC', 'DESC' ], true ) ) {
966 $order = 'DESC';
967 }
968
969 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Data changes frequently, caching would show stale results.
970 $results = 'ASC' === $order
971 ? $wpdb->get_results(
972 $wpdb->prepare(
973 'SELECT * FROM %i WHERE campaign_id = %d ORDER BY %i ASC LIMIT %d, %d',
974 $table,
975 absint( $campaign_id ),
976 $orderby,
977 absint( $offset ),
978 absint( $limit )
979 ),
980 ARRAY_A
981 )
982 : $wpdb->get_results(
983 $wpdb->prepare(
984 'SELECT * FROM %i WHERE campaign_id = %d ORDER BY %i DESC LIMIT %d, %d',
985 $table,
986 absint( $campaign_id ),
987 $orderby,
988 absint( $offset ),
989 absint( $limit )
990 ),
991 ARRAY_A
992 );
993 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
994
995 if ( ! $results || ! is_array( $results ) ) {
996 return [];
997 }
998
999 return array_map( [ $instance, 'decode_by_datatype' ], $results );
1000 }
1001
1002 /**
1003 * Delete a donation record.
1004 *
1005 * @param int $donation_id Donation ID.
1006 * @return int|false Number of rows deleted or false on error.
1007 * @since 0.0.1
1008 */
1009 public static function delete( $donation_id ) {
1010 if ( empty( $donation_id ) ) {
1011 return false;
1012 }
1013
1014 return self::get_instance()->use_delete( [ 'id' => absint( $donation_id ) ] );
1015 }
1016
1017 /**
1018 * Get donations by donor email.
1019 *
1020 * @param string $email Donor email.
1021 * @param int $limit Max rows to return; 0 (default) returns all rows.
1022 * @param int $offset Row offset, applied only when $limit > 0.
1023 * @return array<mixed> Array of donations.
1024 * @since 0.0.1
1025 */
1026 public static function get_by_donor_email( $email, $limit = 0, $offset = 0 ) {
1027 if ( empty( $email ) ) {
1028 return [];
1029 }
1030
1031 $instance = self::get_instance();
1032 global $wpdb;
1033
1034 $limit = max( 0, (int) $limit );
1035 $offset = max( 0, (int) $offset );
1036
1037 if ( $limit > 0 ) {
1038 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1039 $results = $wpdb->get_results(
1040 $wpdb->prepare(
1041 'SELECT * FROM %i WHERE donor_email = %s ORDER BY created_at DESC, id DESC LIMIT %d OFFSET %d',
1042 $instance->get_tablename(),
1043 sanitize_email( $email ),
1044 $limit,
1045 $offset
1046 ),
1047 ARRAY_A
1048 );
1049 } else {
1050 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1051 $results = $wpdb->get_results(
1052 $wpdb->prepare(
1053 'SELECT * FROM %i WHERE donor_email = %s ORDER BY created_at DESC, id DESC',
1054 $instance->get_tablename(),
1055 sanitize_email( $email )
1056 ),
1057 ARRAY_A
1058 );
1059 }
1060
1061 if ( ! $results || ! is_array( $results ) ) {
1062 return [];
1063 }
1064
1065 return array_map( [ $instance, 'decode_by_datatype' ], $results );
1066 }
1067
1068 /**
1069 * Get donation by transaction ID.
1070 *
1071 * @param string $transaction_id Transaction ID.
1072 * @return array<string, mixed>|null Donation data or null if not found.
1073 * @since 0.0.1
1074 */
1075 public static function get_by_transaction_id( $transaction_id ) {
1076 if ( empty( $transaction_id ) ) {
1077 return null;
1078 }
1079
1080 $instance = self::get_instance();
1081 global $wpdb;
1082
1083 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1084 $result = $wpdb->get_row(
1085 $wpdb->prepare(
1086 'SELECT * FROM %i WHERE transaction_id = %s LIMIT 1',
1087 $instance->get_tablename(),
1088 sanitize_text_field( $transaction_id )
1089 ),
1090 ARRAY_A
1091 );
1092
1093 if ( ! $result ) {
1094 return null;
1095 }
1096
1097 return $instance->decode_by_datatype( $result );
1098 }
1099
1100 /**
1101 * Get total donations count (no filters).
1102 *
1103 * @return int Total count.
1104 * @since 0.0.1
1105 */
1106 public static function count_all() {
1107 $instance = self::get_instance();
1108 global $wpdb;
1109
1110 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1111 $count = $wpdb->get_var(
1112 $wpdb->prepare(
1113 'SELECT COUNT(*) FROM %i',
1114 $instance->get_tablename()
1115 )
1116 );
1117
1118 return is_numeric( $count ) ? (int) $count : 0;
1119 }
1120
1121 /**
1122 * Get total donations count by payment status.
1123 *
1124 * @param string $status Payment status.
1125 * @return int Total count.
1126 * @since 0.0.1
1127 */
1128 public static function count_by_status( $status ) {
1129 $instance = self::get_instance();
1130 global $wpdb;
1131
1132 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1133 $count = $wpdb->get_var(
1134 $wpdb->prepare(
1135 'SELECT COUNT(*) FROM %i WHERE payment_status = %s',
1136 $instance->get_tablename(),
1137 sanitize_text_field( $status )
1138 )
1139 );
1140
1141 return is_numeric( $count ) ? (int) $count : 0;
1142 }
1143
1144 /**
1145 * Get the count of completed, live-mode donations.
1146 *
1147 * Used to gate the review admin notice: a completed live donation is the
1148 * signal that the site has taken a genuine (non-test) donation.
1149 *
1150 * @return int Count of completed live donations.
1151 * @since 1.2.0
1152 */
1153 public static function count_live_completed() {
1154 $instance = self::get_instance();
1155 global $wpdb;
1156
1157 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1158 $count = $wpdb->get_var(
1159 $wpdb->prepare(
1160 'SELECT COUNT(*) FROM %i WHERE payment_status = %s AND payment_mode = %s',
1161 $instance->get_tablename(),
1162 'completed',
1163 'live'
1164 )
1165 );
1166
1167 return is_numeric( $count ) ? (int) $count : 0;
1168 }
1169
1170 /**
1171 * Get total donations count by campaign.
1172 *
1173 * @param int $campaign_id Campaign ID.
1174 * @return int Total count.
1175 * @since 0.0.1
1176 */
1177 public static function count_by_campaign( $campaign_id ) {
1178 $instance = self::get_instance();
1179 global $wpdb;
1180
1181 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1182 $count = $wpdb->get_var(
1183 $wpdb->prepare(
1184 'SELECT COUNT(*) FROM %i WHERE campaign_id = %d',
1185 $instance->get_tablename(),
1186 absint( $campaign_id )
1187 )
1188 );
1189
1190 return is_numeric( $count ) ? (int) $count : 0;
1191 }
1192
1193 /**
1194 * Get total donations count by status and campaign.
1195 *
1196 * @param string $status Payment status ('all' for no filter).
1197 * @param int $campaign_id Optional campaign ID (0 for no filter).
1198 * @return int Total count.
1199 * @since 0.0.1
1200 */
1201 public static function get_total_donations_by_status( $status = 'all', $campaign_id = 0 ) {
1202 $instance = self::get_instance();
1203 global $wpdb;
1204
1205 // Both filters.
1206 if ( 'all' !== $status && $campaign_id > 0 ) {
1207 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1208 $count = $wpdb->get_var(
1209 $wpdb->prepare(
1210 'SELECT COUNT(*) FROM %i WHERE payment_status = %s AND campaign_id = %d',
1211 $instance->get_tablename(),
1212 sanitize_text_field( $status ),
1213 absint( $campaign_id )
1214 )
1215 );
1216 return is_numeric( $count ) ? (int) $count : 0;
1217 }
1218
1219 // Status filter only.
1220 if ( 'all' !== $status ) {
1221 return self::count_by_status( $status );
1222 }
1223
1224 // Campaign filter only.
1225 if ( $campaign_id > 0 ) {
1226 return self::count_by_campaign( $campaign_id );
1227 }
1228
1229 // No filters.
1230 return self::count_all();
1231 }
1232
1233 /**
1234 * Get campaign statistics.
1235 *
1236 * @param int $campaign_id Campaign ID.
1237 * @return array<string,mixed> Campaign statistics.
1238 * @since 0.0.1
1239 */
1240 public static function get_campaign_stats( $campaign_id ) {
1241 $instance = self::get_instance();
1242 global $wpdb;
1243
1244 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1245 $stats = $wpdb->get_row(
1246 $wpdb->prepare(
1247 "SELECT
1248 COUNT(*) as donation_count,
1249 COALESCE(SUM(amount - refunded_amount), 0) as total_raised,
1250 COUNT(DISTINCT donor_email) as unique_donors,
1251 COALESCE(AVG(amount - refunded_amount), 0) as average_donation,
1252 COALESCE(MAX(amount - refunded_amount), 0) as largest_donation
1253 FROM %i
1254 WHERE campaign_id = %d AND payment_status IN ('completed', 'partially_refunded')",
1255 $instance->get_tablename(),
1256 absint( $campaign_id )
1257 ),
1258 ARRAY_A
1259 );
1260
1261 return $stats ? $stats : [
1262 'donation_count' => 0,
1263 'total_raised' => 0,
1264 'unique_donors' => 0,
1265 'average_donation' => 0,
1266 'largest_donation' => 0,
1267 ];
1268 }
1269
1270 /**
1271 * Get global dashboard statistics.
1272 *
1273 * @return array{total_donations: string, total_raised: string, unique_donors: string, average_donation: string, largest_donation: string} Dashboard statistics.
1274 * @since 0.0.1
1275 */
1276 public static function get_dashboard_stats() {
1277 $instance = self::get_instance();
1278 global $wpdb;
1279
1280 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1281 $stats = $wpdb->get_row(
1282 $wpdb->prepare(
1283 "SELECT
1284 COUNT(*) as total_donations,
1285 COALESCE(SUM(amount - refunded_amount), 0) as total_raised,
1286 COUNT(DISTINCT donor_email) as unique_donors,
1287 COALESCE(AVG(amount - refunded_amount), 0) as average_donation,
1288 COALESCE(MAX(amount - refunded_amount), 0) as largest_donation
1289 FROM %i
1290 WHERE payment_status IN ('completed', 'partially_refunded')",
1291 $instance->get_tablename()
1292 ),
1293 ARRAY_A
1294 );
1295
1296 return $stats ? $stats : [
1297 'total_donations' => 0,
1298 'total_raised' => 0,
1299 'unique_donors' => 0,
1300 'average_donation' => 0,
1301 'largest_donation' => 0,
1302 ];
1303 }
1304
1305 /**
1306 * Get recent donations globally (all campaigns).
1307 *
1308 * @param int $limit Number of donations to retrieve.
1309 * @return array<int, array<string, mixed>> Array of recent donations.
1310 * @since 0.0.1
1311 */
1312 public static function get_recent_donations_global( $limit = 5 ) {
1313 $instance = self::get_instance();
1314 global $wpdb;
1315
1316 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1317 $results = $wpdb->get_results(
1318 $wpdb->prepare(
1319 "SELECT * FROM %i WHERE payment_status IN ('completed', 'partially_refunded') ORDER BY created_at DESC LIMIT %d",
1320 $instance->get_tablename(),
1321 absint( $limit )
1322 ),
1323 ARRAY_A
1324 );
1325
1326 if ( ! $results || ! is_array( $results ) ) {
1327 return [];
1328 }
1329
1330 return array_map( [ $instance, 'decode_by_datatype' ], $results );
1331 }
1332
1333 /**
1334 * Get top campaigns by donations.
1335 *
1336 * @param int $limit Number of campaigns to retrieve.
1337 * @return array<int, array{campaign_id: string, donation_count: string, total_raised: string, unique_donors: string}> Array of top campaigns with stats.
1338 * @since 0.0.1
1339 */
1340 public static function get_top_campaigns( $limit = 5 ) {
1341 $instance = self::get_instance();
1342 global $wpdb;
1343
1344 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1345 $results = $wpdb->get_results(
1346 $wpdb->prepare(
1347 "SELECT
1348 campaign_id,
1349 COUNT(*) as donation_count,
1350 COALESCE(SUM(amount - refunded_amount), 0) as total_raised,
1351 COUNT(DISTINCT donor_email) as unique_donors
1352 FROM %i
1353 WHERE payment_status IN ('completed', 'partially_refunded')
1354 GROUP BY campaign_id
1355 ORDER BY total_raised DESC
1356 LIMIT %d",
1357 $instance->get_tablename(),
1358 absint( $limit )
1359 ),
1360 ARRAY_A
1361 );
1362
1363 return $results ? $results : [];
1364 }
1365
1366 /**
1367 * Get donation trends over time.
1368 *
1369 * @param string $after Start date (ISO format).
1370 * @param string $before End date (ISO format).
1371 * @param string $group Grouping: 'day', 'week', or 'month'.
1372 * @return array<int, array{period: string, donation_count: string, total_amount: string}> Array of donation trends.
1373 * @since 0.0.1
1374 */
1375 public static function get_donation_trends( $after = '', $before = '', $group = 'day' ) {
1376 $instance = self::get_instance();
1377 global $wpdb;
1378
1379 // Default to last 30 days if no dates provided.
1380 if ( empty( $after ) ) {
1381 $after = gmdate( 'Y-m-d', strtotime( '-30 days' ) );
1382 }
1383 if ( empty( $before ) ) {
1384 $before = gmdate( 'Y-m-d' );
1385 }
1386
1387 // Determine date format based on grouping.
1388 switch ( $group ) {
1389 case 'month':
1390 $date_format = '%Y-%m-01';
1391 break;
1392 case 'week':
1393 $date_format = '%x-%v'; // ISO year-week.
1394 break;
1395 case 'day':
1396 default:
1397 $date_format = '%Y-%m-%d';
1398 break;
1399 }
1400
1401 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1402 $results = $wpdb->get_results(
1403 $wpdb->prepare(
1404 "SELECT
1405 DATE_FORMAT(created_at, %s) as period,
1406 COUNT(*) as donation_count,
1407 COALESCE(SUM(amount - refunded_amount), 0) as total_amount
1408 FROM %i
1409 WHERE payment_status IN ('completed', 'partially_refunded')
1410 AND DATE(created_at) >= %s
1411 AND DATE(created_at) <= %s
1412 GROUP BY period
1413 ORDER BY period ASC",
1414 $date_format,
1415 $instance->get_tablename(),
1416 $after,
1417 $before
1418 ),
1419 ARRAY_A
1420 );
1421
1422 return $results ? $results : [];
1423 }
1424
1425 /**
1426 * Get recent donations for a campaign.
1427 *
1428 * @param int $campaign_id Campaign ID.
1429 * @param int $limit Number of donations to retrieve.
1430 * @return array<mixed> Array of recent donations.
1431 * @since 0.0.1
1432 */
1433 public static function get_recent_donations( $campaign_id, $limit = 5 ) {
1434 $instance = self::get_instance();
1435 global $wpdb;
1436
1437 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1438 $results = $wpdb->get_results(
1439 $wpdb->prepare(
1440 "SELECT * FROM %i WHERE campaign_id = %d AND payment_status IN ('completed', 'partially_refunded') ORDER BY created_at DESC LIMIT %d",
1441 $instance->get_tablename(),
1442 absint( $campaign_id ),
1443 absint( $limit )
1444 ),
1445 ARRAY_A
1446 );
1447
1448 if ( ! $results || ! is_array( $results ) ) {
1449 return [];
1450 }
1451
1452 return array_map( [ $instance, 'decode_by_datatype' ], $results );
1453 }
1454
1455 /**
1456 * Get paginated donations for a specific donor.
1457 *
1458 * @param int $donor_id Donor ID.
1459 * @param int $limit Number of records to return.
1460 * @param int $offset Offset for pagination.
1461 * @return array{donations: array<int, array<string, mixed>>, total: int} Paginated donations and total count.
1462 * @since 1.0.0
1463 */
1464 public static function get_by_donor_id( $donor_id, $limit = 10, $offset = 0 ) {
1465 if ( empty( $donor_id ) ) {
1466 return [
1467 'donations' => [],
1468 'total' => 0,
1469 ];
1470 }
1471
1472 $instance = self::get_instance();
1473 global $wpdb;
1474 $table = $instance->get_tablename();
1475
1476 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1477
1478 $total = $wpdb->get_var(
1479 $wpdb->prepare(
1480 'SELECT COUNT(*) FROM %i WHERE donor_id = %d',
1481 $table,
1482 absint( $donor_id )
1483 )
1484 );
1485
1486 $results = $wpdb->get_results(
1487 $wpdb->prepare(
1488 'SELECT * FROM %i WHERE donor_id = %d ORDER BY created_at DESC LIMIT %d, %d',
1489 $table,
1490 absint( $donor_id ),
1491 absint( $offset ),
1492 absint( $limit )
1493 ),
1494 ARRAY_A
1495 );
1496
1497 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1498
1499 if ( ! $results || ! is_array( $results ) ) {
1500 $results = [];
1501 }
1502
1503 return [
1504 'donations' => array_map( [ $instance, 'decode_by_datatype' ], $results ),
1505 'total' => is_numeric( $total ) ? (int) $total : 0,
1506 ];
1507 }
1508
1509 /**
1510 * Get donation activity data for a specific donor (for chart).
1511 *
1512 * @param int $donor_id Donor ID.
1513 * @param string $after Start date (Y-m-d).
1514 * @param string $before End date (Y-m-d).
1515 * @return array{chart_data: array<int, array{date: string, amount: float}>, stats: array{lifetime: float, highest: float, average: float}} Activity data.
1516 * @since 1.0.0
1517 */
1518 public static function get_donor_activity( $donor_id, $after = '', $before = '' ) {
1519 if ( empty( $donor_id ) ) {
1520 return [
1521 'chart_data' => [],
1522 'stats' => [
1523 'lifetime' => 0,
1524 'highest' => 0,
1525 'average' => 0,
1526 ],
1527 ];
1528 }
1529
1530 $instance = self::get_instance();
1531 global $wpdb;
1532 $table = $instance->get_tablename();
1533
1534 // Default date range: last 30 days.
1535 if ( empty( $after ) ) {
1536 $after = gmdate( 'Y-m-d', strtotime( '-30 days' ) );
1537 }
1538 if ( empty( $before ) ) {
1539 $before = gmdate( 'Y-m-d' );
1540 }
1541
1542 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1543
1544 // Chart data: donations grouped by date.
1545 $chart_data = $wpdb->get_results(
1546 $wpdb->prepare(
1547 "SELECT DATE(created_at) as date, COALESCE(SUM(amount), 0) as amount
1548 FROM %i
1549 WHERE donor_id = %d
1550 AND payment_status IN ('completed', 'partially_refunded')
1551 AND DATE(created_at) >= %s
1552 AND DATE(created_at) <= %s
1553 GROUP BY DATE(created_at)
1554 ORDER BY date ASC",
1555 $table,
1556 absint( $donor_id ),
1557 $after,
1558 $before
1559 ),
1560 ARRAY_A
1561 );
1562
1563 // Lifetime stats for this donor.
1564 $stats = $wpdb->get_row(
1565 $wpdb->prepare(
1566 "SELECT
1567 COALESCE(SUM(amount - refunded_amount), 0) as lifetime,
1568 COALESCE(MAX(amount), 0) as highest,
1569 COALESCE(AVG(amount), 0) as average
1570 FROM %i
1571 WHERE donor_id = %d AND payment_status IN ('completed', 'partially_refunded')",
1572 $table,
1573 absint( $donor_id )
1574 ),
1575 ARRAY_A
1576 );
1577
1578 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1579
1580 $stats = is_array( $stats ) ? $stats : [];
1581
1582 return [
1583 'chart_data' => is_array( $chart_data ) ? $chart_data : [],
1584 'stats' => [
1585 'lifetime' => is_numeric( $stats['lifetime'] ?? 0 ) ? round( (float) ( $stats['lifetime'] ?? 0 ), 2 ) : 0,
1586 'highest' => is_numeric( $stats['highest'] ?? 0 ) ? round( (float) ( $stats['highest'] ?? 0 ), 2 ) : 0,
1587 'average' => is_numeric( $stats['average'] ?? 0 ) ? round( (float) ( $stats['average'] ?? 0 ), 2 ) : 0,
1588 ],
1589 ];
1590 }
1591
1592 /**
1593 * Update donation status.
1594 *
1595 * @param int $donation_id Donation ID.
1596 * @param string $status New status.
1597 * @return int|false Number of rows updated or false on error.
1598 * @since 0.0.1
1599 */
1600 public static function update_status( $donation_id, $status ) {
1601 if ( empty( $donation_id ) || ! in_array( $status, self::$valid_statuses, true ) ) {
1602 return false;
1603 }
1604
1605 return self::update( $donation_id, [ 'payment_status' => $status ] );
1606 }
1607
1608 /**
1609 * Get valid payment statuses.
1610 *
1611 * @return array<string> Valid statuses.
1612 * @since 0.0.1
1613 */
1614 public static function get_valid_statuses() {
1615 return self::$valid_statuses;
1616 }
1617
1618 /**
1619 * Add a log entry to a donation.
1620 *
1621 * @param int $donation_id Donation ID.
1622 * @param string $action Action type (e.g., 'status_change', 'refund', 'webhook').
1623 * @param string $message Log message.
1624 * @param array<string, mixed> $data Optional additional data.
1625 * @return int|false Number of rows updated or false on error.
1626 * @since 0.0.1
1627 */
1628 public static function add_log( $donation_id, $action, $message, $data = [] ) {
1629 if ( empty( $donation_id ) ) {
1630 return false;
1631 }
1632
1633 $donation = self::get( $donation_id );
1634 if ( ! $donation ) {
1635 return false;
1636 }
1637
1638 // Get existing log or initialize empty array.
1639 // Note: decode_by_datatype() already decodes JSON to array, so check for array first.
1640 $log_data = $donation['log'] ?? [];
1641 if ( is_array( $log_data ) ) {
1642 $log = $log_data;
1643 } elseif ( is_string( $log_data ) && ! empty( $log_data ) ) {
1644 $log = json_decode( $log_data, true );
1645 if ( ! is_array( $log ) ) {
1646 $log = [];
1647 }
1648 } else {
1649 $log = [];
1650 }
1651
1652 // Add new log entry.
1653 $log[] = [
1654 'action' => sanitize_text_field( $action ),
1655 'message' => sanitize_text_field( $message ),
1656 'data' => $data,
1657 'timestamp' => current_time( 'mysql' ),
1658 ];
1659
1660 return self::update( $donation_id, [ 'log' => $log ] );
1661 }
1662
1663 /**
1664 * Get log entries for a donation.
1665 *
1666 * @param int $donation_id Donation ID.
1667 * @return array<int, array<string, mixed>> Log entries.
1668 * @since 0.0.1
1669 */
1670 public static function get_log( $donation_id ) {
1671 if ( empty( $donation_id ) ) {
1672 return [];
1673 }
1674
1675 $donation = self::get( $donation_id );
1676 if ( ! $donation || empty( $donation['log'] ) ) {
1677 return [];
1678 }
1679
1680 // Note: decode_by_datatype() already decodes JSON to array, so check for array first.
1681 $log_data = $donation['log'];
1682 if ( is_array( $log_data ) ) {
1683 return $log_data;
1684 }
1685
1686 if ( is_string( $log_data ) ) {
1687 $log = json_decode( $log_data, true );
1688 return is_array( $log ) ? $log : [];
1689 }
1690
1691 return [];
1692 }
1693
1694 /**
1695 * Add refund data to donation_data for audit trail and duplicate prevention.
1696 *
1697 * Stores each refund with its ID as the key for O(1) lookups.
1698 *
1699 * @param int $donation_id Donation ID.
1700 * @param array<string, mixed> $refund_data Refund data to store.
1701 * @return bool True on success, false on failure.
1702 * @since 0.0.1
1703 */
1704 public static function add_refund_to_donation_data( $donation_id, $refund_data ) {
1705 $refund_id = $refund_data['refund_id'] ?? '';
1706
1707 if ( empty( $refund_id ) || empty( $donation_id ) ) {
1708 return false;
1709 }
1710
1711 $donation = self::get( $donation_id );
1712 if ( ! $donation ) {
1713 return false;
1714 }
1715
1716 // Get existing donation_data.
1717 $donation_data = $donation['donation_data'] ?? [];
1718 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
1719 $donation_data = json_decode( $donation_data, true );
1720 }
1721 if ( ! is_array( $donation_data ) ) {
1722 $donation_data = [];
1723 }
1724
1725 // Initialize refunds array if not exists.
1726 if ( ! isset( $donation_data['refunds'] ) || ! is_array( $donation_data['refunds'] ) ) {
1727 $donation_data['refunds'] = [];
1728 }
1729
1730 // Store with refund ID as key for O(1) lookup (duplicate prevention).
1731 $donation_data['refunds'][ $refund_id ] = $refund_data;
1732
1733 // Update donation_data in database.
1734 $result = self::update( $donation_id, [ 'donation_data' => $donation_data ] );
1735
1736 return false !== $result;
1737 }
1738
1739 /**
1740 * Store the submitted form field values under the donation_data['fields'] key.
1741 *
1742 * The donation_data column is shared JSON (also holds refunds, notes and
1743 * subscription metadata), so the field data is merged under a dedicated
1744 * 'fields' key and never overwrites the column.
1745 *
1746 * Fields are written at donation creation (before the payment is confirmed)
1747 * and are intentionally retained for abandoned/failed donations — pending
1748 * records are legitimate business data (recovery, reconciliation, reporting).
1749 * There is deliberately no automatic PII purge here; erasure is handled on
1750 * demand via the admin delete actions (and can be wired to WordPress's
1751 * personal-data eraser hooks if a retention policy is later required).
1752 *
1753 * @param int $donation_id Donation ID.
1754 * @param array<string, array{label: string, value: string}> $field_data Submitted fields as label/value pairs.
1755 * @return bool True on success, false on failure.
1756 * @since 1.1.1
1757 */
1758 public static function set_submitted_fields( $donation_id, $field_data ) {
1759 if ( empty( $donation_id ) || empty( $field_data ) || ! is_array( $field_data ) ) {
1760 return false;
1761 }
1762
1763 $donation = self::get( $donation_id );
1764 if ( ! $donation ) {
1765 return false;
1766 }
1767
1768 // Get existing donation_data.
1769 $donation_data = $donation['donation_data'] ?? [];
1770 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
1771 $donation_data = json_decode( $donation_data, true );
1772 }
1773 if ( ! is_array( $donation_data ) ) {
1774 $donation_data = [];
1775 }
1776
1777 // Merge under a dedicated key — never overwrite the shared column.
1778 $donation_data['fields'] = $field_data;
1779
1780 // Update donation_data in database.
1781 $result = self::update( $donation_id, [ 'donation_data' => $donation_data ] );
1782
1783 return false !== $result;
1784 }
1785
1786 /**
1787 * Check if a refund already exists in the donation data.
1788 *
1789 * This prevents duplicate processing of the same refund.
1790 *
1791 * @param int $donation_id Donation ID.
1792 * @param string $refund_id Refund ID to check.
1793 * @return bool True if refund already exists, false otherwise.
1794 * @since 0.0.1
1795 */
1796 public static function check_refund_exists( $donation_id, $refund_id ) {
1797 if ( empty( $donation_id ) || empty( $refund_id ) ) {
1798 return false;
1799 }
1800
1801 $donation = self::get( $donation_id );
1802 if ( ! $donation ) {
1803 return false;
1804 }
1805
1806 // Get donation_data and parse if needed.
1807 $donation_data = $donation['donation_data'] ?? [];
1808 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
1809 $donation_data = json_decode( $donation_data, true );
1810 }
1811 if ( ! is_array( $donation_data ) ) {
1812 return false;
1813 }
1814
1815 // Check if refunds array exists and contains this refund ID.
1816 if ( empty( $donation_data['refunds'] ) || ! is_array( $donation_data['refunds'] ) ) {
1817 return false;
1818 }
1819
1820 // O(1) lookup using refund ID as array key.
1821 return isset( $donation_data['refunds'][ $refund_id ] );
1822 }
1823
1824 /**
1825 * Add a note to a donation.
1826 *
1827 * @param int $donation_id Donation ID.
1828 * @param string $note_content Note content.
1829 * @param int $author_id Author user ID.
1830 * @return array{success: bool, note_id: string|null} Result with success status and note ID.
1831 * @since 0.0.1
1832 */
1833 public static function add_note( $donation_id, $note_content, $author_id = 0 ) {
1834 $result = [
1835 'success' => false,
1836 'note_id' => null,
1837 ];
1838
1839 if ( empty( $donation_id ) || empty( $note_content ) ) {
1840 return $result;
1841 }
1842
1843 $donation = self::get( $donation_id );
1844 if ( ! $donation ) {
1845 return $result;
1846 }
1847
1848 // Get existing donation_data.
1849 $donation_data = $donation['donation_data'] ?? [];
1850 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
1851 $donation_data = json_decode( $donation_data, true );
1852 }
1853 if ( ! is_array( $donation_data ) ) {
1854 $donation_data = [];
1855 }
1856
1857 // Initialize notes array if not exists.
1858 if ( ! isset( $donation_data['notes'] ) || ! is_array( $donation_data['notes'] ) ) {
1859 $donation_data['notes'] = [];
1860 }
1861
1862 // Generate unique note ID.
1863 $note_id = uniqid( 'note_', true );
1864
1865 // Get author info.
1866 $author_name = __( 'System', 'suredonation' );
1867 if ( $author_id > 0 ) {
1868 $user = get_userdata( $author_id );
1869 if ( $user ) {
1870 $author_name = $user->display_name;
1871 }
1872 }
1873
1874 // Add new note.
1875 $donation_data['notes'][ $note_id ] = [
1876 'id' => $note_id,
1877 'content' => wp_kses_post( $note_content ),
1878 'author_id' => $author_id,
1879 'author_name' => $author_name,
1880 'created_at' => current_time( 'mysql' ),
1881 ];
1882
1883 // Update donation_data in database.
1884 $update_result = self::update( $donation_id, [ 'donation_data' => $donation_data ] );
1885
1886 if ( false !== $update_result ) {
1887 $result['success'] = true;
1888 $result['note_id'] = $note_id;
1889 }
1890
1891 return $result;
1892 }
1893
1894 /**
1895 * Get notes for a donation with pagination.
1896 *
1897 * @param int $donation_id Donation ID.
1898 * @param int $page Current page (1-indexed).
1899 * @param int $per_page Notes per page.
1900 * @return array{notes: array<int, array<string, mixed>>, total: int, total_pages: int} Paginated notes.
1901 * @since 0.0.1
1902 */
1903 public static function get_notes( $donation_id, $page = 1, $per_page = 3 ) {
1904 $result = [
1905 'notes' => [],
1906 'total' => 0,
1907 'total_pages' => 0,
1908 ];
1909
1910 if ( empty( $donation_id ) ) {
1911 return $result;
1912 }
1913
1914 $donation = self::get( $donation_id );
1915 if ( ! $donation ) {
1916 return $result;
1917 }
1918
1919 // Get donation_data and parse if needed.
1920 $donation_data = $donation['donation_data'] ?? [];
1921 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
1922 $donation_data = json_decode( $donation_data, true );
1923 }
1924 if ( ! is_array( $donation_data ) ) {
1925 return $result;
1926 }
1927
1928 // Get notes array.
1929 if ( empty( $donation_data['notes'] ) || ! is_array( $donation_data['notes'] ) ) {
1930 return $result;
1931 }
1932
1933 // Convert to array values and sort by created_at (newest first).
1934 $all_notes = array_values( $donation_data['notes'] );
1935 usort(
1936 $all_notes,
1937 static function ( $a, $b ) {
1938 return strtotime( $b['created_at'] ?? '0' ) - strtotime( $a['created_at'] ?? '0' );
1939 }
1940 );
1941
1942 $total = count( $all_notes );
1943 $total_pages = (int) ceil( $total / $per_page );
1944 $offset = ( $page - 1 ) * $per_page;
1945
1946 // Get paginated notes.
1947 $notes = array_slice( $all_notes, $offset, $per_page );
1948
1949 return [
1950 'notes' => $notes,
1951 'total' => $total,
1952 'total_pages' => $total_pages,
1953 ];
1954 }
1955
1956 /**
1957 * Delete a note from a donation.
1958 *
1959 * @param int $donation_id Donation ID.
1960 * @param string $note_id Note ID to delete.
1961 * @return bool True on success, false on failure.
1962 * @since 0.0.1
1963 */
1964 public static function delete_note( $donation_id, $note_id ) {
1965 if ( empty( $donation_id ) || empty( $note_id ) ) {
1966 return false;
1967 }
1968
1969 $donation = self::get( $donation_id );
1970 if ( ! $donation ) {
1971 return false;
1972 }
1973
1974 // Get donation_data and parse if needed.
1975 $donation_data = $donation['donation_data'] ?? [];
1976 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
1977 $donation_data = json_decode( $donation_data, true );
1978 }
1979 if ( ! is_array( $donation_data ) ) {
1980 return false;
1981 }
1982
1983 // Check if note exists.
1984 if ( empty( $donation_data['notes'] ) || ! isset( $donation_data['notes'][ $note_id ] ) ) {
1985 return false;
1986 }
1987
1988 // Remove the note.
1989 unset( $donation_data['notes'][ $note_id ] );
1990
1991 // Update donation_data in database.
1992 $result = self::update( $donation_id, [ 'donation_data' => $donation_data ] );
1993
1994 return false !== $result;
1995 }
1996
1997 /**
1998 * Remove a refund from donation_data.
1999 *
2000 * Used when a refund is canceled.
2001 *
2002 * @param int $donation_id Donation ID.
2003 * @param string $refund_id Refund ID to remove.
2004 * @return array{removed: bool, refund_data: array<string, mixed>|null} Result with removed status and refund data.
2005 * @since 0.0.1
2006 */
2007 public static function remove_refund_from_donation_data( $donation_id, $refund_id ) {
2008 $result = [
2009 'removed' => false,
2010 'refund_data' => null,
2011 ];
2012
2013 if ( empty( $donation_id ) || empty( $refund_id ) ) {
2014 return $result;
2015 }
2016
2017 $donation = self::get( $donation_id );
2018 if ( ! $donation ) {
2019 return $result;
2020 }
2021
2022 // Get donation_data and parse if needed.
2023 $donation_data = $donation['donation_data'] ?? [];
2024 if ( is_string( $donation_data ) && ! empty( $donation_data ) ) {
2025 $donation_data = json_decode( $donation_data, true );
2026 }
2027 if ( ! is_array( $donation_data ) ) {
2028 return $result;
2029 }
2030
2031 // Check if refund exists.
2032 if ( empty( $donation_data['refunds'] ) || ! isset( $donation_data['refunds'][ $refund_id ] ) ) {
2033 return $result;
2034 }
2035
2036 // Store the refund data before removing.
2037 $result['refund_data'] = $donation_data['refunds'][ $refund_id ];
2038
2039 // Remove the refund.
2040 unset( $donation_data['refunds'][ $refund_id ] );
2041
2042 // Update donation_data in database.
2043 $update_result = self::update( $donation_id, [ 'donation_data' => $donation_data ] );
2044
2045 $result['removed'] = false !== $update_result;
2046
2047 return $result;
2048 }
2049 }
2050