<?php
/**
* The button module.
*
* @package WooCommerce\PayPalCommerce\Button
*/
declare (strict_types=1);
namespace WooCommerce\PayPalCommerce\Button;
use WC_Order;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Factory\ReturnUrlFactory;
use WooCommerce\PayPalCommerce\Button\Assets\SmartButtonInterface;
use WooCommerce\PayPalCommerce\Button\Endpoint\ApproveOrderEndpoint;
use WooCommerce\PayPalCommerce\Button\Endpoint\ApproveSubscriptionEndpoint;
use WooCommerce\PayPalCommerce\Button\Endpoint\CartScriptParamsEndpoint;
use WooCommerce\PayPalCommerce\Button\Endpoint\ChangeCartEndpoint;
use WooCommerce\PayPalCommerce\Button\Endpoint\CreateOrderEndpoint;
use WooCommerce\PayPalCommerce\Button\Endpoint\DataClientIdEndpoint;
use WooCommerce\PayPalCommerce\Button\Endpoint\GetOrderEndpoint;
use WooCommerce\PayPalCommerce\Button\Endpoint\SaveCheckoutFormEndpoint;
use WooCommerce\PayPalCommerce\Button\Endpoint\SimulateCartEndpoint;
use WooCommerce\PayPalCommerce\Button\Endpoint\ValidateCheckoutEndpoint;
use WooCommerce\PayPalCommerce\Button\Helper\EarlyOrderHandler;
use WooCommerce\PayPalCommerce\Button\Helper\WooCommerceOrderCreator;
use WooCommerce\PayPalCommerce\Button\Session\CartDataTransientStorage;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExecutableModule;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ModuleClassNameIdTrait;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ServiceModule;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway;
/**
* Class ButtonModule
*/
class ButtonModule implements ServiceModule, ExecutableModule
{
use ModuleClassNameIdTrait;
/**
* {@inheritDoc}
*/
public function services(): array
{
return require __DIR__ . '/../services.php';
}
/**
* {@inheritDoc}
*/
public function run(ContainerInterface $c): bool
{
add_action('wp', static function () use ($c) {
if (is_admin()) {
return;
}
$smart_button = $c->get('button.smart-button');
/**
* The Smart Button.
*
* @var SmartButtonInterface $smart_button
*/
$smart_button->render_wrapper();
});
add_action('wp_enqueue_scripts', static function () use ($c) {
$smart_button = $c->get('button.smart-button');
assert($smart_button instanceof SmartButtonInterface);
if ($smart_button->should_load_ppcp_script()) {
$smart_button->enqueue();
}
});
add_filter('woocommerce_create_order', static function ($value) use ($c) {
$early_order_handler = $c->get('button.helper.early-order-handler');
if (!is_null($value)) {
$value = (int) $value;
}
/**
* The Early Order Handler
*
* @var EarlyOrderHandler $early_order_handler
*/
return $early_order_handler->determine_wc_order_id($value);
});
$this->register_ajax_endpoints($c);
$this->register_appswitch_crossbrowser_handler($c);
return \true;
}
/**
* Registers the Ajax Endpoints.
*
* @param ContainerInterface $container The Container.
*/
private function register_ajax_endpoints(ContainerInterface $container): void
{
add_action('wc_ajax_' . DataClientIdEndpoint::ENDPOINT, static function () use ($container) {
$endpoint = $container->get('button.endpoint.data-client-id');
/**
* The Data Client ID Endpoint.
*
* @var DataClientIdEndpoint $endpoint
*/
$endpoint->handle_request();
});
add_action('wc_ajax_' . SimulateCartEndpoint::ENDPOINT, static function () use ($container) {
$endpoint = $container->get('button.endpoint.simulate-cart');
/**
* The Simulate Cart Endpoint.
*
* @var SimulateCartEndpoint $endpoint
*/
$endpoint->handle_request();
});
add_action('wc_ajax_' . ChangeCartEndpoint::ENDPOINT, static function () use ($container) {
$endpoint = $container->get('button.endpoint.change-cart');
/**
* The Change Cart Endpoint.
*
* @var ChangeCartEndpoint $endpoint
*/
$endpoint->handle_request();
});
add_action('wc_ajax_' . ApproveOrderEndpoint::ENDPOINT, static function () use ($container) {
$endpoint = $container->get('button.endpoint.approve-order');
/**
* The Approve Order Endpoint.
*
* @var ApproveOrderEndpoint $endpoint
*/
$endpoint->handle_request();
});
add_action('wc_ajax_' . ApproveSubscriptionEndpoint::ENDPOINT, static function () use ($container) {
$endpoint = $container->get('button.endpoint.approve-subscription');
assert($endpoint instanceof ApproveSubscriptionEndpoint);
$endpoint->handle_request();
});
add_action('wc_ajax_' . CreateOrderEndpoint::ENDPOINT, static function () use ($container) {
$endpoint = $container->get('button.endpoint.create-order');
/**
* The Create Order Endpoint.
*
* @var CreateOrderEndpoint $endpoint
*/
$endpoint->handle_request();
});
add_action('wc_ajax_' . SaveCheckoutFormEndpoint::ENDPOINT, static function () use ($container) {
$endpoint = $container->get('button.endpoint.save-checkout-form');
assert($endpoint instanceof SaveCheckoutFormEndpoint);
$endpoint->handle_request();
});
add_action('wc_ajax_' . ValidateCheckoutEndpoint::ENDPOINT, static function () use ($container) {
$endpoint = $container->get('button.endpoint.validate-checkout');
assert($endpoint instanceof ValidateCheckoutEndpoint);
$endpoint->handle_request();
});
add_action('wc_ajax_' . CartScriptParamsEndpoint::ENDPOINT, static function () use ($container) {
$endpoint = $container->get('button.endpoint.cart-script-params');
assert($endpoint instanceof CartScriptParamsEndpoint);
$endpoint->handle_request();
});
add_action('wc_ajax_' . GetOrderEndpoint::ENDPOINT, static function () use ($container) {
$endpoint = $container->get('button.endpoint.get-order');
assert($endpoint instanceof GetOrderEndpoint);
$endpoint->handle_request();
});
}
private static function is_cross_browser_order(WC_Order $wc_order): bool
{
return wc_string_to_bool($wc_order->get_meta(PayPalGateway::CROSS_BROWSER_APPSWITCH_META_KEY));
}
private function register_appswitch_crossbrowser_handler(ContainerInterface $container): void
{
if (!$container->get('wcgateway.appswitch-enabled')) {
return;
}
// After returning from cross-browser AppSwitch (started in non-default browser, then redirected to the default one)
// we need to retrieve the saved cart and PayPal order, create a WC order and redirect to Pay for order.
add_action('wp', static function () use ($container) {
// phpcs:ignore WordPress.Security.NonceVerification
if (!isset($_GET[ReturnUrlFactory::PCP_QUERY_ARG])) {
return;
}
if (is_checkout_pay_page()) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification
if (!isset($_GET[CreateOrderEndpoint::RETURN_URL_CART_QUERY_ARG])) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification
$cart_key = wc_clean(wp_unslash($_GET[CreateOrderEndpoint::RETURN_URL_CART_QUERY_ARG]));
if (!is_string($cart_key)) {
return;
}
$card_data_storage = $container->get('button.session.storage.card-data.transient');
assert($card_data_storage instanceof CartDataTransientStorage);
$cart_data = $card_data_storage->get($cart_key);
if (!$cart_data) {
return;
}
// Delete the data to avoid accidentally triggering it again, duplicating orders etc.
$card_data_storage->remove($cart_data);
if (!WC()->cart) {
return;
}
// The current cart is the same, so we don't need to do anything (probably not cross-browser).
if (WC()->cart->get_cart_hash() === $cart_data->cart_hash()) {
return;
}
$paypal_order_id = $cart_data->paypal_order_id();
if (empty($paypal_order_id)) {
return;
}
$order_endpoint = $container->get('api.endpoint.order');
assert($order_endpoint instanceof OrderEndpoint);
$paypal_order = $order_endpoint->order($paypal_order_id);
$wc_order_creator = $container->get('button.helper.wc-order-creator');
assert($wc_order_creator instanceof WooCommerceOrderCreator);
$wc_order = $wc_order_creator->create_from_paypal_order($paypal_order, $cart_data);
$wc_order->update_meta_data(PayPalGateway::CROSS_BROWSER_APPSWITCH_META_KEY, wc_bool_to_string(\true));
$wc_order->save();
// Redirect via JS because we need to keep the # parameters which are not accessible on the server side.
// phpcs:ignore WordPress.Security.EscapeOutput
echo "<script>location.href = '" . $wc_order->get_checkout_payment_url() . "' + location.hash;</script>";
});
/**
* Restore PayPal as the chosen payment method when returning from an App Switch flow.
*
* Context:
* --------
* When Fastlane (AXO) is active, AxoModule forces the chosen payment method to
* AxoGateway on every checkout page load. This causes a problem when the customer
* initiated checkout with PayPal and was redirected to the PayPal app
* (App Switch): after resuming, the checkout would incorrectly show AxoGateway,
* leading to validation errors and failed payments.
*
* Solution:
* ---------
* This handler runs after the AxoModule one (priority 20). It checks for the
* PayPal return URL query arguments that indicate an App Switch resume, and if
* present, forces the chosen method back to PayPalGateway. This ensures the
* resumed PayPal flow continues as expected.
*/
add_action('template_redirect', function () {
// phpcs:ignore WordPress.Security.NonceVerification
if (!isset($_GET[ReturnUrlFactory::PCP_QUERY_ARG])) {
return;
}
if (is_checkout_pay_page()) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification
if (!isset($_GET[CreateOrderEndpoint::RETURN_URL_CART_QUERY_ARG])) {
return;
}
WC()->session->set('chosen_payment_method', PayPalGateway::ID);
}, 20);
/**
* By default, WC asks to log in when opening a non-guest Pay for order page as a guest,
* so we disable this for cross-browser AppSwitch.
*
* @param array<string, bool> $allcaps Array of key/value pairs where keys represent a capability name
* and boolean values represent whether the user has that capability.
* @param string[] $caps Required primitive capabilities for the requested capability.
* @param array $args {
* Arguments that accompany the requested capability check.
*
* @type string $0 Requested capability.
* @type int $1 Concerned user ID.
* @type mixed ...$2 Optional second and further parameters, typically object ID.
* }
*
* @returns array<string, bool>
*
* @psalm-suppress MissingClosureParamType
*/
add_filter('user_has_cap', static function ($allcaps, $cap, $args) {
if (!in_array('pay_for_order', $cap, \true)) {
return $allcaps;
}
$wc_order_id = $args[2] ?? null;
if (!is_int($wc_order_id)) {
return $allcaps;
}
$wc_order = wc_get_order($wc_order_id);
if (!$wc_order instanceof WC_Order) {
return $allcaps;
}
if (!self::is_cross_browser_order($wc_order)) {
return $allcaps;
}
return array_merge($allcaps, array('pay_for_order' => \true));
}, 10, 3);
/**
* By default, WC asks to log in when opening a non-guest order received page as a guest,
* so we disable this for cross-browser AppSwitch.
*
* @param bool $result
*
* @psalm-suppress MissingClosureParamType
*/
add_filter('woocommerce_order_received_verify_known_shoppers', static function ($result) {
if (!is_order_received_page()) {
return $result;
}
$wc_order = null;
// phpcs:disable WordPress.Security.NonceVerification
if (isset($_GET['key']) && is_string($_GET['key'])) {
$wc_order_key = sanitize_text_field(wp_unslash($_GET['key']));
// phpcs:enable WordPress.Security.NonceVerification
$wc_order_id = wc_get_order_id_by_order_key($wc_order_key);
$wc_order = wc_get_order($wc_order_id);
}
if (!$wc_order instanceof WC_Order) {
return $result;
}
if (!self::is_cross_browser_order($wc_order)) {
return $result;
}
return \false;
});
/**
* Disabling the email prompt on Pay for order page for guests.
* Should not affect anything in most cases because it is skipped for just created orders (< 10 min).
*
* @param bool $email_verification_required
* @param WC_Order $order
*
* @returns bool
*
* @psalm-suppress MissingClosureParamType
*/
add_filter('woocommerce_order_email_verification_required', static function ($email_verification_required, $order) {
if (!$order instanceof WC_Order) {
return $email_verification_required;
}
if (!self::is_cross_browser_order($order)) {
return $email_verification_required;
}
return \false;
}, 10, 2);
}
}