<?php
/**
* The endpoint to create an PayPal order.
*
* @package WooCommerce\PayPalCommerce\Button\Endpoint
*/
declare (strict_types=1);
namespace WooCommerce\PayPalCommerce\Button\Endpoint;
use Exception;
use WooCommerce\PayPalCommerce\Vendor\Psr\Log\LoggerInterface;
use stdClass;
use Throwable;
use WC_Order;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Amount;
use WooCommerce\PayPalCommerce\ApiClient\Entity\ExperienceContext;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Money;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Order;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Payer;
use WooCommerce\PayPalCommerce\ApiClient\Entity\PaymentSource;
use WooCommerce\PayPalCommerce\ApiClient\Entity\PurchaseUnit;
use WooCommerce\PayPalCommerce\ApiClient\Exception\PayPalApiException;
use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException;
use WooCommerce\PayPalCommerce\ApiClient\Factory\ExperienceContextBuilder;
use WooCommerce\PayPalCommerce\ApiClient\Factory\PayerFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\PurchaseUnitFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\ReturnUrlFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\ShippingPreferenceFactory;
use WooCommerce\PayPalCommerce\Button\Exception\NonceValidationException;
use WooCommerce\PayPalCommerce\Button\Exception\ValidationException;
use WooCommerce\PayPalCommerce\Button\Session\CartDataFactory;
use WooCommerce\PayPalCommerce\Button\Session\CartDataTransientStorage;
use WooCommerce\PayPalCommerce\Button\Validation\CheckoutFormValidator;
use WooCommerce\PayPalCommerce\Button\Helper\EarlyOrderHandler;
use WooCommerce\PayPalCommerce\Session\SessionHandler;
use WooCommerce\PayPalCommerce\WcSubscriptions\FreeTrialHandlerTrait;
use WooCommerce\PayPalCommerce\WcGateway\CardBillingMode;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\CardButtonGateway;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\CreditCardGateway;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway;
use WooCommerce\PayPalCommerce\Settings\Data\SettingsProvider;
use WooCommerce\PayPalCommerce\ApiClient\Factory\ContactPreferenceFactory;
/**
* Class CreateOrderEndpoint
*/
class CreateOrderEndpoint implements \WooCommerce\PayPalCommerce\Button\Endpoint\EndpointInterface
{
use FreeTrialHandlerTrait;
const ENDPOINT = 'ppc-create-order';
public const RETURN_URL_CART_QUERY_ARG = 'pcp-cart';
/**
* The request data helper.
*
* @var RequestData
*/
private $request_data;
/**
* The PurchaseUnit factory.
*
* @var PurchaseUnitFactory
*/
private $purchase_unit_factory;
/**
* The shipping_preference factory.
*
* @var ShippingPreferenceFactory
*/
private $shipping_preference_factory;
private ReturnUrlFactory $return_url_factory;
/**
* The contact_preference factors.
*/
private ContactPreferenceFactory $contact_preference_factory;
/**
* The ExperienceContextBuilder.
*/
private ExperienceContextBuilder $experience_context_builder;
/**
* The order endpoint.
*
* @var OrderEndpoint
*/
private $api_endpoint;
/**
* The payer factory.
*
* @var PayerFactory
*/
private $payer_factory;
/**
* The session handler.
*
* @var SessionHandler
*/
private $session_handler;
private SettingsProvider $settings_provider;
/**
* The early order handler.
*
* @var EarlyOrderHandler
*/
private $early_order_handler;
protected CartDataFactory $cart_data_factory;
protected CartDataTransientStorage $cart_data_transient_storage;
/**
* Data from the request.
*
* @var array
*/
private $parsed_request_data;
/**
* The purchase unit for order.
*
* @var PurchaseUnit|null
*/
private $purchase_unit;
/**
* Whether a new user must be registered during checkout.
*
* @var bool
*/
private $registration_needed;
/**
* The value of card_billing_data_mode from the settings.
*
* @var string
*/
protected $card_billing_data_mode;
/**
* Whether to execute WC validation of the checkout form.
*
* @var bool
*/
protected $early_validation_enabled;
/**
* The contexts that should have the Pay Now button.
*
* @var string[]
*/
private $pay_now_contexts;
/**
* If true, the shipping methods are sent to PayPal allowing the customer to select it inside the popup.
*
* @var bool
*/
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.
*
* @var string[]
*/
private $funding_sources_without_redirect;
/**
* The logger.
*
* @var LoggerInterface
*/
protected $logger;
/**
* The form data, or empty if not available.
*
* @var array
*/
private $form = array();
/**
* CreateOrderEndpoint constructor.
*
* @param RequestData $request_data The RequestData object.
* @param PurchaseUnitFactory $purchase_unit_factory The PurchaseUnit factory.
* @param ShippingPreferenceFactory $shipping_preference_factory The shipping_preference factory.
* @param ReturnUrlFactory $return_url_factory The return URL factory.
* @param ContactPreferenceFactory $contact_preference_factory The contact_preference factory.
* @param ExperienceContextBuilder $experience_context_builder The ExperienceContextBuilder.
* @param OrderEndpoint $order_endpoint The OrderEndpoint object.
* @param PayerFactory $payer_factory The PayerFactory object.
* @param SessionHandler $session_handler The SessionHandler object.
* @param SettingsProvider $settings_provider The SettingsProvider object.
* @param EarlyOrderHandler $early_order_handler The EarlyOrderHandler object.
* @param CartDataFactory $cart_data_factory
* @param CartDataTransientStorage $cart_data_transient_storage
* @param bool $registration_needed Whether a new user must be registered during checkout.
* @param string $card_billing_data_mode The value of card_billing_data_mode from the settings.
* @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.
*/
public function __construct(\WooCommerce\PayPalCommerce\Button\Endpoint\RequestData $request_data, PurchaseUnitFactory $purchase_unit_factory, ShippingPreferenceFactory $shipping_preference_factory, ReturnUrlFactory $return_url_factory, ContactPreferenceFactory $contact_preference_factory, ExperienceContextBuilder $experience_context_builder, OrderEndpoint $order_endpoint, PayerFactory $payer_factory, SessionHandler $session_handler, SettingsProvider $settings_provider, EarlyOrderHandler $early_order_handler, CartDataFactory $cart_data_factory, CartDataTransientStorage $cart_data_transient_storage, bool $registration_needed, string $card_billing_data_mode, 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)
{
$this->request_data = $request_data;
$this->purchase_unit_factory = $purchase_unit_factory;
$this->shipping_preference_factory = $shipping_preference_factory;
$this->contact_preference_factory = $contact_preference_factory;
$this->return_url_factory = $return_url_factory;
$this->experience_context_builder = $experience_context_builder;
$this->api_endpoint = $order_endpoint;
$this->payer_factory = $payer_factory;
$this->session_handler = $session_handler;
$this->settings_provider = $settings_provider;
$this->early_order_handler = $early_order_handler;
$this->cart_data_factory = $cart_data_factory;
$this->cart_data_transient_storage = $cart_data_transient_storage;
$this->registration_needed = $registration_needed;
$this->card_billing_data_mode = $card_billing_data_mode;
$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;
}
/**
* Returns the nonce.
*
* @return string
*/
public static function nonce(): string
{
return self::ENDPOINT;
}
/**
* Handles the request.
*
* @throws Exception On Error.
*/
public function handle_request(): void
{
try {
$data = $this->request_data->read_request($this->nonce());
$this->parsed_request_data = $data;
$payment_method = $data['payment_method'] ?? '';
$funding_source = $data['funding_source'] ?? '';
$wc_order = null;
do_action('woocommerce_paypal_payments_create_order_request_started', $data);
if ('pay-now' === $data['context']) {
$wc_order = wc_get_order((int) $data['order_id']);
if (!$wc_order instanceof WC_Order) {
wp_send_json_error(array('name' => 'order-not-found', 'message' => __('Order not found', 'woocommerce-paypal-payments'), 'code' => 0, 'details' => array()));
}
$order_key = $data['order_key'] ?? '';
//phpcs:ignore WordPress.WP.Capabilities.Unknown
if (!$wc_order->key_is_valid($order_key) || !current_user_can('view_order', $data['order_id'])) {
wp_send_json_error(array('name' => 'invalid-request', 'message' => __('You cannot pay for this order. Contact the shop for assistance.', 'woocommerce-paypal-payments'), 'code' => 0, 'details' => array()));
}
$this->purchase_unit = $this->purchase_unit_factory->from_wc_order($wc_order, $payment_method);
} else {
$this->purchase_unit = $this->purchase_unit_factory->from_wc_cart(null, $this->should_handle_shipping_in_paypal($funding_source), $payment_method);
// Do not allow completion by webhooks when started via non-checkout buttons,
// it is needed only for some APMs in checkout.
if (in_array($data['context'], array('product', 'cart', 'cart-block'), \true)) {
$this->purchase_unit->set_custom_id('');
}
// The cart does not have any info about payment method, so we must handle free trial here.
if ((in_array($payment_method, array(CreditCardGateway::ID, CardButtonGateway::ID), \true) || PayPalGateway::ID === $payment_method && 'card' === $funding_source) && $this->is_free_trial_cart()) {
$this->purchase_unit->set_amount(new Amount(new Money(1.0, $this->purchase_unit->amount()->currency_code()), $this->purchase_unit->amount()->breakdown()));
}
}
$this->set_bn_code($data);
if (isset($data['form'])) {
$this->form = $data['form'];
}
if ($this->early_validation_enabled && $this->form && 'checkout' === $data['context'] && in_array($payment_method, array(PayPalGateway::ID, CardButtonGateway::ID, CreditCardGateway::ID), \true)) {
$this->validate_form($this->form);
}
if ('pay-now' === $data['context'] && $this->form && get_option('woocommerce_terms_page_id', '') !== '') {
$this->validate_paynow_form($this->form);
}
try {
$order = $this->create_paypal_order($wc_order, $payment_method, $data);
} catch (Exception $exception) {
$this->logger->error('Order creation failed: ' . $exception->getMessage());
throw $exception;
}
if ('checkout' === $data['context']) {
if ($payment_method === PayPalGateway::ID && !in_array($funding_source, $this->funding_sources_without_redirect, \true)) {
$this->session_handler->replace_order($order);
$this->session_handler->replace_funding_source($funding_source);
}
if (!$this->early_order_handler->should_create_early_order() || $this->registration_needed || isset($data['createaccount']) && '1' === $data['createaccount']) {
wp_send_json_success($this->make_response($order));
}
$this->early_order_handler->register_for_order($order);
}
if ('pay-now' === $data['context'] && $wc_order instanceof WC_Order) {
$wc_order->update_meta_data(PayPalGateway::ORDER_ID_META_KEY, $order->id());
$wc_order->update_meta_data(PayPalGateway::INTENT_META_KEY, $order->intent());
$payment_source = $order->payment_source();
$payment_source_name = $payment_source ? $payment_source->name() : null;
$payer = $order->payer();
if ($payer && $payment_source_name && in_array($payment_source_name, PayPalGateway::PAYMENT_SOURCES_WITH_PAYER_EMAIL, \true)) {
$payer_email = $payer->email_address();
if ($payer_email) {
$wc_order->update_meta_data(PayPalGateway::ORDER_PAYER_EMAIL_META_KEY, $payer_email);
}
}
$wc_order->save_meta_data();
do_action('woocommerce_paypal_payments_woocommerce_order_created', $wc_order, $order);
}
wp_send_json_success($this->make_response($order));
} catch (NonceValidationException $error) {
wp_send_json_error(array('message' => $error->getMessage()), 400);
} catch (ValidationException $error) {
$response = array('message' => $error->getMessage(), 'errors' => $error->errors(), 'refresh' => isset(WC()->session->refresh_totals));
unset(WC()->session->refresh_totals);
wp_send_json_error($response);
} catch (\RuntimeException $error) {
$this->logger->error('Order creation failed: ' . $error->getMessage());
wp_send_json_error(array('name' => $error instanceof PayPalApiException ? $error->name() : '', 'message' => $error->getMessage(), 'code' => $error->getCode(), 'details' => $error instanceof PayPalApiException ? $error->details() : array()));
} catch (Exception $exception) {
$this->logger->error('Order creation failed: ' . $exception->getMessage());
wc_add_notice($exception->getMessage(), 'error');
}
}
/**
* Creates the order in the PayPal, uses data from WC order if provided.
*
* @param WC_Order|null $wc_order WC order to get data from.
* @param string $payment_method WC payment method.
* @param array $data Request data.
*
* @return Order Created PayPal order.
*
* @throws RuntimeException If create order request fails.
* @throws PayPalApiException If create order request fails.
*
* phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
*/
private function create_paypal_order(?WC_Order $wc_order = null, string $payment_method = '', array $data = array()): Order
{
assert($this->purchase_unit instanceof PurchaseUnit);
$funding_source = $this->parsed_request_data['funding_source'] ?? '';
$payer = $this->payer($this->parsed_request_data, $wc_order);
$shipping_preference = $this->shipping_preference_factory->from_state($this->purchase_unit, $this->parsed_request_data['context'], WC()->cart, $funding_source, $wc_order);
$action = in_array($this->parsed_request_data['context'], $this->pay_now_contexts, \true) ? ExperienceContext::USER_ACTION_PAY_NOW : ExperienceContext::USER_ACTION_CONTINUE;
if ('card' === $funding_source) {
if (CardBillingMode::MINIMAL_INPUT === $this->card_billing_data_mode) {
if (ExperienceContext::SHIPPING_PREFERENCE_SET_PROVIDED_ADDRESS === $shipping_preference) {
if ($payer) {
$payer->set_address(null);
}
}
if (ExperienceContext::SHIPPING_PREFERENCE_NO_SHIPPING === $shipping_preference) {
if ($payer) {
$payer->set_name(null);
}
}
}
if (CardBillingMode::NO_WC === $this->card_billing_data_mode) {
$payer = null;
}
}
if ('venmo' === $funding_source) {
$payment_source_key = 'venmo';
} else {
$payment_source_key = 'paypal';
}
$contact_preference = $this->contact_preference_factory->from_state($payment_source_key);
$custom_args = array();
$cart_data = null;
// Save the cart to be able to access it after cross-browser AppSwitch.
if ('pay-now' !== $data['context']) {
try {
$cart_data = $this->cart_data_factory->from_current_cart();
$cart_data->generate_key();
$key = $cart_data->key();
assert(!empty($key));
$custom_args[self::RETURN_URL_CART_QUERY_ARG] = $key;
} catch (Throwable $exception) {
$this->logger->error('Failed to serialize cart: ' . $exception->getMessage());
}
}
$return_url = $this->return_url_factory->from_context($this->parsed_request_data['context'], $this->parsed_request_data, $custom_args);
$experience_context = $this->experience_context_builder->with_default_paypal_config($shipping_preference, $action)->with_contact_preference($contact_preference)->with_custom_return_url($return_url)->with_custom_cancel_url($return_url);
if ($this->should_handle_shipping_in_paypal($funding_source) && $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' => $experience_context->build()->to_array()));
try {
$order = $this->api_endpoint->create(array($this->purchase_unit), $shipping_preference, $payer, $payment_method, $data, $payment_source);
} catch (PayPalApiException $exception) {
// Looks like currently there is no proper way to validate the shipping address for PayPal,
// so we cannot make some invalid addresses null in PurchaseUnitFactory,
// which causes failure e.g. for guests using the button on products pages when the country does not have postal codes.
if (422 === $exception->status_code() && array_filter($exception->details(), function (stdClass $detail): bool {
return isset($detail->field) && strpos((string) $detail->field, 'shipping/address') !== \false;
})) {
$this->logger->info('Invalid shipping address for order creation, retrying without it.');
$this->purchase_unit->set_shipping(null);
$order = $this->api_endpoint->create(array($this->purchase_unit), $shipping_preference, $payer, $payment_method, $data, $payment_source);
} else {
throw $exception;
}
}
if ($cart_data) {
try {
$cart_data->set_paypal_order_id($order->id());
$this->cart_data_transient_storage->save($cart_data);
} catch (Throwable $exception) {
$this->logger->error('Failed to save cart: ' . $exception->getMessage());
}
}
return $order;
}
/**
* Returns the Payer entity based on the request data.
*
* @param array $data The request data.
* @param WC_Order|null $wc_order The order.
*
* @return Payer|null
*/
private function payer(array $data, ?WC_Order $wc_order = null)
{
if ('pay-now' === $data['context']) {
$payer = $this->payer_factory->from_wc_order($wc_order);
return $payer;
}
$payer = null;
if (isset($data['payer']) && $data['payer']) {
if (isset($data['payer']['phone']['phone_number']['national_number'])) {
// make sure the phone number contains only numbers and is max 14. chars long.
$number = $data['payer']['phone']['phone_number']['national_number'];
$number = preg_replace('/[^0-9]/', '', $number);
$number = substr($number, 0, 14);
$data['payer']['phone']['phone_number']['national_number'] = $number;
if (empty($data['payer']['phone']['phone_number']['national_number'])) {
unset($data['payer']['phone']);
}
}
$payer = $this->payer_factory->from_paypal_response(json_decode(wp_json_encode($data['payer'])));
}
if (!$payer && $this->form) {
if (isset($this->form['billing_email']) && '' !== $this->form['billing_email']) {
return $this->payer_factory->from_checkout_form($this->form);
}
}
return $payer;
}
/**
* Sets the BN Code for the following request.
*
* @param array $data The request data.
*/
private function set_bn_code(array $data)
{
$bn_code = isset($data['bn_code']) ? (string) $data['bn_code'] : '';
if (!$bn_code) {
return;
}
$this->session_handler->replace_bn_code($bn_code);
$this->api_endpoint->with_bn_code($bn_code);
}
/**
* Checks whether the form fields are valid.
*
* @param array $form_fields The form fields.
* @throws ValidationException When fields are not valid.
*/
private function validate_form(array $form_fields): void
{
try {
$v = new CheckoutFormValidator();
$v->validate($form_fields);
} catch (ValidationException $exception) {
throw $exception;
} catch (Throwable $exception) {
$this->logger->error("Form validation execution failed. {$exception->getMessage()} {$exception->getFile()}:{$exception->getLine()}");
}
}
/**
* Checks whether the terms input field is checked.
*
* @param array $form_fields The form fields.
* @throws ValidationException When field is not checked.
*/
private function validate_paynow_form(array $form_fields): void
{
if (isset($form_fields['terms-field']) && !isset($form_fields['terms'])) {
throw new ValidationException(array(__('Please read and accept the terms and conditions to proceed with your order.', 'woocommerce-paypal-payments')));
}
}
/**
* Returns the response data for success response.
*
* @param Order $order The PayPal order.
* @return array
*/
private function make_response(Order $order): array
{
return array('id' => $order->id(), 'custom_id' => $order->purchase_units()[0]->custom_id());
}
/**
* Checks if the shipping should be handled in PayPal popup.
*
* @param string $funding_source The funding source.
* @return bool true if the shipping should be handled in PayPal popup, otherwise false.
*/
protected function should_handle_shipping_in_paypal(string $funding_source): bool
{
$is_vaulting_enabled = $this->settings_provider->save_paypal_and_venmo();
if (!$this->handle_shipping_in_paypal) {
return \false;
}
return !$is_vaulting_enabled || $funding_source !== 'venmo';
}
}