| @@ -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 | |