diff --git a/changelog.txt b/changelog.txt index 994a374362..37c94270e7 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,6 +1,7 @@ *** Changelog *** = 9.10.0 - xxxx-xx-xx = +* Add - Allow the purchase of free trials using the Express Payment methods when the product does not require shipping * Update - Changes the documentation page URL for the Optimized Checkout feature to https://woocommerce.com/document/stripe/admin-experience/optimized-checkout-suite/ * Update - Changes the background color and spacing for the Woo logo shown in the account modal * Add - Allow Klarna to be used for recurring payments and subscriptions diff --git a/client/blocks/express-checkout/express-checkout-container.js b/client/blocks/express-checkout/express-checkout-container.js index 4e6523f487..ed6d2b6b59 100644 --- a/client/blocks/express-checkout/express-checkout-container.js +++ b/client/blocks/express-checkout/express-checkout-container.js @@ -10,9 +10,13 @@ import { export const ExpressCheckoutContainer = ( props ) => { const { stripe, billing, expressPaymentMethod } = props; + const hasFreeTrial = getExpressCheckoutData( 'has_free_trial' ); const options = { - mode: 'payment', - ...( isManualPaymentMethodCreation( expressPaymentMethod ) && { + mode: hasFreeTrial ? 'subscription' : 'payment', + ...( isManualPaymentMethodCreation( + expressPaymentMethod, + hasFreeTrial + ) && { paymentMethodCreation: 'manual', } ), amount: billing.cartTotal.value, diff --git a/client/blocks/express-checkout/index.js b/client/blocks/express-checkout/index.js index 03c78b3166..9cac0d31ac 100644 --- a/client/blocks/express-checkout/index.js +++ b/client/blocks/express-checkout/index.js @@ -17,6 +17,7 @@ import { EXPRESS_PAYMENT_METHOD_SETTING_GOOGLE_PAY, EXPRESS_PAYMENT_METHOD_SETTING_LINK, } from 'wcstripe/stripe-utils/constants'; +import { getExpressCheckoutData } from 'wcstripe/express-checkout/utils'; /** @typedef {import('react')} React */ @@ -83,7 +84,10 @@ const expressCheckoutElement = ( expressPaymentMethod, api ) => { ); const edit = getEditorElement( expressPaymentMethod ); const canMakePayment = ( { cart } ) => { - if ( parseFloat( cart.cartTotals.total_price ) === 0.0 ) { + if ( + parseFloat( cart.cartTotals.total_price ) === 0.0 && + ! getExpressCheckoutData( 'has_free_trial' ) + ) { return false; } diff --git a/client/entrypoints/express-checkout/index.js b/client/entrypoints/express-checkout/index.js index 51b5bc9ef3..7666fc0b07 100644 --- a/client/entrypoints/express-checkout/index.js +++ b/client/entrypoints/express-checkout/index.js @@ -331,11 +331,16 @@ jQuery( function ( $ ) { return; } + const hasFreeTrial = getExpressCheckoutData( 'has_free_trial' ); + const elements = api.getStripe().elements( { - mode: options.mode ? options.mode : 'payment', + mode: hasFreeTrial ? 'subscription' : 'payment', amount: options.total, currency: options.currency, - ...( isManualPaymentMethodCreation( expressPaymentType ) && { + ...( isManualPaymentMethodCreation( + expressPaymentType, + hasFreeTrial + ) && { paymentMethodCreation: 'manual', } ), appearance: getExpressCheckoutButtonAppearance(), @@ -462,6 +467,7 @@ jQuery( function ( $ ) { event, order, orderDetails, + hasFreeTrial, } ); } ); @@ -518,7 +524,6 @@ jQuery( function ( $ ) { } wcStripeECE.startExpressCheckout( { - mode: 'payment', total, currency: getExpressCheckoutData( 'checkout' ).currency_code, @@ -538,7 +543,6 @@ jQuery( function ( $ ) { const displayItems = getExpressCheckoutData( 'product' ).displayItems ?? []; wcStripeECE.startExpressCheckout( { - mode: 'payment', total: getExpressCheckoutData( 'product' )?.total .amount, currency: getExpressCheckoutData( 'product' )?.currency, @@ -562,13 +566,15 @@ jQuery( function ( $ ) { cart.totals ); - if ( total === 0 ) { + if ( + total === 0 && + ! getExpressCheckoutData( 'has_free_trial' ) + ) { wcStripeECE.hide(); return; } wcStripeECE.startExpressCheckout( { - mode: 'payment', total, currency: getExpressCheckoutData( 'checkout' )?.currency_code, diff --git a/client/express-checkout/event-handler.js b/client/express-checkout/event-handler.js index 76f9f602d6..64f2e35809 100644 --- a/client/express-checkout/event-handler.js +++ b/client/express-checkout/event-handler.js @@ -50,14 +50,19 @@ export const shippingRateChangeHandler = async ( api, event, elements ) => { }; export const onConfirmHandler = async ( params ) => { - const { abortPayment, elements, event } = params; + const { abortPayment, elements, event, hasFreeTrial } = params; const submitResponse = await elements.submit(); if ( submitResponse?.error ) { return abortPayment( event, submitResponse?.error?.message ); } - if ( ! isManualPaymentMethodCreation( event.expressPaymentType ) ) { + if ( + ! isManualPaymentMethodCreation( + event.expressPaymentType, + hasFreeTrial + ) + ) { return handleConfirmationTokenFlow( params ); } diff --git a/client/express-checkout/utils/check-payment-method-availability.js b/client/express-checkout/utils/check-payment-method-availability.js index 746f04b020..947d35978a 100644 --- a/client/express-checkout/utils/check-payment-method-availability.js +++ b/client/express-checkout/utils/check-payment-method-availability.js @@ -2,6 +2,7 @@ import { createRoot } from 'react-dom/client'; import { ExpressCheckoutElement, Elements } from '@stripe/react-stripe-js'; import { memoize } from 'lodash'; import { + getExpressCheckoutData, getPaymentMethodTypesForExpressMethod, isManualPaymentMethodCreation, } from 'wcstripe/express-checkout/utils'; @@ -15,6 +16,8 @@ import { export const checkPaymentMethodIsAvailable = memoize( ( paymentMethod, api, cart ) => { return new Promise( ( resolve ) => { + const hasFreeTrial = getExpressCheckoutData( 'has_free_trial' ); + // Create the DIV container on the fly const containerEl = document.createElement( 'div' ); @@ -29,8 +32,11 @@ export const checkPaymentMethodIsAvailable = memoize( { /** * Determine if the express payment type should use manual payment method creation. * - * @param {string} expressPaymentType The express payment type, e.g 'googlePay' or 'google_pay' + * @param {string} expressPaymentType The express payment type, e.g 'googlePay' or 'google_pay' + * @param {boolean} hasFreeTrial Whether the product being purchased has a free trial. * @return {boolean} True if manual payment method creation should be used, false otherwise. */ -export const isManualPaymentMethodCreation = ( expressPaymentType ) => { - return ! [ - EXPRESS_PAYMENT_METHOD_SETTING_AMAZON_PAY, - PAYMENT_METHOD_AMAZON_PAY, - ].includes( expressPaymentType ); +export const isManualPaymentMethodCreation = ( + expressPaymentType, + hasFreeTrial +) => { + return ( + ! [ + EXPRESS_PAYMENT_METHOD_SETTING_AMAZON_PAY, + PAYMENT_METHOD_AMAZON_PAY, + ].includes( expressPaymentType ) || hasFreeTrial + ); }; diff --git a/includes/payment-methods/class-wc-stripe-express-checkout-element.php b/includes/payment-methods/class-wc-stripe-express-checkout-element.php index caed1d652f..886d87ad55 100644 --- a/includes/payment-methods/class-wc-stripe-express-checkout-element.php +++ b/includes/payment-methods/class-wc-stripe-express-checkout-element.php @@ -240,6 +240,7 @@ public function javascript_params() { 'taxes_based_on_billing' => wc_tax_enabled() && get_option( 'woocommerce_tax_based_on' ) === 'billing', 'allowed_shipping_countries' => $this->express_checkout_helper->get_allowed_shipping_countries(), 'custom_checkout_fields' => ( new WC_Stripe_Express_Checkout_Custom_Fields() )->get_custom_checkout_fields(), + 'has_free_trial' => $this->express_checkout_helper->has_free_trial(), ]; } diff --git a/includes/payment-methods/class-wc-stripe-express-checkout-helper.php b/includes/payment-methods/class-wc-stripe-express-checkout-helper.php index 33138ab40b..b6f826bd9e 100644 --- a/includes/payment-methods/class-wc-stripe-express-checkout-helper.php +++ b/includes/payment-methods/class-wc-stripe-express-checkout-helper.php @@ -571,6 +571,31 @@ public function has_subscription_product() { return false; } + /** + * Checks whether the subscription product has a free trial. + * + * @return bool + */ + public function has_free_trial() { + if ( $this->is_product() ) { + $product = $this->get_product(); + if ( ! $product ) { + return false; + } + if ( class_exists( 'WC_Subscriptions_Product' ) + && WC_Subscriptions_Product::is_subscription( $product ) + && WC_Subscriptions_Product::get_trial_length( $product ) > 0 ) { + return true; + } + } elseif ( WC_Stripe_Helper::has_cart_or_checkout_on_current_page() ) { + if ( class_exists( 'WC_Subscriptions_Cart' ) && WC_Subscriptions_Cart::cart_contains_free_trial() ) { + return true; + } + } + + return false; + } + /** * Checks if this is a product page or content contains a product_page shortcode. * @@ -685,10 +710,9 @@ public function should_show_express_checkout_button() { return false; } - // Don't show in the product page if the product price is 0. - // ToDo: support free trials. Free trials should be supported if the product does not require shipping. - if ( $is_product && $product && 0.0 === (float) $product->get_price() ) { - WC_Stripe_Logger::log( 'Stripe Express Checkout does not support free products.' ); + // Don't show in the product page if the product price is 0 and the product requires shipping. + if ( $is_product && $product && 0.0 === (float) $product->get_price() && $this->product_or_cart_needs_shipping() ) { + WC_Stripe_Logger::log( 'Stripe Express Checkout does not support free products that requires shipping.' ); return false; } diff --git a/readme.txt b/readme.txt index 1dc7b8c39b..4fcaf23775 100644 --- a/readme.txt +++ b/readme.txt @@ -111,6 +111,7 @@ If you get stuck, you can ask for help in the [Plugin Forum](https://wordpress.o == Changelog == = 9.10.0 - xxxx-xx-xx = +* Add - Allow the purchase of free trials using the Express Payment methods when the product does not require shipping * Update - Changes the documentation page URL for the Optimized Checkout feature to https://woocommerce.com/document/stripe/admin-experience/optimized-checkout-suite/ * Update - Changes the background color and spacing for the Woo logo shown in the account modal * Add - Allow Klarna to be used for recurring payments and subscriptions diff --git a/tests/phpunit/Helpers/WC_Subscriptions_Product.php b/tests/phpunit/Helpers/WC_Subscriptions_Product.php new file mode 100644 index 0000000000..34567e0c4c --- /dev/null +++ b/tests/phpunit/Helpers/WC_Subscriptions_Product.php @@ -0,0 +1,61 @@ +payment_gateways()->payment_gateways = $original_gateways; } + /** + * Test should_show_express_checkout_button, free trial logic. + * + * @return void + */ + public function test_hides_ece_if_free_trial_requires_shipping() { + $this->set_up_shipping_methods(); + + $wc_stripe_ece_helper_mock = $this->createPartialMock( + WC_Stripe_Express_Checkout_Helper::class, + [ + 'is_product', + 'get_product', + 'allowed_items_in_cart', + 'should_show_ece_on_cart_page', + 'should_show_ece_on_checkout_page', + ], + ); + + $wc_stripe_ece_helper_mock->expects( $this->any() )->method( 'is_product' )->willReturn( true ); + $wc_stripe_ece_helper_mock->expects( $this->any() )->method( 'allowed_items_in_cart' )->willReturn( true ); + $wc_stripe_ece_helper_mock->expects( $this->any() )->method( 'should_show_ece_on_cart_page' )->willReturn( true ); + $wc_stripe_ece_helper_mock->expects( $this->any() )->method( 'should_show_ece_on_checkout_page' )->willReturn( true ); + $wc_stripe_ece_helper_mock->testmode = true; + + if ( ! defined( 'WOOCOMMERCE_CHECKOUT' ) ) { + define( 'WOOCOMMERCE_CHECKOUT', true ); + } + + // Ensure that the 'stripe' gateway is available. + $original_gateways = WC()->payment_gateways()->payment_gateways; + WC()->payment_gateways()->payment_gateways = [ + 'stripe' => new WC_Gateway_Stripe(), + ]; + + update_option( 'woocommerce_calc_taxes', 'no' ); + + // Should show, as free virtual products does not require shipping. + $virtual_product = WC_Helper_Product::create_simple_product(); + $virtual_product->set_virtual( true ); + $virtual_product->set_tax_status( 'none' ); + $virtual_product->set_price( 0 ); + $virtual_product->save(); + + WC()->session->init(); + WC()->cart->empty_cart(); + + WC()->cart->add_to_cart( $virtual_product->get_id(), 1 ); + $wc_stripe_ece_helper_mock + ->expects( $this->any() ) + ->method( 'get_product' ) + ->willReturn( $virtual_product ); + + $this->assertTrue( $wc_stripe_ece_helper_mock->should_show_express_checkout_button() ); + + // Should hide if the free product requires shipping. + $shippable_product = WC_Helper_Product::create_simple_product(); + $shippable_product->set_virtual( false ); + $shippable_product->set_tax_status( 'none' ); + $shippable_product->save(); + + WC()->session->init(); + WC()->cart->empty_cart(); + + WC()->cart->add_to_cart( $shippable_product->get_id(), 1 ); + $wc_stripe_ece_helper_mock + ->expects( $this->any() ) + ->method( 'get_product' ) + ->willReturn( $shippable_product ); + + $this->assertFalse( $wc_stripe_ece_helper_mock->should_show_express_checkout_button() ); + + // Restore original settings. + WC()->cart->empty_cart(); + WC()->session->cleanup_sessions(); + WC()->payment_gateways()->payment_gateways = $original_gateways; + + update_option( 'woocommerce_calc_taxes', 'yes' ); + } + /** * Test for get_checkout_data(). */ @@ -805,4 +889,101 @@ public function provide_test_get_booking_ids_from_cart() { ], ]; } + + /** + * Test for has_free_trial(). + * + * @param bool $is_product Whether is product page. + * @param \WC_Order|null $product Product on product page. + * @param int $trial_length Trial length of the product. + * @param bool $is_checkout Whether is checkout page. + * @param bool $cart_contains_free_trial Whether cart contains a product with free trial. + * @param bool $expected Expected result. + * @return void + * @dataProvider provide_test_has_free_trial + */ + public function test_has_free_trial( $is_product, $product, $trial_length, $is_checkout, $cart_contains_free_trial, $expected ) { + add_filter( + 'woocommerce_is_checkout', + function () use ( $is_checkout ) { + return $is_checkout; + } + ); + + WC_Subscriptions_Cart::set_cart_contains_free_trial( $cart_contains_free_trial ); + + WC_Subscriptions_Product::set_is_subscription( true ); + + WC_Subscriptions_Product::set_trial_length( $trial_length ); + + $helper = $this->getMockBuilder( WC_Stripe_Express_Checkout_Helper::class ) + ->onlyMethods( [ 'is_product', 'get_product' ] ) + ->getMock(); + + $helper->method( 'is_product' ) + ->willReturn( $is_product ); + + $helper->method( 'get_product' ) + ->willReturn( $product ); + + $actual = $helper->has_free_trial(); + + $this->assertSame( $expected, $actual ); + } + + /** + * Provider for `test_has_free_trial`. + * + * @return array + */ + public function provide_test_has_free_trial() { + $subscription = new WC_Subscription(); + + $subscription_with_trial = new WC_Subscription(); + $subscription_with_trial->update_meta_data( 'subscription_trial_length', 14 ); + $subscription_with_trial->save_meta_data(); + + return [ + 'product page, missing product' => [ + 'is_product' => true, + 'product' => null, + 'trial length' => 0, + 'is checkout' => false, + 'cart contains free trial' => false, + 'expected' => false, + ], + 'product page, no free trial' => [ + 'is_product' => true, + 'product' => $subscription, + 'trial length' => 0, + 'is checkout' => false, + 'cart contains free trial' => false, + 'expected' => false, + ], + 'product page, with free trial' => [ + 'is_product' => true, + 'product' => $subscription_with_trial, + 'trial length' => 14, + 'is checkout' => false, + 'cart contains free trial' => false, + 'expected' => true, + ], + 'cart/checkout page, no free trial' => [ + 'is_product' => false, + 'product' => $subscription, + 'trial length' => 0, + 'is checkout' => true, + 'cart contains free trial' => false, + 'expected' => false, + ], + 'cart/checkout page, with free trial' => [ + 'is_product' => false, + 'product' => $subscription_with_trial, + 'trial length' => 14, + 'is checkout' => true, + 'cart contains free trial' => true, + 'expected' => true, + ], + ]; + } } diff --git a/tests/phpunit/bootstrap.php b/tests/phpunit/bootstrap.php index 8b89518293..bfcf3d94d1 100644 --- a/tests/phpunit/bootstrap.php +++ b/tests/phpunit/bootstrap.php @@ -58,4 +58,5 @@ function _manually_load_plugin() { require_once __DIR__ . '/Helpers/WC_Subscriptions.php'; require_once __DIR__ . '/Helpers/WC_Subscriptions_Cart.php'; require_once __DIR__ . '/Helpers/WC_Subscriptions_Helpers.php'; +require_once __DIR__ . '/Helpers/WC_Subscriptions_Product.php'; require_once __DIR__ . '/Helpers/WC_Subscriptions_Switcher.php';