diff --git a/.psalm/wcs.php b/.psalm/wcs.php index 2106378e2..043ccb3ec 100644 --- a/.psalm/wcs.php +++ b/.psalm/wcs.php @@ -734,7 +734,7 @@ class WC_Subscriptions_Product /** * Hooked to the @see 'wp_scheduled_delete' WP-Cron scheduled task to rename the '_wp_trash_meta_time' meta value * as '_wc_trash_meta_time'. This is the flag used by WordPress to determine which posts should be automatically - * purged from the trash. We want to make sure Subscriptions products are not automatically purged (but still want + * purged from the trash. We want to make sure BillingPlans products are not automatically purged (but still want * to keep a record of when the product was trashed). * * @since 1.4.9 @@ -1629,7 +1629,7 @@ function wcs_get_order_items_product_id($item_id) /** * Get the variation ID for variation items or the product ID for non-variation items. * - * When acting on cart items or order items, Subscriptions often needs to use an item's canonical product ID. For + * When acting on cart items or order items, BillingPlans often needs to use an item's canonical product ID. For * items representing a variation, that means the 'variation_id' value, if the item is not a variation, that means * the 'product_id value. This function helps save keystrokes on the idiom to check if an item is to a variation or not. * diff --git a/modules/ppcp-api-client/services.php b/modules/ppcp-api-client/services.php index f8e5a5023..2a4c9aed1 100644 --- a/modules/ppcp-api-client/services.php +++ b/modules/ppcp-api-client/services.php @@ -10,7 +10,7 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\ApiClient; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\CatalogProducts; -use WooCommerce\PayPalCommerce\ApiClient\Endpoint\Subscriptions; +use WooCommerce\PayPalCommerce\ApiClient\Endpoint\BillingPlans; use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; use WooCommerce\PayPalCommerce\ApiClient\Authentication\Bearer; use WooCommerce\PayPalCommerce\ApiClient\Authentication\PayPalBearer; @@ -215,8 +215,8 @@ return array( $container->get( 'woocommerce.logger.woocommerce' ) ); }, - 'api.endpoint.subscriptions' => static function( ContainerInterface $container ): Subscriptions { - return new Subscriptions( + 'api.endpoint.billing-plans' => static function( ContainerInterface $container ): BillingPlans { + return new BillingPlans( $container->get( 'api.host' ), $container->get( 'api.bearer' ), $container->get( 'woocommerce.logger.woocommerce' ) diff --git a/modules/ppcp-api-client/src/Endpoint/Subscriptions.php b/modules/ppcp-api-client/src/Endpoint/BillingPlans.php similarity index 55% rename from modules/ppcp-api-client/src/Endpoint/Subscriptions.php rename to modules/ppcp-api-client/src/Endpoint/BillingPlans.php index 90224f149..285203d06 100644 --- a/modules/ppcp-api-client/src/Endpoint/Subscriptions.php +++ b/modules/ppcp-api-client/src/Endpoint/BillingPlans.php @@ -1,6 +1,6 @@ array( 'Authorization' => 'Bearer ' . $bearer->token(), 'Content-Type' => 'application/json', + 'Prefer' => 'return=representation' ), 'body' => wp_json_encode( $data ), ); @@ -111,4 +112,60 @@ class Subscriptions { return $json; } + + /** + * Updates a subscription plan. + * + * @param string $billing_plan_id Billing plan ID. + * @param array $billing_cycles Billing cycles. + * + * @return void + * + * @throws RuntimeException If the request fails. + * @throws PayPalApiException If the request fails. + */ + public function update_pricing(string $billing_plan_id, array $billing_cycles):void { + $data = array( + "pricing_schemes" => array( + (object)array( + "billing_cycle_sequence" => 1, + "pricing_scheme" => array( + "fixed_price" => array( + "value" => $billing_cycles['pricing_scheme']['fixed_price']['value'], + "currency_code" => "USD" + ), + "roll_out_strategy" => array( + "effective_time" => "2022-11-01T00:00:00Z", + "process_change_from" => "NEXT_PAYMENT" + ), + ), + ), + ), + ); + + $bearer = $this->bearer->bearer(); + $url = trailingslashit( $this->host ) . 'v1/billing/plans/' . $billing_plan_id . '/update-pricing-schemes'; + $args = array( + 'method' => 'POST', + 'headers' => array( + 'Authorization' => 'Bearer ' . $bearer->token(), + 'Content-Type' => 'application/json', + ), + 'body' => wp_json_encode( $data ), + ); + + $response = $this->request( $url, $args ); + if ( is_wp_error( $response ) || ! is_array( $response ) ) { + throw new RuntimeException( 'Not able to create plan.' ); + } + + $json = json_decode( $response['body'] ); + $status_code = (int) wp_remote_retrieve_response_code( $response ); + if ( 204 !== $status_code ) { + throw new PayPalApiException( + $json, + $status_code + ); + } + } } diff --git a/modules/ppcp-api-client/src/Endpoint/CatalogProducts.php b/modules/ppcp-api-client/src/Endpoint/CatalogProducts.php index ff2b44f47..cfbeabc56 100644 --- a/modules/ppcp-api-client/src/Endpoint/CatalogProducts.php +++ b/modules/ppcp-api-client/src/Endpoint/CatalogProducts.php @@ -79,6 +79,7 @@ class CatalogProducts { 'headers' => array( 'Authorization' => 'Bearer ' . $bearer->token(), 'Content-Type' => 'application/json', + 'Prefer' => 'return=representation' ), 'body' => wp_json_encode( $data ), ); diff --git a/modules/ppcp-compat/src/PPEC/SubscriptionsHandler.php b/modules/ppcp-compat/src/PPEC/SubscriptionsHandler.php index 523909d77..28fbb57f0 100644 --- a/modules/ppcp-compat/src/PPEC/SubscriptionsHandler.php +++ b/modules/ppcp-compat/src/PPEC/SubscriptionsHandler.php @@ -38,7 +38,7 @@ class SubscriptionsHandler { /** * Constructor. * - * @param RenewalHandler $ppcp_renewal_handler PayPal Payments Subscriptions renewal handler. + * @param RenewalHandler $ppcp_renewal_handler PayPal Payments BillingPlans renewal handler. * @param MockGateway $gateway Mock gateway instance. */ public function __construct( RenewalHandler $ppcp_renewal_handler, MockGateway $gateway ) { @@ -69,7 +69,7 @@ class SubscriptionsHandler { /** * Adds a mock gateway to disguise as PPEC when needed. Hooked onto `woocommerce_payment_gateways`. * The mock gateway fixes display issues where subscriptions paid via PPEC appear as "via Manual Renewal" and also - * prevents Subscriptions from automatically changing the payment method to "manual" when a subscription is edited. + * prevents BillingPlans from automatically changing the payment method to "manual" when a subscription is edited. * * @param array $gateways List of gateways. * @return array @@ -144,14 +144,14 @@ class SubscriptionsHandler { return true; } - // My Account > Subscriptions. + // My Account > BillingPlans. if ( is_wc_endpoint_url( 'subscriptions' ) ) { return true; } - // Checks that require Subscriptions. + // Checks that require BillingPlans. if ( class_exists( \WC_Subscriptions::class ) ) { - // My Account > Subscriptions > (Subscription). + // My Account > BillingPlans > (Subscription). if ( wcs_is_view_subscription_page() ) { $subscription = wcs_get_subscription( absint( get_query_var( 'view-subscription' ) ) ); @@ -183,7 +183,7 @@ class SubscriptionsHandler { return true; } - // Are we on the WC > Subscriptions screen? + // Are we on the WC > BillingPlans screen? // phpcs:ignore WordPress.Security.NonceVerification.Missing $post_type = wc_clean( wp_unslash( $_GET['post_type'] ?? $_POST['post_type'] ?? '' ) ); if ( $post_type === 'shop_subscription' ) { diff --git a/modules/ppcp-subscription/src/SubscriptionModule.php b/modules/ppcp-subscription/src/SubscriptionModule.php index 09d812946..95200fc93 100644 --- a/modules/ppcp-subscription/src/SubscriptionModule.php +++ b/modules/ppcp-subscription/src/SubscriptionModule.php @@ -11,7 +11,7 @@ namespace WooCommerce\PayPalCommerce\Subscription; use WC_Product_Subscription; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\CatalogProducts; -use WooCommerce\PayPalCommerce\ApiClient\Endpoint\Subscriptions; +use WooCommerce\PayPalCommerce\ApiClient\Endpoint\BillingPlans; use WooCommerce\PayPalCommerce\ApiClient\Exception\PayPalApiException; use WooCommerce\PayPalCommerce\Vendor\Dhii\Container\ServiceProvider; use WooCommerce\PayPalCommerce\Vendor\Dhii\Modular\Module\ModuleInterface; @@ -161,13 +161,47 @@ class SubscriptionModule implements ModuleInterface { $product = wc_get_product( $product_id ); if ( $product->get_type() === 'subscription' ) { - if ( ! $product->meta_exists( 'ppcp_subscription_product_id' ) ) { + $billing_plans_endpoint = $c->get( 'api.endpoint.billing-plans' ); + assert( $billing_plans_endpoint instanceof BillingPlans ); + + if($product->meta_exists( 'ppcp_subscription_product' ) && $product->meta_exists( 'ppcp_subscription_plan' )) { + if($product->get_meta( '_subscription_price' ) === $product->get_meta('ppcp_subscription_plan')->billing_cycles[0]->pricing_scheme->fixed_price->value) { + return; + } + + $billing_cycles = array( + 'pricing_scheme' => array( + 'fixed_price' => array( + 'value' => $product->get_meta( '_subscription_price' ), + ), + ), + ); + + try { + $billing_plans_endpoint->update_pricing( + $product->get_meta('ppcp_subscription_plan')->id, + $billing_cycles + ); + } catch ( RuntimeException $exception ) { + $error = $exception->getMessage(); + if ( is_a( $exception, PayPalApiException::class ) ) { + $error = $exception->get_details( $error ); + } + + $logger = $c->get( 'woocommerce.logger.woocommerce' ); + $logger->error( 'Could not update subscription product on PayPal. ' . $error ); + } + + return; + } + + if ( ! $product->meta_exists( 'ppcp_subscription_product' ) ) { $products_endpoint = $c->get( 'api.endpoint.catalog-products' ); assert( $products_endpoint instanceof CatalogProducts ); try { $subscription_product = $products_endpoint->create( $product->get_title() ); - $product->update_meta_data( 'ppcp_subscription_product_id', $subscription_product->id ); + $product->update_meta_data( 'ppcp_subscription_product', $subscription_product ); $product->save(); } catch ( RuntimeException $exception ) { $error = $exception->getMessage(); @@ -180,10 +214,7 @@ class SubscriptionModule implements ModuleInterface { } } - if ( $product->get_meta( 'ppcp_subscription_product_id' ) && ! $product->meta_exists( 'ppcp_subscription_plan' ) ) { - $subscriptions_endpoint = $c->get( 'api.endpoint.subscriptions' ); - assert( $subscriptions_endpoint instanceof Subscriptions ); - + if ( $product->get_meta( 'ppcp_subscription_product' ) && ! $product->meta_exists( 'ppcp_subscription_plan' ) ) { $billing_cycles = array( 'frequency' => array( 'interval_unit' => $product->get_meta( '_subscription_period' ), @@ -211,13 +242,13 @@ class SubscriptionModule implements ModuleInterface { ); try { - $subscription_plan = $subscriptions_endpoint->create_plan( - $product->get_meta( 'ppcp_subscription_product_id' ), + $subscription_plan = $billing_plans_endpoint->create( + $product->get_meta( 'ppcp_subscription_product' )->id, $billing_cycles, $payment_preferences ); - $product->update_meta_data( 'ppcp_subscription_plan', $subscription_plan->id ); + $product->update_meta_data( 'ppcp_subscription_plan', $subscription_plan ); $product->save(); } catch ( RuntimeException $exception ) { $error = $exception->getMessage(); @@ -236,20 +267,23 @@ class SubscriptionModule implements ModuleInterface { add_action( 'add_meta_boxes', - function( string $post_type ) { + function( string $post_type ) use($c) { if ( $post_type === 'product' ) { $post_id = wc_clean( wp_unslash( $_GET['post'] ?? '' ) ); $product = wc_get_product( $post_id ); - if ( is_a( $product, WC_Product_Subscription::class ) ) { - $product_id = $product->get_meta( 'ppcp_subscription_product_id' ); - $plan_id = $product->get_meta( 'ppcp_subscription_plan' ); - if ( $product_id && $plan_id ) { - add_meta_box( - 'ppcp_subscription', - __( 'PayPal Subscription', 'woocommerce-paypal-payments' ), - function() use ( $product_id, $plan_id ) { - echo '
Product ID: ' . esc_attr( $product_id ) . '
'; - echo 'Plan ID: ' . esc_attr( $plan_id ) . '
'; + if (is_a($product, WC_Product_Subscription::class)) { + $settings = $c->get('wcgateway.settings'); + assert($settings instanceof Settings); + if ($settings->get('subscriptions_mode') && $settings->get('subscriptions_mode') === 'subscriptions_api') { + $subscription_product = $product->get_meta('ppcp_subscription_product'); + $subscription_plan = $product->get_meta('ppcp_subscription_plan'); + add_meta_box('ppcp_subscription', __('PayPal Subscription', 'woocommerce-paypal-payments'), + function () use ($subscription_product, $subscription_plan) { + echo ''; + if ($subscription_product && $subscription_plan) { + echo 'Product ID: ' . esc_attr($subscription_product->id) . '
'; + echo 'Plan ID: ' . esc_attr($subscription_plan->id) . '
'; + } }, $post_type, 'side', diff --git a/modules/ppcp-wc-gateway/src/Settings/Fields/connection-tab-fields.php b/modules/ppcp-wc-gateway/src/Settings/Fields/connection-tab-fields.php index e820ef443..087373aea 100644 --- a/modules/ppcp-wc-gateway/src/Settings/Fields/connection-tab-fields.php +++ b/modules/ppcp-wc-gateway/src/Settings/Fields/connection-tab-fields.php @@ -409,17 +409,17 @@ return function ( ContainerInterface $container, array $fields ): array { 'input_class' => $container->get( 'wcgateway.settings.should-disable-fraudnet-checkbox' ) ? array( 'ppcp-disabled-checkbox' ) : array(), ), 'subscriptions_configuration_heading' => array( - 'heading' => __( 'Subscriptions', 'woocommerce-paypal-payments' ), + 'heading' => __( 'BillingPlans', 'woocommerce-paypal-payments' ), 'type' => 'ppcp-heading', 'screens' => array( State::STATE_ONBOARDED, ), 'requirements' => array(), 'gateway' => Settings::CONNECTION_TAB_ID, - 'description' => __( 'Configure WooCommerce Subscriptions integration with PayPal.', 'woocommerce-paypal-payments' ), + 'description' => __( 'Configure WooCommerce BillingPlans integration with PayPal.', 'woocommerce-paypal-payments' ), ), 'subscriptions_mode' => array( - 'title' => __( 'Subscriptions Mode', 'woocommerce-paypal-payments' ), + 'title' => __( 'BillingPlans Mode', 'woocommerce-paypal-payments' ), 'type' => 'select', 'class' => array(), 'input_class' => array( 'wc-enhanced-select' ), @@ -428,7 +428,7 @@ return function ( ContainerInterface $container, array $fields ): array { 'default' => 'vaulting_api', 'options' => array( 'vaulting_api' => __( 'PayPal Vaulting', 'woocommerce-paypal-payments' ), - 'subscriptions_api' => __( 'PayPal Subscriptions', 'woocommerce-paypal-payments' ), + 'subscriptions_api' => __( 'PayPal BillingPlans', 'woocommerce-paypal-payments' ), ), 'screens' => array( State::STATE_ONBOARDED, diff --git a/tests/playwright/subscriptions-api.spec.js b/tests/playwright/subscriptions-api.spec.js index 3596a7498..173f8395f 100644 --- a/tests/playwright/subscriptions-api.spec.js +++ b/tests/playwright/subscriptions-api.spec.js @@ -15,11 +15,14 @@ async function loginAsAdmin(page) { ]); } -test.describe('Merchant', () => { +test.describe.serial('Merchant', () => { + const title = (Math.random() + 1).toString(36).substring(7); + let product_id = ''; + let plan_id = ''; + test('Create new subscription product', async ({page, request}) => { await loginAsAdmin(page); - const title = (Math.random() + 1).toString(36).substring(7); await page.goto('/wp-admin/post-new.php?post_type=product'); await page.fill('#title', title); await page.selectOption('select#product-type', 'subscription'); @@ -47,7 +50,9 @@ test.describe('Merchant', () => { }); await expect(product.id).toBeTruthy; - const plans = await request.get(`https://api.sandbox.paypal.com/v1/billing/plans?product_id=${product.id}&page_size=10&page=1&total_required=true`, { + product_id = product.id; + + const plans = await request.get(`https://api.sandbox.paypal.com/v1/billing/plans?product_id=${product_id}&page_size=10&page=1&total_required=true`, { headers: { 'Authorization': AUTHORIZATION, 'Content-Type': 'application/json' @@ -60,5 +65,35 @@ test.describe('Merchant', () => { return p.product_id === product.id; }); await expect(plan.id).toBeTruthy; + + plan_id = plan.id; + }); + + test('Update subscription product', async ({page, request}) => { + await loginAsAdmin(page); + + await page.goto('/wp-admin/edit.php?post_type=product'); + await page.getByRole('link', { name: title, exact: true }).click(); + + await page.fill('#_subscription_price', '20'); + + await Promise.all([ + page.waitForNavigation(), + page.locator('#publish').click(), + ]); + + const message = await page.locator('.notice-success'); + await expect(message).toContainText('Product updated.'); + + const plan = await request.get(`https://api.sandbox.paypal.com/v1/billing/plans/${plan_id}`, { + headers: { + 'Authorization': AUTHORIZATION, + 'Content-Type': 'application/json' + } + }); + expect(plan.ok()).toBeTruthy(); + + const plan_content = await plan.json(); + await expect(plan_content.billing_cycles[0].pricing_scheme.fixed_price.value).toBe('20.0') }); });