diff --git a/modules/ppcp-paypal-subscriptions/services.php b/modules/ppcp-paypal-subscriptions/services.php index 84bd96cc0..ccd3c1dd1 100644 --- a/modules/ppcp-paypal-subscriptions/services.php +++ b/modules/ppcp-paypal-subscriptions/services.php @@ -40,4 +40,7 @@ return array( dirname( realpath( __FILE__ ), 3 ) . '/woocommerce-paypal-payments.php' ); }, + 'paypal-subscriptions.renewal-handler' => static function ( ContainerInterface $container ): RenewalHandler { + return new RenewalHandler( $container->get( 'woocommerce.logger.woocommerce' ) ); + }, ); diff --git a/modules/ppcp-paypal-subscriptions/src/RenewalHandler.php b/modules/ppcp-paypal-subscriptions/src/RenewalHandler.php new file mode 100644 index 000000000..f88fea7d8 --- /dev/null +++ b/modules/ppcp-paypal-subscriptions/src/RenewalHandler.php @@ -0,0 +1,107 @@ +logger = $logger; + } + + /** + * Process subscription renewal. + * + * @param WC_Subscription[] $subscriptions WC Subscriptions. + * @param string $transaction_id PayPal transaction ID. + * @return void + * @throws WC_Data_Exception If something goes wrong while setting payment method. + */ + public function process( array $subscriptions, string $transaction_id ): void { + foreach ( $subscriptions as $subscription ) { + if ( $this->is_for_renewal_order( $subscription ) ) { + $renewal_order = wcs_create_renewal_order( $subscription ); + if ( is_a( $renewal_order, WC_Order::class ) ) { + $this->logger->info( + sprintf( + 'Processing renewal order #%s for subscription #%s', + $renewal_order->get_id(), + $subscription->get_id() + ) + ); + + $renewal_order->set_payment_method( $subscription->get_payment_method() ); + $renewal_order->payment_complete(); + $this->update_transaction_id( $transaction_id, $renewal_order, $this->logger ); + break; + } + } + + $parent_order = wc_get_order( $subscription->get_parent() ); + if ( is_a( $parent_order, WC_Order::class ) ) { + $this->logger->info( + sprintf( + 'Processing parent order #%s for subscription #%s', + $parent_order->get_id(), + $subscription->get_id() + ) + ); + + $subscription->update_meta_data( '_ppcp_is_subscription_renewal', 'true' ); + $subscription->save_meta_data(); + $this->update_transaction_id( $transaction_id, $parent_order, $this->logger ); + } + } + } + + /** + * Checks whether subscription order is for renewal or not. + * + * @param WC_Subscription $subscription WC Subscription. + * @return bool + */ + private function is_for_renewal_order( WC_Subscription $subscription ): bool { + $subscription_renewal_meta = $subscription->get_meta( '_ppcp_is_subscription_renewal' ) ?? ''; + if ( $subscription_renewal_meta === 'true' ) { + return true; + } + + if ( + time() >= $subscription->get_time( 'start' ) + && ( time() - $subscription->get_time( 'start' ) ) <= ( 8 * HOUR_IN_SECONDS ) + ) { + return false; + } + + return true; + } +} diff --git a/modules/ppcp-webhooks/services.php b/modules/ppcp-webhooks/services.php index 0f75a58a0..e179fd24a 100644 --- a/modules/ppcp-webhooks/services.php +++ b/modules/ppcp-webhooks/services.php @@ -99,7 +99,7 @@ return array( new VaultPaymentTokenCreated( $logger, $prefix, $authorized_payments_processor, $payment_token_factory, $payment_token_helper ), new VaultPaymentTokenDeleted( $logger ), new PaymentCapturePending( $logger ), - new PaymentSaleCompleted( $logger ), + new PaymentSaleCompleted( $logger, $container->get( 'paypal-subscriptions.renewal-handler' ) ), new PaymentSaleRefunded( $logger, $refund_fees_updater ), new BillingSubscriptionCancelled( $logger ), new BillingPlanPricingChangeActivated( $logger ), diff --git a/modules/ppcp-webhooks/src/Handler/PaymentSaleCompleted.php b/modules/ppcp-webhooks/src/Handler/PaymentSaleCompleted.php index 1679c46db..757cfd163 100644 --- a/modules/ppcp-webhooks/src/Handler/PaymentSaleCompleted.php +++ b/modules/ppcp-webhooks/src/Handler/PaymentSaleCompleted.php @@ -10,8 +10,8 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\Webhooks\Handler; use Psr\Log\LoggerInterface; -use WC_Order; -use WooCommerce\PayPalCommerce\WcGateway\Processor\TransactionIdHandlingTrait; +use WC_Data_Exception; +use WooCommerce\PayPalCommerce\PayPalSubscriptions\RenewalHandler; use WP_REST_Request; use WP_REST_Response; @@ -20,7 +20,14 @@ use WP_REST_Response; */ class PaymentSaleCompleted implements RequestHandler { - use TransactionIdHandlingTrait, RequestHandlerTrait; + use RequestHandlerTrait; + + /** + * Renewal handler. + * + * @var RenewalHandler + */ + private $renewal_handler; /** * The logger. @@ -33,9 +40,11 @@ class PaymentSaleCompleted implements RequestHandler { * PaymentSaleCompleted constructor. * * @param LoggerInterface $logger The logger. + * @param RenewalHandler $renewal_handler Renewal handler. */ - public function __construct( LoggerInterface $logger ) { - $this->logger = $logger; + public function __construct( LoggerInterface $logger, RenewalHandler $renewal_handler ) { + $this->logger = $logger; + $this->renewal_handler = $renewal_handler; } /** @@ -68,7 +77,7 @@ class PaymentSaleCompleted implements RequestHandler { */ public function handle_request( WP_REST_Request $request ): WP_REST_Response { if ( is_null( $request['resource'] ) ) { - return $this->failure_response(); + return $this->failure_response( 'Could not retrieve resource.' ); } if ( ! function_exists( 'wcs_get_subscriptions' ) ) { @@ -85,7 +94,7 @@ class PaymentSaleCompleted implements RequestHandler { return $this->failure_response( 'Could not retrieve transaction id for subscription.' ); } - $args = array( + $args = array( // phpcs:ignore WordPress.DB.SlowDBQuery 'meta_query' => array( array( @@ -95,24 +104,13 @@ class PaymentSaleCompleted implements RequestHandler { ), ), ); - $subscriptions = wcs_get_subscriptions( $args ); - foreach ( $subscriptions as $subscription ) { - $is_renewal = $subscription->get_meta( '_ppcp_is_subscription_renewal' ) ?? ''; - if ( $is_renewal ) { - $renewal_order = wcs_create_renewal_order( $subscription ); - if ( is_a( $renewal_order, WC_Order::class ) ) { - $renewal_order->set_payment_method( $subscription->get_payment_method() ); - $renewal_order->payment_complete(); - $this->update_transaction_id( $transaction_id, $renewal_order, $this->logger ); - break; - } - } - $parent_order = wc_get_order( $subscription->get_parent() ); - if ( is_a( $parent_order, WC_Order::class ) ) { - $subscription->update_meta_data( '_ppcp_is_subscription_renewal', 'true' ); - $subscription->save_meta_data(); - $this->update_transaction_id( $transaction_id, $parent_order, $this->logger ); + $subscriptions = wcs_get_subscriptions( $args ); + if ( $subscriptions ) { + try { + $this->renewal_handler->process( $subscriptions, $transaction_id ); + } catch ( WC_Data_Exception $exception ) { + return $this->failure_response( 'Could not update payment method.' ); } } diff --git a/tests/e2e/PHPUnit/PayPalSubscriptionsRenewalTest.php b/tests/e2e/PHPUnit/PayPalSubscriptionsRenewalTest.php new file mode 100644 index 000000000..322b54731 --- /dev/null +++ b/tests/e2e/PHPUnit/PayPalSubscriptionsRenewalTest.php @@ -0,0 +1,107 @@ +getContainer(); + $handler = new RenewalHandler($c->get('woocommerce.logger.woocommerce')); + + // Simulates receiving webhook 1 minute after subscription start. + $subscription = $this->createSubscription('-1 minute'); + + $handler->process([$subscription], 'TRANSACTION-ID'); + $renewal = $subscription->get_related_orders( 'ids', array( 'renewal' ) ); + $this->assertEquals(count($renewal), 0); + } + + public function test_renewal_order() + { + $c = $this->getContainer(); + $handler = new RenewalHandler($c->get('woocommerce.logger.woocommerce')); + + // Simulates receiving webhook 9 hours after subscription start. + $subscription = $this->createSubscription('-9 hour'); + + $handler->process([$subscription], 'TRANSACTION-ID'); + $renewal = $subscription->get_related_orders( 'ids', array( 'renewal' ) ); + $this->assertEquals(count($renewal), 1); + } + + private function createSubscription(string $startDate) + { + $args = [ + 'method' => 'POST', + 'headers' => [ + 'Authorization' => 'Basic ' . base64_encode( 'admin:admin' ), + 'Content-Type' => 'application/json', + ], + 'body' => wp_json_encode([ + 'customer_id' => 1, + 'set_paid' => true, + 'payment_method' => 'ppcp-gateway', + 'billing' => [ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'address_1' => '969 Market', + 'address_2' => '', + 'city' => 'San Francisco', + 'state' => 'CA', + 'postcode' => '94103', + 'country' => 'US', + 'email' => 'john.doe@example.com', + 'phone' => '(555) 555-5555' + ], + 'line_items' => [ + [ + 'product_id' => 156, + 'quantity' => 1 + ] + ], + ]), + ]; + + $response = wp_remote_request( + 'https://woocommerce-paypal-payments.ddev.site/wp-json/wc/v3/orders', + $args + ); + + $body = json_decode( $response['body'] ); + + $args = [ + 'method' => 'POST', + 'headers' => [ + 'Authorization' => 'Basic ' . base64_encode( 'admin:admin' ), + 'Content-Type' => 'application/json', + ], + 'body' => wp_json_encode([ + 'start_date' => gmdate( 'Y-m-d H:i:s', strtotime($startDate) ), + 'parent_id' => $body->id, + 'customer_id' => 1, + 'status' => 'active', + 'billing_period' => 'day', + 'billing_interval' => 1, + 'payment_method' => 'ppcp-gateway', + 'line_items' => [ + [ + 'product_id' => $_ENV['PAYPAL_SUBSCRIPTIONS_PRODUCT_ID'], + 'quantity' => 1 + ] + ], + ]), + ]; + + $response = wp_remote_request( + 'https://woocommerce-paypal-payments.ddev.site/wp-json/wc/v3/subscriptions?per_page=1', + $args + ); + + $body = json_decode( $response['body'] ); + + return wcs_get_subscription($body->id); + } +}