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 +77 -14 1.3.0 → 1.6.1 View file →
@@ -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.