PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.6.1
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.6.1
1.6.1 1.6.0 1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
← All changes | inc/database/tables/donors.php +204 -18 1.0.0 → 1.6.1 View file →
@@ -168,9 +168,9 @@
168 168 'donor_status VARCHAR(20) NOT NULL',
169 169 'donor_data LONGTEXT',
170 170 'stripe_customer_id VARCHAR(255) DEFAULT NULL',
171 171 'import_source_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0',
172 - 'import_source VARCHAR(20) NOT NULL DEFAULT ""',
172 + 'import_source VARCHAR(20) NOT NULL DEFAULT \'\'',
173 173 'created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP',
174 174 'updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
175 175 'INDEX idx_email (email)',
176 176 'INDEX idx_user (user_id)',
@@ -198,12 +198,12 @@
198 198 // produces silent divergence at the data layer — a future
199 199 // `WHERE address = ''` filter would miss legacy rows that landed as
200 200 // NULL from the migration.
201 201 return [
202 - 'company VARCHAR(255) NOT NULL DEFAULT "" AFTER phone',
203 - 'address TEXT NOT NULL DEFAULT "" AFTER company',
202 + 'company VARCHAR(255) NOT NULL DEFAULT \'\' AFTER phone',
203 + 'address TEXT NOT NULL DEFAULT \'\' AFTER company',
204 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',
205 + 'import_source VARCHAR(20) NOT NULL DEFAULT \'\' AFTER import_source_id',
206 206 'INDEX idx_import_source (import_source_id, import_source)',
207 207 ];
208 208 }
209 209
@@ -446,12 +446,13 @@
446 446 /**
447 447 * Get or create donor by email.
448 448 *
449 449 * @param string $email Donor email.
450 - * @param string $name Donor name.
451 - * @param string $phone Donor phone.
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 452 * @return int|false Donor ID or false on error.
453 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.
454 455 */
455 456 public static function get_or_create( $email, $name = '', $phone = '' ) {
456 457 if ( empty( $email ) ) {
457 458 return false;
@@ -459,27 +460,31 @@
459 460
460 461 $existing = self::get_by_email( $email );
461 462
462 463 if ( $existing ) {
463 - // Update name/phone if provided and different.
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.
464 476 $updates = [];
465 -
466 - if ( ! empty( $name ) && $name !== $existing['name'] ) {
477 + if ( ! empty( $name ) && '' === (string) ( $existing['name'] ?? '' ) ) {
467 478 $updates['name'] = $name;
468 479 }
469 -
470 - if ( ! empty( $phone ) && $phone !== $existing['phone'] ) {
480 + if ( ! empty( $phone ) && '' === (string) ( $existing['phone'] ?? '' ) ) {
471 481 $updates['phone'] = $phone;
472 482 }
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 - }
483 + if ( ! empty( $updates ) && $existing_id > 0 ) {
484 + self::update( $existing_id, $updates );
479 485 }
480 486
481 - $existing_id = isset( $existing['id'] ) && is_numeric( $existing['id'] ) ? (int) $existing['id'] : 0;
482 487 return $existing_id > 0 ? $existing_id : false;
483 488 }
484 489
485 490 // Create new donor.
@@ -596,10 +601,68 @@
596 601 do_action( 'suredonation_donor_user_created', $user_id, $donor_id, $email );
597 602 }
598 603
599 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 + /**
600 659 * Update donor statistics after a donation.
601 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 + *
602 665 * @param int $donor_id Donor ID.
603 666 * @param float $amount Donation amount.
604 667 * @return int|false Number of rows updated or false on error.
605 668 * @since 0.0.1
@@ -798,8 +861,131 @@
798 861 return false;
799 862 }
800 863
801 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 );
802 988
803 989 return false !== $result;
804 990 }
805 991