From 85a4efac775973ccd793a9308c33778297a2b884 Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 6 Nov 2026 14:09:09 -0700 Subject: [PATCH 1/5] 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/5] 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/5] 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 From 542eec4f725e07bed9cb9c11e4f93c52e1e4c18f Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 6 Nov 2026 18:40:14 -0700 Subject: [PATCH 4/5] test: isolate deferred receipt gateway fixtures --- tests/WP_Ultimo/Managers/Customer_Manager_Test.php | 10 ++++++++-- tests/WP_Ultimo/Managers/Payment_Manager_Test.php | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/tests/WP_Ultimo/Managers/Customer_Manager_Test.php b/tests/WP_Ultimo/Managers/Customer_Manager_Test.php index b6452ffe4..264ee824c 100644 --- a/tests/WP_Ultimo/Managers/Customer_Manager_Test.php +++ b/tests/WP_Ultimo/Managers/Customer_Manager_Test.php @@ -9,6 +9,7 @@ use WP_Ultimo\Database\Memberships\Membership_Status; use WP_Ultimo\Database\Payments\Payment_Status; +use WP_Ultimo\Gateways\Free_Gateway; use WP_Ultimo\Managers\Customer_Manager; class Customer_Manager_Test extends \WP_UnitTestCase { @@ -184,12 +185,16 @@ public function test_email_verification_dispatches_free_signup_receipt_once(): v $this->assertNotWPError($payment); - $dispatch_count = 0; - $count_dispatch = static function () use (&$dispatch_count): void { + $dispatch_count = 0; + $count_dispatch = static function () use (&$dispatch_count): void { ++$dispatch_count; }; + $resolve_gateway = static function ($gateway, $gateway_id) { + return ! $gateway && 'free' === $gateway_id ? new Free_Gateway() : $gateway; + }; add_action('wu_gateway_payment_processed', $count_dispatch, PHP_INT_MAX, 0); + add_filter('wu_get_gateway', $resolve_gateway, 10, 2); try { $this->assert_email_verification_request_succeeds($customer); @@ -205,6 +210,7 @@ public function test_email_verification_dispatches_free_signup_receipt_once(): v $method->invoke($this->get_manager_instance(), $payment, $membership); } finally { remove_action('wu_gateway_payment_processed', $count_dispatch, PHP_INT_MAX); + remove_filter('wu_get_gateway', $resolve_gateway, 10); } $this->assertSame(1, $dispatch_count); diff --git a/tests/WP_Ultimo/Managers/Payment_Manager_Test.php b/tests/WP_Ultimo/Managers/Payment_Manager_Test.php index 605a860fb..b60080f2e 100644 --- a/tests/WP_Ultimo/Managers/Payment_Manager_Test.php +++ b/tests/WP_Ultimo/Managers/Payment_Manager_Test.php @@ -6,6 +6,7 @@ use WP_Ultimo\Models\Customer; use WP_Ultimo\Database\Memberships\Membership_Status; use WP_Ultimo\Database\Payments\Payment_Status; +use WP_Ultimo\Gateways\Manual_Gateway; use WP_Ultimo\Invoices\Invoice; use WP_UnitTestCase; @@ -14,6 +15,7 @@ class Payment_Manager_Test extends WP_UnitTestCase { private static Customer $customer; private static Payment $payment; private Payment_Manager $payment_manager; + private $gateway_resolver; public static function set_up_before_class() { parent::set_up_before_class(); @@ -65,6 +67,18 @@ public function 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'); + + $this->gateway_resolver = static function ($gateway, $gateway_id) { + return ! $gateway && 'manual' === $gateway_id ? new Manual_Gateway() : $gateway; + }; + + add_filter('wu_get_gateway', $this->gateway_resolver, 10, 2); + } + + public function tear_down() { + remove_filter('wu_get_gateway', $this->gateway_resolver, 10); + + parent::tear_down(); } /** From cda90b1205a222cbfc6ce2fcd6f5da29d83dde5a Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 9 Nov 2026 14:55:41 -0700 Subject: [PATCH 5/5] fix: harden deferred receipt dispatch per review - Refresh payment meta cache before the sent-flag check so a receipt sent by a concurrent request is not dispatched again. - Catch receipt dispatch failures after email verification and manual confirmation so the redirect/save completes; pending flag keeps it retryable. - Count discount uses once per payment so retried dispatches do not double-count. For #2055 --- .../class-payment-edit-admin-page.php | 17 +++- inc/managers/class-customer-manager.php | 14 ++- inc/managers/class-discount-code-manager.php | 12 ++- inc/managers/class-payment-manager.php | 6 ++ .../Managers/Discount_Code_Manager_Test.php | 93 +++++++++++++++++++ 5 files changed, 139 insertions(+), 3 deletions(-) diff --git a/inc/admin-pages/class-payment-edit-admin-page.php b/inc/admin-pages/class-payment-edit-admin-page.php index 7a0762d8d..54ac3c95f 100644 --- a/inc/admin-pages/class-payment-edit-admin-page.php +++ b/inc/admin-pages/class-payment-edit-admin-page.php @@ -1509,7 +1509,22 @@ public function handle_save(): bool { } } - \WP_Ultimo\Managers\Payment_Manager::get_instance()->maybe_trigger_payment_received($payment, $membership); + // The payment is already saved; a failing receipt listener stays retryable on the next save. + try { + \WP_Ultimo\Managers\Payment_Manager::get_instance()->maybe_trigger_payment_received($payment, $membership); + } catch (\Throwable $exception) { + wu_log_add( + 'payment-received', + sprintf('Payment %d receipt dispatch failed after manual confirmation: %s', $payment->get_id(), $exception->getMessage()), + \Psr\Log\LogLevel::ERROR + ); + + WP_Ultimo()->notices->add( + __('The payment was saved, but the payment receipt could not be sent. Save the payment again to retry.', 'ultimate-multisite'), + 'warning', + 'network-admin' + ); + } } return $saved; diff --git a/inc/managers/class-customer-manager.php b/inc/managers/class-customer-manager.php index 6b67014be..a211fffe6 100644 --- a/inc/managers/class-customer-manager.php +++ b/inc/managers/class-customer-manager.php @@ -254,7 +254,19 @@ protected function maybe_send_payment_received($payment, $membership): void { return; } - Payment_Manager::get_instance()->maybe_trigger_payment_received($payment, $membership); + /* + * The customer is already verified at this point, so a failing receipt + * listener must not block the redirect. The pending flag keeps it retryable. + */ + try { + Payment_Manager::get_instance()->maybe_trigger_payment_received($payment, $membership); + } catch (\Throwable $exception) { + wu_log_add( + 'payment-received', + sprintf('Payment %d receipt dispatch failed after email verification: %s', $payment->get_id(), $exception->getMessage()), + \Psr\Log\LogLevel::ERROR + ); + } } /** diff --git a/inc/managers/class-discount-code-manager.php b/inc/managers/class-discount-code-manager.php index 9ee288cee..183f63807 100644 --- a/inc/managers/class-discount-code-manager.php +++ b/inc/managers/class-discount-code-manager.php @@ -96,6 +96,14 @@ public function maybe_add_use_on_payment_received($payment): void { return; } + /* + * The processed event can fire more than once for the same payment, + * e.g. a retried receipt dispatch, so count each payment only once. + */ + if ($payment->get_meta('wu_discount_use_counted')) { + return; + } + /* * Refetch the object, as the original version * might be too old and out-of-date by now. @@ -105,7 +113,9 @@ public function maybe_add_use_on_payment_received($payment): void { if ($discount_code) { $discount_code->add_use(); - $discount_code->save(); + if (true === $discount_code->save()) { + $payment->update_meta('wu_discount_use_counted', 1); + } } } } diff --git a/inc/managers/class-payment-manager.php b/inc/managers/class-payment-manager.php index a3a1c1fcb..4d3e53fd5 100644 --- a/inc/managers/class-payment-manager.php +++ b/inc/managers/class-payment-manager.php @@ -271,6 +271,12 @@ public function maybe_trigger_payment_received($payment, $membership = null): bo $in_progress[ $lock ] = true; try { + /* + * Another request may have sent the receipt and released the lock after + * this request primed the meta cache, so re-read the flag from the database. + */ + wp_cache_delete($payment->get_id(), 'wu_payment_meta'); + if ($payment->get_meta('wu_payment_received_sent')) { return false; } diff --git a/tests/WP_Ultimo/Managers/Discount_Code_Manager_Test.php b/tests/WP_Ultimo/Managers/Discount_Code_Manager_Test.php index 6438b9a7a..44756f4ab 100644 --- a/tests/WP_Ultimo/Managers/Discount_Code_Manager_Test.php +++ b/tests/WP_Ultimo/Managers/Discount_Code_Manager_Test.php @@ -24,4 +24,97 @@ protected function get_expected_slug(): ?string { protected function get_expected_model_class(): ?string { return \WP_Ultimo\Models\Discount_Code::class; } + + public function test_discount_use_is_counted_once_when_receipt_dispatch_is_retried(): void { + + $code = 'RETRY' . wp_rand(1000, 9999); + $discount = wu_create_discount_code( + [ + 'name' => 'Retry Discount', + 'code' => $code, + 'description' => '', + 'value' => 10, + 'type' => 'percentage', + 'active' => true, + 'skip_validation' => true, + ] + ); + + $this->assertNotWPError($discount); + $this->assertGreaterThan(0, $discount->get_id()); + + $customer = wu_create_customer( + [ + 'username' => 'discount-retry-' . wp_rand(1000, 9999), + 'email' => 'discount-retry-' . wp_rand(1000, 9999) . '@example.com', + 'password' => 'password123', + ] + ); + + $this->assertNotWPError($customer); + + $membership = wu_create_membership( + [ + 'customer_id' => $customer->get_id(), + 'status' => \WP_Ultimo\Database\Memberships\Membership_Status::ACTIVE, + 'skip_validation' => true, + ] + ); + + $this->assertNotWPError($membership); + + $payment = wu_create_payment( + [ + 'customer_id' => $customer->get_id(), + 'membership_id' => $membership->get_id(), + 'currency' => 'USD', + 'subtotal' => 90, + 'total' => 90, + 'status' => \WP_Ultimo\Database\Payments\Payment_Status::COMPLETED, + 'gateway' => 'manual', + ] + ); + + $this->assertNotWPError($payment); + + $payment->update_meta('wu_original_cart', new \WP_Ultimo\Checkout\Cart(['discount_code' => $code])); + + $resolve_gateway = static function ($gateway, $gateway_id) { + return ! $gateway && 'manual' === $gateway_id ? new \WP_Ultimo\Gateways\Manual_Gateway() : $gateway; + }; + $fail_once = static function (): void { + static $failed = false; + + if ( ! $failed) { + $failed = true; + + throw new \RuntimeException('receipt dispatch failed'); + } + }; + + // Fail inside the receipt event, after the discount listener but before the sent flag is stored. + add_filter('wu_get_gateway', $resolve_gateway, 10, 2); + add_action('wu_event_payment_received', $fail_once, 1, 0); + + $payment_manager = \WP_Ultimo\Managers\Payment_Manager::get_instance(); + + try { + try { + $payment_manager->maybe_trigger_payment_received($payment); + $this->fail('The first dispatch should fail.'); + } catch (\RuntimeException $exception) { + $this->assertSame('receipt dispatch failed', $exception->getMessage()); + } + + $this->assertSame(1, wu_get_discount_code($discount->get_id())->get_uses()); + + $this->assertTrue($payment_manager->maybe_trigger_payment_received($payment)); + } finally { + remove_filter('wu_get_gateway', $resolve_gateway, 10); + remove_action('wu_event_payment_received', $fail_once, 1); + } + + $this->assertSame(1, wu_get_discount_code($discount->get_id())->get_uses()); + $this->assertSame(1, (int) $payment->get_meta('wu_payment_received_sent')); + } }