PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.0.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.0.0
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 / donors.php

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

1,022 lines 28.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * SureDonation Database Donors Table Class.
4 *
5 * @package SureDonation
6 */
7
8 namespace SureDonation\Inc\Database\Tables;
9
10 use SureDonation\Inc\Database\Base;
11 use SureDonation\Inc\Helper;
12 use SureDonation\Inc\Traits\Get_Instance;
13
14 // Exit if accessed directly.
15 defined( 'ABSPATH' ) || exit;
16
17 /**
18 * SureDonation Database Donors Table Class.
19 *
20 * @since 0.0.1
21 */
22 class Donors extends Base {
23 use Get_Instance;
24
25 /**
26 * Table suffix.
27 *
28 * @var string
29 * @since 0.0.1
30 */
31 protected $table_suffix = 'donors';
32
33 /**
34 * Table version.
35 *
36 * @var int
37 * @since 0.0.1
38 */
39 protected $table_version = 4;
40
41 /**
42 * Valid donor statuses.
43 *
44 * @var array<string>
45 * @since 0.0.1
46 */
47 private static $valid_statuses = [
48 'active',
49 'inactive',
50 'blocked',
51 ];
52
53 /**
54 * Valid order columns.
55 *
56 * @var array<string>
57 * @since 0.0.1
58 */
59 private static $valid_order_columns = [
60 'id',
61 'email',
62 'name',
63 'total_donated',
64 'donation_count',
65 'created_at',
66 'updated_at',
67 'last_donation_date',
68 ];
69
70 /**
71 * {@inheritDoc}
72 */
73 public function get_schema() {
74 return [
75 'id' => [
76 'type' => 'number',
77 ],
78 'email' => [
79 'type' => 'string',
80 ],
81 'name' => [
82 'type' => 'string',
83 'default' => '',
84 ],
85 'phone' => [
86 'type' => 'string',
87 'default' => '',
88 ],
89 'company' => [
90 'type' => 'string',
91 'default' => '',
92 ],
93 'address' => [
94 'type' => 'string',
95 'default' => '',
96 ],
97 'user_id' => [
98 'type' => 'number',
99 'default' => 0,
100 ],
101 'total_donated' => [
102 'type' => 'decimal',
103 'default' => 0,
104 ],
105 'donation_count' => [
106 'type' => 'number',
107 'default' => 0,
108 ],
109 'largest_donation' => [
110 'type' => 'decimal',
111 'default' => 0,
112 ],
113 'first_donation_date' => [
114 'type' => 'datetime',
115 ],
116 'last_donation_date' => [
117 'type' => 'datetime',
118 ],
119 'donor_tags' => [
120 'type' => 'array',
121 'default' => [],
122 ],
123 'donor_status' => [
124 'type' => 'string',
125 'default' => 'active',
126 ],
127 'donor_data' => [
128 'type' => 'array',
129 'default' => [],
130 ],
131 'stripe_customer_id' => [
132 'type' => 'string',
133 'default' => '',
134 ],
135 'import_source_id' => [
136 'type' => 'number',
137 'default' => 0,
138 ],
139 'import_source' => [
140 'type' => 'string',
141 'default' => '',
142 ],
143 'created_at' => [
144 'type' => 'datetime',
145 ],
146 'updated_at' => [
147 'type' => 'datetime',
148 ],
149 ];
150 }
151
152 /**
153 * {@inheritDoc}
154 */
155 public function get_columns_definition() {
156 return [
157 'id BIGINT(20) UNSIGNED AUTO_INCREMENT PRIMARY KEY',
158 'email VARCHAR(255) NOT NULL UNIQUE',
159 'name VARCHAR(255) NOT NULL',
160 'phone VARCHAR(50) NOT NULL',
161 'user_id BIGINT(20) UNSIGNED NULL',
162 'total_donated DECIMAL(26,8) NOT NULL DEFAULT 0',
163 'donation_count INT(11) NOT NULL DEFAULT 0',
164 'largest_donation DECIMAL(26,8) NOT NULL DEFAULT 0',
165 'first_donation_date TIMESTAMP NULL',
166 'last_donation_date TIMESTAMP NULL',
167 'donor_tags LONGTEXT',
168 'donor_status VARCHAR(20) NOT NULL',
169 'donor_data LONGTEXT',
170 'stripe_customer_id VARCHAR(255) DEFAULT NULL',
171 'import_source_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0',
172 'import_source VARCHAR(20) NOT NULL DEFAULT ""',
173 'created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP',
174 'updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
175 'INDEX idx_email (email)',
176 'INDEX idx_user (user_id)',
177 'INDEX idx_total (total_donated)',
178 'INDEX idx_status (donor_status)',
179 'INDEX idx_import_source (import_source_id, import_source)',
180 ];
181 }
182
183 /**
184 * New columns added across versions.
185 *
186 * Version 3 added company/address; version 4 added the
187 * source-agnostic pair `import_source_id` + `import_source` used by
188 * the migration tool.
189 *
190 * {@inheritDoc}
191 *
192 * @since 1.0.0
193 */
194 public function get_new_columns_definition() {
195 // Keep migration defaults consistent with the schema's runtime
196 // defaults (the column definitions above use 'default' => ''). Mixing
197 // NOT NULL DEFAULT '' for one column with DEFAULT NULL for another
198 // produces silent divergence at the data layer — a future
199 // `WHERE address = ''` filter would miss legacy rows that landed as
200 // NULL from the migration.
201 return [
202 'company VARCHAR(255) NOT NULL DEFAULT "" AFTER phone',
203 'address TEXT NOT NULL DEFAULT "" AFTER company',
204 'import_source_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 AFTER stripe_customer_id',
205 'import_source VARCHAR(20) NOT NULL DEFAULT "" AFTER import_source_id',
206 'INDEX idx_import_source (import_source_id, import_source)',
207 ];
208 }
209
210 /**
211 * Add a new donor record.
212 *
213 * @param array<mixed> $data Donor data to insert.
214 * @return int|false The donor ID on success, false on error.
215 * @since 0.0.1
216 */
217 public static function add( $data ) {
218 if ( empty( $data['email'] ) ) {
219 return false;
220 }
221
222 $instance = self::get_instance();
223
224 // Set created_at if not provided.
225 if ( ! isset( $data['created_at'] ) ) {
226 $data['created_at'] = current_time( 'mysql' );
227 }
228
229 return $instance->use_insert( $data );
230 }
231
232 /**
233 * Update a donor record.
234 *
235 * @param int $donor_id Donor ID to update.
236 * @param array<string,mixed> $data Data to update.
237 * @return int|false Number of rows updated or false on error.
238 * @since 0.0.1
239 */
240 public static function update( $donor_id, $data = [] ) {
241 if ( empty( $donor_id ) ) {
242 return false;
243 }
244
245 $data['updated_at'] = current_time( 'mysql' );
246
247 return self::get_instance()->use_update( $data, [ 'id' => absint( $donor_id ) ] );
248 }
249
250 /**
251 * Get a single donor by ID.
252 *
253 * @param int $donor_id Donor ID.
254 * @return array<mixed>|null Donor data or null if not found.
255 * @since 0.0.1
256 */
257 public static function get( $donor_id ) {
258 if ( empty( $donor_id ) ) {
259 return null;
260 }
261
262 $instance = self::get_instance();
263 global $wpdb;
264
265 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
266 $result = $wpdb->get_row(
267 $wpdb->prepare(
268 'SELECT * FROM %i WHERE id = %d',
269 $instance->get_tablename(),
270 absint( $donor_id )
271 ),
272 ARRAY_A
273 );
274
275 if ( ! $result ) {
276 return null;
277 }
278
279 return $instance->decode_by_datatype( $result );
280 }
281
282 /**
283 * Get donor by email.
284 *
285 * @param string $email Donor email.
286 * @return array<mixed>|null Donor data or null if not found.
287 * @since 0.0.1
288 */
289 public static function get_by_email( $email ) {
290 if ( empty( $email ) ) {
291 return null;
292 }
293
294 $instance = self::get_instance();
295 global $wpdb;
296
297 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
298 $result = $wpdb->get_row(
299 $wpdb->prepare(
300 'SELECT * FROM %i WHERE email = %s',
301 $instance->get_tablename(),
302 sanitize_email( $email )
303 ),
304 ARRAY_A
305 );
306
307 if ( ! $result ) {
308 return null;
309 }
310
311 return $instance->decode_by_datatype( $result );
312 }
313
314 /**
315 * Get all donors with pagination.
316 *
317 * @param int $limit Number of records to return.
318 * @param int $offset Offset for pagination.
319 * @param string $orderby Column to order by.
320 * @param string $order Order direction (ASC or DESC).
321 * @return array<mixed> Array of donors.
322 * @since 0.0.1
323 */
324 public static function get_all( $limit = 10, $offset = 0, $orderby = 'created_at', $order = 'DESC' ) {
325 $instance = self::get_instance();
326 global $wpdb;
327 $table = $instance->get_tablename();
328
329 // Validate orderby column.
330 if ( ! in_array( $orderby, self::$valid_order_columns, true ) ) {
331 $orderby = 'created_at';
332 }
333
334 // Validate order direction.
335 $order = strtoupper( $order );
336 if ( ! in_array( $order, [ 'ASC', 'DESC' ], true ) ) {
337 $order = 'DESC';
338 }
339
340 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Data changes frequently, caching would show stale results.
341 $results = 'ASC' === $order
342 ? $wpdb->get_results(
343 $wpdb->prepare(
344 'SELECT * FROM %i ORDER BY %i ASC LIMIT %d, %d',
345 $table,
346 $orderby,
347 absint( $offset ),
348 absint( $limit )
349 ),
350 ARRAY_A
351 )
352 : $wpdb->get_results(
353 $wpdb->prepare(
354 'SELECT * FROM %i ORDER BY %i DESC LIMIT %d, %d',
355 $table,
356 $orderby,
357 absint( $offset ),
358 absint( $limit )
359 ),
360 ARRAY_A
361 );
362 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
363
364 if ( ! $results || ! is_array( $results ) ) {
365 return [];
366 }
367
368 return array_map( [ $instance, 'decode_by_datatype' ], $results );
369 }
370
371 /**
372 * Get donors by status with pagination.
373 *
374 * @param string $status Donor status.
375 * @param int $limit Number of records to return.
376 * @param int $offset Offset for pagination.
377 * @param string $orderby Column to order by.
378 * @param string $order Order direction (ASC or DESC).
379 * @return array<mixed> Array of donors.
380 * @since 0.0.1
381 */
382 public static function get_by_status( $status, $limit = 10, $offset = 0, $orderby = 'created_at', $order = 'DESC' ) {
383 $instance = self::get_instance();
384 global $wpdb;
385 $table = $instance->get_tablename();
386
387 // Validate orderby column.
388 if ( ! in_array( $orderby, self::$valid_order_columns, true ) ) {
389 $orderby = 'created_at';
390 }
391
392 // Validate order direction.
393 $order = strtoupper( $order );
394 if ( ! in_array( $order, [ 'ASC', 'DESC' ], true ) ) {
395 $order = 'DESC';
396 }
397
398 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Data changes frequently, caching would show stale results.
399 $results = 'ASC' === $order
400 ? $wpdb->get_results(
401 $wpdb->prepare(
402 'SELECT * FROM %i WHERE donor_status = %s ORDER BY %i ASC LIMIT %d, %d',
403 $table,
404 sanitize_text_field( $status ),
405 $orderby,
406 absint( $offset ),
407 absint( $limit )
408 ),
409 ARRAY_A
410 )
411 : $wpdb->get_results(
412 $wpdb->prepare(
413 'SELECT * FROM %i WHERE donor_status = %s ORDER BY %i DESC LIMIT %d, %d',
414 $table,
415 sanitize_text_field( $status ),
416 $orderby,
417 absint( $offset ),
418 absint( $limit )
419 ),
420 ARRAY_A
421 );
422 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
423
424 if ( ! $results || ! is_array( $results ) ) {
425 return [];
426 }
427
428 return array_map( [ $instance, 'decode_by_datatype' ], $results );
429 }
430
431 /**
432 * Delete a donor.
433 *
434 * @param int $donor_id Donor ID.
435 * @return int|false Number of rows deleted or false on error.
436 * @since 0.0.1
437 */
438 public static function delete( $donor_id ) {
439 if ( empty( $donor_id ) ) {
440 return false;
441 }
442
443 return self::get_instance()->use_delete( [ 'id' => absint( $donor_id ) ] );
444 }
445
446 /**
447 * Get or create donor by email.
448 *
449 * @param string $email Donor email.
450 * @param string $name Donor name.
451 * @param string $phone Donor phone.
452 * @return int|false Donor ID or false on error.
453 * @since 0.0.1
454 */
455 public static function get_or_create( $email, $name = '', $phone = '' ) {
456 if ( empty( $email ) ) {
457 return false;
458 }
459
460 $existing = self::get_by_email( $email );
461
462 if ( $existing ) {
463 // Update name/phone if provided and different.
464 $updates = [];
465
466 if ( ! empty( $name ) && $name !== $existing['name'] ) {
467 $updates['name'] = $name;
468 }
469
470 if ( ! empty( $phone ) && $phone !== $existing['phone'] ) {
471 $updates['phone'] = $phone;
472 }
473
474 if ( ! empty( $updates ) && isset( $existing['id'] ) ) {
475 $existing_id = is_numeric( $existing['id'] ) ? (int) $existing['id'] : 0;
476 if ( $existing_id > 0 ) {
477 self::update( $existing_id, $updates );
478 }
479 }
480
481 $existing_id = isset( $existing['id'] ) && is_numeric( $existing['id'] ) ? (int) $existing['id'] : 0;
482 return $existing_id > 0 ? $existing_id : false;
483 }
484
485 // Create new donor.
486 $donor_id = self::add(
487 [
488 'email' => sanitize_email( $email ),
489 'name' => sanitize_text_field( $name ),
490 'phone' => sanitize_text_field( $phone ),
491 'first_donation_date' => current_time( 'mysql' ),
492 ]
493 );
494
495 if ( $donor_id ) {
496 // Auto-create or link WP user for this donor.
497 self::maybe_link_wp_user( $donor_id, sanitize_email( $email ), sanitize_text_field( $name ) );
498 }
499
500 return $donor_id;
501 }
502
503 /**
504 * Link a donor to an existing WP user, or create a new WP user if none exists.
505 *
506 * @param int $donor_id Donor ID.
507 * @param string $email Donor email.
508 * @param string $name Donor name.
509 * @return void
510 * @since 1.0.0
511 */
512 public static function maybe_link_wp_user( $donor_id, $email, $name = '' ) {
513 if ( empty( $donor_id ) || empty( $email ) ) {
514 return;
515 }
516
517 // Check if donor already has a linked user.
518 $donor = self::get( $donor_id );
519 if ( $donor && ! empty( $donor['user_id'] ) && $donor['user_id'] > 0 ) {
520 return;
521 }
522
523 // Check if a WP user already exists with this email.
524 $existing_user = get_user_by( 'email', $email );
525
526 if ( $existing_user ) {
527 self::update( $donor_id, [ 'user_id' => $existing_user->ID ] );
528 return;
529 }
530
531 // Creating a brand-new WordPress account for a donor is gated behind an
532 // explicit, default-off setting. On public (nopriv) donation paths this
533 // prevents unsolicited account creation and new-user notification emails
534 // for attacker-supplied emails. Linking to an already-existing user
535 // (handled above) is always allowed.
536 $donor_settings = Helper::get_suredonation_option( 'donor_settings', [] );
537 if ( empty( $donor_settings['create_wp_user'] ) ) {
538 return;
539 }
540
541 // Create a new WP user.
542 $username = sanitize_user( $email, true );
543 $password = wp_generate_password( 24, true, true );
544
545 $user_data = [
546 'user_login' => $username,
547 'user_email' => $email,
548 'user_pass' => $password,
549 'role' => 'suredonation_donor',
550 ];
551
552 // Split name into first/last if provided.
553 if ( ! empty( $name ) ) {
554 $parts = explode( ' ', $name, 2 );
555 $user_data['first_name'] = $parts[0];
556 $user_data['last_name'] = $parts[1] ?? '';
557 $user_data['display_name'] = $name;
558 }
559
560 /**
561 * Filter the user data before creating a WP user for a donor.
562 *
563 * Return false to prevent user creation.
564 *
565 * @param array $user_data WP user data array for wp_insert_user().
566 * @param int $donor_id Donor ID.
567 * @param string $email Donor email.
568 * @since 1.0.0
569 */
570 $user_data = apply_filters( 'suredonation_new_donor_user_data', $user_data, $donor_id, $email );
571
572 if ( false === $user_data || ! is_array( $user_data ) ) {
573 return;
574 }
575
576 $user_id = wp_insert_user( $user_data );
577
578 if ( is_wp_error( $user_id ) ) {
579 return;
580 }
581
582 // Link the WP user to the donor.
583 self::update( $donor_id, [ 'user_id' => $user_id ] );
584
585 // Send new user notification email.
586 wp_new_user_notification( $user_id, null, 'user' );
587
588 /**
589 * Fires after a WP user is created and linked to a donor.
590 *
591 * @param int $user_id WP user ID.
592 * @param int $donor_id Donor ID.
593 * @param string $email Donor email.
594 * @since 1.0.0
595 */
596 do_action( 'suredonation_donor_user_created', $user_id, $donor_id, $email );
597 }
598
599 /**
600 * Update donor statistics after a donation.
601 *
602 * @param int $donor_id Donor ID.
603 * @param float $amount Donation amount.
604 * @return int|false Number of rows updated or false on error.
605 * @since 0.0.1
606 */
607 public static function record_donation( $donor_id, $amount ) {
608 if ( empty( $donor_id ) || $amount <= 0 ) {
609 return false;
610 }
611
612 $donor = self::get( $donor_id );
613
614 if ( ! $donor ) {
615 return false;
616 }
617
618 $total_value = $donor['total_donated'] ?? 0;
619 $current_total = is_numeric( $total_value ) ? (float) $total_value : 0.0;
620 $count_value = $donor['donation_count'] ?? 0;
621 $current_count = is_numeric( $count_value ) ? (int) $count_value : 0;
622 $largest_value = $donor['largest_donation'] ?? 0;
623 $current_largest = is_numeric( $largest_value ) ? (float) $largest_value : 0.0;
624
625 $updates = [
626 'total_donated' => $current_total + $amount,
627 'donation_count' => $current_count + 1,
628 'last_donation_date' => current_time( 'mysql' ),
629 ];
630
631 if ( $amount > $current_largest ) {
632 $updates['largest_donation'] = $amount;
633 }
634
635 return self::update( $donor_id, $updates );
636 }
637
638 /**
639 * Get top donors by total donated.
640 *
641 * @param int $limit Number of donors to retrieve.
642 * @return array<int, array<string, mixed>> Array of top donors.
643 * @since 0.0.1
644 */
645 public static function get_top_donors( $limit = 10 ) {
646 $instance = self::get_instance();
647 global $wpdb;
648
649 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
650 $results = $wpdb->get_results(
651 $wpdb->prepare(
652 'SELECT * FROM %i WHERE donor_status = %s ORDER BY total_donated DESC LIMIT %d',
653 $instance->get_tablename(),
654 'active',
655 absint( $limit )
656 ),
657 ARRAY_A
658 );
659
660 if ( ! $results || ! is_array( $results ) ) {
661 return [];
662 }
663
664 return array_map( [ $instance, 'decode_by_datatype' ], $results );
665 }
666
667 /**
668 * Get total donors count.
669 *
670 * @return int Total count.
671 * @since 0.0.1
672 */
673 public static function count_all() {
674 $instance = self::get_instance();
675 global $wpdb;
676
677 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
678 $count = $wpdb->get_var(
679 $wpdb->prepare(
680 'SELECT COUNT(*) FROM %i',
681 $instance->get_tablename()
682 )
683 );
684
685 return is_numeric( $count ) ? (int) $count : 0;
686 }
687
688 /**
689 * Get total donors count by status.
690 *
691 * @param string $status Donor status ('all' for no filter).
692 * @return int Total count.
693 * @since 0.0.1
694 */
695 public static function get_total_donors( $status = 'all' ) {
696 $instance = self::get_instance();
697 global $wpdb;
698
699 if ( 'all' === $status ) {
700 return self::count_all();
701 }
702
703 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
704 $count = $wpdb->get_var(
705 $wpdb->prepare(
706 'SELECT COUNT(*) FROM %i WHERE donor_status = %s',
707 $instance->get_tablename(),
708 sanitize_text_field( $status )
709 )
710 );
711
712 return is_numeric( $count ) ? (int) $count : 0;
713 }
714
715 /**
716 * Get valid donor statuses.
717 *
718 * @return array<string> Valid statuses.
719 * @since 0.0.1
720 */
721 public static function get_valid_statuses() {
722 return self::$valid_statuses;
723 }
724
725 /**
726 * Get Stripe customer ID for a donor by email.
727 *
728 * @param string $email Donor email.
729 * @return string|null Stripe customer ID or null if not found.
730 * @since 0.0.1
731 */
732 public static function get_stripe_customer_id_by_email( $email ) {
733 if ( empty( $email ) ) {
734 return null;
735 }
736
737 $donor = self::get_by_email( $email );
738
739 if ( $donor && ! empty( $donor['stripe_customer_id'] ) && is_string( $donor['stripe_customer_id'] ) ) {
740 return $donor['stripe_customer_id'];
741 }
742
743 return null;
744 }
745
746 /**
747 * Update Stripe customer ID for a donor by email.
748 *
749 * @param string $email Donor email.
750 * @param string $stripe_customer_id Stripe customer ID.
751 * @return bool True on success, false on failure.
752 * @since 0.0.1
753 */
754 public static function set_stripe_customer_id_by_email( $email, $stripe_customer_id ) {
755 if ( empty( $email ) || empty( $stripe_customer_id ) ) {
756 return false;
757 }
758
759 $donor = self::get_by_email( $email );
760
761 if ( ! $donor || empty( $donor['id'] ) ) {
762 return false;
763 }
764
765 $donor_id = is_numeric( $donor['id'] ) ? (int) $donor['id'] : 0;
766 if ( $donor_id <= 0 ) {
767 return false;
768 }
769
770 $result = self::update( $donor_id, [ 'stripe_customer_id' => sanitize_text_field( $stripe_customer_id ) ] );
771
772 return false !== $result;
773 }
774
775 /**
776 * Clear Stripe customer ID for a donor by email.
777 *
778 * This is used when a cached customer ID is no longer valid
779 * (e.g., customer was deleted from Stripe or mode switched).
780 *
781 * @param string $email Donor email.
782 * @return bool True on success, false on failure.
783 * @since 0.0.1
784 */
785 public static function clear_stripe_customer_id_by_email( $email ) {
786 if ( empty( $email ) ) {
787 return false;
788 }
789
790 $donor = self::get_by_email( $email );
791
792 if ( ! $donor || empty( $donor['id'] ) ) {
793 return false;
794 }
795
796 $donor_id = is_numeric( $donor['id'] ) ? (int) $donor['id'] : 0;
797 if ( $donor_id <= 0 ) {
798 return false;
799 }
800
801 $result = self::update( $donor_id, [ 'stripe_customer_id' => '' ] );
802
803 return false !== $result;
804 }
805
806 /**
807 * Get donors for admin listing with optional filters.
808 *
809 * @param string $search Search term for name, email, or phone.
810 * @param int $campaign_id Campaign ID filter (0 for no filter).
811 * @param string $status Donor status filter ('all' for no filter).
812 * @param int $limit Number of records to return.
813 * @param int $offset Offset for pagination.
814 * @param string $orderby Column to order by.
815 * @param string $order Order direction (ASC or DESC).
816 * @param string $after Start date filter (Y-m-d).
817 * @param string $before End date filter (Y-m-d).
818 * @return array<mixed> Array of donors.
819 * @since 1.0.0
820 */
821 public static function get_admin_list( $search = '', $campaign_id = 0, $status = 'all', $limit = 20, $offset = 0, $orderby = 'created_at', $order = 'DESC', $after = '', $before = '' ) {
822 $instance = self::get_instance();
823 global $wpdb;
824
825 $donors_table = $instance->get_tablename();
826 $donations_table = $wpdb->prefix . 'suredonation_donations';
827
828 // Validate orderby column.
829 if ( ! in_array( $orderby, self::$valid_order_columns, true ) ) {
830 $orderby = 'created_at';
831 }
832
833 // Validate order direction.
834 $order = strtoupper( $order );
835 if ( ! in_array( $order, [ 'ASC', 'DESC' ], true ) ) {
836 $order = 'DESC';
837 }
838
839 $conditions = self::build_admin_list_conditions( $search, $campaign_id, $status, $after, $before );
840 $where = $conditions['where'];
841 $query_args = $conditions['args'];
842
843 if ( $conditions['has_campaign'] ) {
844 $order_col = 'd.' . $orderby;
845
846 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Dynamic query with validated conditions.
847 $results = $wpdb->get_results(
848 $wpdb->prepare(
849 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $where is built with prepare-safe conditions, $order_col and $order are validated against whitelists.
850 "SELECT DISTINCT d.* FROM %i d INNER JOIN %i don ON d.id = don.donor_id {$where} ORDER BY {$order_col} {$order} LIMIT %d, %d",
851 array_merge( [ $donors_table, $donations_table ], $query_args, [ absint( $offset ), absint( $limit ) ] )
852 ),
853 ARRAY_A
854 );
855 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
856 } else {
857 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Dynamic query with validated conditions.
858 $results = $wpdb->get_results(
859 $wpdb->prepare(
860 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $where is built with prepare-safe conditions, $orderby and $order are validated against whitelists.
861 "SELECT * FROM %i {$where} ORDER BY {$orderby} {$order} LIMIT %d, %d",
862 array_merge( [ $donors_table ], $query_args, [ absint( $offset ), absint( $limit ) ] )
863 ),
864 ARRAY_A
865 );
866 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
867 }
868
869 if ( ! $results || ! is_array( $results ) ) {
870 return [];
871 }
872
873 return array_map( [ $instance, 'decode_by_datatype' ], $results );
874 }
875
876 /**
877 * Get total donors count with filters.
878 *
879 * @param string $search Search term for name, email, or phone.
880 * @param int $campaign_id Campaign ID filter (0 for no filter).
881 * @param string $status Donor status filter ('all' for no filter).
882 * @param string $after Start date filter (Y-m-d).
883 * @param string $before End date filter (Y-m-d).
884 * @return int Total count.
885 * @since 1.0.0
886 */
887 public static function get_total_donors_filtered( $search = '', $campaign_id = 0, $status = 'all', $after = '', $before = '' ) {
888 $instance = self::get_instance();
889 global $wpdb;
890
891 $donors_table = $instance->get_tablename();
892 $donations_table = $wpdb->prefix . 'suredonation_donations';
893
894 $conditions = self::build_admin_list_conditions( $search, $campaign_id, $status, $after, $before );
895 $where = $conditions['where'];
896 $query_args = $conditions['args'];
897
898 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Data changes frequently, caching would show stale counts.
899 if ( $conditions['has_campaign'] ) {
900 $count = $wpdb->get_var(
901 $wpdb->prepare(
902 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $where is built with prepare-safe conditions.
903 "SELECT COUNT(DISTINCT d.id) FROM %i d INNER JOIN %i don ON d.id = don.donor_id {$where}",
904 array_merge( [ $donors_table, $donations_table ], $query_args )
905 )
906 );
907 } elseif ( ! empty( $query_args ) ) {
908 $count = $wpdb->get_var(
909 $wpdb->prepare(
910 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $where is built with prepare-safe conditions.
911 "SELECT COUNT(*) FROM %i {$where}",
912 array_merge( [ $donors_table ], $query_args )
913 )
914 );
915 } else {
916 $count = $wpdb->get_var(
917 $wpdb->prepare(
918 'SELECT COUNT(*) FROM %i',
919 $donors_table
920 )
921 );
922 }
923 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
924
925 return is_numeric( $count ) ? (int) $count : 0;
926 }
927
928 /**
929 * Get aggregate donor statistics.
930 *
931 * @return array{total_donors: int, total_donated: float, average_donation: float} Aggregate stats.
932 * @since 1.0.0
933 */
934 public static function get_aggregate_stats() {
935 $instance = self::get_instance();
936 global $wpdb;
937 $table = $instance->get_tablename();
938
939 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
940 $row = $wpdb->get_row(
941 $wpdb->prepare(
942 'SELECT COUNT(*) AS total_donors, COALESCE(SUM(total_donated), 0) AS total_donated, COALESCE(SUM(donation_count), 0) AS total_donation_count FROM %i',
943 $table
944 ),
945 ARRAY_A
946 );
947
948 $total_donors = is_numeric( $row['total_donors'] ?? 0 ) ? (int) $row['total_donors'] : 0;
949 $total_donated = is_numeric( $row['total_donated'] ?? 0 ) ? (float) $row['total_donated'] : 0.0;
950 $total_donation_count = is_numeric( $row['total_donation_count'] ?? 0 ) ? (int) $row['total_donation_count'] : 0;
951 $average_donation = $total_donation_count > 0 ? $total_donated / $total_donation_count : 0.0;
952
953 return [
954 'total_donors' => $total_donors,
955 'total_donated' => $total_donated,
956 'average_donation' => round( $average_donation, 2 ),
957 ];
958 }
959
960 /**
961 * Build WHERE conditions and prepare args for admin list queries.
962 *
963 * @param string $search Search term for name, email, or phone.
964 * @param int $campaign_id Campaign ID filter (0 for no filter).
965 * @param string $status Donor status filter ('all' for no filter).
966 * @param string $after Start date filter (Y-m-d).
967 * @param string $before End date filter (Y-m-d).
968 * @return array{where: string, args: array<mixed>, has_campaign: bool} Query parts.
969 * @since 1.0.0
970 */
971 private static function build_admin_list_conditions( $search, $campaign_id, $status, $after = '', $before = '' ) {
972 global $wpdb;
973
974 $has_search = ! empty( $search );
975 $has_campaign = $campaign_id > 0;
976 $has_status = 'all' !== $status && ! empty( $status ) && in_array( $status, self::$valid_statuses, true );
977
978 $conditions = [];
979 $args = [];
980
981 if ( $has_campaign ) {
982 $conditions[] = 'don.campaign_id = %d';
983 $args[] = absint( $campaign_id );
984 }
985
986 if ( $has_status ) {
987 $col_prefix = $has_campaign ? 'd.' : '';
988 $conditions[] = $col_prefix . 'donor_status = %s';
989 $args[] = sanitize_text_field( $status );
990 }
991
992 if ( $has_search ) {
993 $col_prefix = $has_campaign ? 'd.' : '';
994 $search_term = '%' . $wpdb->esc_like( sanitize_text_field( $search ) ) . '%';
995 $conditions[] = '(' . $col_prefix . 'name LIKE %s OR ' . $col_prefix . 'email LIKE %s OR ' . $col_prefix . 'phone LIKE %s)';
996 $args[] = $search_term;
997 $args[] = $search_term;
998 $args[] = $search_term;
999 }
1000
1001 if ( ! empty( $after ) ) {
1002 $col_prefix = $has_campaign ? 'd.' : '';
1003 $conditions[] = $col_prefix . 'last_donation_date >= %s';
1004 $args[] = sanitize_text_field( $after ) . ' 00:00:00';
1005 }
1006
1007 if ( ! empty( $before ) ) {
1008 $col_prefix = $has_campaign ? 'd.' : '';
1009 $conditions[] = $col_prefix . 'last_donation_date <= %s';
1010 $args[] = sanitize_text_field( $before ) . ' 23:59:59';
1011 }
1012
1013 $where = ! empty( $conditions ) ? 'WHERE ' . implode( ' AND ', $conditions ) : '';
1014
1015 return [
1016 'where' => $where,
1017 'args' => $args,
1018 'has_campaign' => $has_campaign,
1019 ];
1020 }
1021 }
1022