From 85a4efac775973ccd793a9308c33778297a2b884 Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 6 Nov 2026 14:09:09 -0700 Subject: [PATCH 1/3] fix: send deferred payment receipts --- .../class-payment-edit-admin-page.php | 16 +++-- inc/managers/class-customer-manager.php | 27 +++++++ inc/managers/class-payment-manager.php | 65 +++++++++++++++++ .../Managers/Customer_Manager_Test.php | 70 +++++++++++++++++++ .../Managers/Payment_Manager_Test.php | 68 ++++++++++++++++++ 5 files changed, 242 insertions(+), 4 deletions(-) diff --git a/inc/admin-pages/class-payment-edit-admin-page.php b/inc/admin-pages/class-payment-edit-admin-page.php index 5f3a9b712..7a0762d8d 100644 --- a/inc/admin-pages/class-payment-edit-admin-page.php +++ b/inc/admin-pages/class-payment-edit-admin-page.php @@ -1459,19 +1459,25 @@ public function handle_save(): bool { $payment = $this->get_object(); - $should_confirm_membership = wu_request('confirm_membership') - && Payment_Status::PENDING === $payment->get_status() + $is_pending_to_completed = Payment_Status::PENDING === $payment->get_status() && Payment_Status::COMPLETED === wu_request('status'); + $should_confirm_membership = wu_request('confirm_membership') && $is_pending_to_completed; + $should_trigger_payment_received = $is_pending_to_completed + || (1 === (int) $payment->get_meta('wu_payment_received_pending') && Payment_Status::COMPLETED === wu_request('status')); + + if ($is_pending_to_completed) { + $payment->update_meta('wu_payment_received_pending', 1); + } $payment->recalculate_totals(); // Persist the payment first: failed saves and repeat submissions grant nothing. $saved = parent::handle_save(); - if ($saved && $should_confirm_membership && Payment_Status::COMPLETED === $payment->get_status()) { + if ($saved && $should_trigger_payment_received && Payment_Status::COMPLETED === $payment->get_status()) { $membership = $payment->get_membership(); - if ($membership) { + if ($should_confirm_membership && $membership) { $is_initial_payment = Membership_Status::PENDING === $membership->get_status() && 0 === $membership->get_times_billed(); @@ -1502,6 +1508,8 @@ public function handle_save(): bool { return false; } } + + \WP_Ultimo\Managers\Payment_Manager::get_instance()->maybe_trigger_payment_received($payment, $membership); } return $saved; diff --git a/inc/managers/class-customer-manager.php b/inc/managers/class-customer-manager.php index a8496441b..6b67014be 100644 --- a/inc/managers/class-customer-manager.php +++ b/inc/managers/class-customer-manager.php @@ -14,6 +14,7 @@ use WP_Ultimo\Checkout\Checkout; use WP_Ultimo\Models\Customer; use WP_Ultimo\Database\Memberships\Membership_Status; +use WP_Ultimo\Database\Payments\Payment_Status; // Exit if accessed directly defined('ABSPATH') || exit; @@ -232,6 +233,30 @@ protected function maybe_send_verification_email(int $customer_id): void { $customer->send_verification_email(); } + /** + * Sends the deferred receipt for a free signup once the customer is verified. + * + * Paid and manual payments are skipped: they are confirmed by their own flows. + * + * @since 2.18.1 + * + * @param \WP_Ultimo\Models\Payment $payment The payment. + * @param \WP_Ultimo\Models\Membership $membership The membership. + * @return void + */ + protected function maybe_send_payment_received($payment, $membership): void { + + if (Payment_Status::COMPLETED !== $payment->get_status() || 0 < (float) $payment->get_total()) { + return; + } + + if (Membership_Status::ACTIVE !== $membership->get_status()) { + return; + } + + Payment_Manager::get_instance()->maybe_trigger_payment_received($payment, $membership); + } + /** * Verifies a customer by checking the email key. * @@ -317,6 +342,8 @@ public function maybe_verify_email_address(): void { $payments = $membership->get_payments(); if ($payments) { + $this->maybe_send_payment_received($payments[0], $membership); + $redirect_url = add_query_arg( [ 'payment' => $payments[0]->get_hash(), diff --git a/inc/managers/class-payment-manager.php b/inc/managers/class-payment-manager.php index b805b42f6..a3a1c1fcb 100644 --- a/inc/managers/class-payment-manager.php +++ b/inc/managers/class-payment-manager.php @@ -217,6 +217,71 @@ public function handle_payment_success($payment, $membership, $gateway): void { } wu_do_event('payment_received', $payload); + + if ($payment->get_id()) { + $marked_sent = $payment->update_meta('wu_payment_received_sent', 1); + + if ($marked_sent) { + $payment->delete_meta('wu_payment_received_pending'); + } + } + } + + /** + * Sends the payment received notifications once per payment. + * + * Used by flows that confirm a payment outside a gateway callback, such as + * manual payment confirmation and deferred free signups after email verification. + * + * @since 2.18.1 + * + * @param \WP_Ultimo\Models\Payment $payment The payment. + * @param \WP_Ultimo\Models\Membership|null $membership The membership, if known. + * @return bool True when the notification was triggered now. + */ + public function maybe_trigger_payment_received($payment, $membership = null): bool { + + if ( ! $payment || ! $payment->get_id()) { + return false; + } + + $gateway = wu_get_gateway($payment->get_gateway() ?: 'manual'); + + if ( ! $gateway) { + $gateway = wu_get_gateway('free'); + } + + if ( ! $gateway) { + return false; + } + + global $wpdb; + + $lock = sprintf('wu_payment_received_%d_%d', get_current_network_id(), $payment->get_id()); + static $in_progress = []; + + if (isset($in_progress[ $lock ])) { + return false; + } + + if ('1' !== (string) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 0)', $lock))) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- named lock, never cached. + return false; + } + + $in_progress[ $lock ] = true; + + try { + if ($payment->get_meta('wu_payment_received_sent')) { + return false; + } + + $gateway->trigger_payment_processed($payment, $membership); + + return true; + } finally { + unset($in_progress[ $lock ]); + $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- named lock, never cached. + } } /** diff --git a/tests/WP_Ultimo/Managers/Customer_Manager_Test.php b/tests/WP_Ultimo/Managers/Customer_Manager_Test.php index acc5ee899..b6452ffe4 100644 --- a/tests/WP_Ultimo/Managers/Customer_Manager_Test.php +++ b/tests/WP_Ultimo/Managers/Customer_Manager_Test.php @@ -7,6 +7,8 @@ namespace WP_Ultimo\Tests\Managers; +use WP_Ultimo\Database\Memberships\Membership_Status; +use WP_Ultimo\Database\Payments\Payment_Status; use WP_Ultimo\Managers\Customer_Manager; class Customer_Manager_Test extends \WP_UnitTestCase { @@ -140,6 +142,74 @@ public function test_email_verification_allows_different_logged_in_user(): void $this->assert_email_verification_request_succeeds($customer); } + public function test_email_verification_dispatches_free_signup_receipt_once(): void { + + $customer = $this->create_pending_customer_with_verification_key('free-signup-verification@example.com'); + $product = wu_create_product( + [ + 'name' => 'Verified Free Plan', + 'slug' => 'verified-free-plan-' . wp_generate_uuid4(), + 'pricing_type' => 'free', + 'amount' => 0, + 'type' => 'plan', + ] + ); + + $this->assertNotWPError($product); + + $membership = wu_create_membership( + [ + 'customer_id' => $customer->get_id(), + 'plan_id' => $product->get_id(), + 'status' => Membership_Status::PENDING, + 'date_trial_end' => gmdate('Y-m-d H:i:s', time() - HOUR_IN_SECONDS), + 'skip_validation' => true, + ] + ); + + $this->assertNotWPError($membership); + + $payment = wu_create_payment( + [ + 'customer_id' => $customer->get_id(), + 'membership_id' => $membership->get_id(), + 'product_id' => $product->get_id(), + 'currency' => 'USD', + 'subtotal' => 0, + 'total' => 0, + 'status' => Payment_Status::COMPLETED, + 'gateway' => 'free', + ] + ); + + $this->assertNotWPError($payment); + + $dispatch_count = 0; + $count_dispatch = static function () use (&$dispatch_count): void { + ++$dispatch_count; + }; + + add_action('wu_gateway_payment_processed', $count_dispatch, PHP_INT_MAX, 0); + + try { + $this->assert_email_verification_request_succeeds($customer); + + $membership = wu_get_membership($membership->get_id()); + $payment = wu_get_payment($payment->get_id()); + + $this->assertSame(Membership_Status::ACTIVE, $membership->get_status()); + $this->assertSame(1, (int) $payment->get_meta('wu_payment_received_sent')); + + $reflection = new \ReflectionClass($this->get_manager_instance()); + $method = $reflection->getMethod('maybe_send_payment_received'); + $method->invoke($this->get_manager_instance(), $payment, $membership); + } finally { + remove_action('wu_gateway_payment_processed', $count_dispatch, PHP_INT_MAX); + } + + $this->assertSame(1, $dispatch_count); + } + /** * Create a pending customer with a saved verification key. * diff --git a/tests/WP_Ultimo/Managers/Payment_Manager_Test.php b/tests/WP_Ultimo/Managers/Payment_Manager_Test.php index d56ec44e1..605a860fb 100644 --- a/tests/WP_Ultimo/Managers/Payment_Manager_Test.php +++ b/tests/WP_Ultimo/Managers/Payment_Manager_Test.php @@ -63,6 +63,8 @@ public static function set_up_before_class() { public function set_up() { parent::set_up(); $this->payment_manager = Payment_Manager::get_instance(); + self::$payment->delete_meta('wu_payment_received_sent'); + self::$payment->delete_meta('wu_payment_received_pending'); } /** @@ -157,6 +159,72 @@ public function test_invoice_viewer_with_missing_reference(): void { unset($_REQUEST['action']); } + public function test_maybe_trigger_payment_received_only_dispatches_once(): void { + + self::$payment->update_meta('wu_payment_received_pending', 1); + + $dispatch_count = 0; + $count_dispatch = static function () use (&$dispatch_count): void { + ++$dispatch_count; + }; + + add_action('wu_gateway_payment_processed', $count_dispatch, PHP_INT_MAX, 0); + + try { + $this->assertTrue($this->payment_manager->maybe_trigger_payment_received(self::$payment)); + $this->assertFalse($this->payment_manager->maybe_trigger_payment_received(self::$payment)); + } finally { + remove_action('wu_gateway_payment_processed', $count_dispatch, PHP_INT_MAX); + } + + $this->assertSame(1, $dispatch_count); + $this->assertSame(1, (int) self::$payment->get_meta('wu_payment_received_sent')); + $this->assertFalse(self::$payment->get_meta('wu_payment_received_pending')); + } + + public function test_maybe_trigger_payment_received_remains_retryable_after_failure(): void { + + self::$payment->update_meta('wu_payment_received_pending', 1); + + $fail_dispatch = static function (): void { + throw new \RuntimeException('payment received dispatch failed'); + }; + + add_action('wu_gateway_payment_processed', $fail_dispatch, 1, 0); + + try { + $this->payment_manager->maybe_trigger_payment_received(self::$payment); + $this->fail('The dispatch failure should be propagated.'); + } catch (\RuntimeException $exception) { + $this->assertSame('payment received dispatch failed', $exception->getMessage()); + } finally { + remove_action('wu_gateway_payment_processed', $fail_dispatch, 1); + } + + $this->assertFalse(self::$payment->get_meta('wu_payment_received_sent')); + $this->assertSame(1, (int) self::$payment->get_meta('wu_payment_received_pending')); + $this->assertTrue($this->payment_manager->maybe_trigger_payment_received(self::$payment)); + $this->assertFalse(self::$payment->get_meta('wu_payment_received_pending')); + } + + public function test_maybe_trigger_payment_received_rejects_reentrant_dispatch(): void { + + $reentrant_result = null; + $reenter_dispatch = function () use (&$reentrant_result): void { + $reentrant_result = $this->payment_manager->maybe_trigger_payment_received(self::$payment); + }; + + add_action('wu_gateway_payment_processed', $reenter_dispatch, 1, 0); + + try { + $this->assertTrue($this->payment_manager->maybe_trigger_payment_received(self::$payment)); + } finally { + remove_action('wu_gateway_payment_processed', $reenter_dispatch, 1); + } + + $this->assertFalse($reentrant_result); + } + public static function tear_down_after_class() { self::$payment->delete(); From 4e5c38e70ceb64e20048175d00188691553d8009 Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 6 Nov 2026 14:10:24 -0700 Subject: [PATCH 2/3] fix: send deferred payment receipts --- .opencode/opencode-loop/ses_e4d520862ffeeTaW7rHlzRQt4M.json | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .opencode/opencode-loop/ses_e4d520862ffeeTaW7rHlzRQt4M.json diff --git a/.opencode/opencode-loop/ses_e4d520862ffeeTaW7rHlzRQt4M.json b/.opencode/opencode-loop/ses_e4d520862ffeeTaW7rHlzRQt4M.json new file mode 100644 index 000000000..f50781068 --- /dev/null +++ b/.opencode/opencode-loop/ses_e4d520862ffeeTaW7rHlzRQt4M.json @@ -0,0 +1,4 @@ +{ + "version": 4, + "jobs": [] +} \ No newline at end of file From f99530f29786cf209e262c632d01d9e6c5615251 Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 6 Nov 2026 14:11:52 -0700 Subject: [PATCH 3/3] chore: remove runtime review artifact --- .opencode/opencode-loop/ses_e4d520862ffeeTaW7rHlzRQt4M.json | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 .opencode/opencode-loop/ses_e4d520862ffeeTaW7rHlzRQt4M.json diff --git a/.opencode/opencode-loop/ses_e4d520862ffeeTaW7rHlzRQt4M.json b/.opencode/opencode-loop/ses_e4d520862ffeeTaW7rHlzRQt4M.json deleted file mode 100644 index f50781068..000000000 --- a/.opencode/opencode-loop/ses_e4d520862ffeeTaW7rHlzRQt4M.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "version": 4, - "jobs": [] -} \ No newline at end of file