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/Subscriptions/SubscriptionProductHelper.php b/woocommerce/Helpers/Subscriptions/SubscriptionProductHelper.php new file mode 100644 index 000000000..04232bc68 --- /dev/null +++ b/woocommerce/Helpers/Subscriptions/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 ( ! is_callable( [ 'WCS_ATT_Product_Schemes', 'has_subscription_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