PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.6.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.6.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.6.0, at inc/database/tables/donors.php

1,208 lines 36.5 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. Stored when creating a new donor, or backfilled onto an existing donor only when its stored name is empty; never overwrites a populated value.
451 * @param string $phone Donor phone. Stored when creating a new donor, or backfilled onto an existing donor only when its stored phone is empty; never overwrites a populated value.
452 * @return int|false Donor ID or false on error.
453 * @since 0.0.1
454 * @since 1.4.0 A subsequent donation no longer overwrites an existing donor's name/phone; missing values are backfilled, populated ones are left intact.
455 */
456 public static function get_or_create( $email, $name = '', $phone = '' ) {
457 if ( empty( $email ) ) {
458 return false;
459 }
460
461 $existing = self::get_by_email( $email );
462
463 if ( $existing ) {
464 $existing_id = isset( $existing['id'] ) && is_numeric( $existing['id'] ) ? (int) $existing['id'] : 0;
465
466 // Backfill name/phone only when the stored value is empty — a later
467 // donation never overwrites a populated donor name/phone. This closes
468 // the unauthenticated-tampering vector (an attacker who knows a
469 // donor's email cannot change that donor's existing name/phone on a
470 // bare, unverified match) while still letting genuinely missing
471 // details fill in from a later donation — e.g. an optional-name
472 // gateway, or a phone field mapped after the donor's first donation.
473 // The name/phone entered for each donation are always captured on the
474 // donation row regardless, and admins can edit a donor directly via
475 // the donor management endpoints.
476 $updates = [];
477 if ( ! empty( $name ) && '' === (string) ( $existing['name'] ?? '' ) ) {
478 $updates['name'] = $name;
479 }
480 if ( ! empty( $phone ) && '' === (string) ( $existing['phone'] ?? '' ) ) {
481 $updates['phone'] = $phone;
482 }
483 if ( ! empty( $updates ) && $existing_id > 0 ) {
484 self::update( $existing_id, $updates );
485 }
486
487 return $existing_id > 0 ? $existing_id : false;
488 }
489
490 // Create new donor.
491 $donor_id = self::add(
492 [
493 'email' => sanitize_email( $email ),
494 'name' => sanitize_text_field( $name ),
495 'phone' => sanitize_text_field( $phone ),
496 'first_donation_date' => current_time( 'mysql' ),
497 ]
498 );
499
500 if ( $donor_id ) {
501 // Auto-create or link WP user for this donor.
502 self::maybe_link_wp_user( $donor_id, sanitize_email( $email ), sanitize_text_field( $name ) );
503 }
504
505 return $donor_id;
506 }
507
508 /**
509 * Link a donor to an existing WP user, or create a new WP user if none exists.
510 *
511 * @param int $donor_id Donor ID.
512 * @param string $email Donor email.
513 * @param string $name Donor name.
514 * @return void
515 * @since 1.0.0
516 */
517 public static function maybe_link_wp_user( $donor_id, $email, $name = '' ) {
518 if ( empty( $donor_id ) || empty( $email ) ) {
519 return;
520 }
521
522 // Check if donor already has a linked user.
523 $donor = self::get( $donor_id );
524 if ( $donor && ! empty( $donor['user_id'] ) && $donor['user_id'] > 0 ) {
525 return;
526 }
527
528 // Check if a WP user already exists with this email.
529 $existing_user = get_user_by( 'email', $email );
530
531 if ( $existing_user ) {
532 self::update( $donor_id, [ 'user_id' => $existing_user->ID ] );
533 return;
534 }
535
536 // Creating a brand-new WordPress account for a donor is gated behind an
537 // explicit, default-off setting. On public (nopriv) donation paths this
538 // prevents unsolicited account creation and new-user notification emails
539 // for attacker-supplied emails. Linking to an already-existing user
540 // (handled above) is always allowed.
541 $donor_settings = Helper::get_suredonation_option( 'donor_settings', [] );
542 if ( empty( $donor_settings['create_wp_user'] ) ) {
543 return;
544 }
545
546 // Create a new WP user.
547 $username = sanitize_user( $email, true );
548 $password = wp_generate_password( 24, true, true );
549
550 $user_data = [
551 'user_login' => $username,
552 'user_email' => $email,
553 'user_pass' => $password,
554 'role' => 'suredonation_donor',
555 ];
556
557 // Split name into first/last if provided.
558 if ( ! empty( $name ) ) {
559 $parts = explode( ' ', $name, 2 );
560 $user_data['first_name'] = $parts[0];
561 $user_data['last_name'] = $parts[1] ?? '';
562 $user_data['display_name'] = $name;
563 }
564
565 /**
566 * Filter the user data before creating a WP user for a donor.
567 *
568 * Return false to prevent user creation.
569 *
570 * @param array $user_data WP user data array for wp_insert_user().
571 * @param int $donor_id Donor ID.
572 * @param string $email Donor email.
573 * @since 1.0.0
574 */
575 $user_data = apply_filters( 'suredonation_new_donor_user_data', $user_data, $donor_id, $email );
576
577 if ( false === $user_data || ! is_array( $user_data ) ) {
578 return;
579 }
580
581 $user_id = wp_insert_user( $user_data );
582
583 if ( is_wp_error( $user_id ) ) {
584 return;
585 }
586
587 // Link the WP user to the donor.
588 self::update( $donor_id, [ 'user_id' => $user_id ] );
589
590 // Send new user notification email.
591 wp_new_user_notification( $user_id, null, 'user' );
592
593 /**
594 * Fires after a WP user is created and linked to a donor.
595 *
596 * @param int $user_id WP user ID.
597 * @param int $donor_id Donor ID.
598 * @param string $email Donor email.
599 * @since 1.0.0
600 */
601 do_action( 'suredonation_donor_user_created', $user_id, $donor_id, $email );
602 }
603
604 /**
605 * Record a donation against a donor's aggregates exactly once.
606 *
607 * Two paths complete the same Stripe donation — the client-side confirm
608 * (`complete_donation()`) and the `payment_intent.succeeded` webhook — and
609 * neither knows whether the other got there first. The webhook applies no
610 * "still pending" guard, so calling record_donation() from both would double
611 * a donor's total; calling it from neither (until now) left the totals stale
612 * on every donation whose webhook never arrived.
613 *
614 * Keyed on the donation, not the donor, so a second genuine gift still
615 * counts. Marked before the write: a duplicated total is harder to notice
616 * and impossible to unpick, whereas a missed one is visible against the
617 * donation list and recomputable.
618 *
619 * @param int $donor_id Donor row ID.
620 * @param float $amount Donation amount.
621 * @param int $donation_id Donation row ID this call is for.
622 * @return bool True when this call recorded it, false when already recorded or invalid.
623 * @since 1.6.0
624 */
625 public static function record_donation_once( $donor_id, $amount, $donation_id ) {
626 $donation_id = absint( $donation_id );
627
628 if ( $donation_id <= 0 ) {
629 return false;
630 }
631
632 $key = 'suredonation_donor_recorded_' . $donation_id;
633
634 if ( get_transient( $key ) ) {
635 return false;
636 }
637
638 // Marked before the write, so two racers cannot both get through on a
639 // read that saw nothing.
640 set_transient( $key, true, WEEK_IN_SECONDS );
641
642 $recorded = (bool) self::record_donation( $donor_id, $amount );
643
644 if ( ! $recorded ) {
645 // record_donation() refuses a non-positive amount or a donor row
646 // that no longer exists (the privacy eraser can remove one), and
647 // returns false without writing anything. Leaving the marker up
648 // after that would be worse than not having it: the gateway
649 // webhook retry is this row's safety net, and it would find the
650 // marker and skip, so the donation would never reach the donor's
651 // totals at all.
652 delete_transient( $key );
653 }
654
655 return $recorded;
656 }
657
658 /**
659 * Update donor statistics after a donation.
660 *
661 * Unconditional: it takes no status and no donation id, so it cannot tell a
662 * repeat call for the same donation from a second gift. Callers that can be
663 * reached twice for one donation should use record_donation_once().
664 *
665 * @param int $donor_id Donor ID.
666 * @param float $amount Donation amount.
667 * @return int|false Number of rows updated or false on error.
668 * @since 0.0.1
669 */
670 public static function record_donation( $donor_id, $amount ) {
671 if ( empty( $donor_id ) || $amount <= 0 ) {
672 return false;
673 }
674
675 $donor = self::get( $donor_id );
676
677 if ( ! $donor ) {
678 return false;
679 }
680
681 $total_value = $donor['total_donated'] ?? 0;
682 $current_total = is_numeric( $total_value ) ? (float) $total_value : 0.0;
683 $count_value = $donor['donation_count'] ?? 0;
684 $current_count = is_numeric( $count_value ) ? (int) $count_value : 0;
685 $largest_value = $donor['largest_donation'] ?? 0;
686 $current_largest = is_numeric( $largest_value ) ? (float) $largest_value : 0.0;
687
688 $updates = [
689 'total_donated' => $current_total + $amount,
690 'donation_count' => $current_count + 1,
691 'last_donation_date' => current_time( 'mysql' ),
692 ];
693
694 if ( $amount > $current_largest ) {
695 $updates['largest_donation'] = $amount;
696 }
697
698 return self::update( $donor_id, $updates );
699 }
700
701 /**
702 * Get top donors by total donated.
703 *
704 * @param int $limit Number of donors to retrieve.
705 * @return array<int, array<string, mixed>> Array of top donors.
706 * @since 0.0.1
707 */
708 public static function get_top_donors( $limit = 10 ) {
709 $instance = self::get_instance();
710 global $wpdb;
711
712 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
713 $results = $wpdb->get_results(
714 $wpdb->prepare(
715 'SELECT * FROM %i WHERE donor_status = %s ORDER BY total_donated DESC LIMIT %d',
716 $instance->get_tablename(),
717 'active',
718 absint( $limit )
719 ),
720 ARRAY_A
721 );
722
723 if ( ! $results || ! is_array( $results ) ) {
724 return [];
725 }
726
727 return array_map( [ $instance, 'decode_by_datatype' ], $results );
728 }
729
730 /**
731 * Get total donors count.
732 *
733 * @return int Total count.
734 * @since 0.0.1
735 */
736 public static function count_all() {
737 $instance = self::get_instance();
738 global $wpdb;
739
740 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
741 $count = $wpdb->get_var(
742 $wpdb->prepare(
743 'SELECT COUNT(*) FROM %i',
744 $instance->get_tablename()
745 )
746 );
747
748 return is_numeric( $count ) ? (int) $count : 0;
749 }
750
751 /**
752 * Get total donors count by status.
753 *
754 * @param string $status Donor status ('all' for no filter).
755 * @return int Total count.
756 * @since 0.0.1
757 */
758 public static function get_total_donors( $status = 'all' ) {
759 $instance = self::get_instance();
760 global $wpdb;
761
762 if ( 'all' === $status ) {
763 return self::count_all();
764 }
765
766 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
767 $count = $wpdb->get_var(
768 $wpdb->prepare(
769 'SELECT COUNT(*) FROM %i WHERE donor_status = %s',
770 $instance->get_tablename(),
771 sanitize_text_field( $status )
772 )
773 );
774
775 return is_numeric( $count ) ? (int) $count : 0;
776 }
777
778 /**
779 * Get valid donor statuses.
780 *
781 * @return array<string> Valid statuses.
782 * @since 0.0.1
783 */
784 public static function get_valid_statuses() {
785 return self::$valid_statuses;
786 }
787
788 /**
789 * Get Stripe customer ID for a donor by email.
790 *
791 * @param string $email Donor email.
792 * @return string|null Stripe customer ID or null if not found.
793 * @since 0.0.1
794 */
795 public static function get_stripe_customer_id_by_email( $email ) {
796 if ( empty( $email ) ) {
797 return null;
798 }
799
800 $donor = self::get_by_email( $email );
801
802 if ( $donor && ! empty( $donor['stripe_customer_id'] ) && is_string( $donor['stripe_customer_id'] ) ) {
803 return $donor['stripe_customer_id'];
804 }
805
806 return null;
807 }
808
809 /**
810 * Update Stripe customer ID for a donor by email.
811 *
812 * @param string $email Donor email.
813 * @param string $stripe_customer_id Stripe customer ID.
814 * @return bool True on success, false on failure.
815 * @since 0.0.1
816 */
817 public static function set_stripe_customer_id_by_email( $email, $stripe_customer_id ) {
818 if ( empty( $email ) || empty( $stripe_customer_id ) ) {
819 return false;
820 }
821
822 $donor = self::get_by_email( $email );
823
824 if ( ! $donor || empty( $donor['id'] ) ) {
825 return false;
826 }
827
828 $donor_id = is_numeric( $donor['id'] ) ? (int) $donor['id'] : 0;
829 if ( $donor_id <= 0 ) {
830 return false;
831 }
832
833 $result = self::update( $donor_id, [ 'stripe_customer_id' => sanitize_text_field( $stripe_customer_id ) ] );
834
835 return false !== $result;
836 }
837
838 /**
839 * Clear Stripe customer ID for a donor by email.
840 *
841 * This is used when a cached customer ID is no longer valid
842 * (e.g., customer was deleted from Stripe or mode switched).
843 *
844 * @param string $email Donor email.
845 * @return bool True on success, false on failure.
846 * @since 0.0.1
847 */
848 public static function clear_stripe_customer_id_by_email( $email ) {
849 if ( empty( $email ) ) {
850 return false;
851 }
852
853 $donor = self::get_by_email( $email );
854
855 if ( ! $donor || empty( $donor['id'] ) ) {
856 return false;
857 }
858
859 $donor_id = is_numeric( $donor['id'] ) ? (int) $donor['id'] : 0;
860 if ( $donor_id <= 0 ) {
861 return false;
862 }
863
864 $result = self::update( $donor_id, [ 'stripe_customer_id' => '' ] );
865
866 return false !== $result;
867 }
868
869 /**
870 * Get the Stripe customer ID for a donor on a specific connected account.
871 *
872 * Reads the per-account map stored in `donor_data['stripe_customers']`.
873 * Falls back to the legacy single `stripe_customer_id` column when the
874 * account is the site default, so pre-multi-account donors keep working.
875 *
876 * @param string $email Donor email.
877 * @param string $account_id Stripe account id (`acct_…`).
878 * @param bool $is_default Whether this is the site default account.
879 * @return string Customer ID, or '' when none is stored.
880 * @since 1.3.0
881 */
882 public static function get_stripe_customer_id_for_account( $email, $account_id, $is_default = false ) {
883 if ( empty( $email ) || empty( $account_id ) ) {
884 return '';
885 }
886
887 $donor = self::get_by_email( $email );
888 if ( ! $donor ) {
889 return '';
890 }
891
892 $donor_data = isset( $donor['donor_data'] ) && is_array( $donor['donor_data'] ) ? $donor['donor_data'] : [];
893 $map = isset( $donor_data['stripe_customers'] ) && is_array( $donor_data['stripe_customers'] ) ? $donor_data['stripe_customers'] : [];
894
895 if ( isset( $map[ $account_id ] ) && is_string( $map[ $account_id ] ) && '' !== $map[ $account_id ] ) {
896 return $map[ $account_id ];
897 }
898
899 // Legacy fallback: the single column holds the default account's customer.
900 if ( $is_default && ! empty( $donor['stripe_customer_id'] ) && is_string( $donor['stripe_customer_id'] ) ) {
901 return $donor['stripe_customer_id'];
902 }
903
904 return '';
905 }
906
907 /**
908 * Store the Stripe customer ID for a donor on a specific connected account.
909 *
910 * Writes the per-account map in `donor_data['stripe_customers']` and mirrors
911 * the default account's customer into the legacy `stripe_customer_id` column
912 * so back-compat readers keep working.
913 *
914 * @param string $email Donor email.
915 * @param string $account_id Stripe account id (`acct_…`).
916 * @param string $customer_id Stripe customer ID.
917 * @param bool $is_default Whether this is the site default account.
918 * @return bool True on success, false on failure.
919 * @since 1.3.0
920 */
921 public static function set_stripe_customer_id_for_account( $email, $account_id, $customer_id, $is_default = false ) {
922 if ( empty( $email ) || empty( $account_id ) || empty( $customer_id ) ) {
923 return false;
924 }
925
926 $donor = self::get_by_email( $email );
927 if ( ! $donor || empty( $donor['id'] ) ) {
928 return false;
929 }
930 $donor_id = is_numeric( $donor['id'] ) ? (int) $donor['id'] : 0;
931 if ( $donor_id <= 0 ) {
932 return false;
933 }
934
935 $donor_data = isset( $donor['donor_data'] ) && is_array( $donor['donor_data'] ) ? $donor['donor_data'] : [];
936 $map = isset( $donor_data['stripe_customers'] ) && is_array( $donor_data['stripe_customers'] ) ? $donor_data['stripe_customers'] : [];
937
938 $map[ $account_id ] = sanitize_text_field( $customer_id );
939 $donor_data['stripe_customers'] = $map;
940
941 $update = [ 'donor_data' => $donor_data ];
942 if ( $is_default ) {
943 $update['stripe_customer_id'] = sanitize_text_field( $customer_id );
944 }
945
946 $result = self::update( $donor_id, $update );
947
948 return false !== $result;
949 }
950
951 /**
952 * Clear the stored Stripe customer ID for a donor on a specific account.
953 *
954 * Used when a cached customer id is no longer valid on that account
955 * (deleted in Stripe, or a test/live mismatch).
956 *
957 * @param string $email Donor email.
958 * @param string $account_id Stripe account id (`acct_…`).
959 * @param bool $is_default Whether this is the site default account.
960 * @return bool True on success, false on failure.
961 * @since 1.3.0
962 */
963 public static function clear_stripe_customer_id_for_account( $email, $account_id, $is_default = false ) {
964 if ( empty( $email ) || empty( $account_id ) ) {
965 return false;
966 }
967
968 $donor = self::get_by_email( $email );
969 if ( ! $donor || empty( $donor['id'] ) ) {
970 return false;
971 }
972 $donor_id = is_numeric( $donor['id'] ) ? (int) $donor['id'] : 0;
973 if ( $donor_id <= 0 ) {
974 return false;
975 }
976
977 $donor_data = isset( $donor['donor_data'] ) && is_array( $donor['donor_data'] ) ? $donor['donor_data'] : [];
978 $map = isset( $donor_data['stripe_customers'] ) && is_array( $donor_data['stripe_customers'] ) ? $donor_data['stripe_customers'] : [];
979 unset( $map[ $account_id ] );
980 $donor_data['stripe_customers'] = $map;
981
982 $update = [ 'donor_data' => $donor_data ];
983 if ( $is_default ) {
984 $update['stripe_customer_id'] = '';
985 }
986
987 $result = self::update( $donor_id, $update );
988
989 return false !== $result;
990 }
991
992 /**
993 * Get donors for admin listing with optional filters.
994 *
995 * @param string $search Search term for name, email, or phone.
996 * @param int $campaign_id Campaign ID filter (0 for no filter).
997 * @param string $status Donor status filter ('all' for no filter).
998 * @param int $limit Number of records to return.
999 * @param int $offset Offset for pagination.
1000 * @param string $orderby Column to order by.
1001 * @param string $order Order direction (ASC or DESC).
1002 * @param string $after Start date filter (Y-m-d).
1003 * @param string $before End date filter (Y-m-d).
1004 * @return array<mixed> Array of donors.
1005 * @since 1.0.0
1006 */
1007 public static function get_admin_list( $search = '', $campaign_id = 0, $status = 'all', $limit = 20, $offset = 0, $orderby = 'created_at', $order = 'DESC', $after = '', $before = '' ) {
1008 $instance = self::get_instance();
1009 global $wpdb;
1010
1011 $donors_table = $instance->get_tablename();
1012 $donations_table = $wpdb->prefix . 'suredonation_donations';
1013
1014 // Validate orderby column.
1015 if ( ! in_array( $orderby, self::$valid_order_columns, true ) ) {
1016 $orderby = 'created_at';
1017 }
1018
1019 // Validate order direction.
1020 $order = strtoupper( $order );
1021 if ( ! in_array( $order, [ 'ASC', 'DESC' ], true ) ) {
1022 $order = 'DESC';
1023 }
1024
1025 $conditions = self::build_admin_list_conditions( $search, $campaign_id, $status, $after, $before );
1026 $where = $conditions['where'];
1027 $query_args = $conditions['args'];
1028
1029 if ( $conditions['has_campaign'] ) {
1030 $order_col = 'd.' . $orderby;
1031
1032 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Dynamic query with validated conditions.
1033 $results = $wpdb->get_results(
1034 $wpdb->prepare(
1035 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $where is built with prepare-safe conditions, $order_col and $order are validated against whitelists.
1036 "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",
1037 array_merge( [ $donors_table, $donations_table ], $query_args, [ absint( $offset ), absint( $limit ) ] )
1038 ),
1039 ARRAY_A
1040 );
1041 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
1042 } else {
1043 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Dynamic query with validated conditions.
1044 $results = $wpdb->get_results(
1045 $wpdb->prepare(
1046 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $where is built with prepare-safe conditions, $orderby and $order are validated against whitelists.
1047 "SELECT * FROM %i {$where} ORDER BY {$orderby} {$order} LIMIT %d, %d",
1048 array_merge( [ $donors_table ], $query_args, [ absint( $offset ), absint( $limit ) ] )
1049 ),
1050 ARRAY_A
1051 );
1052 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
1053 }
1054
1055 if ( ! $results || ! is_array( $results ) ) {
1056 return [];
1057 }
1058
1059 return array_map( [ $instance, 'decode_by_datatype' ], $results );
1060 }
1061
1062 /**
1063 * Get total donors count with filters.
1064 *
1065 * @param string $search Search term for name, email, or phone.
1066 * @param int $campaign_id Campaign ID filter (0 for no filter).
1067 * @param string $status Donor status filter ('all' for no filter).
1068 * @param string $after Start date filter (Y-m-d).
1069 * @param string $before End date filter (Y-m-d).
1070 * @return int Total count.
1071 * @since 1.0.0
1072 */
1073 public static function get_total_donors_filtered( $search = '', $campaign_id = 0, $status = 'all', $after = '', $before = '' ) {
1074 $instance = self::get_instance();
1075 global $wpdb;
1076
1077 $donors_table = $instance->get_tablename();
1078 $donations_table = $wpdb->prefix . 'suredonation_donations';
1079
1080 $conditions = self::build_admin_list_conditions( $search, $campaign_id, $status, $after, $before );
1081 $where = $conditions['where'];
1082 $query_args = $conditions['args'];
1083
1084 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Data changes frequently, caching would show stale counts.
1085 if ( $conditions['has_campaign'] ) {
1086 $count = $wpdb->get_var(
1087 $wpdb->prepare(
1088 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $where is built with prepare-safe conditions.
1089 "SELECT COUNT(DISTINCT d.id) FROM %i d INNER JOIN %i don ON d.id = don.donor_id {$where}",
1090 array_merge( [ $donors_table, $donations_table ], $query_args )
1091 )
1092 );
1093 } elseif ( ! empty( $query_args ) ) {
1094 $count = $wpdb->get_var(
1095 $wpdb->prepare(
1096 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $where is built with prepare-safe conditions.
1097 "SELECT COUNT(*) FROM %i {$where}",
1098 array_merge( [ $donors_table ], $query_args )
1099 )
1100 );
1101 } else {
1102 $count = $wpdb->get_var(
1103 $wpdb->prepare(
1104 'SELECT COUNT(*) FROM %i',
1105 $donors_table
1106 )
1107 );
1108 }
1109 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
1110
1111 return is_numeric( $count ) ? (int) $count : 0;
1112 }
1113
1114 /**
1115 * Get aggregate donor statistics.
1116 *
1117 * @return array{total_donors: int, total_donated: float, average_donation: float} Aggregate stats.
1118 * @since 1.0.0
1119 */
1120 public static function get_aggregate_stats() {
1121 $instance = self::get_instance();
1122 global $wpdb;
1123 $table = $instance->get_tablename();
1124
1125 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1126 $row = $wpdb->get_row(
1127 $wpdb->prepare(
1128 'SELECT COUNT(*) AS total_donors, COALESCE(SUM(total_donated), 0) AS total_donated, COALESCE(SUM(donation_count), 0) AS total_donation_count FROM %i',
1129 $table
1130 ),
1131 ARRAY_A
1132 );
1133
1134 $total_donors = is_numeric( $row['total_donors'] ?? 0 ) ? (int) $row['total_donors'] : 0;
1135 $total_donated = is_numeric( $row['total_donated'] ?? 0 ) ? (float) $row['total_donated'] : 0.0;
1136 $total_donation_count = is_numeric( $row['total_donation_count'] ?? 0 ) ? (int) $row['total_donation_count'] : 0;
1137 $average_donation = $total_donation_count > 0 ? $total_donated / $total_donation_count : 0.0;
1138
1139 return [
1140 'total_donors' => $total_donors,
1141 'total_donated' => $total_donated,
1142 'average_donation' => round( $average_donation, 2 ),
1143 ];
1144 }
1145
1146 /**
1147 * Build WHERE conditions and prepare args for admin list queries.
1148 *
1149 * @param string $search Search term for name, email, or phone.
1150 * @param int $campaign_id Campaign ID filter (0 for no filter).
1151 * @param string $status Donor status filter ('all' for no filter).
1152 * @param string $after Start date filter (Y-m-d).
1153 * @param string $before End date filter (Y-m-d).
1154 * @return array{where: string, args: array<mixed>, has_campaign: bool} Query parts.
1155 * @since 1.0.0
1156 */
1157 private static function build_admin_list_conditions( $search, $campaign_id, $status, $after = '', $before = '' ) {
1158 global $wpdb;
1159
1160 $has_search = ! empty( $search );
1161 $has_campaign = $campaign_id > 0;
1162 $has_status = 'all' !== $status && ! empty( $status ) && in_array( $status, self::$valid_statuses, true );
1163
1164 $conditions = [];
1165 $args = [];
1166
1167 if ( $has_campaign ) {
1168 $conditions[] = 'don.campaign_id = %d';
1169 $args[] = absint( $campaign_id );
1170 }
1171
1172 if ( $has_status ) {
1173 $col_prefix = $has_campaign ? 'd.' : '';
1174 $conditions[] = $col_prefix . 'donor_status = %s';
1175 $args[] = sanitize_text_field( $status );
1176 }
1177
1178 if ( $has_search ) {
1179 $col_prefix = $has_campaign ? 'd.' : '';
1180 $search_term = '%' . $wpdb->esc_like( sanitize_text_field( $search ) ) . '%';
1181 $conditions[] = '(' . $col_prefix . 'name LIKE %s OR ' . $col_prefix . 'email LIKE %s OR ' . $col_prefix . 'phone LIKE %s)';
1182 $args[] = $search_term;
1183 $args[] = $search_term;
1184 $args[] = $search_term;
1185 }
1186
1187 if ( ! empty( $after ) ) {
1188 $col_prefix = $has_campaign ? 'd.' : '';
1189 $conditions[] = $col_prefix . 'last_donation_date >= %s';
1190 $args[] = sanitize_text_field( $after ) . ' 00:00:00';
1191 }
1192
1193 if ( ! empty( $before ) ) {
1194 $col_prefix = $has_campaign ? 'd.' : '';
1195 $conditions[] = $col_prefix . 'last_donation_date <= %s';
1196 $args[] = sanitize_text_field( $before ) . ' 23:59:59';
1197 }
1198
1199 $where = ! empty( $conditions ) ? 'WHERE ' . implode( ' AND ', $conditions ) : '';
1200
1201 return [
1202 'where' => $where,
1203 'args' => $args,
1204 'has_campaign' => $has_campaign,
1205 ];
1206 }
1207 }
1208