Merge trunk

This commit is contained in:
Emili Castells Guasch 2025-06-23 10:56:29 +02:00
commit 8f6f5c0c35
No known key found for this signature in database
30 changed files with 1045 additions and 43 deletions

View file

@ -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

View file

@ -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' )
);
},
);

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\ApiClient\Entity;
/**
* The config for experience_context.order_update_callback_config.
*/
class CallbackConfig {
public const EVENT_SHIPPING_ADDRESS = 'SHIPPING_ADDRESS';
public const EVENT_SHIPPING_OPTIONS = 'SHIPPING_OPTIONS';
/**
* The events.
*
* @var string[]
*/
private array $events;
/**
* The URL that will be called when the events occur.
*/
private string $url;
/**
* @param string[] $events The events.
* @param string $url The URL that will be called when the events occur.
*/
public function __construct( array $events, string $url ) {
$this->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,
);
}
}

View file

@ -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;
}

View file

@ -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.
*

View file

@ -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.
*

View file

@ -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;
}

View file

@ -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(

View file

@ -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
);

View file

@ -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,

View file

@ -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(),
)
);

View file

@ -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'
);
},
);

View file

@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\WcGateway\Endpoint;
use Psr\Log\LoggerInterface;
use WooCommerce\PayPalCommerce\ApiClient\Entity\ShippingOption;
use WooCommerce\PayPalCommerce\ApiClient\Factory\AmountFactory;
use WooCommerce\PayPalCommerce\WcGateway\StoreApi\Endpoint\CartEndpoint;
use WooCommerce\PayPalCommerce\WcGateway\StoreApi\Factory\ShippingRate;
use WP_REST_Response;
/**
* Handles the shipping callback.
*/
class ShippingCallbackEndpoint {
private const NAMESPACE = 'paypal/v1';
private const ROUTE = 'shipping-callback';
private CartEndpoint $cart_endpoint;
private AmountFactory $amount_factory;
private LoggerInterface $logger;
public function __construct( CartEndpoint $cart_endpoint, AmountFactory $amount_factory, LoggerInterface $logger ) {
$this->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' => '',
);
}
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\WcGateway\Shipping;
use WooCommerce\PayPalCommerce\WcGateway\Endpoint\ShippingCallbackEndpoint;
use WooCommerce\PayPalCommerce\WcGateway\StoreApi\Endpoint\CartEndpoint;
/**
* URL generation for the server-side shipping callback.
*/
class ShippingCallbackUrlFactory {
private CartEndpoint $cart_endpoint;
private ShippingCallbackEndpoint $shipping_callback_endpoint;
public function __construct( CartEndpoint $cart_endpoint, ShippingCallbackEndpoint $shipping_callback_endpoint ) {
$this->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;
}
}

View file

@ -0,0 +1,143 @@
<?php
declare( strict_types=1 );
namespace WooCommerce\PayPalCommerce\WcGateway\StoreApi\Endpoint;
use Exception;
use Psr\Log\LoggerInterface;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\RequestTrait;
use WooCommerce\PayPalCommerce\WcGateway\StoreApi\Entity\CartResponse;
use WooCommerce\PayPalCommerce\WcGateway\StoreApi\Factory\CartFactory;
/**
* The wrapper for the WC Store API cart requests.
*/
class CartEndpoint {
use RequestTrait;
private CartFactory $cart_factory;
private LoggerInterface $logger;
/**
* Unused (for trait).
*/
private string $host = '';
public function __construct( CartFactory $cart_factory, LoggerInterface $logger ) {
$this->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<string, mixed> $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/' );
}
}

View file

@ -0,0 +1,36 @@
<?php
declare( strict_types=1 );
namespace WooCommerce\PayPalCommerce\WcGateway\StoreApi\Entity;
use WooCommerce\PayPalCommerce\WcGateway\StoreApi\Factory\ShippingRate;
/**
* Cart object for the Store API.
*/
class Cart {
private CartTotals $totals;
/**
* @var ShippingRate[]
*/
private array $shipping_rates;
/**
* @param CartTotals $totals
* @param ShippingRate[] $shipping_rates
*/
public function __construct( CartTotals $totals, array $shipping_rates ) {
$this->totals = $totals;
$this->shipping_rates = $shipping_rates;
}
public function totals(): CartTotals {
return $this->totals;
}
public function shipping_rates(): array {
return $this->shipping_rates;
}
}

View file

@ -0,0 +1,30 @@
<?php
declare( strict_types=1 );
namespace WooCommerce\PayPalCommerce\WcGateway\StoreApi\Entity;
/**
* CartResponse for the Store API.
*/
class CartResponse {
private Cart $cart;
private string $cart_token;
public function __construct( Cart $cart, string $cart_token ) {
$this->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;
}
}

View file

@ -0,0 +1,96 @@
<?php
declare( strict_types=1 );
namespace WooCommerce\PayPalCommerce\WcGateway\StoreApi\Entity;
/**
* CartTotals object for the Store API.
*/
class CartTotals {
private Money $total_items;
private Money $total_items_tax;
private Money $total_fees;
private Money $total_fees_tax;
private Money $total_discount;
private Money $total_discount_tax;
private Money $total_shipping;
private Money $total_shipping_tax;
private Money $total_price;
private Money $total_tax;
public function __construct(
Money $total_items,
Money $total_items_tax,
Money $total_fees,
Money $total_fees_tax,
Money $total_discount,
Money $total_discount_tax,
Money $total_shipping,
Money $total_shipping_tax,
Money $total_price,
Money $total_tax
) {
$this->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;
}
}

View file

