From 37bafda9a0f9eb44563f0b7bc18108997c541977 Mon Sep 17 00:00:00 2001 From: Ashley Gibson Date: Thu, 30 Jul 2026 12:10:21 +0100 Subject: [PATCH 1/3] Add subscription v9 helpers --- tests/Stubs/WCS_ATT_Product_Schemes.php | 40 +++ tests/Stubs/WCS_ATT_Scheme.php | 41 +++ tests/Stubs/WC_Subscriptions_Product.php | 64 ++++ tests/bootstrap.php | 12 + .../Helpers/SubscriptionProductHelperTest.php | 231 +++++++++++++ .../Helpers/SubscriptionProductHelper.php | 321 ++++++++++++++++++ woocommerce/changelog.txt | 1 + 7 files changed, 710 insertions(+) create mode 100644 tests/Stubs/WCS_ATT_Product_Schemes.php create mode 100644 tests/Stubs/WCS_ATT_Scheme.php create mode 100644 tests/Stubs/WC_Subscriptions_Product.php create mode 100644 tests/unit/Helpers/SubscriptionProductHelperTest.php create mode 100644 woocommerce/Helpers/SubscriptionProductHelper.php diff --git a/tests/Stubs/WCS_ATT_Product_Schemes.php b/tests/Stubs/WCS_ATT_Product_Schemes.php new file mode 100644 index 000000000..336c834cd --- /dev/null +++ b/tests/Stubs/WCS_ATT_Product_Schemes.php @@ -0,0 +1,40 @@ +data = $data; + } + + public function get_period() { + return $this->data['period'] ?? ''; + } + + public function get_interval() { + return $this->data['interval'] ?? 1; + } + + public function get_length() { + return $this->data['length'] ?? 0; + } + + public function get_trial_length() { + return $this->data['trial_length'] ?? 0; + } + + public function get_trial_period() { + return $this->data['trial_period'] ?? ''; + } + + public function get_signup_fee() { + return $this->data['signup_fee'] ?? 0.0; + } +} diff --git a/tests/Stubs/WC_Subscriptions_Product.php b/tests/Stubs/WC_Subscriptions_Product.php new file mode 100644 index 000000000..8361d9808 --- /dev/null +++ b/tests/Stubs/WC_Subscriptions_Product.php @@ -0,0 +1,64 @@ +andReturnUsing(static function (string $date) { + return strtotime($date); + }); + + WP_Mock::userFunction('wcs_add_time') + ->andReturnUsing(static function (int $number, string $period, int $timestamp) { + $secondsByPeriod = [ + 'day' => self::SECONDS_PER_DAY, + 'week' => self::SECONDS_PER_WEEK, + 'month' => self::SECONDS_PER_MONTH, + 'year' => self::SECONDS_PER_YEAR, + ]; + + return $timestamp + ($number * ($secondsByPeriod[$period] ?? self::SECONDS_PER_DAY)); + }); + } + + /** @covers ::isSubscriptionProduct() */ + public function testIsNotSubscriptionProductWhenNeitherMechanismApplies() : void + { + $this->assertFalse(SubscriptionProductHelper::isSubscriptionProduct($this->getProduct())); + } + + /** @covers ::isSubscriptionProduct() */ + public function testIsSubscriptionProductForLegacySubscriptionType() : void + { + WC_Subscriptions_Product::$isSubscription = true; + + $this->assertTrue(SubscriptionProductHelper::isSubscriptionProduct($this->getProduct())); + } + + /** @covers ::isSubscriptionProduct() */ + public function testIsSubscriptionProductForApfsScheme() : void + { + WCS_ATT_Product_Schemes::$hasSchemes = true; + + $this->assertTrue(SubscriptionProductHelper::isSubscriptionProduct($this->getProduct())); + } + + /** @covers ::getSubscriptionPeriod() */ + /** @covers ::getSubscriptionPeriodInterval() */ + /** @covers ::getSubscriptionLength() */ + /** @covers ::getSubscriptionTrialLength() */ + /** @covers ::getSubscriptionTrialPeriod() */ + /** @covers ::getSubscriptionSignUpFee() */ + public function testGettersReturnDefaultsWhenNotASubscription() : void + { + $product = $this->getProduct(); + + $this->assertSame('', SubscriptionProductHelper::getSubscriptionPeriod($product)); + $this->assertSame(1, SubscriptionProductHelper::getSubscriptionPeriodInterval($product)); + $this->assertSame(0, SubscriptionProductHelper::getSubscriptionLength($product)); + $this->assertSame(0, SubscriptionProductHelper::getSubscriptionTrialLength($product)); + $this->assertSame('', SubscriptionProductHelper::getSubscriptionTrialPeriod($product)); + $this->assertSame(0.0, SubscriptionProductHelper::getSubscriptionSignUpFee($product)); + } + + /** @covers ::getSubscriptionPeriod() */ + /** @covers ::getSubscriptionPeriodInterval() */ + /** @covers ::getSubscriptionLength() */ + /** @covers ::getSubscriptionTrialLength() */ + /** @covers ::getSubscriptionTrialPeriod() */ + /** @covers ::getSubscriptionSignUpFee() */ + public function testGettersDelegateToLegacySubscriptionsProductForLegacyType() : void + { + WC_Subscriptions_Product::$isSubscription = true; + WC_Subscriptions_Product::$period = 'month'; + WC_Subscriptions_Product::$interval = 2; + WC_Subscriptions_Product::$length = 12; + WC_Subscriptions_Product::$trialLength = 1; + WC_Subscriptions_Product::$trialPeriod = 'week'; + WC_Subscriptions_Product::$signUpFee = 9.99; + + // APFS schemes should be entirely ignored once the legacy type is detected. + WCS_ATT_Product_Schemes::$hasSchemes = true; + WCS_ATT_Product_Schemes::$activeScheme = new WCS_ATT_Scheme(['period' => 'year', 'length' => 99]); + + $product = $this->getProduct(); + + $this->assertSame('month', SubscriptionProductHelper::getSubscriptionPeriod($product)); + $this->assertSame(2, SubscriptionProductHelper::getSubscriptionPeriodInterval($product)); + $this->assertSame(12, SubscriptionProductHelper::getSubscriptionLength($product)); + $this->assertSame(1, SubscriptionProductHelper::getSubscriptionTrialLength($product)); + $this->assertSame('week', SubscriptionProductHelper::getSubscriptionTrialPeriod($product)); + $this->assertSame(9.99, SubscriptionProductHelper::getSubscriptionSignUpFee($product)); + } + + /** @covers ::getSubscriptionPeriod() */ + /** @covers ::getSubscriptionLength() */ + public function testGettersReadFromActiveApfsScheme() : void + { + WCS_ATT_Product_Schemes::$hasSchemes = true; + WCS_ATT_Product_Schemes::$activeScheme = new WCS_ATT_Scheme(['period' => 'month', 'length' => 6]); + WCS_ATT_Product_Schemes::$defaultScheme = new WCS_ATT_Scheme(['period' => 'year', 'length' => 1]); + WCS_ATT_Product_Schemes::$baseScheme = new WCS_ATT_Scheme(['period' => 'week', 'length' => 52]); + + $product = $this->getProduct(); + + $this->assertSame('month', SubscriptionProductHelper::getSubscriptionPeriod($product)); + $this->assertSame(6, SubscriptionProductHelper::getSubscriptionLength($product)); + } + + /** @covers ::getSubscriptionPeriod() */ + /** @covers ::getSubscriptionLength() */ + public function testGettersFallBackToDefaultApfsSchemeWhenNoneIsActive() : void + { + WCS_ATT_Product_Schemes::$hasSchemes = true; + WCS_ATT_Product_Schemes::$activeScheme = null; + WCS_ATT_Product_Schemes::$defaultScheme = new WCS_ATT_Scheme(['period' => 'year', 'length' => 1]); + WCS_ATT_Product_Schemes::$baseScheme = new WCS_ATT_Scheme(['period' => 'week', 'length' => 52]); + + $product = $this->getProduct(); + + $this->assertSame('year', SubscriptionProductHelper::getSubscriptionPeriod($product)); + $this->assertSame(1, SubscriptionProductHelper::getSubscriptionLength($product)); + } + + /** @covers ::getSubscriptionPeriod() */ + /** @covers ::getSubscriptionLength() */ + public function testGettersFallBackToBaseApfsSchemeWhenNoActiveOrDefault() : void + { + WCS_ATT_Product_Schemes::$hasSchemes = true; + WCS_ATT_Product_Schemes::$activeScheme = null; + WCS_ATT_Product_Schemes::$defaultScheme = null; + WCS_ATT_Product_Schemes::$baseScheme = new WCS_ATT_Scheme(['period' => 'week', 'length' => 52]); + + $product = $this->getProduct(); + + $this->assertSame('week', SubscriptionProductHelper::getSubscriptionPeriod($product)); + $this->assertSame(52, SubscriptionProductHelper::getSubscriptionLength($product)); + } + + /** @covers ::getSubscriptionExpirationDate() */ + public function testExpirationDateDelegatesToLegacySubscriptionsProductForLegacyType() : void + { + WC_Subscriptions_Product::$isSubscription = true; + WC_Subscriptions_Product::$expirationDate = '2027-01-01 00:00:00'; + + $this->assertSame( + '2027-01-01 00:00:00', + SubscriptionProductHelper::getSubscriptionExpirationDate($this->getProduct()) + ); + } + + /** @covers ::getSubscriptionExpirationDate() */ + public function testExpirationDateIsZeroWhenApfsSchemeHasNoLength() : void + { + WCS_ATT_Product_Schemes::$hasSchemes = true; + WCS_ATT_Product_Schemes::$activeScheme = new WCS_ATT_Scheme(['period' => 'month', 'length' => 0]); + + $this->assertSame(0, SubscriptionProductHelper::getSubscriptionExpirationDate($this->getProduct())); + } + + /** @covers ::getSubscriptionExpirationDate() */ + public function testExpirationDateIsCalculatedFromApfsSchemeLength() : void + { + $this->mockTimeFunctions(); + + WCS_ATT_Product_Schemes::$hasSchemes = true; + WCS_ATT_Product_Schemes::$activeScheme = new WCS_ATT_Scheme(['period' => 'month', 'length' => 3]); + + $fromDate = '2027-01-01 00:00:00'; + $expected = gmdate('Y-m-d H:i:s', strtotime($fromDate) + (3 * self::SECONDS_PER_MONTH)); + + $this->assertSame( + $expected, + SubscriptionProductHelper::getSubscriptionExpirationDate($this->getProduct(), $fromDate) + ); + } + + /** @covers ::getSubscriptionExpirationDate() */ + public function testExpirationDateAccountsForApfsSchemeTrialBeforeLength() : void + { + $this->mockTimeFunctions(); + + WCS_ATT_Product_Schemes::$hasSchemes = true; + WCS_ATT_Product_Schemes::$activeScheme = new WCS_ATT_Scheme([ + 'period' => 'month', + 'length' => 3, + 'trial_length' => 2, + 'trial_period' => 'week', + ]); + + $fromDate = '2027-01-01 00:00:00'; + $trialExpiration = strtotime($fromDate) + (2 * self::SECONDS_PER_WEEK); + $expected = gmdate('Y-m-d H:i:s', $trialExpiration + (3 * self::SECONDS_PER_MONTH)); + + $this->assertSame( + $expected, + SubscriptionProductHelper::getSubscriptionExpirationDate($this->getProduct(), $fromDate) + ); + } +} diff --git a/woocommerce/Helpers/SubscriptionProductHelper.php b/woocommerce/Helpers/SubscriptionProductHelper.php new file mode 100644 index 000000000..49a32b550 --- /dev/null +++ b/woocommerce/Helpers/SubscriptionProductHelper.php @@ -0,0 +1,321 @@ +get_period() : ''; + } + + + /** + * Gets the subscription period interval for a product. + * + * @since 6.4.0 + * + * @param int|\WC_Product $product product or product ID + * @return int + */ + public static function getSubscriptionPeriodInterval( $product ) : int { + + $product = self::resolveProduct( $product ); + + if ( ! $product ) { + return 1; + } + + if ( is_callable( [ 'WC_Subscriptions_Product', 'is_subscription' ] ) && \WC_Subscriptions_Product::is_subscription( $product ) ) { + return (int) \WC_Subscriptions_Product::get_interval( $product ); + } + + $scheme = self::resolveEffectiveScheme( $product ); + + return $scheme ? (int) $scheme->get_interval() : 1; + } + + + /** + * Gets the subscription length for a product, or 0 if it continues for perpetuity (or isn't a subscription). + * + * @since 6.4.0 + * + * @param int|\WC_Product $product product or product ID + * @return int + */ + public static function getSubscriptionLength( $product ) : int { + + $product = self::resolveProduct( $product ); + + if ( ! $product ) { + return 0; + } + + if ( is_callable( [ 'WC_Subscriptions_Product', 'is_subscription' ] ) && \WC_Subscriptions_Product::is_subscription( $product ) ) { + return (int) \WC_Subscriptions_Product::get_length( $product ); + } + + $scheme = self::resolveEffectiveScheme( $product ); + + return $scheme ? (int) $scheme->get_length() : 0; + } + + + /** + * Gets the subscription trial length for a product, or 0 if there is no trial. + * + * @since 6.4.0 + * + * @param int|\WC_Product $product product or product ID + * @return int + */ + public static function getSubscriptionTrialLength( $product ) : int { + + $product = self::resolveProduct( $product ); + + if ( ! $product ) { + return 0; + } + + if ( is_callable( [ 'WC_Subscriptions_Product', 'is_subscription' ] ) && \WC_Subscriptions_Product::is_subscription( $product ) ) { + return (int) \WC_Subscriptions_Product::get_trial_length( $product ); + } + + $scheme = self::resolveEffectiveScheme( $product ); + + return $scheme ? (int) $scheme->get_trial_length() : 0; + } + + + /** + * Gets the subscription trial period for a product (e.g. `day`, `week`, `month`, `year`). + * + * @since 6.4.0 + * + * @param int|\WC_Product $product product or product ID + * @return string + */ + public static function getSubscriptionTrialPeriod( $product ) : string { + + $product = self::resolveProduct( $product ); + + if ( ! $product ) { + return ''; + } + + if ( is_callable( [ 'WC_Subscriptions_Product', 'is_subscription' ] ) && \WC_Subscriptions_Product::is_subscription( $product ) ) { + return (string) \WC_Subscriptions_Product::get_trial_period( $product ); + } + + $scheme = self::resolveEffectiveScheme( $product ); + + return $scheme ? (string) $scheme->get_trial_period() : ''; + } + + + /** + * Gets the subscription sign-up fee for a product, or 0.0 if there is none. + * + * @since 6.4.0 + * + * @param int|\WC_Product $product product or product ID + * @return float + */ + public static function getSubscriptionSignUpFee( $product ) : float { + + $product = self::resolveProduct( $product ); + + if ( ! $product ) { + return 0.0; + } + + if ( is_callable( [ 'WC_Subscriptions_Product', 'is_subscription' ] ) && \WC_Subscriptions_Product::is_subscription( $product ) ) { + return (float) \WC_Subscriptions_Product::get_sign_up_fee( $product ); + } + + $scheme = self::resolveEffectiveScheme( $product ); + + return $scheme ? (float) $scheme->get_signup_fee() : 0.0; + } + + + /** + * Gets the date a product's subscription will expire, calculated from either the given date or now. + * + * For legacy subscription products this delegates to {@see \WC_Subscriptions_Product::get_expiration_date()} + * unchanged. For APFS scheme products, the same two-stage calculation (trial, then length) is replicated + * here using this helper's own scheme-aware getters, since the legacy method only ever reads flat legacy + * product meta and returns nothing useful for scheme-based products. + * + * @since 6.4.0 + * + * @param int|\WC_Product $product product or product ID + * @param string $fromDate MySQL formatted date/time string to calculate from, or empty to use now + * @return string|int MySQL formatted date/time string, or 0 if there is no expiration + */ + public static function getSubscriptionExpirationDate( $product, string $fromDate = '' ) { + + $resolvedProduct = self::resolveProduct( $product ); + + if ( ! $resolvedProduct ) { + return 0; + } + + if ( is_callable( [ 'WC_Subscriptions_Product', 'is_subscription' ] ) && \WC_Subscriptions_Product::is_subscription( $resolvedProduct ) ) { + return \WC_Subscriptions_Product::get_expiration_date( $resolvedProduct, $fromDate ); + } + + if ( ! function_exists( 'wcs_add_time' ) || ! function_exists( 'wcs_date_to_time' ) ) { + return 0; + } + + $length = self::getSubscriptionLength( $resolvedProduct ); + + if ( $length <= 0 ) { + return 0; + } + + if ( empty( $fromDate ) ) { + $fromDate = gmdate( 'Y-m-d H:i:s' ); + } + + $trialLength = self::getSubscriptionTrialLength( $resolvedProduct ); + + if ( $trialLength > 0 ) { + $fromDate = gmdate( 'Y-m-d H:i:s', wcs_add_time( $trialLength, self::getSubscriptionTrialPeriod( $resolvedProduct ), wcs_date_to_time( $fromDate ) ) ); + } + + return gmdate( 'Y-m-d H:i:s', wcs_add_time( $length, self::getSubscriptionPeriod( $resolvedProduct ), wcs_date_to_time( $fromDate ) ) ); + } + + + /** + * Normalizes a product ID or object into a product instance. + * + * @since 6.4.0 + * + * @param int|\WC_Product $product product or product ID + * @return \WC_Product|null + */ + private static function resolveProduct( $product ) : ?\WC_Product { + + if ( $product instanceof \WC_Product ) { + return $product; + } + + $resolved = wc_get_product( $product ); + + return $resolved instanceof \WC_Product ? $resolved : null; + } + + + /** + * Resolves the APFS subscription scheme that should be used to read period/length/trial/fee details from. + * + * Prefers the scheme currently active on the product object (e.g. set via cart/order context), falls back + * to the product's default scheme, and finally to its "base" (lowest-price) scheme. Returns null if the + * product has no schemes, or if APFS isn't available. + * + * No return type hint is declared here, in order to avoid a hard reference to the optional `WCS_ATT_Scheme` + * class - callers only ever receive an instance when `WCS_ATT_Product_Schemes` has already been confirmed + * to exist. + * + * @since 6.4.0 + * + * @param \WC_Product $product product + * @return object|null a `WCS_ATT_Scheme` instance, or null + */ + private static function resolveEffectiveScheme( \WC_Product $product ) { + + if ( ! class_exists( 'WCS_ATT_Product_Schemes' ) || ! \WCS_ATT_Product_Schemes::has_subscription_schemes( $product ) ) { + return null; + } + + $scheme = \WCS_ATT_Product_Schemes::get_subscription_scheme( $product, 'object' ); + + if ( is_object( $scheme ) ) { + return $scheme; + } + + $scheme = \WCS_ATT_Product_Schemes::get_default_subscription_scheme( $product, 'object' ); + + if ( is_object( $scheme ) ) { + return $scheme; + } + + $scheme = \WCS_ATT_Product_Schemes::get_base_subscription_scheme( $product ); + + return is_object( $scheme ) ? $scheme : null; + } + +} diff --git a/woocommerce/changelog.txt b/woocommerce/changelog.txt index 57afec976..5d55a97ae 100644 --- a/woocommerce/changelog.txt +++ b/woocommerce/changelog.txt @@ -1,6 +1,7 @@ *** SkyVerge WooCommerce Plugin Framework Changelog *** 2026.nn.nn - version 6.3.0 +* New - Added `SubscriptionProductHelper` for detecting subscription products and reading subscription details across legacy WC Subscriptions product types and WC Subscriptions 9.0+/"All Products for Subscriptions" subscription schemes 2026.06.23 - version 6.2.3 * Fix - Address "wp_script_is was called incorrectly" errors when instantiating the `SV_WP_Job_Batch_Handler` class From 3f4c1b30c3ec42d1eb57b95ffe1b3f191d01ae6b Mon Sep 17 00:00:00 2001 From: Ashley Gibson Date: Thu, 30 Jul 2026 12:14:29 +0100 Subject: [PATCH 2/3] Nest into subscriptions subdir --- .../{ => Subscriptions}/SubscriptionProductHelperTest.php | 6 +++--- .../{ => Subscriptions}/SubscriptionProductHelper.php | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) rename tests/unit/Helpers/{ => Subscriptions}/SubscriptionProductHelperTest.php (98%) rename woocommerce/Helpers/{ => Subscriptions}/SubscriptionProductHelper.php (99%) diff --git a/tests/unit/Helpers/SubscriptionProductHelperTest.php b/tests/unit/Helpers/Subscriptions/SubscriptionProductHelperTest.php similarity index 98% rename from tests/unit/Helpers/SubscriptionProductHelperTest.php rename to tests/unit/Helpers/Subscriptions/SubscriptionProductHelperTest.php index 9515ac490..872dd48d9 100644 --- a/tests/unit/Helpers/SubscriptionProductHelperTest.php +++ b/tests/unit/Helpers/Subscriptions/SubscriptionProductHelperTest.php @@ -1,9 +1,9 @@ Date: Thu, 30 Jul 2026 12:19:45 +0100 Subject: [PATCH 3/3] Use is_callable to be consistent --- .../Subscriptions/SubscriptionProductHelper.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/woocommerce/Helpers/Subscriptions/SubscriptionProductHelper.php b/woocommerce/Helpers/Subscriptions/SubscriptionProductHelper.php index 7a9a614e1..04232bc68 100644 --- a/woocommerce/Helpers/Subscriptions/SubscriptionProductHelper.php +++ b/woocommerce/Helpers/Subscriptions/SubscriptionProductHelper.php @@ -4,10 +4,10 @@ * * Supports both the legacy WC Subscriptions product types (`subscription`, `variable-subscription`) * and the "subscription scheme" mechanism introduced by "All Products for Subscriptions" (APFS), which - * WooCommerce Subscriptions 9.0 merged into core. Detection is gated by `class_exists( 'WCS_ATT_Product_Schemes' )` - * rather than a Subscriptions version check, since that class is present whenever the scheme mechanism is - * available - either because WC Subscriptions 9.0+ bundles it, or because an older WC Subscriptions (7/8) has - * the standalone APFS plugin active alongside it. + * WooCommerce Subscriptions 9.0 merged into core. Detection is gated by `is_callable()` checks against the + * relevant classes/methods rather than a Subscriptions version check, since those classes are present whenever + * the corresponding mechanism is available - either because WC Subscriptions 9.0+ bundles the scheme mechanism, + * or because an older WC Subscriptions (7/8) has the standalone APFS plugin active alongside it. * * @package SkyVerge/WooCommerce/Helpers * @since 6.4.0 @@ -45,7 +45,7 @@ public static function isSubscriptionProduct( $product ) : bool { return true; } - if ( class_exists( 'WCS_ATT_Product_Schemes' ) ) { + if ( is_callable( [ 'WCS_ATT_Product_Schemes', 'has_subscription_schemes' ] ) ) { return (bool) \WCS_ATT_Product_Schemes::has_subscription_schemes( $product ); } @@ -297,7 +297,7 @@ private static function resolveProduct( $product ) : ?\WC_Product { */ private static function resolveEffectiveScheme( \WC_Product $product ) { - if ( ! class_exists( 'WCS_ATT_Product_Schemes' ) || ! \WCS_ATT_Product_Schemes::has_subscription_schemes( $product ) ) { + if ( ! is_callable( [ 'WCS_ATT_Product_Schemes', 'has_subscription_schemes' ] ) || ! \WCS_ATT_Product_Schemes::has_subscription_schemes( $product ) ) { return null; }