<?php
/**
* The order endpoint.
*
* @package WooCommerce\PayPalCommerce\ApiClient\Endpoint
*/
declare (strict_types=1);
namespace WooCommerce\PayPalCommerce\ApiClient\Endpoint;
use InvalidArgumentException;
use stdClass;
use WC_Order;
use WooCommerce\PayPalCommerce\ApiClient\Authentication\Bearer;
use WooCommerce\PayPalCommerce\ApiClient\Entity\AuthorizationStatus;
use WooCommerce\PayPalCommerce\ApiClient\Entity\CaptureStatus;
use WooCommerce\PayPalCommerce\ApiClient\Entity\ExperienceContext;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Order;
use WooCommerce\PayPalCommerce\ApiClient\Entity\OrderStatus;
use WooCommerce\PayPalCommerce\ApiClient\Entity\PatchCollection;
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\OrderFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\PatchCollectionFactory;
use WooCommerce\PayPalCommerce\ApiClient\Helper\ErrorResponse;
use WooCommerce\PayPalCommerce\Vendor\Psr\Log\LoggerInterface;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway;
use WooCommerce\PayPalCommerce\WcSubscriptions\Helper\SubscriptionHelper;
use WooCommerce\PayPalCommerce\WcGateway\FraudNet\FraudNet;
use WP_Error;
/**
* Class OrderEndpoint
*/
class OrderEndpoint
{
use \WooCommerce\PayPalCommerce\ApiClient\Endpoint\RequestTrait;
/**
* The subscription helper
*
* @var SubscriptionHelper
*/
protected $subscription_helper;
/**
* The host.
*
* @var string
*/
private $host;
/**
* The bearer.
*
* @var Bearer
*/
private $bearer;
/**
* The order factory.
*
* @var OrderFactory
*/
private $order_factory;
/**
* The patch collection factory.
*
* @var PatchCollectionFactory
*/
private $patch_collection_factory;
/**
* The intent.
*
* @var string
*/
private $intent;
/**
* The logger.
*
* @var LoggerInterface
*/
private $logger;
/**
* True if FraudNet support is enabled in settings, otherwise false.
*
* @var bool
*/
protected $is_fraudnet_enabled;
/**
* The FraudNet entity.
*
* @var FraudNet
*/
protected $fraudnet;
/**
* The BN Code.
*
* @var string
*/
private $bn_code;
/**
* OrderEndpoint constructor.
*
* @param string $host The host.
* @param Bearer $bearer The bearer.
* @param OrderFactory $order_factory The order factory.
* @param PatchCollectionFactory $patch_collection_factory The patch collection factory.
* @param string $intent The intent.
* @param LoggerInterface $logger The logger.
* @param SubscriptionHelper $subscription_helper The subscription helper.
* @param bool $is_fraudnet_enabled true if FraudNet support is enabled in settings, otherwise false.
* @param FraudNet $fraudnet The FraudNet entity.
* @param string $bn_code The BN Code.
*/
public function __construct(string $host, Bearer $bearer, OrderFactory $order_factory, PatchCollectionFactory $patch_collection_factory, string $intent, LoggerInterface $logger, SubscriptionHelper $subscription_helper, bool $is_fraudnet_enabled, FraudNet $fraudnet, string $bn_code = '')
{
$this->host = $host;
$this->bearer = $bearer;
$this->order_factory = $order_factory;
$this->patch_collection_factory = $patch_collection_factory;
$this->intent = $intent;
$this->logger = $logger;
$this->bn_code = $bn_code;
$this->is_fraudnet_enabled = $is_fraudnet_enabled;
$this->subscription_helper = $subscription_helper;
$this->fraudnet = $fraudnet;
}
/**
* Changes the used BN Code.
*
* @param string $bn_code The new BN Code to use.
*
* @return OrderEndpoint
* @throws RuntimeException If the request fails.
*/
public function with_bn_code(string $bn_code): \WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint
{
$this->bn_code = $bn_code;
return $this;
}
/**
* Creates an order.
*
* @param PurchaseUnit[] $items The purchase unit items for the order.
* @param string $shipping_preference One of ExperienceContext::SHIPPING_PREFERENCE_ values.
* @param Payer|null $payer The payer off the order.
* @param string $payment_method WC payment method.
* @param array $request_data Request data.
* @param PaymentSource|null $payment_source The payment source.
*
* @return Order
* @throws RuntimeException If the request fails.
*/
public function create(array $items, string $shipping_preference, ?Payer $payer = null, string $payment_method = '', array $request_data = array(), ?PaymentSource $payment_source = null): Order
{
$bearer = $this->bearer->bearer();
$data = array('intent' => apply_filters('woocommerce_paypal_payments_order_intent', $this->intent), 'purchase_units' => array_map(static function (PurchaseUnit $item) use ($shipping_preference): array {
$data = $item->to_array();
if ($shipping_preference !== ExperienceContext::SHIPPING_PREFERENCE_GET_FROM_FILE) {
// Shipping options are not allowed to be sent when not getting the address from PayPal.
unset($data['shipping']['options']);
}
return $data;
}, $items));
if ($payer && !empty($payer->email_address())) {
$data['payer'] = $payer->to_array();
}
if ($payment_source) {
$data['payment_source'] = array($payment_source->name() => $payment_source->properties());
}
/**
* The filter can be used to modify the order creation request body data.
*/
$data = apply_filters('ppcp_create_order_request_body_data', $data, $payment_method, $request_data);
$url = trailingslashit($this->host) . 'v2/checkout/orders';
$args = array('method' => 'POST', 'headers' => array('Authorization' => 'Bearer ' . $bearer->token(), 'Content-Type' => 'application/json', 'Prefer' => 'return=representation'), 'body' => wp_json_encode($data));
if ($this->bn_code) {
$args['headers']['PayPal-Partner-Attribution-Id'] = $this->bn_code;
}
if ($this->is_fraudnet_enabled) {
$args['headers']['PayPal-Client-Metadata-Id'] = $this->fraudnet->session_id();
}
if (isset($data['payment_source'])) {
$args['headers']['PayPal-Request-Id'] = uniqid('ppcp-', \true);
}
$response = $this->request($url, $args);
if (is_wp_error($response)) {
$error = new RuntimeException('Could not create order.');
$this->logger->warning($error->getMessage(), array('args' => $args, 'response' => $response));
throw $error;
}
$json = json_decode($response['body']);
$status_code = (int) wp_remote_retrieve_response_code($response);
if (!in_array($status_code, array(200, 201), \true)) {
$error = new PayPalApiException($json, $status_code);
$this->logger->warning(sprintf('Failed to create order. PayPal API response: %1$s', $error->getMessage()));
throw $error;
}
$order = $this->order_factory->from_paypal_response($json);
do_action('woocommerce_paypal_payments_paypal_order_created', $order);
return $order;
}
/**
* Captures an order.
*
* @param Order $order The order.
*
* @return Order
* @throws RuntimeException If the request fails.
*/
public function capture(Order $order): Order
{
if ($order->status()->is(OrderStatus::COMPLETED)) {
return $order;
}
do_action('woocommerce_paypal_payments_before_capture_order', $order);
$bearer = $this->bearer->bearer();
$url = trailingslashit($this->host) . 'v2/checkout/orders/' . $order->id() . '/capture';
$args = array('method' => 'POST', 'headers' => array('Authorization' => 'Bearer ' . $bearer->token(), 'Content-Type' => 'application/json', 'Prefer' => 'return=representation'));
if ($this->bn_code) {
$args['headers']['PayPal-Partner-Attribution-Id'] = $this->bn_code;
}
$response = $this->request($url, $args);
if (is_wp_error($response)) {
$error = new RuntimeException(__('Could not capture order.', 'woocommerce-paypal-payments'));
$this->logger->warning($error->getMessage(), array('args' => $args, 'response' => $response));
throw $error;
}
$json = json_decode($response['body']);
$status_code = (int) wp_remote_retrieve_response_code($response);
if (201 !== $status_code) {
$error = new PayPalApiException($json, $status_code);
// If the order has already been captured, we return the updated order.
if (strpos($response['body'], ErrorResponse::ORDER_ALREADY_CAPTURED) !== \false) {
return $this->order($order->id());
}
$this->logger->warning(sprintf('Failed to capture order. PayPal API response: %1$s', $error->getMessage()), array('args' => $args, 'response' => $response));
throw $error;
}
$order = $this->order_factory->from_paypal_response($json);
$first_capture = $order->purchase_units()[0]->payments()->captures()[0] ?? null;
$capture_status = $first_capture ? $first_capture->status() : null;
if ($capture_status && $capture_status->is(CaptureStatus::DECLINED)) {
$fraud = $first_capture->fraud_processor_response();
$decline_message = $fraud ? $fraud->get_customer_decline_message() : __('Payment provider declined the payment, please use a different payment method.', 'woocommerce-paypal-payments');
throw new RuntimeException($decline_message);
}
return $order;
}
/**
* Authorize an order.
*
* @param Order $order The order.
*
* @return Order
* @throws RuntimeException If the request fails.
*/
public function authorize(Order $order): Order
{
$bearer = $this->bearer->bearer();
$url = trailingslashit($this->host) . 'v2/checkout/orders/' . $order->id() . '/authorize';
$args = array('method' => 'POST', 'headers' => array('Authorization' => 'Bearer ' . $bearer->token(), 'Content-Type' => 'application/json', 'Prefer' => 'return=representation'));
if ($this->bn_code) {
$args['headers']['PayPal-Partner-Attribution-Id'] = $this->bn_code;
}
$response = $this->request($url, $args);
if (is_wp_error($response)) {
$error = new RuntimeException(__('Could not authorize order.', 'woocommerce-paypal-payments'));
$this->logger->warning($error->getMessage(), array('args' => $args, 'response' => $response));
throw $error;
}
$json = json_decode($response['body']);
$status_code = (int) wp_remote_retrieve_response_code($response);
if (!in_array($status_code, array(200, 201), \true)) {
if (\false !== strpos($response['body'], ErrorResponse::ORDER_ALREADY_AUTHORIZED)) {
return $this->order($order->id());
}
$error = new PayPalApiException($json, $status_code);
$this->logger->warning(sprintf('Failed to authorize order. PayPal API response: %1$s', $error->getMessage()), array('args' => $args, 'response' => $response));
throw $error;
}
$order = $this->order_factory->from_paypal_response($json);
$authorization_status = $order->purchase_units()[0]->payments()->authorizations()[0]->status() ?? null;
if ($authorization_status && $authorization_status->is(AuthorizationStatus::DENIED)) {
throw new RuntimeException(__('Payment provider declined the payment, please use a different payment method.', 'woocommerce-paypal-payments'));
}
return $order;
}
/**
* Fetches an order for a given ID.
*
* @param string|WC_Order $paypal_id_or_wc_order The ID of PayPal order or a WC order (with the ID in meta).
*
* @return Order
* @throws RuntimeException If the request fails.
* @throws InvalidArgumentException If the ID is not a string or WC order with the ID in meta.
*/
public function order($paypal_id_or_wc_order): Order
{
$paypal_id = $this->resolve_paypal_id($paypal_id_or_wc_order);
$bearer = $this->bearer->bearer();
$url = trailingslashit($this->host) . 'v2/checkout/orders/' . $paypal_id;
$args = array('headers' => array('Authorization' => 'Bearer ' . $bearer->token(), 'Content-Type' => 'application/json'));
if ($this->bn_code) {
$args['headers']['PayPal-Partner-Attribution-Id'] = $this->bn_code;
}
$response = $this->request($url, $args);
if (is_wp_error($response)) {
$error = new RuntimeException('Could not retrieve order.');
$this->logger->warning($error->getMessage());
throw $error;
}
$json = json_decode($response['body']);
$status_code = (int) wp_remote_retrieve_response_code($response);
if (404 === $status_code || empty($response['body'])) {
$error = new RuntimeException('Could not retrieve order.', 404);
$this->logger->warning($error->getMessage(), array('args' => $args, 'response' => $response));
throw $error;
}
if (200 !== $status_code) {
$error = new PayPalApiException($json, $status_code);
$this->logger->warning($error->getMessage(), array('args' => $args, 'response' => $response));
throw $error;
}
return $this->order_factory->from_paypal_response($json);
}
/**
* Patches an order.
*
* @param Order $order_to_update The order to patch.
* @param Order $order_to_compare The target order.
*
* @return Order
* @throws RuntimeException If the request fails.
*/
public function patch_order_with(Order $order_to_update, Order $order_to_compare): Order
{
$patches = $this->patch_collection_factory->from_orders($order_to_update, $order_to_compare);
if (!count($patches->patches())) {
return $order_to_update;
}
$this->patch($order_to_update->id(), $patches);
$new_order = $this->order($order_to_update->id());
return $new_order;
}
/**
* Patches an order.
*
* @param string $order_id The PayPal order ID.
* @param PatchCollection $patches The patches.
*
* @throws RuntimeException If the request fails.
*/
public function patch(string $order_id, PatchCollection $patches): void
{
$patches_array = $patches->to_array();
/**
* The filter can be used to modify the order patching request body data (the final prices, items).
*/
$patches_array = apply_filters('ppcp_patch_order_request_body_data', $patches_array);
$bearer = $this->bearer->bearer();
$url = trailingslashit($this->host) . 'v2/checkout/orders/' . $order_id;
$args = array('method' => 'PATCH', 'headers' => array('Authorization' => 'Bearer ' . $bearer->token(), 'Content-Type' => 'application/json', 'Prefer' => 'return=representation'), 'body' => wp_json_encode($patches_array));
if ($this->bn_code) {
$args['headers']['PayPal-Partner-Attribution-Id'] = $this->bn_code;
}
$response = $this->request($url, $args);
if (is_wp_error($response)) {
$error = new RuntimeException('Could not patch order.');
$this->logger->warning($error->getMessage(), array('args' => $args, 'response' => $response));
throw $error;
}
$json = json_decode($response['body']);
$status_code = (int) wp_remote_retrieve_response_code($response);
if (204 !== $status_code) {
$error = new PayPalApiException($json, $status_code);
$this->logger->warning($error->getMessage(), array('args' => $args, 'response' => $response));
throw $error;
}
}
/**
* Confirms payment source.
*
* @param string $id The PayPal order ID.
* @param array $payment_source The payment source.
* @return stdClass
* @throws PayPalApiException If the request fails.
* @throws RuntimeException If something unexpected happens.
*/
public function confirm_payment_source(string $id, array $payment_source): stdClass
{
$bearer = $this->bearer->bearer();
$url = trailingslashit($this->host) . 'v2/checkout/orders/' . $id . '/confirm-payment-source';
$data = array('payment_source' => $payment_source, 'processing_instruction' => 'ORDER_COMPLETE_ON_PAYMENT_APPROVAL');
$args = array('method' => 'POST', 'headers' => array('Authorization' => 'Bearer ' . $bearer->token(), 'Content-Type' => 'application/json', 'Prefer' => 'return=representation'), 'body' => wp_json_encode($data));
$response = $this->request($url, $args);
if ($response instanceof WP_Error) {
throw new RuntimeException($response->get_error_message());
}
$json = json_decode($response['body']);
$status_code = (int) wp_remote_retrieve_response_code($response);
if (200 !== $status_code) {
throw new PayPalApiException($json, $status_code);
}
return $json;
}
/**
* Resolves the PayPal order ID from the given argument.
*
* @param string|WC_Order $paypal_id_or_wc_order The ID of PayPal order or a WC order (with the ID in meta).
*
* @return string The PayPal order ID.
* @throws InvalidArgumentException If the ID cannot be resolved.
*/
protected function resolve_paypal_id($paypal_id_or_wc_order): string
{
if ($paypal_id_or_wc_order instanceof WC_Order) {
$paypal_id = $paypal_id_or_wc_order->get_meta(PayPalGateway::ORDER_ID_META_KEY);
if (!$paypal_id) {
throw new InvalidArgumentException('PayPal order ID not found in meta.');
}
return $paypal_id;
}
if (!is_string($paypal_id_or_wc_order)) {
throw new InvalidArgumentException('Invalid PayPal order ID, string expected.');
}
return $paypal_id_or_wc_order;
}
}