| @@ -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,9 +601,67 @@ | ||
| 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. |
| 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(). | |
| 601 | 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. |