@ -0,0 +1,59 @@
<?php
declare( strict_types=1 );
namespace WooCommerce\PayPalCommerce\WcGateway\StoreApi\Entity;
/**
* Money value for the Store API.
*/
class Money {
private string $value;
private string $currency_code;
private int $currency_minor_unit;
/**
* @param string $value
* @param string $currency_code
* @param int $currency_minor_unit
*/
public function __construct( string $value, string $currency_code, int $currency_minor_unit ) {
$this->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
);
}
}

View file

@ -0,0 +1,31 @@
<?php
declare( strict_types=1 );
namespace WooCommerce\PayPalCommerce\WcGateway\StoreApi\Factory;
use WooCommerce\PayPalCommerce\WcGateway\StoreApi\Entity\Cart;
/**
* Factory for the Store API cart.
*/
class CartFactory {
private CartTotalsFactory $cart_totals_factory;
private ShippingRatesFactory $shipping_rates_factory;
public function __construct(
CartTotalsFactory $cart_totals_factory,
ShippingRatesFactory $shipping_rate_factory
) {
$this->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() ) )
);
}
}

View file

@ -0,0 +1,36 @@
<?php
declare( strict_types=1 );
namespace WooCommerce\PayPalCommerce\WcGateway\StoreApi\Factory;
use WooCommerce\PayPalCommerce\WcGateway\StoreApi\Entity\CartTotals;
/**
* Factory for the Store API cart totals.
*/
class CartTotalsFactory {
private MoneyFactory $money_factory;
public function __construct( MoneyFactory $money_factory ) {
$this->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' )
);
}
}

View file

@ -0,0 +1,27 @@
<?php
declare( strict_types=1 );
namespace WooCommerce\PayPalCommerce\WcGateway\StoreApi\Factory;
use WooCommerce\PayPalCommerce\WcGateway\StoreApi\Entity\Money;
/**
* Factory for the Store API money values.
*/
class MoneyFactory {
/**
* Parses the specified money property from a Store API response object
* containing fields like currency_minor_unit.
*
* @param array $obj The object.
* @param string $property_name The property name with the money value.
*/
public function from_response_values( array $obj, string $property_name ): Money {
$value = (string) $obj[ $property_name ];
$currency_code = (string) ( $obj['currency_code'] ?? '' );
$currency_minor_unit = (int) ( $obj['currency_minor_unit'] ?? 2 );
return new Money( $value, $currency_code, $currency_minor_unit );
}
}

View file

@ -0,0 +1,70 @@
<?php
declare( strict_types=1 );
namespace WooCommerce\PayPalCommerce\WcGateway\StoreApi\Factory;
use WooCommerce\PayPalCommerce\ApiClient\Entity\ShippingOption;
use WooCommerce\PayPalCommerce\WcGateway\StoreApi\Entity\Money;
/**
* ShippingRate object for the Store API.
*/
class ShippingRate {
private string $rate_id;
private string $name;
private bool $selected;
private Money $price;
private Money $taxes;
public function __construct(
string $rate_id,
string $name,
bool $selected,
Money $price,
Money $taxes
) {
$this->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
);
}
}

View file

@ -0,0 +1,39 @@
<?php
declare( strict_types=1 );
namespace WooCommerce\PayPalCommerce\WcGateway\StoreApi\Factory;
/**
* Factory for the Store API shipping rates.
*/
class ShippingRatesFactory {
private MoneyFactory $money_factory;
public function __construct( MoneyFactory $money_factory ) {
$this->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' )
);
}
}

View file

@ -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;
}

View file

@ -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",

View file

@ -39,11 +39,22 @@
<properties>
<property name="skipIfInheritdoc" value="true" />
</properties>
<exclude name="Squiz.Commenting.FunctionComment.MissingParamTag" />
<exclude name="Squiz.Commenting.FunctionComment.MissingParamComment" />
<exclude name="Squiz.Commenting.FunctionComment.Missing" />
</rule>
<rule ref="Squiz.Commenting.VariableComment">
<exclude name="Squiz.Commenting.VariableComment.MissingVar" />
<exclude name="Squiz.Commenting.VariableComment.Missing" />
</rule>
<rule ref="Squiz.Commenting.FileComment">
<exclude name="Squiz.Commenting.FileComment.Missing" />
<exclude name="Squiz.Commenting.FileComment.MissingPackageTag" />
</rule>
<rule ref="Generic.Commenting.DocComment">
<exclude name="Generic.Commenting.DocComment.MissingShort" />
</rule>
<arg name="extensions" value="php"/>
<file>api</file>

View file

@ -20,7 +20,11 @@ class ExperienceContextTest extends TestCase
->with_shipping_preference('NO_SHIPPING')
->with_user_action('CONTINUE')
->with_payment_method_preference('UNRESTRICTED')
->with_contact_preference('NO_CONTACT_INFO');
->with_contact_preference('NO_CONTACT_INFO')
->with_order_update_callback_config(new CallbackConfig(
[CallbackConfig::EVENT_SHIPPING_ADDRESS, CallbackConfig::EVENT_SHIPPING_OPTIONS],
'example.com/callback',
));
$this->assertEmpty($empty->to_array());
@ -34,6 +38,10 @@ class ExperienceContextTest extends TestCase
'user_action' => 'CONTINUE',
'payment_method_preference' => 'UNRESTRICTED',
'contact_preference' => 'NO_CONTACT_INFO',
'order_update_callback_config' => [
'callback_events' => [CallbackConfig::EVENT_SHIPPING_ADDRESS, CallbackConfig::EVENT_SHIPPING_OPTIONS],
'callback_url' => 'example.com/callback',
]
], $result->to_array());
}
}

View file

@ -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()

View file

@ -175,6 +175,7 @@ class CreateOrderEndpointTest extends TestCase
false,
['checkout'],
false,
false,
['paypal'],
new NullLogger()
);