diff --git a/.editorconfig b/.editorconfig index 9920ff350..c0dc2d80d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -17,3 +17,7 @@ indent_style = space [*.yml] indent_size = 2 + +[*.php] +ij_php_variable_naming_style = snake_case +ij_php_getters_setters_naming_style = snake_case diff --git a/modules/ppcp-api-client/factories.php b/modules/ppcp-api-client/factories.php index 6ed926353..629b8b103 100644 --- a/modules/ppcp-api-client/factories.php +++ b/modules/ppcp-api-client/factories.php @@ -16,7 +16,8 @@ return array( 'wcgateway.builder.experience-context' => static function ( ContainerInterface $container ): ExperienceContextBuilder { return new ExperienceContextBuilder( - $container->get( 'wcgateway.settings' ) + $container->get( 'wcgateway.settings' ), + $container->get( 'wcgateway.shipping.callback.factory.url' ) ); }, ); diff --git a/modules/ppcp-api-client/src/Entity/CallbackConfig.php b/modules/ppcp-api-client/src/Entity/CallbackConfig.php new file mode 100644 index 000000000..ed1c5f1cc --- /dev/null +++ b/modules/ppcp-api-client/src/Entity/CallbackConfig.php @@ -0,0 +1,44 @@ +events = $events; + $this->url = $url; + } + + /** + * Returns the object as array. + */ + public function to_array(): array { + return array( + 'callback_events' => $this->events, + 'callback_url' => $this->url, + ); + } +} diff --git a/modules/ppcp-api-client/src/Entity/ExperienceContext.php b/modules/ppcp-api-client/src/Entity/ExperienceContext.php index 807449027..2de26a114 100644 --- a/modules/ppcp-api-client/src/Entity/ExperienceContext.php +++ b/modules/ppcp-api-client/src/Entity/ExperienceContext.php @@ -79,6 +79,11 @@ class ExperienceContext { */ private ?string $contact_preference = null; + /** + * The callback config. + */ + private ?CallbackConfig $order_update_callback_config = null; + /** * Returns the return URL. */ @@ -255,6 +260,25 @@ class ExperienceContext { return $obj; } + /** + * Returns the callback config. + */ + public function order_update_callback_config(): ?CallbackConfig { + return $this->order_update_callback_config; + } + + /** + * Sets the callback config. + * + * @param CallbackConfig|null $new_value The value to set. + */ + public function with_order_update_callback_config( ?CallbackConfig $new_value ): ExperienceContext { + $obj = clone $this; + + $obj->order_update_callback_config = $new_value; + return $obj; + } + /** * Returns the object as array. */ @@ -267,6 +291,9 @@ class ExperienceContext { if ( $value === null ) { continue; } + if ( is_object( $value ) && method_exists( $value, 'to_array' ) ) { + $value = $value->to_array(); + } $data[ $prop->getName() ] = $value; } diff --git a/modules/ppcp-api-client/src/Factory/AmountFactory.php b/modules/ppcp-api-client/src/Factory/AmountFactory.php index 2db9b8a0a..5cab0b221 100644 --- a/modules/ppcp-api-client/src/Factory/AmountFactory.php +++ b/modules/ppcp-api-client/src/Factory/AmountFactory.php @@ -15,6 +15,7 @@ use WooCommerce\PayPalCommerce\ApiClient\Entity\Item; use WooCommerce\PayPalCommerce\ApiClient\Entity\Money; use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException; use WooCommerce\PayPalCommerce\ApiClient\Helper\CurrencyGetter; +use WooCommerce\PayPalCommerce\WcGateway\StoreApi\Entity\CartTotals; use WooCommerce\PayPalCommerce\WcSubscriptions\FreeTrialHandlerTrait; use WooCommerce\PayPalCommerce\WcGateway\Gateway\CardButtonGateway; use WooCommerce\PayPalCommerce\WcGateway\Gateway\CreditCardGateway; @@ -111,6 +112,24 @@ class AmountFactory { return $amount; } + /** + * Returns an Amount object based off a WooCommerce cart object from the Store API. + */ + public function from_store_api_cart( CartTotals $cart_totals ): Amount { + return new Amount( + $cart_totals->total_price()->to_paypal(), + new AmountBreakdown( + $cart_totals->total_items()->to_paypal(), + $cart_totals->total_shipping()->to_paypal(), + $cart_totals->total_tax()->to_paypal(), + null, + null, + null, + $cart_totals->total_discount()->to_paypal(), + ) + ); + } + /** * Returns an Amount object based off a WooCommerce order. * diff --git a/modules/ppcp-api-client/src/Factory/ExperienceContextBuilder.php b/modules/ppcp-api-client/src/Factory/ExperienceContextBuilder.php index bb19f9974..7a9d64873 100644 --- a/modules/ppcp-api-client/src/Factory/ExperienceContextBuilder.php +++ b/modules/ppcp-api-client/src/Factory/ExperienceContextBuilder.php @@ -11,9 +11,11 @@ namespace WooCommerce\PayPalCommerce\ApiClient\Factory; use WC_AJAX; use WC_Order; +use WooCommerce\PayPalCommerce\ApiClient\Entity\CallbackConfig; use WooCommerce\PayPalCommerce\ApiClient\Entity\ExperienceContext; use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; use WooCommerce\PayPalCommerce\WcGateway\Endpoint\ReturnUrlEndpoint; +use WooCommerce\PayPalCommerce\WcGateway\Shipping\ShippingCallbackUrlFactory; /** * Class ExperienceContextBuilder @@ -30,15 +32,16 @@ class ExperienceContextBuilder { */ private ContainerInterface $settings; - /** - * ExperienceContextBuilder constructor. - * - * @param ContainerInterface $settings The settings. - */ - public function __construct( ContainerInterface $settings ) { + private ShippingCallbackUrlFactory $shipping_callback_url_factory; + + public function __construct( + ContainerInterface $settings, + ShippingCallbackUrlFactory $shipping_callback_url_factory + ) { $this->experience_context = new ExperienceContext(); - $this->settings = $settings; + $this->settings = $settings; + $this->shipping_callback_url_factory = $shipping_callback_url_factory; } /** @@ -161,6 +164,23 @@ class ExperienceContextBuilder { return $builder; } + /** + * Uses the server-side shipping callback configuration. + */ + public function with_shipping_callback(): ExperienceContextBuilder { + $builder = clone $this; + + $builder->experience_context = $builder->experience_context + ->with_order_update_callback_config( + new CallbackConfig( + array( CallbackConfig::EVENT_SHIPPING_ADDRESS, CallbackConfig::EVENT_SHIPPING_OPTIONS ), + $this->shipping_callback_url_factory->create() + ) + ); + + return $builder; + } + /** * Applies a custom contact preference to the experience context. * diff --git a/modules/ppcp-blocks/resources/js/Components/paypal.js b/modules/ppcp-blocks/resources/js/Components/paypal.js index 7257238e5..1ff82f3fe 100644 --- a/modules/ppcp-blocks/resources/js/Components/paypal.js +++ b/modules/ppcp-blocks/resources/js/Components/paypal.js @@ -352,6 +352,10 @@ export const PayPalComponent = ( { ); const getOnShippingOptionsChange = ( fundingSource ) => { + if ( config.scriptData.server_side_shipping_callback.enabled ) { + return null; + } + if ( fundingSource === 'venmo' ) { return null; } @@ -364,6 +368,10 @@ export const PayPalComponent = ( { }; const getOnShippingAddressChange = ( fundingSource ) => { + if ( config.scriptData.server_side_shipping_callback.enabled ) { + return null; + } + if ( fundingSource === 'venmo' ) { return null; } diff --git a/modules/ppcp-button/resources/js/modules/Renderer/Renderer.js b/modules/ppcp-button/resources/js/modules/Renderer/Renderer.js index ab9da6a0b..9ea413fac 100644 --- a/modules/ppcp-button/resources/js/modules/Renderer/Renderer.js +++ b/modules/ppcp-button/resources/js/modules/Renderer/Renderer.js @@ -144,7 +144,10 @@ class Renderer { }; // Check the condition and add the handler if needed - if ( this.shouldEnableShippingCallback() ) { + if ( + this.shouldEnableShippingCallback() && + ! this.defaultSettings.server_side_shipping_callback.enabled + ) { options.onShippingOptionsChange = ( data, actions ) => { const shippingOptionsChange = ! this.isVenmoButtonClickedWhenVaultingIsEnabled( diff --git a/modules/ppcp-button/services.php b/modules/ppcp-button/services.php index b1ac9f881..a9bbdde85 100644 --- a/modules/ppcp-button/services.php +++ b/modules/ppcp-button/services.php @@ -167,6 +167,7 @@ return array( $container->get( 'api.endpoint.payment-tokens' ), $container->get( 'woocommerce.logger.woocommerce' ), $container->get( 'button.handle-shipping-in-paypal' ), + $container->get( 'wcgateway.server-side-shipping-callback-enabled' ), $container->get( 'button.helper.disabled-funding-sources' ), $container->get( 'wcgateway.configuration.card-configuration' ), $container->get( 'api.helper.partner-attribution' ) @@ -239,6 +240,7 @@ return array( $container->get( 'button.early-wc-checkout-validation-enabled' ), $container->get( 'button.pay-now-contexts' ), $container->get( 'button.handle-shipping-in-paypal' ), + $container->get( 'wcgateway.server-side-shipping-callback-enabled' ), $container->get( 'wcgateway.funding-sources-without-redirect' ), $logger ); diff --git a/modules/ppcp-button/src/Assets/SmartButton.php b/modules/ppcp-button/src/Assets/SmartButton.php index a89edda5b..afb907189 100644 --- a/modules/ppcp-button/src/Assets/SmartButton.php +++ b/modules/ppcp-button/src/Assets/SmartButton.php @@ -253,6 +253,11 @@ class SmartButton implements SmartButtonInterface { */ protected PartnerAttribution $partner_attribution; + /** + * Whether the server-side shipping callback is enabled (feature flag). + */ + private bool $server_side_shipping_callback_enabled; + /** * SmartButton constructor. * @@ -279,6 +284,7 @@ class SmartButton implements SmartButtonInterface { * @param PaymentTokensEndpoint $payment_tokens_endpoint Payment tokens endpoint. * @param LoggerInterface $logger The logger. * @param bool $should_handle_shipping_in_paypal Whether the shipping should be handled in PayPal. + * @param bool $server_side_shipping_callback_enabled Whether the server-side shipping callback is enabled (feature flag). * @param DisabledFundingSources $disabled_funding_sources List of funding sources to be disabled. * @param CardPaymentsConfiguration $dcc_configuration The DCC Gateway Configuration. * @param PartnerAttribution $partner_attribution The PayPal Partner Attribution Helper. @@ -307,36 +313,38 @@ class SmartButton implements SmartButtonInterface { PaymentTokensEndpoint $payment_tokens_endpoint, LoggerInterface $logger, bool $should_handle_shipping_in_paypal, + bool $server_side_shipping_callback_enabled, DisabledFundingSources $disabled_funding_sources, CardPaymentsConfiguration $dcc_configuration, PartnerAttribution $partner_attribution ) { - $this->module_url = $module_url; - $this->version = $version; - $this->session_handler = $session_handler; - $this->settings = $settings; - $this->payer_factory = $payer_factory; - $this->client_id = $client_id; - $this->request_data = $request_data; - $this->dcc_applies = $dcc_applies; - $this->subscription_helper = $subscription_helper; - $this->messages_apply = $messages_apply; - $this->environment = $environment; - $this->payment_token_repository = $payment_token_repository; - $this->settings_status = $settings_status; - $this->currency = $currency; - $this->all_funding_sources = $all_funding_sources; - $this->basic_checkout_validation_enabled = $basic_checkout_validation_enabled; - $this->early_validation_enabled = $early_validation_enabled; - $this->pay_now_contexts = $pay_now_contexts; - $this->funding_sources_without_redirect = $funding_sources_without_redirect; - $this->vault_v3_enabled = $vault_v3_enabled; - $this->logger = $logger; - $this->payment_tokens_endpoint = $payment_tokens_endpoint; - $this->should_handle_shipping_in_paypal = $should_handle_shipping_in_paypal; - $this->disabled_funding_sources = $disabled_funding_sources; - $this->dcc_configuration = $dcc_configuration; - $this->partner_attribution = $partner_attribution; + $this->module_url = $module_url; + $this->version = $version; + $this->session_handler = $session_handler; + $this->settings = $settings; + $this->payer_factory = $payer_factory; + $this->client_id = $client_id; + $this->request_data = $request_data; + $this->dcc_applies = $dcc_applies; + $this->subscription_helper = $subscription_helper; + $this->messages_apply = $messages_apply; + $this->environment = $environment; + $this->payment_token_repository = $payment_token_repository; + $this->settings_status = $settings_status; + $this->currency = $currency; + $this->all_funding_sources = $all_funding_sources; + $this->basic_checkout_validation_enabled = $basic_checkout_validation_enabled; + $this->early_validation_enabled = $early_validation_enabled; + $this->pay_now_contexts = $pay_now_contexts; + $this->funding_sources_without_redirect = $funding_sources_without_redirect; + $this->vault_v3_enabled = $vault_v3_enabled; + $this->logger = $logger; + $this->payment_tokens_endpoint = $payment_tokens_endpoint; + $this->should_handle_shipping_in_paypal = $should_handle_shipping_in_paypal; + $this->server_side_shipping_callback_enabled = $server_side_shipping_callback_enabled; + $this->disabled_funding_sources = $disabled_funding_sources; + $this->dcc_configuration = $dcc_configuration; + $this->partner_attribution = $partner_attribution; } /** @@ -1341,6 +1349,9 @@ document.querySelector("#payment").before(document.querySelector(".ppcp-messages 'has_wc_card_payment_tokens' => $this->user_has_wc_card_payment_tokens( get_current_user_id() ), ), 'should_handle_shipping_in_paypal' => $this->should_handle_shipping_in_paypal && ! $this->is_checkout(), + 'server_side_shipping_callback' => array( + 'enabled' => $this->server_side_shipping_callback_enabled, + ), 'needShipping' => $this->need_shipping(), 'vaultingEnabled' => $this->settings->has( 'vault_enabled' ) && $this->settings->get( 'vault_enabled' ), 'productType' => null, diff --git a/modules/ppcp-button/src/Endpoint/CreateOrderEndpoint.php b/modules/ppcp-button/src/Endpoint/CreateOrderEndpoint.php index 5e28c352b..eec1d1b64 100644 --- a/modules/ppcp-button/src/Endpoint/CreateOrderEndpoint.php +++ b/modules/ppcp-button/src/Endpoint/CreateOrderEndpoint.php @@ -163,6 +163,11 @@ class CreateOrderEndpoint implements EndpointInterface { */ private $handle_shipping_in_paypal; + /** + * Whether the server-side shipping callback is enabled (feature flag). + */ + private bool $server_side_shipping_callback_enabled; + /** * The sources that do not cause issues about redirecting (on mobile, ...) and sometimes not returning back. * @@ -202,6 +207,7 @@ class CreateOrderEndpoint implements EndpointInterface { * @param bool $early_validation_enabled Whether to execute WC validation of the checkout form. * @param string[] $pay_now_contexts The contexts that should have the Pay Now button. * @param bool $handle_shipping_in_paypal If true, the shipping methods are sent to PayPal allowing the customer to select it inside the popup. + * @param bool $server_side_shipping_callback_enabled Whether the server-side shipping callback is enabled (feature flag). * @param string[] $funding_sources_without_redirect The sources that do not cause issues about redirecting (on mobile, ...) and sometimes not returning back. * @param LoggerInterface $logger The logger. */ @@ -221,6 +227,7 @@ class CreateOrderEndpoint implements EndpointInterface { bool $early_validation_enabled, array $pay_now_contexts, bool $handle_shipping_in_paypal, + bool $server_side_shipping_callback_enabled, array $funding_sources_without_redirect, LoggerInterface $logger ) { @@ -240,6 +247,7 @@ class CreateOrderEndpoint implements EndpointInterface { $this->early_validation_enabled = $early_validation_enabled; $this->pay_now_contexts = $pay_now_contexts; $this->handle_shipping_in_paypal = $handle_shipping_in_paypal; + $this->server_side_shipping_callback_enabled = $server_side_shipping_callback_enabled; $this->funding_sources_without_redirect = $funding_sources_without_redirect; $this->logger = $logger; } @@ -460,13 +468,19 @@ class CreateOrderEndpoint implements EndpointInterface { $payment_source_key ); + $experience_context = $this->experience_context_builder + ->with_default_paypal_config( $shipping_preference, $action ) + ->with_contact_preference( $contact_preference ); + + if ( $this->server_side_shipping_callback_enabled + && $shipping_preference === ExperienceContext::SHIPPING_PREFERENCE_GET_FROM_FILE ) { + $experience_context = $experience_context->with_shipping_callback(); + } + $payment_source = new PaymentSource( $payment_source_key, (object) array( - 'experience_context' => $this->experience_context_builder - ->with_default_paypal_config( $shipping_preference, $action ) - ->with_contact_preference( $contact_preference ) - ->build()->to_array(), + 'experience_context' => $experience_context->build()->to_array(), ) ); diff --git a/modules/ppcp-wc-gateway/services.php b/modules/ppcp-wc-gateway/services.php index 3661d3d54..e6a5c05da 100644 --- a/modules/ppcp-wc-gateway/services.php +++ b/modules/ppcp-wc-gateway/services.php @@ -21,6 +21,7 @@ use WooCommerce\PayPalCommerce\Axo\Gateway\AxoGateway; use WooCommerce\PayPalCommerce\Button\Helper\MessagesDisclaimers; use WooCommerce\PayPalCommerce\Common\Pattern\SingletonDecorator; use WooCommerce\PayPalCommerce\Googlepay\GooglePayGateway; +use WooCommerce\PayPalCommerce\WcGateway\Endpoint\ShippingCallbackEndpoint; use WooCommerce\PayPalCommerce\WcGateway\Helper\Environment; use WooCommerce\PayPalCommerce\Onboarding\Render\OnboardingOptionsRenderer; use WooCommerce\PayPalCommerce\Onboarding\State; @@ -38,6 +39,12 @@ use WooCommerce\PayPalCommerce\WcGateway\Settings\WcTasks\Factory\SimpleRedirect use WooCommerce\PayPalCommerce\WcGateway\Settings\WcTasks\Registrar\TaskRegistrar; use WooCommerce\PayPalCommerce\WcGateway\Settings\WcTasks\Registrar\TaskRegistrarInterface; use WooCommerce\PayPalCommerce\WcGateway\Settings\WcTasks\Tasks\SimpleRedirectTask; +use WooCommerce\PayPalCommerce\WcGateway\Shipping\ShippingCallbackUrlFactory; +use WooCommerce\PayPalCommerce\WcGateway\StoreApi\Endpoint\CartEndpoint; +use WooCommerce\PayPalCommerce\WcGateway\StoreApi\Factory\CartFactory; +use WooCommerce\PayPalCommerce\WcGateway\StoreApi\Factory\CartTotalsFactory; +use WooCommerce\PayPalCommerce\WcGateway\StoreApi\Factory\MoneyFactory; +use WooCommerce\PayPalCommerce\WcGateway\StoreApi\Factory\ShippingRatesFactory; use WooCommerce\PayPalCommerce\WcSubscriptions\Helper\SubscriptionHelper; use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; use WooCommerce\PayPalCommerce\WcGateway\Admin\FeesRenderer; @@ -2206,4 +2213,54 @@ return array( return $prefix . '-'; }, + + 'wcgateway.store-api.endpoint.cart' => static function( ContainerInterface $container ) : CartEndpoint { + return new CartEndpoint( + $container->get( 'wcgateway.store-api.factory.cart' ), + $container->get( 'woocommerce.logger.woocommerce' ) + ); + }, + + 'wcgateway.store-api.factory.cart' => static function( ContainerInterface $container ) : CartFactory { + return new CartFactory( + $container->get( 'wcgateway.store-api.factory.cart-totals' ), + $container->get( 'wcgateway.store-api.factory.shipping-rates' ) + ); + }, + 'wcgateway.store-api.factory.cart-totals' => static function( ContainerInterface $container ) : CartTotalsFactory { + return new CartTotalsFactory( + $container->get( 'wcgateway.store-api.factory.money' ) + ); + }, + 'wcgateway.store-api.factory.shipping-rates' => static function( ContainerInterface $container ) : ShippingRatesFactory { + return new ShippingRatesFactory( + $container->get( 'wcgateway.store-api.factory.money' ) + ); + }, + 'wcgateway.store-api.factory.money' => static function( ContainerInterface $container ) : MoneyFactory { + return new MoneyFactory(); + }, + + 'wcgateway.shipping.callback.endpoint' => static function( ContainerInterface $container ) : ShippingCallbackEndpoint { + return new ShippingCallbackEndpoint( + $container->get( 'wcgateway.store-api.endpoint.cart' ), + $container->get( 'api.factory.amount' ), + $container->get( 'woocommerce.logger.woocommerce' ) + ); + }, + + 'wcgateway.shipping.callback.factory.url' => static function( ContainerInterface $container ) : ShippingCallbackUrlFactory { + return new ShippingCallbackUrlFactory( + $container->get( 'wcgateway.store-api.endpoint.cart' ), + $container->get( 'wcgateway.shipping.callback.endpoint' ) + ); + }, + + 'wcgateway.server-side-shipping-callback-enabled' => static function( ContainerInterface $container ) : bool { + return apply_filters( + // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + 'woocommerce.feature-flags.woocommerce_paypal_payments.server_side_shipping_callback_enabled', + getenv( 'PCP_SERVER_SIDE_SHIPPING_CALLBACK_ENABLED' ) === '1' + ); + }, ); diff --git a/modules/ppcp-wc-gateway/src/Endpoint/ShippingCallbackEndpoint.php b/modules/ppcp-wc-gateway/src/Endpoint/ShippingCallbackEndpoint.php new file mode 100644 index 000000000..60e281764 --- /dev/null +++ b/modules/ppcp-wc-gateway/src/Endpoint/ShippingCallbackEndpoint.php @@ -0,0 +1,156 @@ +cart_endpoint = $cart_endpoint; + $this->amount_factory = $amount_factory; + $this->logger = $logger; + } + + /** + * Registers the endpoint. + */ + public function register(): bool { + return (bool) register_rest_route( + self::NAMESPACE, + self::ROUTE, + array( + 'methods' => array( + 'POST', + ), + 'callback' => array( + $this, + 'handle_request', + ), + 'permission_callback' => array( + $this, + 'verify_request', + ), + ) + ); + } + + public function verify_request( \WP_REST_Request $request ): bool { + return true; + } + + public function handle_request( \WP_REST_Request $request ): WP_REST_Response { + $cart_token = (string) $request->get_param( 'cart_token' ); + + $request_data = $request->get_params(); + + $this->logger->debug( 'Shipping callback received: ' . $request->get_body() ); + + $request_id = $request_data['id']; + $pu_id = $request_data['purchase_units'][0]['reference_id']; + + $address = $this->convert_address_to_wc( $request_data['shipping_address'] ); + + $cart_response = $this->cart_endpoint->update_customer( + $cart_token, + array( + 'shipping_address' => $address, + ) + ); + + if ( empty( $cart_response->cart()->shipping_rates() ) ) { + $this->logger->debug( 'Shipping callback response: ADDRESS_ERROR (no shipping rates found).' ); + + return new WP_REST_Response( + array( + 'name' => 'UNPROCESSABLE_ENTITY', + 'details' => array( + array( + 'issue' => 'ADDRESS_ERROR', + ), + ), + ), + 422 + ); + } + + if ( isset( $request_data['shipping_option'] ) ) { + $selected_shipping_method_id = $request_data['shipping_option']['id']; + + $cart_response = $this->cart_endpoint->select_shipping_rate( $cart_token, 0, $selected_shipping_method_id ); + } + + $cart = $cart_response->cart(); + + $amount = $this->amount_factory->from_store_api_cart( $cart->totals() ); + + $shipping_options = array_map( + function ( ShippingRate $rate ): ShippingOption { + return $rate->to_paypal(); + }, + $cart->shipping_rates() + ); + + $response = array( + 'id' => $request_id, + 'purchase_units' => array( + array( + 'reference_id' => $pu_id, + 'amount' => $amount->to_array(), + 'shipping_options' => array_map( + function ( ShippingOption $shipping_option ): array { + return $shipping_option->to_array(); + }, + $shipping_options + ), + ), + ), + ); + + $this->logger->debug( 'Shipping callback response: ' . (string) wp_json_encode( $response ) ); + + return new WP_REST_Response( + $response, + 200 + ); + } + + /** + * Returns the URL to the endpoint. + */ + public function url(): string { + $url = rest_url( self::NAMESPACE . '/' . self::ROUTE ); + + return $url; + } + + private function convert_address_to_wc( array $address ): array { + return array( + 'country' => $address['country_code'] ?? '', + 'state' => $address['admin_area_1'] ?? '', + 'city' => $address['admin_area_2'] ?? '', + 'postcode' => $address['postal_code'] ?? '', + 'address_line_1' => '', + 'address_line_2' => '', + ); + } +} diff --git a/modules/ppcp-wc-gateway/src/Shipping/ShippingCallbackUrlFactory.php b/modules/ppcp-wc-gateway/src/Shipping/ShippingCallbackUrlFactory.php new file mode 100644 index 000000000..f7bc99f51 --- /dev/null +++ b/modules/ppcp-wc-gateway/src/Shipping/ShippingCallbackUrlFactory.php @@ -0,0 +1,34 @@ +cart_endpoint = $cart_endpoint; + $this->shipping_callback_endpoint = $shipping_callback_endpoint; + } + + /** + * Creates the callback URL. + */ + public function create() : string { + $cart_response = $this->cart_endpoint->get_cart(); + + $url = $this->shipping_callback_endpoint->url(); + $url = add_query_arg( 'cart_token', $cart_response->cart_token(), $url ); + + return $url; + } +} diff --git a/modules/ppcp-wc-gateway/src/StoreApi/Endpoint/CartEndpoint.php b/modules/ppcp-wc-gateway/src/StoreApi/Endpoint/CartEndpoint.php new file mode 100644 index 000000000..ec516cbab --- /dev/null +++ b/modules/ppcp-wc-gateway/src/StoreApi/Endpoint/CartEndpoint.php @@ -0,0 +1,143 @@ +cart_factory = $cart_factory; + $this->logger = $logger; + } + + /** + * Returns the cart of the current user (based on the current cookies). + * + * @throws Exception When request fails. + */ + public function get_cart(): CartResponse { + return $this->perform_cart_request( + 'cart', + array( + 'method' => 'GET', + 'cookies' => $_COOKIE, + ) + ); + } + + /** + * Updates the customer address to the specified data and returns the cart. + * + * @see https://developer.woocommerce.com/docs/apis/store-api/resources-endpoints/cart#update-customer + * @param string $cart_token The cart token from the get_cart response. + * @param array $fields The address fields to set and their new values. + * @throws Exception When request fails. + */ + public function update_customer( string $cart_token, array $fields ): CartResponse { + return $this->perform_cart_request( + 'cart/update-customer', + array( + 'method' => 'POST', + 'headers' => array( + 'cart-token' => $cart_token, + 'Content-Type' => 'application/json', + ), + 'body' => wp_json_encode( $fields, JSON_FORCE_OBJECT ), + ) + ); + } + + /** + * Sets the shipping rate and returns the cart. + * + * @param string $cart_token The cart token from the get_cart response. + * @param int $package_id The package number, normally should be 0. + * @param string $rate_id The rate id, like "flat_rate:1". + * @throws Exception When request fails. + */ + public function select_shipping_rate( string $cart_token, int $package_id, string $rate_id ): CartResponse { + return $this->perform_cart_request( + 'cart/select-shipping-rate', + array( + 'method' => 'POST', + 'headers' => array( + 'cart-token' => $cart_token, + 'Content-Type' => 'application/json', + ), + 'body' => wp_json_encode( + array( + 'package_id' => $package_id, + 'rate_id' => $rate_id, + ), + JSON_FORCE_OBJECT + ), + ) + ); + } + + protected function perform_cart_request( string $path, array $args ): CartResponse { + $response = $this->request( $this->cart_endpoint_url( $path ), $args ); + + if ( is_wp_error( $response ) ) { + $error = new Exception( "$path request failed: " . $response->get_error_message() ); + $this->logger->warning( + $error->getMessage(), + array( + 'args' => $args, + 'response' => $response, + ) + ); + throw $error; + } + + $json = json_decode( $response['body'], true ); + + $error_code = $json['code'] ?? null; + $error_message = $json['message'] ?? null; + if ( $error_code ) { + $error = new Exception( "$path request return error: $error_code - $error_message" ); + $this->logger->warning( + $error->getMessage(), + array( + 'args' => $args, + 'response' => $response, + ) + ); + throw $error; + } + + $cart = $this->cart_factory->from_response( $json ); + + $cart_token = $response['headers']['cart-token'] ?? ''; + + return new CartResponse( $cart, $cart_token ); + } + + protected function cart_endpoint_url( string $path ): string { + return $this->base_api_url() . $path; + } + + protected function base_api_url(): string { + return rest_url( '/wc/store/v1/' ); + } +} diff --git a/modules/ppcp-wc-gateway/src/StoreApi/Entity/Cart.php b/modules/ppcp-wc-gateway/src/StoreApi/Entity/Cart.php new file mode 100644 index 000000000..215975c56 --- /dev/null +++ b/modules/ppcp-wc-gateway/src/StoreApi/Entity/Cart.php @@ -0,0 +1,36 @@ +totals = $totals; + $this->shipping_rates = $shipping_rates; + } + + public function totals(): CartTotals { + return $this->totals; + } + + public function shipping_rates(): array { + return $this->shipping_rates; + } +} diff --git a/modules/ppcp-wc-gateway/src/StoreApi/Entity/CartResponse.php b/modules/ppcp-wc-gateway/src/StoreApi/Entity/CartResponse.php new file mode 100644 index 000000000..5c534f0de --- /dev/null +++ b/modules/ppcp-wc-gateway/src/StoreApi/Entity/CartResponse.php @@ -0,0 +1,30 @@ +cart = $cart; + $this->cart_token = $cart_token; + } + + public function cart(): Cart { + return $this->cart; + } + + /** + * The token required for the API requests (except Get Cart). + */ + public function cart_token(): string { + return $this->cart_token; + } +} diff --git a/modules/ppcp-wc-gateway/src/StoreApi/Entity/CartTotals.php b/modules/ppcp-wc-gateway/src/StoreApi/Entity/CartTotals.php new file mode 100644 index 000000000..632236483 --- /dev/null +++ b/modules/ppcp-wc-gateway/src/StoreApi/Entity/CartTotals.php @@ -0,0 +1,96 @@ +total_items = $total_items; + $this->total_items_tax = $total_items_tax; + $this->total_fees = $total_fees; + $this->total_fees_tax = $total_fees_tax; + $this->total_discount = $total_discount; + $this->total_discount_tax = $total_discount_tax; + $this->total_shipping = $total_shipping; + $this->total_shipping_tax = $total_shipping_tax; + $this->total_price = $total_price; + $this->total_tax = $total_tax; + } + + public function total_items(): Money { + return $this->total_items; + } + + public function total_items_tax(): Money { + return $this->total_items_tax; + } + + public function total_fees(): Money { + return $this->total_fees; + } + + public function total_fees_tax(): Money { + return $this->total_fees_tax; + } + + public function total_discount(): Money { + return $this->total_discount; + } + + public function total_discount_tax(): Money { + return $this->total_discount_tax; + } + + public function total_shipping(): Money { + return $this->total_shipping; + } + + public function total_shipping_tax(): Money { + return $this->total_shipping_tax; + } + + public function total_price(): Money { + return $this->total_price; + } + + public function total_tax(): Money { + return $this->total_tax; + } + + +} diff --git a/modules/ppcp-wc-gateway/src/StoreApi/Entity/Money.php b/modules/ppcp-wc-gateway/src/StoreApi/Entity/Money.php new file mode 100644 index 000000000..00c333e30 --- /dev/null +++ b/modules/ppcp-wc-gateway/src/StoreApi/Entity/Money.php @@ -0,0 +1,59 @@ +value = $value; + $this->currency_code = $currency_code; + $this->currency_minor_unit = $currency_minor_unit; + } + + public function value(): string { + return $this->value; + } + + public function currency_code(): string { + return $this->currency_code; + } + + /** + * The number of digits after ".". For most currencies it is 2. + */ + public function currency_minor_unit(): int { + return $this->currency_minor_unit; + } + + /** + * Converts to float, e.g. value=123, currency_minor_unit=2 --> 1.23. + */ + public function to_float(): float { + return round( ( (int) $this->value ) / 10 ** $this->currency_minor_unit, $this->currency_minor_unit ); + } + + /** + * Returns the Money object for the PayPal API. + */ + public function to_paypal(): \WooCommerce\PayPalCommerce\ApiClient\Entity\Money { + return new \WooCommerce\PayPalCommerce\ApiClient\Entity\Money( + $this->to_float(), + $this->currency_code + ); + } +} diff --git a/modules/ppcp-wc-gateway/src/StoreApi/Factory/CartFactory.php b/modules/ppcp-wc-gateway/src/StoreApi/Factory/CartFactory.php new file mode 100644 index 000000000..0cc2ef951 --- /dev/null +++ b/modules/ppcp-wc-gateway/src/StoreApi/Factory/CartFactory.php @@ -0,0 +1,31 @@ +cart_totals_factory = $cart_totals_factory; + $this->shipping_rates_factory = $shipping_rate_factory; + } + + public function from_response( array $obj ): Cart { + return new Cart( + $this->cart_totals_factory->from_response_obj( (array) ( $obj['totals'] ?? array() ) ), + $this->shipping_rates_factory->from_response_obj( (array) ( $obj['shipping_rates'] ?? array() ) ) + ); + } +} diff --git a/modules/ppcp-wc-gateway/src/StoreApi/Factory/CartTotalsFactory.php b/modules/ppcp-wc-gateway/src/StoreApi/Factory/CartTotalsFactory.php new file mode 100644 index 000000000..bd50b6160 --- /dev/null +++ b/modules/ppcp-wc-gateway/src/StoreApi/Factory/CartTotalsFactory.php @@ -0,0 +1,36 @@ +money_factory = $money_factory; + } + + /** + * Parses the 'totals' object from the cart response. + */ + public function from_response_obj( array $obj ): CartTotals { + return new CartTotals( + $this->money_factory->from_response_values( $obj, 'total_items' ), + $this->money_factory->from_response_values( $obj, 'total_items_tax' ), + $this->money_factory->from_response_values( $obj, 'total_fees' ), + $this->money_factory->from_response_values( $obj, 'total_fees_tax' ), + $this->money_factory->from_response_values( $obj, 'total_discount' ), + $this->money_factory->from_response_values( $obj, 'total_discount_tax' ), + $this->money_factory->from_response_values( $obj, 'total_shipping' ), + $this->money_factory->from_response_values( $obj, 'total_shipping_tax' ), + $this->money_factory->from_response_values( $obj, 'total_price' ), + $this->money_factory->from_response_values( $obj, 'total_tax' ) + ); + } +} diff --git a/modules/ppcp-wc-gateway/src/StoreApi/Factory/MoneyFactory.php b/modules/ppcp-wc-gateway/src/StoreApi/Factory/MoneyFactory.php new file mode 100644 index 000000000..1edb2d1b1 --- /dev/null +++ b/modules/ppcp-wc-gateway/src/StoreApi/Factory/MoneyFactory.php @@ -0,0 +1,27 @@ +rate_id = $rate_id; + $this->name = $name; + $this->selected = $selected; + $this->price = $price; + $this->taxes = $taxes; + } + + public function rate_id(): string { + return $this->rate_id; + } + + public function name(): string { + return $this->name; + } + + public function selected(): bool { + return $this->selected; + } + + public function price(): Money { + return $this->price; + } + + public function taxes(): Money { + return $this->taxes; + } + + /** + * Returns the ShippingOption object for the PayPal API. + */ + public function to_paypal(): ShippingOption { + return new ShippingOption( + $this->rate_id, + $this->name, + $this->selected, + $this->price->to_paypal(), + ShippingOption::TYPE_SHIPPING + ); + } +} diff --git a/modules/ppcp-wc-gateway/src/StoreApi/Factory/ShippingRatesFactory.php b/modules/ppcp-wc-gateway/src/StoreApi/Factory/ShippingRatesFactory.php new file mode 100644 index 000000000..7895e1e42 --- /dev/null +++ b/modules/ppcp-wc-gateway/src/StoreApi/Factory/ShippingRatesFactory.php @@ -0,0 +1,39 @@ +money_factory = $money_factory; + } + + /** + * Extracts shipping rates from the 'shipping_rates' object in the cart response. + */ + public function from_response_obj( array $obj ): array { + $rates = array(); + foreach ( $obj as $package ) { + foreach ( $package['shipping_rates'] as $item ) { + $rates[] = $this->parse_shipping_rate( $item ); + } + } + return $rates; + } + + private function parse_shipping_rate( array $obj ): ShippingRate { + return new ShippingRate( + $obj['rate_id'], + $obj['name'], + $obj['selected'], + $this->money_factory->from_response_values( $obj, 'price' ), + $this->money_factory->from_response_values( $obj, 'taxes' ) + ); + } +} diff --git a/modules/ppcp-wc-gateway/src/WCGatewayModule.php b/modules/ppcp-wc-gateway/src/WCGatewayModule.php index 044c3f19d..58ffb6310 100644 --- a/modules/ppcp-wc-gateway/src/WCGatewayModule.php +++ b/modules/ppcp-wc-gateway/src/WCGatewayModule.php @@ -23,6 +23,7 @@ use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ModuleClassNameI use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ServiceModule; use WooCommerce\PayPalCommerce\WcGateway\Assets\VoidButtonAssets; use WooCommerce\PayPalCommerce\WcGateway\Endpoint\RefreshFeatureStatusEndpoint; +use WooCommerce\PayPalCommerce\WcGateway\Endpoint\ShippingCallbackEndpoint; use WooCommerce\PayPalCommerce\WcGateway\Endpoint\VoidOrderEndpoint; use WooCommerce\PayPalCommerce\WcGateway\Helper\InstallmentsProductStatus; use WooCommerce\PayPalCommerce\WcGateway\Notice\SendOnlyCountryNotice; @@ -603,6 +604,16 @@ class WCGatewayModule implements ServiceModule, ExtendingModule, ExecutableModul } ); + add_action( + 'rest_api_init', + static function () use ( $c ) { + $endpoint = $c->get( 'wcgateway.shipping.callback.endpoint' ); + assert( $endpoint instanceof ShippingCallbackEndpoint ); + + $endpoint->register(); + } + ); + return true; } diff --git a/package.json b/package.json index 4187e99af..bf73a6a18 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,7 @@ "ddev:pw-tests": "ddev yarn playwright test", "ddev:test": "yarn run ddev:unit-tests && yarn run ddev:e2e-tests && yarn run ddev:pw-tests", "ddev:lint": "yarn ddev:phpcs && yarn ddev:psalm", - "ddev:phpcs": "ddev exec phpcs --parallel=8 -s", + "ddev:phpcs": "ddev exec phpcs --parallel=8 -s --runtime-set ignore_warnings_on_exit 1", "ddev:psalm": "ddev exec psalm --show-info=false --threads=8 --diff", "ddev:fix-lint": "ddev exec phpcbf", "ddev:xdebug-on": "ddev xdebug", diff --git a/phpcs.xml.dist b/phpcs.xml.dist index f70389d11..b8ae0871d 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -39,11 +39,22 @@ + + + - + + + + + + + + + api diff --git a/tests/PHPUnit/ApiClient/Entity/ExperienceContextTest.php b/tests/PHPUnit/ApiClient/Entity/ExperienceContextTest.php index bf074fc67..9e3d4991a 100644 --- a/tests/PHPUnit/ApiClient/Entity/ExperienceContextTest.php +++ b/tests/PHPUnit/ApiClient/Entity/ExperienceContextTest.php @@ -19,7 +19,11 @@ class ExperienceContextTest extends TestCase ->with_landing_page('NO_PREFERENCE') ->with_shipping_preference('NO_SHIPPING') ->with_user_action('CONTINUE') - ->with_payment_method_preference('UNRESTRICTED'); + ->with_payment_method_preference('UNRESTRICTED') + ->with_order_update_callback_config(new CallbackConfig( + [CallbackConfig::EVENT_SHIPPING_ADDRESS, CallbackConfig::EVENT_SHIPPING_OPTIONS], + 'example.com/callback', + )); $this->assertEmpty($empty->to_array()); @@ -32,6 +36,10 @@ class ExperienceContextTest extends TestCase 'shipping_preference' => 'NO_SHIPPING', 'user_action' => 'CONTINUE', 'payment_method_preference' => 'UNRESTRICTED', + 'order_update_callback_config' => [ + 'callback_events' => [CallbackConfig::EVENT_SHIPPING_ADDRESS, CallbackConfig::EVENT_SHIPPING_OPTIONS], + 'callback_url' => 'example.com/callback', + ] ], $result->to_array()); } } diff --git a/tests/PHPUnit/ApiClient/Factory/ExperienceContextBuilderTest.php b/tests/PHPUnit/ApiClient/Factory/ExperienceContextBuilderTest.php index e179f93f6..b800165dc 100644 --- a/tests/PHPUnit/ApiClient/Factory/ExperienceContextBuilderTest.php +++ b/tests/PHPUnit/ApiClient/Factory/ExperienceContextBuilderTest.php @@ -8,6 +8,7 @@ use WooCommerce\PayPalCommerce\ApiClient\Factory\ExperienceContextBuilder; use WooCommerce\PayPalCommerce\TestCase; use WooCommerce\PayPalCommerce\WcGateway\Endpoint\ReturnUrlEndpoint; use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings; +use WooCommerce\PayPalCommerce\WcGateway\Shipping\ShippingCallbackUrlFactory; use function Brain\Monkey\Functions\expect; use Mockery; @@ -15,6 +16,8 @@ class ExperienceContextBuilderTest extends TestCase { private $settings; + private $shipping_callback_url_factory; + private $sut; public function setUp(): void @@ -22,8 +25,9 @@ class ExperienceContextBuilderTest extends TestCase parent::setUp(); $this->settings = Mockery::mock(Settings::class); + $this->shipping_callback_url_factory = Mockery::mock(ShippingCallbackUrlFactory::class); - $this->sut = new ExperienceContextBuilder($this->settings); + $this->sut = new ExperienceContextBuilder($this->settings, $this->shipping_callback_url_factory); } public function testOrderReturnUrls() diff --git a/tests/PHPUnit/Button/Endpoint/CreateOrderEndpointTest.php b/tests/PHPUnit/Button/Endpoint/CreateOrderEndpointTest.php index aff66b210..b120ccdb4 100644 --- a/tests/PHPUnit/Button/Endpoint/CreateOrderEndpointTest.php +++ b/tests/PHPUnit/Button/Endpoint/CreateOrderEndpointTest.php @@ -175,6 +175,7 @@ class CreateOrderEndpointTest extends TestCase false, ['checkout'], false, + false, ['paypal'], new NullLogger() );