PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.1.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.1.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
← All changes | inc/database/tables/donors.php +368 -2 0.0.1 → 1.1.0 View file →
@@ -7,8 +7,9 @@
7 7
8 8 namespace SureDonation\Inc\Database\Tables;
9 9
10 10 use SureDonation\Inc\Database\Base;
11 +use SureDonation\Inc\Helper;
11 12 use SureDonation\Inc\Traits\Get_Instance;
12 13
13 14 // Exit if accessed directly.
14 15 defined( 'ABSPATH' ) || exit;
@@ -34,9 +35,9 @@
34 35 *
35 36 * @var int
36 37 * @since 0.0.1
37 38 */
38 - protected $table_version = 2;
39 + protected $table_version = 4;
39 40
40 41 /**
41 42 * Valid donor statuses.
42 43 *
@@ -84,8 +85,16 @@
84 85 'phone' => [
85 86 'type' => 'string',
86 87 'default' => '',
87 88 ],
89 + 'company' => [
90 + 'type' => 'string',
91 + 'default' => '',
92 + ],
93 + 'address' => [
94 + 'type' => 'string',
95 + 'default' => '',
96 + ],
88 97 'user_id' => [
89 98 'type' => 'number',
90 99 'default' => 0,
91 100 ],
@@ -122,8 +131,16 @@
122 131 'stripe_customer_id' => [
123 132 'type' => 'string',
124 133 'default' => '',
125 134 ],
135 + 'import_source_id' => [
136 + 'type' => 'number',
137 + 'default' => 0,
138 + ],
139 + 'import_source' => [
140 + 'type' => 'string',
141 + 'default' => '',
142 + ],
126 143 'created_at' => [
127 144 'type' => 'datetime',
128 145 ],
129 146 'updated_at' => [
@@ -150,8 +167,10 @@
150 167 'donor_tags LONGTEXT',
151 168 'donor_status VARCHAR(20) NOT NULL',
152 169 'donor_data LONGTEXT',
153 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 ""',
154 173 'created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP',
155 174 'updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
156 175 'INDEX idx_email (email)',
157 176 'INDEX idx_user (user_id)',
@@ -156,12 +175,40 @@
156 175 'INDEX idx_email (email)',
157 176 'INDEX idx_user (user_id)',
158 177 'INDEX idx_total (total_donated)',
159 178 'INDEX idx_status (donor_status)',
179 + 'INDEX idx_import_source (import_source_id, import_source)',
160 180 ];
161 181 }
162 182
163 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 + /**
164 211 * Add a new donor record.
165 212 *
166 213 * @param array<mixed> $data Donor data to insert.
167 214 * @return int|false The donor ID on success, false on error.
@@ -435,9 +482,9 @@
435 482 return $existing_id > 0 ? $existing_id : false;
436 483 }
437 484
438 485 // Create new donor.
439 - return self::add(
486 + $donor_id = self::add(
440 487 [
441 488 'email' => sanitize_email( $email ),
442 489 'name' => sanitize_text_field( $name ),
443 490 'phone' => sanitize_text_field( $phone ),
@@ -443,11 +490,114 @@
443 490 'phone' => sanitize_text_field( $phone ),
444 491 'first_donation_date' => current_time( 'mysql' ),
445 492 ]
446 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;
447 501 }
448 502
449 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 + /**
450 600 * Update donor statistics after a donation.
451 601 *
452 602 * @param int $donor_id Donor ID.
453 603 * @param float $amount Donation amount.
@@ -650,6 +800,222 @@
650 800
651 801 $result = self::update( $donor_id, [ 'stripe_customer_id' => '' ] );
652 802
653 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 + ];
654 1020 }
655 1021 }