From a95fac4b270305ea6c458bd76472a079b3711429 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 9 Sep 2026 13:44:02 -0600 Subject: [PATCH 1/2] fix(stripe): handle Basil invoice subscription references --- inc/gateways/class-base-stripe-gateway.php | 12 ++++- .../Gateways/Stripe_Webhook_Process_Test.php | 52 +++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/inc/gateways/class-base-stripe-gateway.php b/inc/gateways/class-base-stripe-gateway.php index 4beddc1d4..d48494782 100644 --- a/inc/gateways/class-base-stripe-gateway.php +++ b/inc/gateways/class-base-stripe-gateway.php @@ -2806,9 +2806,17 @@ public function process_webhooks() { /* * Now try to get a subscription from the invoice object. + * Basil moved the reference into parent.subscription_details. Keep the + * legacy shape for older events, including expanded subscription objects. */ - if ( ! empty($invoice->subscription)) { - $subscription = $this->get_stripe_client()->subscriptions->retrieve($invoice->subscription); + $invoice_subscription_id = $invoice->subscription ?? $invoice->parent->subscription_details->subscription ?? null; + + if (is_object($invoice_subscription_id)) { + $invoice_subscription_id = $invoice_subscription_id->id ?? null; + } + + if (is_string($invoice_subscription_id) && '' !== $invoice_subscription_id) { + $subscription = $this->get_stripe_client()->subscriptions->retrieve($invoice_subscription_id); } /* diff --git a/tests/WP_Ultimo/Gateways/Stripe_Webhook_Process_Test.php b/tests/WP_Ultimo/Gateways/Stripe_Webhook_Process_Test.php index 465d15878..aae796787 100644 --- a/tests/WP_Ultimo/Gateways/Stripe_Webhook_Process_Test.php +++ b/tests/WP_Ultimo/Gateways/Stripe_Webhook_Process_Test.php @@ -155,6 +155,58 @@ public function tearDown(): void { parent::tearDown(); } + public static function invoice_subscription_shapes(): array { + $expanded = [ + 'id' => 'sub_test123', + 'object' => 'subscription', + ]; + return [ + 'legacy ID' => [['subscription' => 'sub_test123'], true], + 'legacy expanded' => [['subscription' => $expanded], true], + 'Basil ID' => [['parent' => ['subscription_details' => ['subscription' => 'sub_test123']]], true], + 'Basil expanded' => [['parent' => ['subscription_details' => ['subscription' => $expanded]]], true], + 'no reference' => [[], false], + ]; + } + + /** + * @dataProvider invoice_subscription_shapes + */ + public function test_invoice_payment_completes_active_membership(array $shape, bool $linked): void { + $payment = wu_create_payment([ + 'customer_id' => self::$customer->get_id(), + 'membership_id' => $this->membership->get_id(), + 'gateway' => 'stripe', + 'status' => 'pending', + 'subtotal' => 29, + 'total' => 29, + ]); + $this->assertNotWPError($payment); + $this->assertGreaterThan(0, $payment->get_id()); + + $this->subscriptions_mock->expects($linked ? $this->once() : $this->never()) + ->method('retrieve') + ->with('sub_test123') + ->willReturn($this->make_stripe_subscription()); + + $event = $this->make_stripe_event('invoice.payment_succeeded', array_merge([ + 'id' => 'in_active_membership', + 'object' => 'invoice', + 'customer' => 'cus_test123', + 'currency' => 'usd', + 'amount_paid' => 2900, + 'total' => 2900, + ], $shape)); + + try { + $this->dispatch_webhook($event); + $this->assertSame($linked ? 'completed' : 'pending', wu_get_payment($payment->get_id())->get_status()); + $this->assertSame(Membership_Status::ACTIVE, wu_get_membership($this->membership->get_id())->get_status()); + } finally { + $payment->delete(); + } + } + // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- From a20cb1cea4cf48092d132907d823bb4b248483c6 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 9 Sep 2026 16:07:57 -0600 Subject: [PATCH 2/2] fix(stripe): pin requests and webhook API version --- inc/gateways/class-base-stripe-gateway.php | 17 ++- .../Gateways/Stripe_API_Version_Test.php | 115 ++++++++++++++++++ 2 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 tests/WP_Ultimo/Gateways/Stripe_API_Version_Test.php diff --git a/inc/gateways/class-base-stripe-gateway.php b/inc/gateways/class-base-stripe-gateway.php index d48494782..71dcc8fca 100644 --- a/inc/gateways/class-base-stripe-gateway.php +++ b/inc/gateways/class-base-stripe-gateway.php @@ -36,6 +36,13 @@ */ class Base_Stripe_Gateway extends Base_Gateway { + /** + * Pin requests and new webhook endpoints independently of SDK upgrades. + * + * @var string + */ + private const STRIPE_API_VERSION = '2025-08-27.basil'; + /** * Allow gateways to declare multiple additional ids. * @@ -153,7 +160,8 @@ protected function get_stripe_client(): StripeClient { } $client_config = [ - 'api_key' => $this->secret_key, + 'api_key' => $this->secret_key, + 'stripe_version' => self::STRIPE_API_VERSION, ]; // Set Stripe-Account header for Connect mode @@ -673,6 +681,7 @@ protected function install_webhook_for_oauth(): void { $this->get_stripe_client()->webhookEndpoints->create( [ + 'api_version' => self::STRIPE_API_VERSION, 'enabled_events' => ['*'], 'url' => $webhook_url, 'description' => 'Added by Ultimate Multisite. Required to correctly handle changes in subscription status.', @@ -1303,6 +1312,7 @@ public function install_webhook($settings, $settings_to_save, $saved_settings) { */ $this->get_stripe_client()->webhookEndpoints->create( [ + 'api_version' => self::STRIPE_API_VERSION, 'enabled_events' => ['*'], 'url' => $webhook_url, 'description' => 'Added by Ultimate Multisite. Required to correctly handle changes in subscription status.', @@ -1777,9 +1787,8 @@ protected function create_recurring_payment($membership, $cart, $payment_method, * unlocks the multi-interval case without changing single-interval * behaviour. * - * Requires Stripe API version 2025-06-30.basil or later. The bundled - * stripe/stripe-php SDK pins a newer version (2025-08-27.basil at the - * time of writing), so this is always satisfied. + * Requires Stripe API version 2025-06-30.basil or later. The explicit + * STRIPE_API_VERSION pin satisfies this independently of SDK upgrades. * * @since 2.5.x */ diff --git a/tests/WP_Ultimo/Gateways/Stripe_API_Version_Test.php b/tests/WP_Ultimo/Gateways/Stripe_API_Version_Test.php new file mode 100644 index 000000000..1c9c6e4ec --- /dev/null +++ b/tests/WP_Ultimo/Gateways/Stripe_API_Version_Test.php @@ -0,0 +1,115 @@ + [Stripe_Gateway::class, false], + 'stripe OAuth' => [Stripe_Gateway::class, true], + 'checkout direct' => [Stripe_Checkout_Gateway::class, false], + 'checkout OAuth' => [Stripe_Checkout_Gateway::class, true], + ]; + } + + /** + * @dataProvider client_modes + */ + public function test_client_uses_pinned_version(string $gateway_class, bool $oauth): void { + $gateway = $this->getMockBuilder($gateway_class) + ->disableOriginalConstructor() + ->onlyMethods(['is_using_oauth']) + ->getMock(); + $gateway->method('is_using_oauth')->willReturn($oauth); + + $reflection = new \ReflectionClass(Base_Stripe_Gateway::class); + $reflection->getProperty('secret_key')->setValue($gateway, 'sk_test_version_fixture'); + $reflection->getProperty('oauth_account_id')->setValue($gateway, 'acct_version_fixture'); + $client = $reflection->getMethod('get_stripe_client')->invoke($gateway); + $config = (new \ReflectionClass(\Stripe\BaseStripeClient::class))->getProperty('config')->getValue($client); + + $this->assertSame('2025-08-27.basil', $config['stripe_version']); + $this->assertSame($oauth ? 'acct_version_fixture' : null, $client->getStripeAccount()); + } + + public static function webhook_paths(): array { + return [ + 'direct new' => [false, ''], + 'OAuth new' => [true, ''], + 'direct enabled' => [false, 'enabled'], + 'OAuth enabled' => [true, 'enabled'], + 'direct disabled' => [false, 'disabled'], + 'OAuth disabled' => [true, 'disabled'], + ]; + } + + /** + * @dataProvider webhook_paths + */ + public function test_webhook_version_is_pinned_only_on_creation(bool $oauth, string $status): void { + $gateway = $this->getMockBuilder(Stripe_Gateway::class) + ->disableOriginalConstructor() + ->onlyMethods(['setup_api_keys', 'has_webhook_installed', 'get_webhook_listener_url']) + ->getMock(); + $url = home_url('/?wu-gateway=stripe'); + $gateway->method('get_webhook_listener_url')->willReturn($url); + $gateway->method('has_webhook_installed')->willReturn( + $status ? \Stripe\WebhookEndpoint::constructFrom([ + 'id' => 'we_version_fixture', + 'status' => $status, + 'api_version' => '2024-06-20', + ]) : false + ); + + $endpoints = $this->getMockBuilder(\Stripe\Service\WebhookEndpointService::class) + ->disableOriginalConstructor() + ->getMock(); + $client = $this->getMockBuilder(\Stripe\StripeClient::class) + ->disableOriginalConstructor() + ->getMock(); + $client->method('__get')->with('webhookEndpoints')->willReturn($endpoints); + $gateway->set_stripe_client($client); + + if ('' === $status) { + $endpoints->expects($this->once())->method('create')->with([ + 'api_version' => '2025-08-27.basil', + 'enabled_events' => ['*'], + 'url' => $url, + 'description' => 'Added by Ultimate Multisite. Required to correctly handle changes in subscription status.', + ]); + } else { + $endpoints->expects($this->never())->method('create'); + } + + if ('disabled' === $status) { + $endpoints->expects($this->once())->method('update')->with('we_version_fixture', ['status' => 'enabled']); + } else { + $endpoints->expects($this->never())->method('update'); + } + + if ($oauth) { + (new \ReflectionMethod(Base_Stripe_Gateway::class, 'install_webhook_for_oauth'))->invoke($gateway); + } else { + $settings = [ + 'active_gateways' => ['stripe'], + 'stripe_sandbox_mode' => '1', + 'stripe_test_pk_key' => 'pk_test_version_fixture', + 'stripe_test_sk_key' => 'sk_test_version_fixture', + 'stripe_live_pk_key' => '', + 'stripe_live_sk_key' => '', + ]; + $previous = array_merge($settings, ['stripe_test_pk_key' => 'pk_test_previous_fixture']); + $gateway->install_webhook($settings, $settings, $previous); + } + } +}