Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 23 additions & 6 deletions inc/gateways/class-base-stripe-gateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.',
Expand Down Expand Up @@ -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.',
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -2806,9 +2815,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);
}

/*
Expand Down
115 changes: 115 additions & 0 deletions tests/WP_Ultimo/Gateways/Stripe_API_Version_Test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<?php
/**
* Stripe request and webhook API version regression tests.
*
* @package WP_Ultimo
* @subpackage Tests/Gateways
* @since 2.15.2
*/

namespace WP_Ultimo\Gateways;

defined('ABSPATH') || exit;

class Stripe_API_Version_Test extends \WP_UnitTestCase {

public static function client_modes(): array {
return [
'stripe direct' => [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);
}
}
}
52 changes: 52 additions & 0 deletions tests/WP_Ultimo/Gateways/Stripe_Webhook_Process_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
// -------------------------------------------------------------------------
Expand Down
Loading