| @@ -601,9 +601,67 @@ | ||
| 601 | 601 | do_action( 'suredonation_donor_user_created', $user_id, $donor_id, $email ); |
| 602 | 602 | } |
| 603 | 603 | |
| 604 | 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 | + /** | |
| 605 | 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(). | |
| 606 | 664 | * |
| 607 | 665 | * @param int $donor_id Donor ID. |
| 608 | 666 | * @param float $amount Donation amount. |
| 609 | 667 | * @return int|false Number of rows updated or false on error. |