Merge branch 'trunk' into PCP-881-compatibility-with-third-party-product-add-ons-plugins

This commit is contained in:
Pedro Silva 2023-09-07 08:46:06 +01:00 committed by GitHub
commit 3a8de5f3de
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
83 changed files with 3281 additions and 293 deletions

View file

@ -19,6 +19,7 @@ use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Order; use WooCommerce\PayPalCommerce\ApiClient\Entity\Order;
use WooCommerce\PayPalCommerce\PPCP; use WooCommerce\PayPalCommerce\PPCP;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway; use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway;
use WooCommerce\PayPalCommerce\WcGateway\Helper\RefundFeesUpdater;
use WooCommerce\PayPalCommerce\WcGateway\Processor\AuthorizedPaymentsProcessor; use WooCommerce\PayPalCommerce\WcGateway\Processor\AuthorizedPaymentsProcessor;
use WooCommerce\PayPalCommerce\WcGateway\Processor\RefundProcessor; use WooCommerce\PayPalCommerce\WcGateway\Processor\RefundProcessor;
@ -107,3 +108,14 @@ function ppcp_void_order( WC_Order $wc_order ): void {
$refund_processor->void( $order ); $refund_processor->void( $order );
} }
/**
* Updates the PayPal refund fees totals on an order.
*
* @param WC_Order $wc_order The WC order.
*/
function ppcp_update_order_refund_fees( WC_Order $wc_order ): void {
$updater = PPCP::container()->get( 'wcgateway.helper.refund-fees-updater' );
assert( $updater instanceof RefundFeesUpdater );
$updater->update( $wc_order );
}

View file

@ -1,6 +1,11 @@
*** Changelog *** *** Changelog ***
= 2.2.1 - xxxx-xx-xx = = 2.2.2 - 2023-08-29 =
* Fix - High rate of auth voids on vaulted subscriptions for guest users #1529
* Enhancement - HPOS compatibility issues #1594
* Feature preview - PayPal Subscriptions API fixes and improvements #1600 #1607
= 2.2.1 - 2023-08-24 =
* Fix - One-page checkout causes mini cart not showing the PP button on certain pages #1536 * Fix - One-page checkout causes mini cart not showing the PP button on certain pages #1536
* Fix - When onboarding loading the return_url too fast may cause the onboarding to fail #1565 * Fix - When onboarding loading the return_url too fast may cause the onboarding to fail #1565
* Fix - PayPal button doesn't work for variable products on product page after recent 2.2.0 release #1533 * Fix - PayPal button doesn't work for variable products on product page after recent 2.2.0 release #1533

View file

@ -29,6 +29,7 @@
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"WooCommerce\\PayPalCommerce\\": "src", "WooCommerce\\PayPalCommerce\\": "src",
"WooCommerce\\PayPalCommerce\\Common\\": "lib/common/",
"WooCommerce\\PayPalCommerce\\Vendor\\": "lib/packages/" "WooCommerce\\PayPalCommerce\\Vendor\\": "lib/packages/"
}, },
"files": [ "files": [

View file

@ -1,5 +1,10 @@
## packages
The packages that are likely to cause conflicts with other plugins (by loading multiple incompatible versions). The packages that are likely to cause conflicts with other plugins (by loading multiple incompatible versions).
Their namespaces are isolated by [Mozart](https://github.com/coenjacobs/mozart). Their namespaces are isolated by [Mozart](https://github.com/coenjacobs/mozart).
Currently, the packages are simply added in the repo to avoid making the build process more complex (Mozart has different PHP requirements). Currently, the packages are simply added in the repo to avoid making the build process more complex (Mozart has different PHP requirements).
We need to isolate only PSR-11 containers and Dhii modularity packages, which are not supposed to change often. We need to isolate only PSR-11 containers and Dhii modularity packages, which are not supposed to change often.
## common
This folder contains reusable classes or components that do not fit into any specific module.
They are designed to be versatile and can be used by any module within the plugin.

View file

@ -0,0 +1,68 @@
<?php
/**
* The Singleton Trait can be used to wrap an execution block, so it behaves like a Singleton.
* It executes the callable once, on subsequent calls returns the same result.
*/
namespace WooCommerce\PayPalCommerce\Common\Pattern;
/**
* Class SingletonDecorator.
*/
class SingletonDecorator {
/**
* The callable with the executing code
*
* @var callable
*/
private $callable;
/**
* The execution result
*
* @var mixed
*/
private $result;
/**
* Indicates if the callable is resolved
*
* @var bool
*/
private $executed = false;
/**
* SingletonDecorator constructor.
*
* @param callable $callable
*/
public function __construct( callable $callable ) {
$this->callable = $callable;
}
/**
* The make constructor.
*
* @param callable $callable
* @return self
*/
public static function make( callable $callable ): self {
return new static( $callable );
}
/**
* Invokes a callable once and returns the same result on subsequent invokes.
*
* @param mixed ...$args Arguments to be passed to the callable.
* @return mixed
*/
public function __invoke( ...$args ) {
if ( ! $this->executed ) {
$this->result = call_user_func_array( $this->callable, $args );
$this->executed = true;
}
return $this->result;
}
}

View file

@ -0,0 +1,41 @@
<?php
/**
* The Singleton Trait can be used to add singleton behaviour to a class.
*
* @package WooCommerce\PayPalCommerce\Common\Pattern
*/
namespace WooCommerce\PayPalCommerce\Common\Pattern;
/**
* Class SingletonTrait.
*/
trait SingletonTrait {
/**
* The single instance of the class.
*
* @var self
*/
protected static $instance = null;
/**
* Static method to get the instance of the Singleton class
*
* @return self|null
*/
public static function get_instance(): ?self {
return self::$instance;
}
/**
* Static method to get the instance of the Singleton class
*
* @param self $instance
* @return self
*/
protected static function set_instance( self $instance ): self {
self::$instance = $instance;
return self::$instance;
}
}

View file

@ -35,17 +35,26 @@ class Message {
*/ */
private $dismissable; private $dismissable;
/**
* The wrapper selector that will contain the notice.
*
* @var string
*/
private $wrapper;
/** /**
* Message constructor. * Message constructor.
* *
* @param string $message The message text. * @param string $message The message text.
* @param string $type The message type. * @param string $type The message type.
* @param bool $dismissable Whether the message is dismissable. * @param bool $dismissable Whether the message is dismissable.
* @param string $wrapper The wrapper selector that will contain the notice.
*/ */
public function __construct( string $message, string $type, bool $dismissable = true ) { public function __construct( string $message, string $type, bool $dismissable = true, string $wrapper = '' ) {
$this->type = $type; $this->type = $type;
$this->message = $message; $this->message = $message;
$this->dismissable = $dismissable; $this->dismissable = $dismissable;
$this->wrapper = $wrapper;
} }
/** /**
@ -74,4 +83,13 @@ class Message {
public function is_dismissable(): bool { public function is_dismissable(): bool {
return $this->dismissable; return $this->dismissable;
} }
/**
* Returns the wrapper selector that will contain the notice.
*
* @return string
*/
public function wrapper(): string {
return $this->wrapper;
}
} }

View file

@ -41,9 +41,10 @@ class Renderer implements RendererInterface {
$messages = $this->repository->current_message(); $messages = $this->repository->current_message();
foreach ( $messages as $message ) { foreach ( $messages as $message ) {
printf( printf(
'<div class="notice notice-%s %s"><p>%s</p></div>', '<div class="notice notice-%s %s" %s><p>%s</p></div>',
$message->type(), $message->type(),
( $message->is_dismissable() ) ? 'is-dismissible' : '', ( $message->is_dismissable() ) ? 'is-dismissible' : '',
( $message->wrapper() ? sprintf( 'data-ppcp-wrapper="%s"', esc_attr( $message->wrapper() ) ) : '' ),
wp_kses_post( $message->message() ) wp_kses_post( $message->message() )
); );
} }

View file

@ -9,13 +9,18 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\ApiClient; namespace WooCommerce\PayPalCommerce\ApiClient;
use WooCommerce\PayPalCommerce\Common\Pattern\SingletonDecorator;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\BillingSubscriptions; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\BillingSubscriptions;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\CatalogProducts; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\CatalogProducts;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\BillingPlans; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\BillingPlans;
use WooCommerce\PayPalCommerce\ApiClient\Entity\SellerPayableBreakdown;
use WooCommerce\PayPalCommerce\ApiClient\Factory\BillingCycleFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\BillingCycleFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\PaymentPreferencesFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\PaymentPreferencesFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\RefundFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\PlanFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\PlanFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\ProductFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\ProductFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\RefundPayerFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\SellerPayableBreakdownFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\ShippingOptionFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\ShippingOptionFactory;
use WooCommerce\PayPalCommerce\Session\SessionHandler; use WooCommerce\PayPalCommerce\Session\SessionHandler;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
@ -58,6 +63,8 @@ use WooCommerce\PayPalCommerce\ApiClient\Factory\WebhookFactory;
use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache; use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache;
use WooCommerce\PayPalCommerce\ApiClient\Helper\DccApplies; use WooCommerce\PayPalCommerce\ApiClient\Helper\DccApplies;
use WooCommerce\PayPalCommerce\ApiClient\Helper\OrderHelper; use WooCommerce\PayPalCommerce\ApiClient\Helper\OrderHelper;
use WooCommerce\PayPalCommerce\ApiClient\Helper\OrderTransient;
use WooCommerce\PayPalCommerce\ApiClient\Helper\PurchaseUnitSanitizer;
use WooCommerce\PayPalCommerce\ApiClient\Repository\ApplicationContextRepository; use WooCommerce\PayPalCommerce\ApiClient\Repository\ApplicationContextRepository;
use WooCommerce\PayPalCommerce\ApiClient\Repository\CustomerRepository; use WooCommerce\PayPalCommerce\ApiClient\Repository\CustomerRepository;
use WooCommerce\PayPalCommerce\ApiClient\Repository\OrderRepository; use WooCommerce\PayPalCommerce\ApiClient\Repository\OrderRepository;
@ -289,6 +296,14 @@ return array(
$container->get( 'api.factory.fraud-processor-response' ) $container->get( 'api.factory.fraud-processor-response' )
); );
}, },
'api.factory.refund' => static function ( ContainerInterface $container ): RefundFactory {
$amount_factory = $container->get( 'api.factory.amount' );
return new RefundFactory(
$amount_factory,
$container->get( 'api.factory.seller-payable-breakdown' ),
$container->get( 'api.factory.refund_payer' )
);
},
'api.factory.purchase-unit' => static function ( ContainerInterface $container ): PurchaseUnitFactory { 'api.factory.purchase-unit' => static function ( ContainerInterface $container ): PurchaseUnitFactory {
$amount_factory = $container->get( 'api.factory.amount' ); $amount_factory = $container->get( 'api.factory.amount' );
@ -299,6 +314,7 @@ return array(
$payments_factory = $container->get( 'api.factory.payments' ); $payments_factory = $container->get( 'api.factory.payments' );
$prefix = $container->get( 'api.prefix' ); $prefix = $container->get( 'api.prefix' );
$soft_descriptor = $container->get( 'wcgateway.soft-descriptor' ); $soft_descriptor = $container->get( 'wcgateway.soft-descriptor' );
$sanitizer = $container->get( 'api.helper.purchase-unit-sanitizer' );
return new PurchaseUnitFactory( return new PurchaseUnitFactory(
$amount_factory, $amount_factory,
@ -308,7 +324,8 @@ return array(
$shipping_factory, $shipping_factory,
$payments_factory, $payments_factory,
$prefix, $prefix,
$soft_descriptor $soft_descriptor,
$sanitizer
); );
}, },
'api.factory.patch-collection-factory' => static function ( ContainerInterface $container ): PatchCollectionFactory { 'api.factory.patch-collection-factory' => static function ( ContainerInterface $container ): PatchCollectionFactory {
@ -351,6 +368,9 @@ return array(
$address_factory = $container->get( 'api.factory.address' ); $address_factory = $container->get( 'api.factory.address' );
return new PayerFactory( $address_factory ); return new PayerFactory( $address_factory );
}, },
'api.factory.refund_payer' => static function ( ContainerInterface $container ): RefundPayerFactory {
return new RefundPayerFactory();
},
'api.factory.address' => static function ( ContainerInterface $container ): AddressFactory { 'api.factory.address' => static function ( ContainerInterface $container ): AddressFactory {
return new AddressFactory(); return new AddressFactory();
}, },
@ -374,7 +394,8 @@ return array(
'api.factory.payments' => static function ( ContainerInterface $container ): PaymentsFactory { 'api.factory.payments' => static function ( ContainerInterface $container ): PaymentsFactory {
$authorizations_factory = $container->get( 'api.factory.authorization' ); $authorizations_factory = $container->get( 'api.factory.authorization' );
$capture_factory = $container->get( 'api.factory.capture' ); $capture_factory = $container->get( 'api.factory.capture' );
return new PaymentsFactory( $authorizations_factory, $capture_factory ); $refund_factory = $container->get( 'api.factory.refund' );
return new PaymentsFactory( $authorizations_factory, $capture_factory, $refund_factory );
}, },
'api.factory.authorization' => static function ( ContainerInterface $container ): AuthorizationFactory { 'api.factory.authorization' => static function ( ContainerInterface $container ): AuthorizationFactory {
return new AuthorizationFactory(); return new AuthorizationFactory();
@ -395,6 +416,12 @@ return array(
$container->get( 'api.factory.platform-fee' ) $container->get( 'api.factory.platform-fee' )
); );
}, },
'api.factory.seller-payable-breakdown' => static function ( ContainerInterface $container ): SellerPayableBreakdownFactory {
return new SellerPayableBreakdownFactory(
$container->get( 'api.factory.money' ),
$container->get( 'api.factory.platform-fee' )
);
},
'api.factory.fraud-processor-response' => static function ( ContainerInterface $container ): FraudProcessorResponseFactory { 'api.factory.fraud-processor-response' => static function ( ContainerInterface $container ): FraudProcessorResponseFactory {
return new FraudProcessorResponseFactory(); return new FraudProcessorResponseFactory();
}, },
@ -814,4 +841,19 @@ return array(
'api.order-helper' => static function( ContainerInterface $container ): OrderHelper { 'api.order-helper' => static function( ContainerInterface $container ): OrderHelper {
return new OrderHelper(); return new OrderHelper();
}, },
'api.helper.order-transient' => static function( ContainerInterface $container ): OrderTransient {
$cache = new Cache( 'ppcp-paypal-bearer' );
$purchase_unit_sanitizer = $container->get( 'api.helper.purchase-unit-sanitizer' );
return new OrderTransient( $cache, $purchase_unit_sanitizer );
},
'api.helper.purchase-unit-sanitizer' => SingletonDecorator::make(
static function( ContainerInterface $container ): PurchaseUnitSanitizer {
$settings = $container->get( 'wcgateway.settings' );
assert( $settings instanceof Settings );
$behavior = $settings->has( 'subtotal_mismatch_behavior' ) ? $settings->get( 'subtotal_mismatch_behavior' ) : null;
$line_name = $settings->has( 'subtotal_mismatch_line_name' ) ? $settings->get( 'subtotal_mismatch_line_name' ) : null;
return new PurchaseUnitSanitizer( $behavior, $line_name );
}
),
); );

View file

@ -9,10 +9,13 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\ApiClient; namespace WooCommerce\PayPalCommerce\ApiClient;
use WC_Order;
use WooCommerce\PayPalCommerce\ApiClient\Helper\OrderTransient;
use WooCommerce\PayPalCommerce\Vendor\Dhii\Container\ServiceProvider; use WooCommerce\PayPalCommerce\Vendor\Dhii\Container\ServiceProvider;
use WooCommerce\PayPalCommerce\Vendor\Dhii\Modular\Module\ModuleInterface; use WooCommerce\PayPalCommerce\Vendor\Dhii\Modular\Module\ModuleInterface;
use WooCommerce\PayPalCommerce\Vendor\Interop\Container\ServiceProviderInterface; use WooCommerce\PayPalCommerce\Vendor\Interop\Container\ServiceProviderInterface;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Order;
/** /**
* Class ApiModule * Class ApiModule
@ -40,7 +43,6 @@ class ApiModule implements ModuleInterface {
WC()->session->set( 'ppcp_fees', $fees ); WC()->session->set( 'ppcp_fees', $fees );
} }
); );
add_filter( add_filter(
'ppcp_create_order_request_body_data', 'ppcp_create_order_request_body_data',
function( array $data ) use ( $c ) { function( array $data ) use ( $c ) {
@ -55,6 +57,30 @@ class ApiModule implements ModuleInterface {
return $data; return $data;
} }
); );
add_action(
'woocommerce_paypal_payments_paypal_order_created',
function ( Order $order ) use ( $c ) {
$transient = $c->has( 'api.helper.order-transient' ) ? $c->get( 'api.helper.order-transient' ) : null;
if ( $transient instanceof OrderTransient ) {
$transient->on_order_created( $order );
}
},
10,
1
);
add_action(
'woocommerce_paypal_payments_woocommerce_order_created',
function ( WC_Order $wc_order, Order $order ) use ( $c ) {
$transient = $c->has( 'api.helper.order-transient' ) ? $c->get( 'api.helper.order-transient' ) : null;
if ( $transient instanceof OrderTransient ) {
$transient->on_woocommerce_order_created( $wc_order, $order );
}
},
10,
2
);
} }
/** /**

View file

@ -54,8 +54,6 @@ class BillingPlans {
private $plan_factory; private $plan_factory;
/** /**
* The logger.
*
* The logger. * The logger.
* *
* @var LoggerInterface * @var LoggerInterface

View file

@ -281,6 +281,9 @@ class OrderEndpoint {
throw $error; throw $error;
} }
$order = $this->order_factory->from_paypal_response( $json ); $order = $this->order_factory->from_paypal_response( $json );
do_action( 'woocommerce_paypal_payments_paypal_order_created', $order );
return $order; return $order;
} }

View file

@ -112,7 +112,7 @@ class PayUponInvoiceOrderEndpoint {
'processing_instruction' => 'ORDER_COMPLETE_ON_PAYMENT_APPROVAL', 'processing_instruction' => 'ORDER_COMPLETE_ON_PAYMENT_APPROVAL',
'purchase_units' => array_map( 'purchase_units' => array_map(
static function ( PurchaseUnit $item ): array { static function ( PurchaseUnit $item ): array {
return $item->to_array( false ); return $item->to_array( true, false );
}, },
$items $items
), ),
@ -166,8 +166,11 @@ class PayUponInvoiceOrderEndpoint {
throw new PayPalApiException( $json, $status_code ); throw new PayPalApiException( $json, $status_code );
} }
$order = $this->order_factory->from_paypal_response( $json );
return $this->order_factory->from_paypal_response( $json ); do_action( 'woocommerce_paypal_payments_paypal_order_created', $order );
return $order;
} }
/** /**

View file

@ -19,6 +19,7 @@ use WooCommerce\PayPalCommerce\ApiClient\Factory\PaymentTokenActionLinksFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\PaymentTokenFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\PaymentTokenFactory;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use WooCommerce\PayPalCommerce\ApiClient\Repository\CustomerRepository; use WooCommerce\PayPalCommerce\ApiClient\Repository\CustomerRepository;
use WP_Error;
/** /**
* Class PaymentTokenEndpoint * Class PaymentTokenEndpoint
@ -97,7 +98,7 @@ class PaymentTokenEndpoint {
} }
/** /**
* Returns the payment tokens for a user. * Returns the payment tokens for the given user id.
* *
* @param int $id The user id. * @param int $id The user id.
* *
@ -118,7 +119,67 @@ class PaymentTokenEndpoint {
$response = $this->request( $url, $args ); $response = $this->request( $url, $args );
if ( is_wp_error( $response ) ) { if ( is_wp_error( $response ) ) {
$error = new RuntimeException( $error = new RuntimeException(
__( 'Could not fetch payment token.', 'woocommerce-paypal-payments' ) __( 'Could not fetch payment token for customer id.', 'woocommerce-paypal-payments' )
);
$this->logger->log(
'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 ( 200 !== $status_code ) {
$error = new PayPalApiException(
$json,
$status_code
);
$this->logger->log(
'warning',
$error->getMessage(),
array(
'args' => $args,
'response' => $response,
)
);
throw $error;
}
$tokens = array();
foreach ( $json->payment_tokens as $token_value ) {
$tokens[] = $this->factory->from_paypal_response( $token_value );
}
return $tokens;
}
/**
* Returns the payment tokens for the given guest customer id.
*
* @param string $customer_id The guest customer id.
*
* @return PaymentToken[]
* @throws RuntimeException If the request fails.
*/
public function for_guest( string $customer_id ): array {
$bearer = $this->bearer->bearer();
$url = trailingslashit( $this->host ) . 'v2/vault/payment-tokens/?customer_id=' . $customer_id;
$args = array(
'method' => 'GET',
'headers' => array(
'Authorization' => 'Bearer ' . $bearer->token(),
'Content-Type' => 'application/json',
),
);
$response = $this->request( $url, $args );
if ( $response instanceof WP_Error ) {
$error = new RuntimeException(
__( 'Could not fetch payment token for guest customer id.', 'woocommerce-paypal-payments' )
); );
$this->logger->log( $this->logger->log(
'warning', 'warning',

View file

@ -13,7 +13,7 @@ use WooCommerce\PayPalCommerce\ApiClient\Authentication\Bearer;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Authorization; use WooCommerce\PayPalCommerce\ApiClient\Entity\Authorization;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Capture; use WooCommerce\PayPalCommerce\ApiClient\Entity\Capture;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Money; use WooCommerce\PayPalCommerce\ApiClient\Entity\Money;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Refund; use WooCommerce\PayPalCommerce\ApiClient\Entity\RefundCapture;
use WooCommerce\PayPalCommerce\ApiClient\Exception\PayPalApiException; use WooCommerce\PayPalCommerce\ApiClient\Exception\PayPalApiException;
use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException; use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException;
use WooCommerce\PayPalCommerce\ApiClient\Factory\AuthorizationFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\AuthorizationFactory;
@ -196,13 +196,13 @@ class PaymentsEndpoint {
/** /**
* Refunds a payment. * Refunds a payment.
* *
* @param Refund $refund The refund to be processed. * @param RefundCapture $refund The refund to be processed.
* *
* @return string Refund ID. * @return string Refund ID.
* @throws RuntimeException If the request fails. * @throws RuntimeException If the request fails.
* @throws PayPalApiException If the request fails. * @throws PayPalApiException If the request fails.
*/ */
public function refund( Refund $refund ) : string { public function refund( RefundCapture $refund ) : string {
$bearer = $this->bearer->bearer(); $bearer = $this->bearer->bearer();
$url = trailingslashit( $this->host ) . 'v2/payments/captures/' . $refund->for_capture()->id() . '/refund'; $url = trailingslashit( $this->host ) . 'v2/payments/captures/' . $refund->for_capture()->id() . '/refund';
$args = array( $args = array(

View file

@ -203,7 +203,7 @@ class Item {
* *
* @return array * @return array
*/ */
public function to_array() { public function to_array(): array {
$item = array( $item = array(
'name' => $this->name(), 'name' => $this->name(),
'unit_amount' => $this->unit_amount()->to_array(), 'unit_amount' => $this->unit_amount()->to_array(),

View file

@ -28,13 +28,21 @@ class Payments {
*/ */
private $captures; private $captures;
/**
* The Refunds.
*
* @var Refund[]
*/
private $refunds;
/** /**
* Payments constructor. * Payments constructor.
* *
* @param array $authorizations The Authorizations. * @param array $authorizations The Authorizations.
* @param array $captures The Captures. * @param array $captures The Captures.
* @param array $refunds The Refunds.
*/ */
public function __construct( array $authorizations, array $captures ) { public function __construct( array $authorizations, array $captures, array $refunds = array() ) {
foreach ( $authorizations as $key => $authorization ) { foreach ( $authorizations as $key => $authorization ) {
if ( is_a( $authorization, Authorization::class ) ) { if ( is_a( $authorization, Authorization::class ) ) {
continue; continue;
@ -47,8 +55,15 @@ class Payments {
} }
unset( $captures[ $key ] ); unset( $captures[ $key ] );
} }
foreach ( $refunds as $key => $refund ) {
if ( is_a( $refund, Refund::class ) ) {
continue;
}
unset( $refunds[ $key ] );
}
$this->authorizations = $authorizations; $this->authorizations = $authorizations;
$this->captures = $captures; $this->captures = $captures;
$this->refunds = $refunds;
} }
/** /**
@ -70,6 +85,12 @@ class Payments {
}, },
$this->captures() $this->captures()
), ),
'refunds' => array_map(
static function ( Refund $refund ): array {
return $refund->to_array();
},
$this->refunds()
),
); );
} }
@ -90,4 +111,13 @@ class Payments {
public function captures(): array { public function captures(): array {
return $this->captures; return $this->captures;
} }
/**
* Returns the Refunds.
*
* @return Refund[]
**/
public function refunds(): array {
return $this->refunds;
}
} }

View file

@ -9,6 +9,8 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\ApiClient\Entity; namespace WooCommerce\PayPalCommerce\ApiClient\Entity;
use WooCommerce\PayPalCommerce\ApiClient\Helper\PurchaseUnitSanitizer;
/** /**
* Class PurchaseUnit * Class PurchaseUnit
*/ */
@ -91,6 +93,13 @@ class PurchaseUnit {
*/ */
private $contains_physical_goods = false; private $contains_physical_goods = false;
/**
* The sanitizer for this purchase unit output.
*
* @var PurchaseUnitSanitizer|null
*/
private $sanitizer;
/** /**
* PurchaseUnit constructor. * PurchaseUnit constructor.
* *
@ -220,6 +229,16 @@ class PurchaseUnit {
$this->custom_id = $custom_id; $this->custom_id = $custom_id;
} }
/**
* Sets the sanitizer for this purchase unit output.
*
* @param PurchaseUnitSanitizer|null $sanitizer The sanitizer.
* @return void
*/
public function set_sanitizer( ?PurchaseUnitSanitizer $sanitizer ) {
$this->sanitizer = $sanitizer;
}
/** /**
* Returns the invoice id. * Returns the invoice id.
* *
@ -277,11 +296,12 @@ class PurchaseUnit {
/** /**
* Returns the object as array. * Returns the object as array.
* *
* @param bool $ditch_items_when_mismatch Whether ditch items when mismatch or not. * @param bool $sanitize_output Whether output should be sanitized for PayPal consumption.
* @param bool $allow_ditch_items Whether to allow items to be ditched.
* *
* @return array * @return array
*/ */
public function to_array( bool $ditch_items_when_mismatch = true ): array { public function to_array( bool $sanitize_output = true, bool $allow_ditch_items = true ): array {
$purchase_unit = array( $purchase_unit = array(
'reference_id' => $this->reference_id(), 'reference_id' => $this->reference_id(),
'amount' => $this->amount()->to_array(), 'amount' => $this->amount()->to_array(),
@ -294,17 +314,6 @@ class PurchaseUnit {
), ),
); );
$ditch = $ditch_items_when_mismatch && $this->ditch_items_when_mismatch( $this->amount(), ...$this->items() );
/**
* The filter can be used to control when the items and totals breakdown are removed from PayPal order info.
*/
$ditch = apply_filters( 'ppcp_ditch_items_breakdown', $ditch, $this );
if ( $ditch ) {
unset( $purchase_unit['items'] );
unset( $purchase_unit['amount']['breakdown'] );
}
if ( $this->payee() ) { if ( $this->payee() ) {
$purchase_unit['payee'] = $this->payee()->to_array(); $purchase_unit['payee'] = $this->payee()->to_array();
} }
@ -325,101 +334,45 @@ class PurchaseUnit {
if ( $this->soft_descriptor() ) { if ( $this->soft_descriptor() ) {
$purchase_unit['soft_descriptor'] = $this->soft_descriptor(); $purchase_unit['soft_descriptor'] = $this->soft_descriptor();
} }
return $purchase_unit;
$has_ditched_items_breakdown = false;
if ( $sanitize_output && isset( $this->sanitizer ) ) {
$purchase_unit = ( $this->sanitizer->sanitize( $purchase_unit, $allow_ditch_items ) );
$has_ditched_items_breakdown = $this->sanitizer->has_ditched_items_breakdown();
}
return $this->apply_ditch_items_mismatch_filter(
$has_ditched_items_breakdown,
$purchase_unit
);
} }
/** /**
* All money values send to PayPal can only have 2 decimal points. WooCommerce internally does * Applies the ppcp_ditch_items_breakdown filter.
* not have this restriction. Therefore the totals of the cart in WooCommerce and the totals * If true purchase_unit items and breakdown are ditched from PayPal.
* of the rounded money values of the items, we send to PayPal, can differ. In those cases,
* we can not send the line items.
* *
* @param Amount $amount The amount. * @param bool $ditched_items_breakdown If the breakdown and items were already ditched.
* @param Item ...$items The items. * @param array $purchase_unit The purchase_unit array.
* @return bool * @return array
*/ */
private function ditch_items_when_mismatch( Amount $amount, Item ...$items ): bool { public function apply_ditch_items_mismatch_filter( bool $ditched_items_breakdown, array $purchase_unit ): array {
$breakdown = $amount->breakdown(); /**
if ( ! $breakdown ) { * The filter can be used to control when the items and totals breakdown are removed from PayPal order info.
return false; */
} $ditch = apply_filters( 'ppcp_ditch_items_breakdown', $ditched_items_breakdown, $this );
$item_total = $breakdown->item_total(); if ( $ditch ) {
if ( $item_total ) { unset( $purchase_unit['items'] );
$remaining_item_total = array_reduce( unset( $purchase_unit['amount']['breakdown'] );
$items,
function ( float $total, Item $item ): float {
return $total - (float) $item->unit_amount()->value_str() * (float) $item->quantity();
},
(float) $item_total->value_str()
);
$remaining_item_total = round( $remaining_item_total, 2 ); if ( isset( $this->sanitizer ) && ( $ditch !== $ditched_items_breakdown ) ) {
$this->sanitizer->set_last_message(
if ( 0.0 !== $remaining_item_total ) { __( 'Ditch items breakdown filter. Items and breakdown ditched.', 'woocommerce-paypal-payments' )
return true; );
} }
} }
$tax_total = $breakdown->tax_total(); return $purchase_unit;
$items_with_tax = array_filter(
$this->items,
function ( Item $item ): bool {
return null !== $item->tax();
}
);
if ( $tax_total && ! empty( $items_with_tax ) ) {
$remaining_tax_total = array_reduce(
$items,
function ( float $total, Item $item ): float {
$tax = $item->tax();
if ( $tax ) {
$total -= (float) $tax->value_str() * (float) $item->quantity();
}
return $total;
},
(float) $tax_total->value_str()
);
$remaining_tax_total = round( $remaining_tax_total, 2 );
if ( 0.0 !== $remaining_tax_total ) {
return true;
}
}
$shipping = $breakdown->shipping();
$discount = $breakdown->discount();
$shipping_discount = $breakdown->shipping_discount();
$handling = $breakdown->handling();
$insurance = $breakdown->insurance();
$amount_total = 0.0;
if ( $shipping ) {
$amount_total += (float) $shipping->value_str();
}
if ( $item_total ) {
$amount_total += (float) $item_total->value_str();
}
if ( $discount ) {
$amount_total -= (float) $discount->value_str();
}
if ( $tax_total ) {
$amount_total += (float) $tax_total->value_str();
}
if ( $shipping_discount ) {
$amount_total -= (float) $shipping_discount->value_str();
}
if ( $handling ) {
$amount_total += (float) $handling->value_str();
}
if ( $insurance ) {
$amount_total += (float) $insurance->value_str();
}
$amount_str = $amount->value_str();
$amount_total_str = ( new Money( $amount_total, $amount->currency_code() ) )->value_str();
$needs_to_ditch = $amount_str !== $amount_total_str;
return $needs_to_ditch;
} }
} }

View file

@ -1,6 +1,8 @@
<?php <?php
/** /**
* The refund object. * The refund entity.
*
* @link https://developer.paypal.com/docs/api/orders/v2/#definition-refund
* *
* @package WooCommerce\PayPalCommerce\ApiClient\Entity * @package WooCommerce\PayPalCommerce\ApiClient\Entity
*/ */
@ -15,11 +17,32 @@ namespace WooCommerce\PayPalCommerce\ApiClient\Entity;
class Refund { class Refund {
/** /**
* The Capture. * The ID.
* *
* @var Capture * @var string
*/ */
private $capture; private $id;
/**
* The status.
*
* @var RefundStatus
*/
private $status;
/**
* The amount.
*
* @var Amount
*/
private $amount;
/**
* The detailed breakdown of the refund activity (fees, ...).
*
* @var SellerPayableBreakdown|null
*/
private $seller_payable_breakdown;
/** /**
* The invoice id. * The invoice id.
@ -29,50 +52,97 @@ class Refund {
private $invoice_id; private $invoice_id;
/** /**
* The note to the payer. * The custom id.
*
* @var string
*/
private $custom_id;
/**
* The acquirer reference number.
*
* @var string
*/
private $acquirer_reference_number;
/**
* The acquirer reference number.
* *
* @var string * @var string
*/ */
private $note_to_payer; private $note_to_payer;
/** /**
* The Amount. * The payer of the refund.
* *
* @var Amount|null * @var ?RefundPayer
*/ */
private $amount; private $payer;
/** /**
* Refund constructor. * Refund constructor.
* *
* @param Capture $capture The capture where the refund is supposed to be applied at. * @param string $id The ID.
* @param string $invoice_id The invoice id. * @param RefundStatus $status The status.
* @param string $note_to_payer The note to the payer. * @param Amount $amount The amount.
* @param Amount|null $amount The Amount. * @param string $invoice_id The invoice id.
* @param string $custom_id The custom id.
* @param SellerPayableBreakdown|null $seller_payable_breakdown The detailed breakdown of the refund activity (fees, ...).
* @param string $acquirer_reference_number The acquirer reference number.
* @param string $note_to_payer The note to payer.
* @param RefundPayer|null $payer The payer.
*/ */
public function __construct( public function __construct(
Capture $capture, string $id,
RefundStatus $status,
Amount $amount,
string $invoice_id, string $invoice_id,
string $note_to_payer = '', string $custom_id,
Amount $amount = null ?SellerPayableBreakdown $seller_payable_breakdown,
string $acquirer_reference_number,
string $note_to_payer,
?RefundPayer $payer
) { ) {
$this->capture = $capture; $this->id = $id;
$this->invoice_id = $invoice_id; $this->status = $status;
$this->note_to_payer = $note_to_payer; $this->amount = $amount;
$this->amount = $amount; $this->invoice_id = $invoice_id;
$this->custom_id = $custom_id;
$this->seller_payable_breakdown = $seller_payable_breakdown;
$this->acquirer_reference_number = $acquirer_reference_number;
$this->note_to_payer = $note_to_payer;
$this->payer = $payer;
} }
/** /**
* Returns the capture for the refund. * Returns the ID.
* *
* @return Capture * @return string
*/ */
public function for_capture() : Capture { public function id() : string {
return $this->capture; return $this->id;
} }
/** /**
* Return the invoice id. * Returns the status.
*
* @return RefundStatus
*/
public function status() : RefundStatus {
return $this->status;
}
/**
* Returns the amount.
*
* @return Amount
*/
public function amount() : Amount {
return $this->amount;
}
/**
* Returns the invoice id.
* *
* @return string * @return string
*/ */
@ -81,7 +151,34 @@ class Refund {
} }
/** /**
* Returns the note to the payer. * Returns the custom id.
*
* @return string
*/
public function custom_id() : string {
return $this->custom_id;
}
/**
* Returns the detailed breakdown of the refund activity (fees, ...).
*
* @return SellerPayableBreakdown|null
*/
public function seller_payable_breakdown() : ?SellerPayableBreakdown {
return $this->seller_payable_breakdown;
}
/**
* The acquirer reference number.
*
* @return string
*/
public function acquirer_reference_number() : string {
return $this->acquirer_reference_number;
}
/**
* The note to payer.
* *
* @return string * @return string
*/ */
@ -90,28 +187,38 @@ class Refund {
} }
/** /**
* Returns the Amount. * Returns the refund payer.
* *
* @return Amount|null * @return RefundPayer|null
*/ */
public function amount() { public function payer() : ?RefundPayer {
return $this->amount; return $this->payer;
} }
/** /**
* Returns the object as array. * Returns the entity as array.
* *
* @return array * @return array
*/ */
public function to_array() : array { public function to_array() : array {
$data = array( $data = array(
'invoice_id' => $this->invoice_id(), 'id' => $this->id(),
'status' => $this->status()->name(),
'amount' => $this->amount()->to_array(),
'invoice_id' => $this->invoice_id(),
'custom_id' => $this->custom_id(),
'acquirer_reference_number' => $this->acquirer_reference_number(),
'note_to_payer' => (array) $this->note_to_payer(),
); );
if ( $this->note_to_payer() ) { $details = $this->status()->details();
$data['note_to_payer'] = $this->note_to_payer(); if ( $details ) {
$data['status_details'] = array( 'reason' => $details->reason() );
} }
if ( $this->amount() ) { if ( $this->seller_payable_breakdown ) {
$data['amount'] = $this->amount()->to_array(); $data['seller_payable_breakdown'] = $this->seller_payable_breakdown->to_array();
}
if ( $this->payer ) {
$data['payer'] = $this->payer->to_array();
} }
return $data; return $data;
} }

View file

@ -0,0 +1,118 @@
<?php
/**
* The refund capture object.
*
* @package WooCommerce\PayPalCommerce\ApiClient\Entity
*/
declare( strict_types=1 );
namespace WooCommerce\PayPalCommerce\ApiClient\Entity;
/**
* Class RefundCapture
*/
class RefundCapture {
/**
* The Capture.
*
* @var Capture
*/
private $capture;
/**
* The invoice id.
*
* @var string
*/
private $invoice_id;
/**
* The note to the payer.
*
* @var string
*/
private $note_to_payer;
/**
* The Amount.
*
* @var Amount|null
*/
private $amount;
/**
* Refund constructor.
*
* @param Capture $capture The capture where the refund is supposed to be applied at.
* @param string $invoice_id The invoice id.
* @param string $note_to_payer The note to the payer.
* @param Amount|null $amount The Amount.
*/
public function __construct(
Capture $capture,
string $invoice_id,
string $note_to_payer = '',
Amount $amount = null
) {
$this->capture = $capture;
$this->invoice_id = $invoice_id;
$this->note_to_payer = $note_to_payer;
$this->amount = $amount;
}
/**
* Returns the capture for the refund.
*
* @return Capture
*/
public function for_capture() : Capture {
return $this->capture;
}
/**
* Return the invoice id.
*
* @return string
*/
public function invoice_id() : string {
return $this->invoice_id;
}
/**
* Returns the note to the payer.
*
* @return string
*/
public function note_to_payer() : string {
return $this->note_to_payer;
}
/**
* Returns the Amount.
*
* @return Amount|null
*/
public function amount() {
return $this->amount;
}
/**
* Returns the object as array.
*
* @return array
*/
public function to_array() : array {
$data = array(
'invoice_id' => $this->invoice_id(),
);
if ( $this->note_to_payer() ) {
$data['note_to_payer'] = $this->note_to_payer();
}
if ( $this->amount ) {
$data['amount'] = $this->amount->to_array();
}
return $data;
}
}

View file

@ -0,0 +1,79 @@
<?php
/**
* The refund payer object.
*
* @package WooCommerce\PayPalCommerce\ApiClient\Entity
*/
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\ApiClient\Entity;
/**
* Class RefundPayer
* The customer who sends the money.
*/
class RefundPayer {
/**
* The email address.
*
* @var string
*/
private $email_address;
/**
* The merchant id.
*
* @var string
*/
private $merchant_id;
/**
* RefundPayer constructor.
*
* @param string $email_address The email.
* @param string $merchant_id The merchant id.
*/
public function __construct(
string $email_address,
string $merchant_id
) {
$this->email_address = $email_address;
$this->merchant_id = $merchant_id;
}
/**
* Returns the email address.
*
* @return string
*/
public function email_address(): string {
return $this->email_address;
}
/**
* Returns the merchant id.
*
* @return string
*/
public function merchant_id(): string {
return $this->merchant_id;
}
/**
* Returns the object as array.
*
* @return array
*/
public function to_array() {
$payer = array(
'email_address' => $this->email_address(),
);
if ( $this->merchant_id ) {
$payer['merchant_id'] = $this->merchant_id();
}
return $payer;
}
}

View file

@ -0,0 +1,77 @@
<?php
/**
* The RefundStatus object.
*
* @see https://developer.paypal.com/docs/api/orders/v2/#definition-refund_status
*
* @package WooCommerce\PayPalCommerce\ApiClient\Entity
*/
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\ApiClient\Entity;
/**
* Class RefundStatus
*/
class RefundStatus {
const COMPLETED = 'COMPLETED';
const CANCELLED = 'CANCELLED';
const FAILED = 'FAILED';
const PENDING = 'PENDING';
/**
* The status.
*
* @var string
*/
private $status;
/**
* The details.
*
* @var RefundStatusDetails|null
*/
private $details;
/**
* RefundStatus constructor.
*
* @param string $status The status.
* @param RefundStatusDetails|null $details The details.
*/
public function __construct( string $status, ?RefundStatusDetails $details = null ) {
$this->status = $status;
$this->details = $details;
}
/**
* Compares the current status with a given one.
*
* @param string $status The status to compare with.
*
* @return bool
*/
public function is( string $status ): bool {
return $this->status === $status;
}
/**
* Returns the status.
*
* @return string
*/
public function name(): string {
return $this->status;
}
/**
* Returns the details.
*
* @return RefundStatusDetails|null
*/
public function details(): ?RefundStatusDetails {
return $this->details;
}
}

View file

@ -0,0 +1,71 @@
<?php
/**
* The RefundStatusDetails object.
*
* @see https://developer.paypal.com/docs/api/payments/v2/#definition-refund_status_details
*
* @package WooCommerce\PayPalCommerce\ApiClient\Entity
*/
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\ApiClient\Entity;
/**
* Class RefundStatusDetails
*/
class RefundStatusDetails {
const ECHECK = 'ECHECK';
/**
* The reason.
*
* @var string
*/
private $reason;
/**
* RefundStatusDetails constructor.
*
* @param string $reason The reason explaining refund status.
*/
public function __construct( string $reason ) {
$this->reason = $reason;
}
/**
* Compares the current reason with a given one.
*
* @param string $reason The reason to compare with.
*
* @return bool
*/
public function is( string $reason ): bool {
return $this->reason === $reason;
}
/**
* Returns the reason explaining refund status.
* One of RefundStatusDetails constants.
*
* @return string
*/
public function reason(): string {
return $this->reason;
}
/**
* Returns the human-readable reason text explaining refund status.
*
* @return string
*/
public function text(): string {
switch ( $this->reason ) {
case self::ECHECK:
return __( 'The payer paid by an eCheck that has not yet cleared.', 'woocommerce-paypal-payments' );
default:
return $this->reason;
}
}
}

View file

@ -0,0 +1,202 @@
<?php
/**
* The info about fees and amount that will be paid by the seller.
*
* @package WooCommerce\PayPalCommerce\ApiClient\Entity
*/
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\ApiClient\Entity;
/**
* Class SellerPayableBreakdown
*/
class SellerPayableBreakdown {
/**
* The amount for this refunded payment in the currency of the transaction.
*
* @var Money|null
*/
private $gross_amount;
/**
* The applicable fee for this refunded payment in the currency of the transaction.
*
* @var Money|null
*/
private $paypal_fee;
/**
* The applicable fee for this captured payment in the receivable currency.
*
* Present only in cases the fee is charged in the receivable currency.
*
* @var Money|null
*/
private $paypal_fee_in_receivable_currency;
/**
* The net amount that the payee receives for this refunded payment in their PayPal account.
*
* Computed as gross_amount minus the paypal_fee minus the platform_fees.
*
* @var Money|null
*/
private $net_amount;
/**
* The net amount for this refunded payment in the receivable currency.
*
* @var Money|null
*/
private $net_amount_in_receivable_currency;
/**
* The total amount for this refund.
*
* @var Money|null
*/
private $total_refunded_amount;
/**
* An array of platform or partner fees, commissions, or brokerage fees that associated with the captured payment.
*
* @var PlatformFee[]
*/
private $platform_fees;
/**
* SellerPayableBreakdown constructor.
*
* @param Money|null $gross_amount The amount for this refunded payment in the currency of the transaction.
* @param Money|null $paypal_fee The applicable fee for this refunded payment in the currency of the transaction.
* @param Money|null $paypal_fee_in_receivable_currency The applicable fee for this refunded payment in the receivable currency.
* @param Money|null $net_amount The net amount that the payee receives for this refunded payment in their PayPal account.
* @param Money|null $net_amount_in_receivable_currency The net amount for this refunded payment in the receivable currency.
* @param Money|null $total_refunded_amount The total amount for this refund.
* @param PlatformFee[] $platform_fees An array of platform or partner fees, commissions, or brokerage fees that associated with the captured payment.
*/
public function __construct(
?Money $gross_amount,
?Money $paypal_fee,
?Money $paypal_fee_in_receivable_currency,
?Money $net_amount,
?Money $net_amount_in_receivable_currency,
?Money $total_refunded_amount,
array $platform_fees
) {
$this->gross_amount = $gross_amount;
$this->paypal_fee = $paypal_fee;
$this->paypal_fee_in_receivable_currency = $paypal_fee_in_receivable_currency;
$this->net_amount = $net_amount;
$this->net_amount_in_receivable_currency = $net_amount_in_receivable_currency;
$this->total_refunded_amount = $total_refunded_amount;
$this->platform_fees = $platform_fees;
}
/**
* The amount for this refunded payment in the currency of the transaction.
*
* @return Money|null
*/
public function gross_amount(): ?Money {
return $this->gross_amount;
}
/**
* The applicable fee for this refunded payment in the currency of the transaction.
*
* @return Money|null
*/
public function paypal_fee(): ?Money {
return $this->paypal_fee;
}
/**
* The applicable fee for this refunded payment in the receivable currency.
*
* Present only in cases the fee is charged in the receivable currency.
*
* @return Money|null
*/
public function paypal_fee_in_receivable_currency(): ?Money {
return $this->paypal_fee_in_receivable_currency;
}
/**
* The net amount that the payee receives for this refunded payment in their PayPal account.
*
* Computed as gross_amount minus the paypal_fee minus the platform_fees.
*
* @return Money|null
*/
public function net_amount(): ?Money {
return $this->net_amount;
}
/**
* The net amount for this refunded payment in the receivable currency.
*
* @return Money|null
*/
public function net_amount_in_receivable_currency(): ?Money {
return $this->net_amount_in_receivable_currency;
}
/**
* The total amount for this refund.
*
* @return Money|null
*/
public function total_refunded_amount(): ?Money {
return $this->total_refunded_amount;
}
/**
* An array of platform or partner fees, commissions, or brokerage fees that associated with the refunded payment.
*
* @return PlatformFee[]
*/
public function platform_fees(): array {
return $this->platform_fees;
}
/**
* Returns the object as array.
*
* @return array
*/
public function to_array(): array {
$data = array();
if ( $this->gross_amount ) {
$data['gross_amount'] = $this->gross_amount->to_array();
}
if ( $this->paypal_fee ) {
$data['paypal_fee'] = $this->paypal_fee->to_array();
}
if ( $this->paypal_fee_in_receivable_currency ) {
$data['paypal_fee_in_receivable_currency'] = $this->paypal_fee_in_receivable_currency->to_array();
}
if ( $this->net_amount ) {
$data['net_amount'] = $this->net_amount->to_array();
}
if ( $this->net_amount_in_receivable_currency ) {
$data['net_amount_in_receivable_currency'] = $this->net_amount_in_receivable_currency->to_array();
}
if ( $this->total_refunded_amount ) {
$data['total_refunded_amount'] = $this->total_refunded_amount->to_array();
}
if ( $this->platform_fees ) {
$data['platform_fees'] = array_map(
function ( PlatformFee $fee ) {
return $fee->to_array();
},
$this->platform_fees
);
}
return $data;
}
}

View file

@ -11,6 +11,7 @@ namespace WooCommerce\PayPalCommerce\ApiClient\Factory;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Authorization; use WooCommerce\PayPalCommerce\ApiClient\Entity\Authorization;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Capture; use WooCommerce\PayPalCommerce\ApiClient\Entity\Capture;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Refund;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Payments; use WooCommerce\PayPalCommerce\ApiClient\Entity\Payments;
/** /**
@ -32,19 +33,29 @@ class PaymentsFactory {
*/ */
private $capture_factory; private $capture_factory;
/**
* The Refund factory.
*
* @var RefundFactory
*/
private $refund_factory;
/** /**
* PaymentsFactory constructor. * PaymentsFactory constructor.
* *
* @param AuthorizationFactory $authorization_factory The Authorization factory. * @param AuthorizationFactory $authorization_factory The Authorization factory.
* @param CaptureFactory $capture_factory The Capture factory. * @param CaptureFactory $capture_factory The Capture factory.
* @param RefundFactory $refund_factory The Refund factory.
*/ */
public function __construct( public function __construct(
AuthorizationFactory $authorization_factory, AuthorizationFactory $authorization_factory,
CaptureFactory $capture_factory CaptureFactory $capture_factory,
RefundFactory $refund_factory
) { ) {
$this->authorization_factory = $authorization_factory; $this->authorization_factory = $authorization_factory;
$this->capture_factory = $capture_factory; $this->capture_factory = $capture_factory;
$this->refund_factory = $refund_factory;
} }
/** /**
@ -62,12 +73,18 @@ class PaymentsFactory {
isset( $data->authorizations ) ? $data->authorizations : array() isset( $data->authorizations ) ? $data->authorizations : array()
); );
$captures = array_map( $captures = array_map(
function ( \stdClass $authorization ): Capture { function ( \stdClass $capture ): Capture {
return $this->capture_factory->from_paypal_response( $authorization ); return $this->capture_factory->from_paypal_response( $capture );
}, },
isset( $data->captures ) ? $data->captures : array() isset( $data->captures ) ? $data->captures : array()
); );
$payments = new Payments( $authorizations, $captures ); $refunds = array_map(
function ( \stdClass $refund ): Refund {
return $this->refund_factory->from_paypal_response( $refund );
},
isset( $data->refunds ) ? $data->refunds : array()
);
$payments = new Payments( $authorizations, $captures, $refunds );
return $payments; return $payments;
} }
} }

View file

@ -13,6 +13,7 @@ use WC_Session_Handler;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Item; use WooCommerce\PayPalCommerce\ApiClient\Entity\Item;
use WooCommerce\PayPalCommerce\ApiClient\Entity\PurchaseUnit; use WooCommerce\PayPalCommerce\ApiClient\Entity\PurchaseUnit;
use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException; use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException;
use WooCommerce\PayPalCommerce\ApiClient\Helper\PurchaseUnitSanitizer;
use WooCommerce\PayPalCommerce\ApiClient\Repository\PayeeRepository; use WooCommerce\PayPalCommerce\ApiClient\Repository\PayeeRepository;
use WooCommerce\PayPalCommerce\Webhooks\CustomIds; use WooCommerce\PayPalCommerce\Webhooks\CustomIds;
@ -77,17 +78,25 @@ class PurchaseUnitFactory {
*/ */
private $soft_descriptor; private $soft_descriptor;
/**
* The sanitizer for purchase unit output data.
*
* @var PurchaseUnitSanitizer|null
*/
private $sanitizer;
/** /**
* PurchaseUnitFactory constructor. * PurchaseUnitFactory constructor.
* *
* @param AmountFactory $amount_factory The amount factory. * @param AmountFactory $amount_factory The amount factory.
* @param PayeeRepository $payee_repository The Payee repository. * @param PayeeRepository $payee_repository The Payee repository.
* @param PayeeFactory $payee_factory The Payee factory. * @param PayeeFactory $payee_factory The Payee factory.
* @param ItemFactory $item_factory The item factory. * @param ItemFactory $item_factory The item factory.
* @param ShippingFactory $shipping_factory The shipping factory. * @param ShippingFactory $shipping_factory The shipping factory.
* @param PaymentsFactory $payments_factory The payments factory. * @param PaymentsFactory $payments_factory The payments factory.
* @param string $prefix The prefix. * @param string $prefix The prefix.
* @param string $soft_descriptor The soft descriptor. * @param string $soft_descriptor The soft descriptor.
* @param ?PurchaseUnitSanitizer $sanitizer The purchase unit to_array sanitizer.
*/ */
public function __construct( public function __construct(
AmountFactory $amount_factory, AmountFactory $amount_factory,
@ -97,7 +106,8 @@ class PurchaseUnitFactory {
ShippingFactory $shipping_factory, ShippingFactory $shipping_factory,
PaymentsFactory $payments_factory, PaymentsFactory $payments_factory,
string $prefix = 'WC-', string $prefix = 'WC-',
string $soft_descriptor = '' string $soft_descriptor = '',
PurchaseUnitSanitizer $sanitizer = null
) { ) {
$this->amount_factory = $amount_factory; $this->amount_factory = $amount_factory;
@ -108,6 +118,7 @@ class PurchaseUnitFactory {
$this->payments_factory = $payments_factory; $this->payments_factory = $payments_factory;
$this->prefix = $prefix; $this->prefix = $prefix;
$this->soft_descriptor = $soft_descriptor; $this->soft_descriptor = $soft_descriptor;
$this->sanitizer = $sanitizer;
} }
/** /**
@ -151,6 +162,9 @@ class PurchaseUnitFactory {
$invoice_id, $invoice_id,
$soft_descriptor $soft_descriptor
); );
$this->init_purchase_unit( $purchase_unit );
/** /**
* Returns PurchaseUnit for the WC order. * Returns PurchaseUnit for the WC order.
*/ */
@ -221,6 +235,8 @@ class PurchaseUnitFactory {
$soft_descriptor $soft_descriptor
); );
$this->init_purchase_unit( $purchase_unit );
return $purchase_unit; return $purchase_unit;
} }
@ -283,6 +299,9 @@ class PurchaseUnitFactory {
$soft_descriptor, $soft_descriptor,
$payments $payments
); );
$this->init_purchase_unit( $purchase_unit );
return $purchase_unit; return $purchase_unit;
} }
@ -313,4 +332,16 @@ class PurchaseUnitFactory {
$countries = array( 'AE', 'AF', 'AG', 'AI', 'AL', 'AN', 'AO', 'AW', 'BB', 'BF', 'BH', 'BI', 'BJ', 'BM', 'BO', 'BS', 'BT', 'BW', 'BZ', 'CD', 'CF', 'CG', 'CI', 'CK', 'CL', 'CM', 'CO', 'CR', 'CV', 'DJ', 'DM', 'DO', 'EC', 'EG', 'ER', 'ET', 'FJ', 'FK', 'GA', 'GD', 'GH', 'GI', 'GM', 'GN', 'GQ', 'GT', 'GW', 'GY', 'HK', 'HN', 'HT', 'IE', 'IQ', 'IR', 'JM', 'JO', 'KE', 'KH', 'KI', 'KM', 'KN', 'KP', 'KW', 'KY', 'LA', 'LB', 'LC', 'LK', 'LR', 'LS', 'LY', 'ML', 'MM', 'MO', 'MR', 'MS', 'MT', 'MU', 'MW', 'MZ', 'NA', 'NE', 'NG', 'NI', 'NP', 'NR', 'NU', 'OM', 'PA', 'PE', 'PF', 'PY', 'QA', 'RW', 'SA', 'SB', 'SC', 'SD', 'SL', 'SN', 'SO', 'SR', 'SS', 'ST', 'SV', 'SY', 'TC', 'TD', 'TG', 'TL', 'TO', 'TT', 'TV', 'TZ', 'UG', 'UY', 'VC', 'VE', 'VG', 'VN', 'VU', 'WS', 'XA', 'XB', 'XC', 'XE', 'XL', 'XM', 'XN', 'XS', 'YE', 'ZM', 'ZW' ); $countries = array( 'AE', 'AF', 'AG', 'AI', 'AL', 'AN', 'AO', 'AW', 'BB', 'BF', 'BH', 'BI', 'BJ', 'BM', 'BO', 'BS', 'BT', 'BW', 'BZ', 'CD', 'CF', 'CG', 'CI', 'CK', 'CL', 'CM', 'CO', 'CR', 'CV', 'DJ', 'DM', 'DO', 'EC', 'EG', 'ER', 'ET', 'FJ', 'FK', 'GA', 'GD', 'GH', 'GI', 'GM', 'GN', 'GQ', 'GT', 'GW', 'GY', 'HK', 'HN', 'HT', 'IE', 'IQ', 'IR', 'JM', 'JO', 'KE', 'KH', 'KI', 'KM', 'KN', 'KP', 'KW', 'KY', 'LA', 'LB', 'LC', 'LK', 'LR', 'LS', 'LY', 'ML', 'MM', 'MO', 'MR', 'MS', 'MT', 'MU', 'MW', 'MZ', 'NA', 'NE', 'NG', 'NI', 'NP', 'NR', 'NU', 'OM', 'PA', 'PE', 'PF', 'PY', 'QA', 'RW', 'SA', 'SB', 'SC', 'SD', 'SL', 'SN', 'SO', 'SR', 'SS', 'ST', 'SV', 'SY', 'TC', 'TD', 'TG', 'TL', 'TO', 'TT', 'TV', 'TZ', 'UG', 'UY', 'VC', 'VE', 'VG', 'VN', 'VU', 'WS', 'XA', 'XB', 'XC', 'XE', 'XL', 'XM', 'XN', 'XS', 'YE', 'ZM', 'ZW' );
return in_array( $country_code, $countries, true ); return in_array( $country_code, $countries, true );
} }
/**
* Initializes a purchase unit object.
*
* @param PurchaseUnit $purchase_unit The purchase unit.
* @return void
*/
private function init_purchase_unit( PurchaseUnit $purchase_unit ): void {
if ( $this->sanitizer instanceof PurchaseUnitSanitizer ) {
$purchase_unit->set_sanitizer( $this->sanitizer );
}
}
} }

View file

@ -0,0 +1,91 @@
<?php
/**
* The refund factory.
*
* @package WooCommerce\PayPalCommerce\ApiClient\Factory
*/
declare( strict_types=1 );
namespace WooCommerce\PayPalCommerce\ApiClient\Factory;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Refund;
use WooCommerce\PayPalCommerce\ApiClient\Entity\RefundStatus;
use WooCommerce\PayPalCommerce\ApiClient\Entity\RefundStatusDetails;
/**
* Class RefundFactory
*/
class RefundFactory {
/**
* The Amount factory.
*
* @var AmountFactory
*/
private $amount_factory;
/**
* The SellerPayableBreakdownFactory factory.
*
* @var SellerPayableBreakdownFactory
*/
private $seller_payable_breakdown_factory;
/**
* The RefundPayerFactory factory.
*
* @var RefundPayerFactory
*/
private $refund_payer_factory;
/**
* RefundFactory constructor.
*
* @param AmountFactory $amount_factory The amount factory.
* @param SellerPayableBreakdownFactory $seller_payable_breakdown_factory The payable breakdown factory.
* @param RefundPayerFactory $refund_payer_factory The payer breakdown factory.
*/
public function __construct(
AmountFactory $amount_factory,
SellerPayableBreakdownFactory $seller_payable_breakdown_factory,
RefundPayerFactory $refund_payer_factory
) {
$this->amount_factory = $amount_factory;
$this->seller_payable_breakdown_factory = $seller_payable_breakdown_factory;
$this->refund_payer_factory = $refund_payer_factory;
}
/**
* Returns the refund object based off the PayPal response.
*
* @param \stdClass $data The PayPal response.
*
* @return Refund
*/
public function from_paypal_response( \stdClass $data ) : Refund {
$reason = $data->status_details->reason ?? null;
$seller_payable_breakdown = isset( $data->seller_payable_breakdown ) ?
$this->seller_payable_breakdown_factory->from_paypal_response( $data->seller_payable_breakdown )
: null;
$payer = isset( $data->payer ) ?
$this->refund_payer_factory->from_paypal_response( $data->payer )
: null;
return new Refund(
(string) $data->id,
new RefundStatus(
(string) $data->status,
$reason ? new RefundStatusDetails( $reason ) : null
),
$this->amount_factory->from_paypal_response( $data->amount ),
(string) ( $data->invoice_id ?? '' ),
(string) ( $data->custom_id ?? '' ),
$seller_payable_breakdown,
(string) ( $data->acquirer_reference_number ?? '' ),
(string) ( $data->note_to_payer ?? '' ),
$payer
);
}
}

View file

@ -0,0 +1,39 @@
<?php
/**
* The RefundPayerFactory factory.
*
* @package WooCommerce\PayPalCommerce\ApiClient\Factory
*/
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\ApiClient\Factory;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Address;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Payer;
use WooCommerce\PayPalCommerce\ApiClient\Entity\PayerName;
use WooCommerce\PayPalCommerce\ApiClient\Entity\PayerTaxInfo;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Phone;
use WooCommerce\PayPalCommerce\ApiClient\Entity\PhoneWithType;
use WooCommerce\PayPalCommerce\ApiClient\Entity\RefundPayer;
use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException;
/**
* Class RefundPayerFactory
*/
class RefundPayerFactory {
/**
* Returns a Refund Payer object based off a PayPal Response.
*
* @param \stdClass $data The JSON object.
*
* @return RefundPayer
*/
public function from_paypal_response( \stdClass $data ): RefundPayer {
return new RefundPayer(
isset( $data->email_address ) ? $data->email_address : '',
isset( $data->merchant_id ) ? $data->merchant_id : ''
);
}
}

View file

@ -0,0 +1,81 @@
<?php
/**
* The SellerPayableBreakdownFactory Factory.
*
* @package WooCommerce\PayPalCommerce\ApiClient\Factory
*/
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\ApiClient\Factory;
use stdClass;
use WooCommerce\PayPalCommerce\ApiClient\Entity\PlatformFee;
use WooCommerce\PayPalCommerce\ApiClient\Entity\SellerPayableBreakdown;
/**
* Class SellerPayableBreakdownFactory
*/
class SellerPayableBreakdownFactory {
/**
* The Money factory.
*
* @var MoneyFactory
*/
private $money_factory;
/**
* The PlatformFee factory.
*
* @var PlatformFeeFactory
*/
private $platform_fee_factory;
/**
* SellerPayableBreakdownFactory constructor.
*
* @param MoneyFactory $money_factory The Money factory.
* @param PlatformFeeFactory $platform_fee_factory The PlatformFee factory.
*/
public function __construct(
MoneyFactory $money_factory,
PlatformFeeFactory $platform_fee_factory
) {
$this->money_factory = $money_factory;
$this->platform_fee_factory = $platform_fee_factory;
}
/**
* Returns a SellerPayableBreakdownFactory object based off a PayPal Response.
*
* @param stdClass $data The JSON object.
*
* @return SellerPayableBreakdown
*/
public function from_paypal_response( stdClass $data ): SellerPayableBreakdown {
$gross_amount = ( isset( $data->gross_amount ) ) ? $this->money_factory->from_paypal_response( $data->gross_amount ) : null;
$paypal_fee = ( isset( $data->paypal_fee ) ) ? $this->money_factory->from_paypal_response( $data->paypal_fee ) : null;
$paypal_fee_in_receivable_currency = ( isset( $data->paypal_fee_in_receivable_currency ) ) ? $this->money_factory->from_paypal_response( $data->paypal_fee_in_receivable_currency ) : null;
$net_amount = ( isset( $data->net_amount ) ) ? $this->money_factory->from_paypal_response( $data->net_amount ) : null;
$net_amount_in_receivable_currency = ( isset( $data->net_amount_in_receivable_currency ) ) ? $this->money_factory->from_paypal_response( $data->net_amount_in_receivable_currency ) : null;
$total_refunded_amount = ( isset( $data->total_refunded_amount ) ) ? $this->money_factory->from_paypal_response( $data->total_refunded_amount ) : null;
$platform_fees = ( isset( $data->platform_fees ) ) ? array_map(
function ( stdClass $fee_data ): PlatformFee {
return $this->platform_fee_factory->from_paypal_response( $fee_data );
},
$data->platform_fees
) : array();
return new SellerPayableBreakdown(
$gross_amount,
$paypal_fee,
$paypal_fee_in_receivable_currency,
$net_amount,
$net_amount_in_receivable_currency,
$total_refunded_amount,
$platform_fees
);
}
}

View file

@ -33,4 +33,16 @@ class MoneyFormatter {
? (string) round( $value, 0 ) ? (string) round( $value, 0 )
: number_format( $value, 2, '.', '' ); : number_format( $value, 2, '.', '' );
} }
/**
* Returns the minimum amount a currency can be incremented or decremented.
*
* @param string $currency The 3-letter currency code.
* @return float
*/
public function minimum_increment( string $currency ): float {
return (float) in_array( $currency, $this->currencies_without_decimals, true )
? 1.00
: 0.01;
}
} }

View file

@ -0,0 +1,160 @@
<?php
/**
* PayPal order transient helper.
*
* This class is used to pass transient data between the PayPal order and the WooCommerce order.
* These two orders can be created on different requests and at different times so this transient
* data must be persisted between requests.
*
* @package WooCommerce\PayPalCommerce\ApiClient\Helper
*/
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\ApiClient\Helper;
use WC_Order;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Order;
/**
* Class OrderHelper
*/
class OrderTransient {
const CACHE_KEY = 'order_transient';
const CACHE_TIMEOUT = DAY_IN_SECONDS; // If necessary we can increase this.
/**
* The Cache.
*
* @var Cache
*/
private $cache;
/**
* The purchase unit sanitizer.
*
* @var PurchaseUnitSanitizer
*/
private $purchase_unit_sanitizer;
/**
* OrderTransient constructor.
*
* @param Cache $cache The Cache.
* @param PurchaseUnitSanitizer $purchase_unit_sanitizer The purchase unit sanitizer.
*/
public function __construct( Cache $cache, PurchaseUnitSanitizer $purchase_unit_sanitizer ) {
$this->cache = $cache;
$this->purchase_unit_sanitizer = $purchase_unit_sanitizer;
}
/**
* Processes the created PayPal order.
*
* @param Order $order The PayPal order.
* @return void
*/
public function on_order_created( Order $order ): void {
$message = $this->purchase_unit_sanitizer->get_last_message();
$this->add_order_note( $order, $message );
}
/**
* Processes the created WooCommerce order.
*
* @param WC_Order $wc_order The WooCommerce order.
* @param Order $order The PayPal order.
* @return void
*/
public function on_woocommerce_order_created( WC_Order $wc_order, Order $order ): void {
$cache_key = $this->cache_key( $order );
if ( ! $cache_key ) {
return;
}
$this->apply_order_notes( $order, $wc_order );
$this->cache->delete( $cache_key );
}
/**
* Adds an order note associated with a PayPal order.
* It can be added to a WooCommerce order associated with this PayPal order in the future.
*
* @param Order $order The PayPal order.
* @param string $message The message to be added to order notes.
* @return void
*/
private function add_order_note( Order $order, string $message ): void {
if ( ! $message ) {
return;
}
$cache_key = $this->cache_key( $order );
if ( ! $cache_key ) {
return;
}
$transient = $this->cache->get( $cache_key );
if ( ! is_array( $transient ) ) {
$transient = array();
}
if ( ! is_array( $transient['notes'] ) ) {
$transient['notes'] = array();
}
$transient['notes'][] = $message;
$this->cache->set( $cache_key, $transient, self::CACHE_TIMEOUT );
}
/**
* Adds an order note associated with a PayPal order.
* It can be added to a WooCommerce order associated with this PayPal order in the future.
*
* @param Order $order The PayPal order.
* @param WC_Order $wc_order The WooCommerce order.
* @return void
*/
private function apply_order_notes( Order $order, WC_Order $wc_order ): void {
$cache_key = $this->cache_key( $order );
if ( ! $cache_key ) {
return;
}
$transient = $this->cache->get( $cache_key );
if ( ! is_array( $transient ) ) {
return;
}
if ( ! is_array( $transient['notes'] ) ) {
return;
}
foreach ( $transient['notes'] as $note ) {
if ( ! is_string( $note ) ) {
continue;
}
$wc_order->add_order_note( $note );
}
}
/**
* Build cache key.
*
* @param Order $order The PayPal order.
* @return string|null
*/
private function cache_key( Order $order ): ?string {
if ( ! $order->id() ) {
return null;
}
return implode( '_', array( self::CACHE_KEY . $order->id() ) );
}
}

View file

@ -0,0 +1,368 @@
<?php
/**
* Class PurchaseUnitSanitizer.
*
* Sanitizes a purchase_unit array to be consumed by PayPal.
*
* All money values send to PayPal can only have 2 decimal points. WooCommerce internally does
* not have this restriction. Therefore, the totals of the cart in WooCommerce and the totals
* of the rounded money values of the items, we send to PayPal, can differ. In those case we either:
* - Add an extra line with roundings.
* - Don't send the line items.
*
* @package WooCommerce\PayPalCommerce\ApiClient\Helper
*/
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\ApiClient\Helper;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Item;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Money;
/**
* Class PurchaseUnitSanitizer
*/
class PurchaseUnitSanitizer {
const MODE_DITCH = 'ditch';
const MODE_EXTRA_LINE = 'extra_line';
const VALID_MODES = array(
self::MODE_DITCH,
self::MODE_EXTRA_LINE,
);
const EXTRA_LINE_NAME = 'Subtotal mismatch';
/**
* The purchase unit data
*
* @var array
*/
private $purchase_unit = array();
/**
* Whether to allow items to be ditched.
*
* @var bool
*/
private $allow_ditch_items = true;
/**
* The working mode
*
* @var string
*/
private $mode;
/**
* The name for the extra line
*
* @var string
*/
private $extra_line_name;
/**
* The last message. To be added to order notes.
*
* @var string
*/
private $last_message = '';
/**
* If the items and breakdown has been ditched.
*
* @var bool
*/
private $has_ditched_items_breakdown = false;
/**
* PurchaseUnitSanitizer constructor.
*
* @param string|null $mode The mismatch handling mode, ditch or extra_line.
* @param string|null $extra_line_name The name of the extra line.
*/
public function __construct( string $mode = null, string $extra_line_name = null ) {
if ( ! in_array( $mode, self::VALID_MODES, true ) ) {
$mode = self::MODE_DITCH;
}
if ( ! $extra_line_name ) {
$extra_line_name = self::EXTRA_LINE_NAME;
}
$this->mode = $mode;
$this->extra_line_name = $extra_line_name;
}
/**
* The purchase_unit amount.
*
* @return array
*/
private function amount(): array {
return $this->purchase_unit['amount'] ?? array();
}
/**
* The purchase_unit currency code.
*
* @return string
*/
private function currency_code(): string {
return (string) ( $this->amount()['currency_code'] ?? '' );
}
/**
* The purchase_unit breakdown.
*
* @return array
*/
private function breakdown(): array {
return $this->amount()['breakdown'] ?? array();
}
/**
* The purchase_unit breakdown.
*
* @param string $key The breakdown element to get the value from.
* @return float
*/
private function breakdown_value( string $key ): float {
if ( ! isset( $this->breakdown()[ $key ] ) ) {
return 0.0;
}
return (float) ( $this->breakdown()[ $key ]['value'] ?? 0.0 );
}
/**
* The purchase_unit items array.
*
* @return array
*/
private function items(): array {
return $this->purchase_unit['items'] ?? array();
}
/**
* The sanitizes the purchase_unit array.
*
* @param array $purchase_unit The purchase_unit array that should be sanitized.
* @param bool $allow_ditch_items Whether to allow items to be ditched.
* @return array
*/
public function sanitize( array $purchase_unit, bool $allow_ditch_items = true ): array {
$this->purchase_unit = $purchase_unit;
$this->allow_ditch_items = $allow_ditch_items;
$this->has_ditched_items_breakdown = false;
$this->sanitize_item_amount_mismatch();
$this->sanitize_item_tax_mismatch();
$this->sanitize_breakdown_mismatch();
return $this->purchase_unit;
}
/**
* The sanitizes the purchase_unit items amount.
*
* @return void
*/
private function sanitize_item_amount_mismatch(): void {
$item_mismatch = $this->calculate_item_mismatch();
if ( $this->mode === self::MODE_EXTRA_LINE ) {
if ( $item_mismatch < 0 ) {
// Do floors on item amounts so item_mismatch is a positive value.
foreach ( $this->purchase_unit['items'] as $index => $item ) {
// Get a more intelligent adjustment mechanism.
$increment = ( new MoneyFormatter() )->minimum_increment( $item['unit_amount']['currency_code'] );
$this->purchase_unit['items'][ $index ]['unit_amount'] = ( new Money(
( (float) $item['unit_amount']['value'] ) - $increment,
$item['unit_amount']['currency_code']
) )->to_array();
}
}
$item_mismatch = $this->calculate_item_mismatch();
if ( $item_mismatch > 0 ) {
// Add extra line item with roundings.
$line_name = $this->extra_line_name;
$roundings_money = new Money( $item_mismatch, $this->currency_code() );
$this->purchase_unit['items'][] = ( new Item( $line_name, $roundings_money, 1 ) )->to_array();
$this->set_last_message(
__( 'Item amount mismatch. Extra line added.', 'woocommerce-paypal-payments' )
);
}
$item_mismatch = $this->calculate_item_mismatch();
}
if ( $item_mismatch !== 0.0 ) {
// Ditch items.
if ( $this->allow_ditch_items && isset( $this->purchase_unit['items'] ) ) {
unset( $this->purchase_unit['items'] );
$this->set_last_message(
__( 'Item amount mismatch. Items ditched.', 'woocommerce-paypal-payments' )
);
}
}
}
/**
* The sanitizes the purchase_unit items tax.
*
* @return void
*/
private function sanitize_item_tax_mismatch(): void {
$tax_mismatch = $this->calculate_tax_mismatch();
if ( $this->allow_ditch_items && $tax_mismatch !== 0.0 ) {
// Unset tax in items.
foreach ( $this->purchase_unit['items'] as $index => $item ) {
if ( isset( $this->purchase_unit['items'][ $index ]['tax'] ) ) {
unset( $this->purchase_unit['items'][ $index ]['tax'] );
}
if ( isset( $this->purchase_unit['items'][ $index ]['tax_rate'] ) ) {
unset( $this->purchase_unit['items'][ $index ]['tax_rate'] );
}
}
}
}
/**
* The sanitizes the purchase_unit breakdown.
*
* @return void
*/
private function sanitize_breakdown_mismatch(): void {
$breakdown_mismatch = $this->calculate_breakdown_mismatch();
if ( $this->allow_ditch_items && $breakdown_mismatch !== 0.0 ) {
// Ditch breakdowns and items.
if ( isset( $this->purchase_unit['items'] ) ) {
unset( $this->purchase_unit['items'] );
}
if ( isset( $this->purchase_unit['amount']['breakdown'] ) ) {
unset( $this->purchase_unit['amount']['breakdown'] );
}
$this->has_ditched_items_breakdown = true;
$this->set_last_message(
__( 'Breakdown mismatch. Items and breakdown ditched.', 'woocommerce-paypal-payments' )
);
}
}
/**
* The calculates amount mismatch of items sums with breakdown.
*
* @return float
*/
private function calculate_item_mismatch(): float {
$item_total = $this->breakdown_value( 'item_total' );
if ( ! $item_total ) {
return 0;
}
$remaining_item_total = array_reduce(
$this->items(),
function ( float $total, array $item ): float {
return $total - (float) $item['unit_amount']['value'] * (float) $item['quantity'];
},
$item_total
);
return round( $remaining_item_total, 2 );
}
/**
* The calculates tax mismatch of items sums with breakdown.
*
* @return float
*/
private function calculate_tax_mismatch(): float {
$tax_total = $this->breakdown_value( 'tax_total' );
$items_with_tax = array_filter(
$this->items(),
function ( array $item ): bool {
return isset( $item['tax'] );
}
);
if ( ! $tax_total || empty( $items_with_tax ) ) {
return 0;
}
$remaining_tax_total = array_reduce(
$this->items(),
function ( float $total, array $item ): float {
$tax = $item['tax'] ?? false;
if ( $tax ) {
$total -= (float) $tax['value'] * (float) $item['quantity'];
}
return $total;
},
$tax_total
);
return round( $remaining_tax_total, 2 );
}
/**
* The calculates mismatch of breakdown sums with total amount.
*
* @return float
*/
private function calculate_breakdown_mismatch(): float {
$breakdown = $this->breakdown();
if ( ! $breakdown ) {
return 0;
}
$amount_total = 0.0;
$amount_total += $this->breakdown_value( 'item_total' );
$amount_total += $this->breakdown_value( 'tax_total' );
$amount_total += $this->breakdown_value( 'shipping' );
$amount_total -= $this->breakdown_value( 'discount' );
$amount_total -= $this->breakdown_value( 'shipping_discount' );
$amount_total += $this->breakdown_value( 'handling' );
$amount_total += $this->breakdown_value( 'insurance' );
$amount_str = $this->amount()['value'] ?? 0;
$amount_total_str = ( new Money( $amount_total, $this->currency_code() ) )->value_str();
return $amount_str - $amount_total_str;
}
/**
* Indicates if the items and breakdown were ditched.
*
* @return bool
*/
public function has_ditched_items_breakdown(): bool {
return $this->has_ditched_items_breakdown;
}
/**
* Returns the last sanitization message.
*
* @return string
*/
public function get_last_message(): string {
return $this->last_message;
}
/**
* Set the last sanitization message.
*
* @param string $message The message.
*/
public function set_last_message( string $message ): void {
$this->last_message = $message;
}
}

View file

@ -15,3 +15,7 @@
.ppcp-dcc-order-button { .ppcp-dcc-order-button {
float: right; float: right;
} }
iframe[id^="hosted-fields-tokenization-frame_"] {
display: none;
}

View file

@ -73,7 +73,7 @@ class SingleProductActionHandler {
getSubscriptionProducts() getSubscriptionProducts()
{ {
const id = document.querySelector('[name="add-to-cart"]').value; const id = document.querySelector('[name="add-to-cart"]').value;
return [new Product(id, 1, this.variations())]; return [new Product(id, 1, this.variations(), this.extraFields())];
} }
configuration() configuration()
@ -107,7 +107,7 @@ class SingleProductActionHandler {
{ {
if ( this.isBookingProduct() ) { if ( this.isBookingProduct() ) {
const id = document.querySelector('[name="add-to-cart"]').value; const id = document.querySelector('[name="add-to-cart"]').value;
return [new BookingProduct(id, 1, FormHelper.getPrefixedFields(this.formElement, "wc_bookings_field"))]; return [new BookingProduct(id, 1, FormHelper.getPrefixedFields(this.formElement, "wc_bookings_field"), this.extraFields())];
} else if ( this.isGroupedProduct() ) { } else if ( this.isGroupedProduct() ) {
const products = []; const products = [];
this.formElement.querySelectorAll('input[type="number"]').forEach((element) => { this.formElement.querySelectorAll('input[type="number"]').forEach((element) => {
@ -120,17 +120,25 @@ class SingleProductActionHandler {
} }
const id = parseInt(elementName[1]); const id = parseInt(elementName[1]);
const quantity = parseInt(element.value); const quantity = parseInt(element.value);
products.push(new Product(id, quantity, null)); products.push(new Product(id, quantity, null, this.extraFields()));
}) })
return products; return products;
} else { } else {
const id = document.querySelector('[name="add-to-cart"]').value; const id = document.querySelector('[name="add-to-cart"]').value;
const qty = document.querySelector('[name="quantity"]').value; const qty = document.querySelector('[name="quantity"]').value;
const variations = this.variations(); const variations = this.variations();
return [new Product(id, qty, variations)]; return [new Product(id, qty, variations, this.extraFields())];
} }
} }
extraFields() {
return FormHelper.getFilteredFields(
this.formElement,
['add-to-cart', 'quantity', 'product_id', 'variation_id'],
['attribute_', 'wc_bookings_field']
);
}
createOrder() createOrder()
{ {
this.cartHelper = null; this.cartHelper = null;

View file

@ -2,8 +2,8 @@ import Product from "./Product";
class BookingProduct extends Product { class BookingProduct extends Product {
constructor(id, quantity, booking) { constructor(id, quantity, booking, extra) {
super(id, quantity, null); super(id, quantity, null, extra);
this.booking = booking; this.booking = booking;
} }

View file

@ -1,16 +1,17 @@
class Product { class Product {
constructor(id, quantity, variations) { constructor(id, quantity, variations, extra) {
this.id = id; this.id = id;
this.quantity = quantity; this.quantity = quantity;
this.variations = variations; this.variations = variations;
this.extra = extra;
} }
data() { data() {
return { return {
id:this.id, id:this.id,
quantity:this.quantity, quantity: this.quantity,
variations:this.variations variations: this.variations,
extra: this.extra,
} }
} }
} }

View file

@ -5,13 +5,46 @@
export default class FormHelper { export default class FormHelper {
static getPrefixedFields(formElement, prefix) { static getPrefixedFields(formElement, prefix) {
const formData = new FormData(formElement);
let fields = {}; let fields = {};
for(const element of formElement.elements) {
if( element.name.startsWith(prefix) ) { for (const [name, value] of formData.entries()) {
fields[element.name] = element.value; if (!prefix || name.startsWith(prefix)) {
fields[name] = value;
} }
} }
return fields; return fields;
} }
static getFilteredFields(formElement, exactFilters, prefixFilters) {
const formData = new FormData(formElement);
let fields = {};
let counters = {};
for (let [name, value] of formData.entries()) {
// Handle array format
if (name.indexOf('[]') !== -1) {
const k = name;
counters[k] = counters[k] || 0;
name = name.replace('[]', `[${counters[k]}]`);
counters[k]++;
}
if (!name) {
continue;
}
if (exactFilters && (exactFilters.indexOf(name) !== -1)) {
continue;
}
if (prefixFilters && prefixFilters.some(prefixFilter => name.startsWith(prefixFilter))) {
continue;
}
fields[name] = value;
}
return fields;
}
} }

View file

@ -155,7 +155,7 @@ return array(
$session_handler = $container->get( 'session.handler' ); $session_handler = $container->get( 'session.handler' );
$settings = $container->get( 'wcgateway.settings' ); $settings = $container->get( 'wcgateway.settings' );
$early_order_handler = $container->get( 'button.helper.early-order-handler' ); $early_order_handler = $container->get( 'button.helper.early-order-handler' );
$registration_needed = $container->get( 'button.current-user-must-register' ); $registration_needed = $container->get( 'button.current-user-must-register' );
$logger = $container->get( 'woocommerce.logger.woocommerce' ); $logger = $container->get( 'woocommerce.logger.woocommerce' );
return new CreateOrderEndpoint( return new CreateOrderEndpoint(
$request_data, $request_data,

View file

@ -112,6 +112,18 @@ abstract class AbstractCartEndpoint implements EndpointInterface {
$success = true; $success = true;
foreach ( $products as $product ) { foreach ( $products as $product ) {
// Add extras to POST, they are usually added by custom plugins.
if ( $product['extra'] && is_array( $product['extra'] ) ) {
// Handle cases like field[].
$query = http_build_query( $product['extra'] );
parse_str( $query, $extra );
foreach ( $extra as $key => $value ) {
$_POST[ $key ] = $value;
}
}
if ( $product['product']->is_type( 'booking' ) ) { if ( $product['product']->is_type( 'booking' ) ) {
$success = $success && $this->add_booking_product( $success = $success && $this->add_booking_product(
$product['product'], $product['product'],
@ -229,6 +241,7 @@ abstract class AbstractCartEndpoint implements EndpointInterface {
'quantity' => (int) $product['quantity'], 'quantity' => (int) $product['quantity'],
'variations' => $product['variations'] ?? null, 'variations' => $product['variations'] ?? null,
'booking' => $product['booking'] ?? null, 'booking' => $product['booking'] ?? null,
'extra' => $product['extra'] ?? null,
); );
} }
return $products; return $products;

View file

@ -25,6 +25,7 @@ use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException;
use WooCommerce\PayPalCommerce\ApiClient\Factory\PayerFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\PayerFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\PurchaseUnitFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\PurchaseUnitFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\ShippingPreferenceFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\ShippingPreferenceFactory;
use WooCommerce\PayPalCommerce\ApiClient\Helper\OrderTransient;
use WooCommerce\PayPalCommerce\Button\Exception\ValidationException; use WooCommerce\PayPalCommerce\Button\Exception\ValidationException;
use WooCommerce\PayPalCommerce\Button\Validation\CheckoutFormValidator; use WooCommerce\PayPalCommerce\Button\Validation\CheckoutFormValidator;
use WooCommerce\PayPalCommerce\Button\Helper\EarlyOrderHandler; use WooCommerce\PayPalCommerce\Button\Helper\EarlyOrderHandler;
@ -330,6 +331,8 @@ class CreateOrderEndpoint implements EndpointInterface {
$wc_order->update_meta_data( PayPalGateway::ORDER_ID_META_KEY, $order->id() ); $wc_order->update_meta_data( PayPalGateway::ORDER_ID_META_KEY, $order->id() );
$wc_order->update_meta_data( PayPalGateway::INTENT_META_KEY, $order->intent() ); $wc_order->update_meta_data( PayPalGateway::INTENT_META_KEY, $order->intent() );
$wc_order->save_meta_data(); $wc_order->save_meta_data();
do_action( 'woocommerce_paypal_payments_woocommerce_order_created', $wc_order, $order );
} }
wp_send_json_success( $this->make_response( $order ) ); wp_send_json_success( $this->make_response( $order ) );

View file

@ -11,6 +11,7 @@ namespace WooCommerce\PayPalCommerce\Button\Helper;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Order; use WooCommerce\PayPalCommerce\ApiClient\Entity\Order;
use WooCommerce\PayPalCommerce\ApiClient\Exception\PayPalApiException; use WooCommerce\PayPalCommerce\ApiClient\Exception\PayPalApiException;
use WooCommerce\PayPalCommerce\ApiClient\Helper\OrderTransient;
use WooCommerce\PayPalCommerce\Onboarding\State; use WooCommerce\PayPalCommerce\Onboarding\State;
use WooCommerce\PayPalCommerce\Session\SessionHandler; use WooCommerce\PayPalCommerce\Session\SessionHandler;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway; use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway;
@ -163,6 +164,10 @@ class EarlyOrderHandler {
/** /**
* Patch Order so we have the \WC_Order id added. * Patch Order so we have the \WC_Order id added.
*/ */
return $this->order_processor->patch_order( $wc_order, $order ); $order = $this->order_processor->patch_order( $wc_order, $order );
do_action( 'woocommerce_paypal_payments_woocommerce_order_created', $wc_order, $order );
return $order;
} }
} }

View file

@ -9,6 +9,7 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Subscription; namespace WooCommerce\PayPalCommerce\Subscription;
use WC_Subscription;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Order; use WooCommerce\PayPalCommerce\ApiClient\Entity\Order;
use WooCommerce\PayPalCommerce\ApiClient\Entity\PaymentToken; use WooCommerce\PayPalCommerce\ApiClient\Entity\PaymentToken;
@ -139,8 +140,16 @@ class RenewalHandler {
* *
* @param \WC_Order $wc_order The WooCommerce order. * @param \WC_Order $wc_order The WooCommerce order.
*/ */
public function renew( \WC_Order $wc_order ) { public function renew( \WC_Order $wc_order ): void {
try { try {
$subscription = wcs_get_subscription( $wc_order->get_id() );
if ( is_a( $subscription, WC_Subscription::class ) ) {
$subscription_id = $subscription->get_meta( 'ppcp_subscription' ) ?? '';
if ( $subscription_id ) {
return;
}
}
$this->process_order( $wc_order ); $this->process_order( $wc_order );
} catch ( \Exception $exception ) { } catch ( \Exception $exception ) {
$error = $exception->getMessage(); $error = $exception->getMessage();
@ -204,6 +213,12 @@ class RenewalHandler {
$transaction_id = $this->get_paypal_order_transaction_id( $order ); $transaction_id = $this->get_paypal_order_transaction_id( $order );
if ( $transaction_id ) { if ( $transaction_id ) {
$this->update_transaction_id( $transaction_id, $wc_order ); $this->update_transaction_id( $transaction_id, $wc_order );
$subscriptions = wcs_get_subscriptions_for_order( $wc_order->get_id(), array( 'order_type' => 'any' ) );
foreach ( $subscriptions as $id => $subscription ) {
$subscription->update_meta_data( 'ppcp_previous_transaction_reference', $transaction_id );
$subscription->save();
}
} }
$this->handle_new_order_status( $order, $wc_order ); $this->handle_new_order_status( $order, $wc_order );

View file

@ -17,6 +17,7 @@ use WC_Product_Variable_Subscription;
use WC_Subscriptions_Product; use WC_Subscriptions_Product;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\BillingSubscriptions; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\BillingSubscriptions;
use WooCommerce\PayPalCommerce\ApiClient\Exception\PayPalApiException; use WooCommerce\PayPalCommerce\ApiClient\Exception\PayPalApiException;
use WooCommerce\PayPalCommerce\ApiClient\Repository\OrderRepository;
use WooCommerce\PayPalCommerce\Onboarding\Environment; use WooCommerce\PayPalCommerce\Onboarding\Environment;
use WooCommerce\PayPalCommerce\Vendor\Dhii\Container\ServiceProvider; use WooCommerce\PayPalCommerce\Vendor\Dhii\Container\ServiceProvider;
use WooCommerce\PayPalCommerce\Vendor\Dhii\Modular\Module\ModuleInterface; use WooCommerce\PayPalCommerce\Vendor\Dhii\Modular\Module\ModuleInterface;
@ -30,6 +31,7 @@ use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\CreditCardGateway; use WooCommerce\PayPalCommerce\WcGateway\Gateway\CreditCardGateway;
use WooCommerce\PayPalCommerce\Vendor\Interop\Container\ServiceProviderInterface; use WooCommerce\PayPalCommerce\Vendor\Interop\Container\ServiceProviderInterface;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
use WooCommerce\PayPalCommerce\WcGateway\Processor\TransactionIdHandlingTrait;
use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings; use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings;
use WooCommerce\PayPalCommerce\WcGateway\Exception\NotFoundException; use WooCommerce\PayPalCommerce\WcGateway\Exception\NotFoundException;
use WP_Post; use WP_Post;
@ -39,6 +41,8 @@ use WP_Post;
*/ */
class SubscriptionModule implements ModuleInterface { class SubscriptionModule implements ModuleInterface {
use TransactionIdHandlingTrait;
/** /**
* {@inheritDoc} * {@inheritDoc}
*/ */
@ -74,10 +78,28 @@ class SubscriptionModule implements ModuleInterface {
add_action( add_action(
'woocommerce_subscription_payment_complete', 'woocommerce_subscription_payment_complete',
function ( $subscription ) use ( $c ) { function ( $subscription ) use ( $c ) {
$paypal_subscription_id = $subscription->get_meta( 'ppcp_subscription' ) ?? '';
if ( $paypal_subscription_id ) {
return;
}
$payment_token_repository = $c->get( 'vaulting.repository.payment-token' ); $payment_token_repository = $c->get( 'vaulting.repository.payment-token' );
$logger = $c->get( 'woocommerce.logger.woocommerce' ); $logger = $c->get( 'woocommerce.logger.woocommerce' );
$this->add_payment_token_id( $subscription, $payment_token_repository, $logger ); $this->add_payment_token_id( $subscription, $payment_token_repository, $logger );
if ( count( $subscription->get_related_orders() ) === 1 ) {
$parent_order = $subscription->get_parent();
if ( is_a( $parent_order, WC_Order::class ) ) {
$order_repository = $c->get( 'api.repository.order' );
$order = $order_repository->for_wc_order( $parent_order );
$transaction_id = $this->get_paypal_order_transaction_id( $order );
if ( $transaction_id ) {
$subscription->update_meta_data( 'ppcp_previous_transaction_reference', $transaction_id );
$subscription->save();
}
}
}
} }
); );
@ -128,28 +150,20 @@ class SubscriptionModule implements ModuleInterface {
&& isset( $data['payment_source']['token'] ) && $data['payment_source']['token']['type'] === 'PAYMENT_METHOD_TOKEN' && isset( $data['payment_source']['token'] ) && $data['payment_source']['token']['type'] === 'PAYMENT_METHOD_TOKEN'
&& isset( $data['payment_source']['token']['source']->card ) && isset( $data['payment_source']['token']['source']->card )
) { ) {
$renewal_order_id = absint( $data['purchase_units'][0]['custom_id'] ); $data['payment_source'] = array(
$subscriptions = wcs_get_subscriptions_for_renewal_order( $renewal_order_id ); 'card' => array(
$subscriptions_values = array_values( $subscriptions ); 'vault_id' => $data['payment_source']['token']['id'],
$latest_subscription = array_shift( $subscriptions_values ); 'stored_credential' => array(
if ( is_a( $latest_subscription, WC_Subscription::class ) ) {
$related_renewal_orders = $latest_subscription->get_related_orders( 'ids', 'renewal' );
$latest_order_id_with_transaction = array_slice( $related_renewal_orders, 1, 1, false );
$order_id = ! empty( $latest_order_id_with_transaction ) ? $latest_order_id_with_transaction[0] : 0;
if ( count( $related_renewal_orders ) === 1 ) {
$order_id = $latest_subscription->get_parent_id();
}
$wc_order = wc_get_order( $order_id );
if ( is_a( $wc_order, WC_Order::class ) ) {
$transaction_id = $wc_order->get_transaction_id();
$data['application_context']['stored_payment_source'] = array(
'payment_initiator' => 'MERCHANT', 'payment_initiator' => 'MERCHANT',
'payment_type' => 'RECURRING', 'payment_type' => 'RECURRING',
'usage' => 'SUBSEQUENT', 'usage' => 'SUBSEQUENT',
'previous_transaction_reference' => $transaction_id, ),
); ),
} );
$previous_transaction_reference = $subscription->get_meta( 'ppcp_previous_transaction_reference' );
if ( $previous_transaction_reference ) {
$data['payment_source']['card']['stored_credential']['previous_transaction_reference'] = $previous_transaction_reference;
} }
} }
@ -233,6 +247,10 @@ class SubscriptionModule implements ModuleInterface {
* @psalm-suppress MissingClosureParamType * @psalm-suppress MissingClosureParamType
*/ */
function( string $post_type, $post_or_order_object ) use ( $c ) { function( string $post_type, $post_or_order_object ) use ( $c ) {
if ( ! function_exists( 'wcs_get_subscription' ) ) {
return;
}
$order = ( $post_or_order_object instanceof WP_Post ) $order = ( $post_or_order_object instanceof WP_Post )
? wc_get_order( $post_or_order_object->ID ) ? wc_get_order( $post_or_order_object->ID )
: $post_or_order_object; : $post_or_order_object;

View file

@ -30,6 +30,7 @@ return array(
$container->get( 'wcgateway.settings' ), $container->get( 'wcgateway.settings' ),
$container->get( 'wcgateway.processor.authorized-payments' ), $container->get( 'wcgateway.processor.authorized-payments' ),
$container->get( 'api.endpoint.payments' ), $container->get( 'api.endpoint.payments' ),
$container->get( 'api.endpoint.payment-token' ),
$container->get( 'woocommerce.logger.woocommerce' ) $container->get( 'woocommerce.logger.woocommerce' )
); );
}, },

View file

@ -14,6 +14,8 @@ use Psr\Log\LoggerInterface;
use RuntimeException; use RuntimeException;
use WC_Order; use WC_Order;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PaymentsEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PaymentsEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PaymentTokenEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Entity\PaymentToken;
use WooCommerce\PayPalCommerce\ApiClient\Repository\OrderRepository; use WooCommerce\PayPalCommerce\ApiClient\Repository\OrderRepository;
use WooCommerce\PayPalCommerce\Subscription\FreeTrialHandlerTrait; use WooCommerce\PayPalCommerce\Subscription\FreeTrialHandlerTrait;
use WooCommerce\PayPalCommerce\WcGateway\Exception\NotFoundException; use WooCommerce\PayPalCommerce\WcGateway\Exception\NotFoundException;
@ -67,6 +69,13 @@ class PaymentTokenChecker {
*/ */
protected $payments_endpoint; protected $payments_endpoint;
/**
* The payment token endpoint.
*
* @var PaymentTokenEndpoint
*/
protected $payment_token_endpoint;
/** /**
* The logger. * The logger.
* *
@ -82,6 +91,7 @@ class PaymentTokenChecker {
* @param Settings $settings The settings. * @param Settings $settings The settings.
* @param AuthorizedPaymentsProcessor $authorized_payments_processor The authorized payments processor. * @param AuthorizedPaymentsProcessor $authorized_payments_processor The authorized payments processor.
* @param PaymentsEndpoint $payments_endpoint The payments endpoint. * @param PaymentsEndpoint $payments_endpoint The payments endpoint.
* @param PaymentTokenEndpoint $payment_token_endpoint The payment token endpoint.
* @param LoggerInterface $logger The logger. * @param LoggerInterface $logger The logger.
*/ */
public function __construct( public function __construct(
@ -90,6 +100,7 @@ class PaymentTokenChecker {
Settings $settings, Settings $settings,
AuthorizedPaymentsProcessor $authorized_payments_processor, AuthorizedPaymentsProcessor $authorized_payments_processor,
PaymentsEndpoint $payments_endpoint, PaymentsEndpoint $payments_endpoint,
PaymentTokenEndpoint $payment_token_endpoint,
LoggerInterface $logger LoggerInterface $logger
) { ) {
$this->payment_token_repository = $payment_token_repository; $this->payment_token_repository = $payment_token_repository;
@ -97,6 +108,7 @@ class PaymentTokenChecker {
$this->settings = $settings; $this->settings = $settings;
$this->authorized_payments_processor = $authorized_payments_processor; $this->authorized_payments_processor = $authorized_payments_processor;
$this->payments_endpoint = $payments_endpoint; $this->payments_endpoint = $payments_endpoint;
$this->payment_token_endpoint = $payment_token_endpoint;
$this->logger = $logger; $this->logger = $logger;
} }
@ -130,7 +142,7 @@ class PaymentTokenChecker {
return; return;
} }
$tokens = $this->payment_token_repository->all_for_user_id( $customer_id ); $tokens = $this->tokens_for_user( $customer_id );
if ( $tokens ) { if ( $tokens ) {
try { try {
$this->capture_authorized_payment( $wc_order ); $this->capture_authorized_payment( $wc_order );
@ -215,11 +227,11 @@ class PaymentTokenChecker {
$wc_order->update_status( 'failed', $error_message ); $wc_order->update_status( 'failed', $error_message );
/** /**
* Function already exist in Subscription plugin * Function already exist in WC Subscriptions plugin.
* *
* @psalm-suppress UndefinedFunction * @psalm-suppress UndefinedFunction
*/ */
$subscriptions = wcs_get_subscriptions_for_order( $wc_order->get_id() ); $subscriptions = function_exists( 'wcs_get_subscriptions_for_order' ) ? wcs_get_subscriptions_for_order( $wc_order->get_id() ) : array();
foreach ( $subscriptions as $key => $subscription ) { foreach ( $subscriptions as $key => $subscription ) {
if ( $subscription->get_parent_id() === $wc_order->get_id() ) { if ( $subscription->get_parent_id() === $wc_order->get_id() ) {
try { try {
@ -231,4 +243,32 @@ class PaymentTokenChecker {
} }
} }
} }
/**
* Returns customer tokens either from guest or customer id.
*
* @param int $customer_id The customer id.
* @return PaymentToken[]
*/
private function tokens_for_user( int $customer_id ): array {
$tokens = array();
$guest_customer_id = get_user_meta( $customer_id, 'ppcp_guest_customer_id', true );
if ( $guest_customer_id ) {
$tokens = $this->payment_token_endpoint->for_guest( $guest_customer_id );
}
if ( ! $tokens ) {
$guest_customer_id = get_user_meta( $customer_id, 'ppcp_customer_id', true );
if ( $guest_customer_id ) {
$tokens = $this->payment_token_endpoint->for_guest( $guest_customer_id );
}
}
if ( ! $tokens ) {
$tokens = $this->payment_token_repository->all_for_user_id( $customer_id );
}
return $tokens;
}
} }

View file

@ -72,6 +72,7 @@ class VaultingModule implements ModuleInterface {
'woocommerce_paypal_payments_check_saved_payment', 'woocommerce_paypal_payments_check_saved_payment',
function ( int $order_id, int $customer_id, string $intent ) use ( $container ) { function ( int $order_id, int $customer_id, string $intent ) use ( $container ) {
$payment_token_checker = $container->get( 'vaulting.payment-token-checker' ); $payment_token_checker = $container->get( 'vaulting.payment-token-checker' );
assert( $payment_token_checker instanceof PaymentTokenChecker );
$payment_token_checker->check_and_update( $order_id, $customer_id, $intent ); $payment_token_checker->check_and_update( $order_id, $customer_id, $intent );
}, },
10, 10,

View file

@ -0,0 +1,51 @@
class SubElementsHandler {
constructor(element, options) {
const fieldSelector = 'input, select, textarea';
this.element = element;
this.values = options.values;
this.elements = options.elements;
this.elementsSelector = this.elements.join(',');
this.input = jQuery(this.element).is(fieldSelector)
? this.element
: jQuery(this.element).find(fieldSelector).get(0);
this.updateElementsVisibility();
jQuery(this.input).change(() => {
this.updateElementsVisibility();
});
}
updateElementsVisibility() {
const $elements = jQuery(this.elementsSelector);
let value = this.getValue(this.input);
value = (value !== null ? value.toString() : value);
if (this.values.indexOf(value) !== -1) {
$elements.show();
} else {
$elements.hide();
}
}
getValue(element) {
const $el = jQuery(element);
if ($el.is(':checkbox') || $el.is(':radio')) {
if ($el.is(':checked')) {
return $el.val();
} else {
return null;
}
} else {
return $el.val();
}
}
}
export default SubElementsHandler;

View file

@ -0,0 +1,10 @@
import moveWrappedElements from "./common/wrapped-elements";
document.addEventListener(
'DOMContentLoaded',
() => {
// Wait for current execution context to end.
setTimeout(function () {
moveWrappedElements();
}, 0);
}
);

View file

@ -0,0 +1,14 @@
// This function is needed because WordPress moves our custom notices to the global placeholder.
function moveWrappedElements() {
(($) => {
$('*[data-ppcp-wrapper]').each(function() {
let $wrapper = $('.' + $(this).data('ppcpWrapper'));
if ($wrapper.length) {
$wrapper.append(this);
}
});
})(jQuery)
}
export default moveWrappedElements;

View file

@ -4,6 +4,7 @@ import Renderer from '../../../ppcp-button/resources/js/modules/Renderer/Rendere
import MessageRenderer from "../../../ppcp-button/resources/js/modules/Renderer/MessageRenderer"; import MessageRenderer from "../../../ppcp-button/resources/js/modules/Renderer/MessageRenderer";
import {setVisibleByClass, isVisible} from "../../../ppcp-button/resources/js/modules/Helper/Hiding"; import {setVisibleByClass, isVisible} from "../../../ppcp-button/resources/js/modules/Helper/Hiding";
import widgetBuilder from "../../../ppcp-button/resources/js/modules/Renderer/WidgetBuilder"; import widgetBuilder from "../../../ppcp-button/resources/js/modules/Renderer/WidgetBuilder";
import SubElementsHandler from "./SettingsHandler/SubElementsHandler";
document.addEventListener( document.addEventListener(
'DOMContentLoaded', 'DOMContentLoaded',
@ -307,5 +308,16 @@ document.addEventListener(
createButtonPreview(() => getButtonDefaultSettings('#ppcpPayLaterButtonPreview')); createButtonPreview(() => getButtonDefaultSettings('#ppcpPayLaterButtonPreview'));
}); });
} }
// Generic behaviours, can be moved to common.js once it's on trunk branch.
jQuery( '*[data-ppcp-handlers]' ).each( (index, el) => {
const handlers = jQuery(el).data('ppcpHandlers');
for (const handlerConfig of handlers) {
new {
SubElementsHandler: SubElementsHandler
}[handlerConfig.handler](el, handlerConfig.options)
}
});
} }
); );

View file

@ -53,6 +53,7 @@ use WooCommerce\PayPalCommerce\WcGateway\Helper\CheckoutHelper;
use WooCommerce\PayPalCommerce\WcGateway\Helper\DCCProductStatus; use WooCommerce\PayPalCommerce\WcGateway\Helper\DCCProductStatus;
use WooCommerce\PayPalCommerce\WcGateway\Helper\PayUponInvoiceHelper; use WooCommerce\PayPalCommerce\WcGateway\Helper\PayUponInvoiceHelper;
use WooCommerce\PayPalCommerce\WcGateway\Helper\PayUponInvoiceProductStatus; use WooCommerce\PayPalCommerce\WcGateway\Helper\PayUponInvoiceProductStatus;
use WooCommerce\PayPalCommerce\WcGateway\Helper\RefundFeesUpdater;
use WooCommerce\PayPalCommerce\WcGateway\Helper\SettingsStatus; use WooCommerce\PayPalCommerce\WcGateway\Helper\SettingsStatus;
use WooCommerce\PayPalCommerce\WcGateway\Notice\AuthorizeOrderActionNotice; use WooCommerce\PayPalCommerce\WcGateway\Notice\AuthorizeOrderActionNotice;
use WooCommerce\PayPalCommerce\WcGateway\Notice\ConnectAdminNotice; use WooCommerce\PayPalCommerce\WcGateway\Notice\ConnectAdminNotice;
@ -211,10 +212,18 @@ return array(
return new ConnectAdminNotice( $state, $settings ); return new ConnectAdminNotice( $state, $settings );
}, },
'wcgateway.notice.currency-unsupported' => static function ( ContainerInterface $container ): UnsupportedCurrencyAdminNotice { 'wcgateway.notice.currency-unsupported' => static function ( ContainerInterface $container ): UnsupportedCurrencyAdminNotice {
$state = $container->get( 'onboarding.state' ); $state = $container->get( 'onboarding.state' );
$shop_currency = $container->get( 'api.shop.currency' ); $shop_currency = $container->get( 'api.shop.currency' );
$supported_currencies = $container->get( 'api.supported-currencies' ); $supported_currencies = $container->get( 'api.supported-currencies' );
return new UnsupportedCurrencyAdminNotice( $state, $shop_currency, $supported_currencies ); $is_wc_gateways_list_page = $container->get( 'wcgateway.is-wc-gateways-list-page' );
$is_ppcp_settings_page = $container->get( 'wcgateway.is-ppcp-settings-page' );
return new UnsupportedCurrencyAdminNotice(
$state,
$shop_currency,
$supported_currencies,
$is_wc_gateways_list_page,
$is_ppcp_settings_page
);
}, },
'wcgateway.notice.dcc-without-paypal' => static function ( ContainerInterface $container ): GatewayWithoutPayPalAdminNotice { 'wcgateway.notice.dcc-without-paypal' => static function ( ContainerInterface $container ): GatewayWithoutPayPalAdminNotice {
return new GatewayWithoutPayPalAdminNotice( return new GatewayWithoutPayPalAdminNotice(
@ -336,10 +345,11 @@ return array(
); );
}, },
'wcgateway.processor.refunds' => static function ( ContainerInterface $container ): RefundProcessor { 'wcgateway.processor.refunds' => static function ( ContainerInterface $container ): RefundProcessor {
$order_endpoint = $container->get( 'api.endpoint.order' ); $order_endpoint = $container->get( 'api.endpoint.order' );
$payments_endpoint = $container->get( 'api.endpoint.payments' ); $payments_endpoint = $container->get( 'api.endpoint.payments' );
$logger = $container->get( 'woocommerce.logger.woocommerce' ); $refund_fees_updater = $container->get( 'wcgateway.helper.refund-fees-updater' );
return new RefundProcessor( $order_endpoint, $payments_endpoint, $logger ); $logger = $container->get( 'woocommerce.logger.woocommerce' );
return new RefundProcessor( $order_endpoint, $payments_endpoint, $refund_fees_updater, $logger );
}, },
'wcgateway.processor.authorized-payments' => static function ( ContainerInterface $container ): AuthorizedPaymentsProcessor { 'wcgateway.processor.authorized-payments' => static function ( ContainerInterface $container ): AuthorizedPaymentsProcessor {
$order_endpoint = $container->get( 'api.endpoint.order' ); $order_endpoint = $container->get( 'api.endpoint.order' );
@ -1010,6 +1020,12 @@ return array(
); );
}, },
'wcgateway.helper.refund-fees-updater' => static function ( ContainerInterface $container ): RefundFeesUpdater {
$order_endpoint = $container->get( 'api.endpoint.order' );
$logger = $container->get( 'woocommerce.logger.woocommerce' );
return new RefundFeesUpdater( $order_endpoint, $logger );
},
'button.helper.messages-disclaimers' => static function ( ContainerInterface $container ): MessagesDisclaimers { 'button.helper.messages-disclaimers' => static function ( ContainerInterface $container ): MessagesDisclaimers {
return new MessagesDisclaimers( return new MessagesDisclaimers(
$container->get( 'api.shop.country' ) $container->get( 'api.shop.country' )

View file

@ -24,11 +24,18 @@ class FeesRenderer {
* @return string * @return string
*/ */
public function render( WC_Order $wc_order ) : string { public function render( WC_Order $wc_order ) : string {
$breakdown = $wc_order->get_meta( PayPalGateway::FEES_META_KEY ); $breakdown = $wc_order->get_meta( PayPalGateway::FEES_META_KEY );
$refund_breakdown = $wc_order->get_meta( PayPalGateway::REFUND_FEES_META_KEY ) ?: array();
if ( ! is_array( $breakdown ) ) { if ( ! is_array( $breakdown ) ) {
return ''; return '';
} }
$refund_fee = $refund_breakdown['paypal_fee'] ?? array();
$refund_amount = $refund_breakdown['net_amount'] ?? array();
$refund_total = ( $refund_fee['value'] ?? 0 ) + ( $refund_amount['value'] ?? 0 );
$refund_currency = ( ( $refund_amount['currency_code'] ?? '' ) === ( $refund_fee['currency_code'] ?? '' ) ) ? ( $refund_amount['currency_code'] ?? '' ) : '';
$html = ''; $html = '';
$fee = $breakdown['paypal_fee'] ?? null; $fee = $breakdown['paypal_fee'] ?? null;
@ -42,6 +49,28 @@ class FeesRenderer {
); );
} }
if ( $refund_fee ) {
$html .= $this->render_money_row(
__( 'PayPal Refund Fee:', 'woocommerce-paypal-payments' ),
__( 'The fee PayPal collects for the refund transactions.', 'woocommerce-paypal-payments' ),
$refund_fee['value'],
$refund_fee['currency_code'],
true,
'refunded-total'
);
}
if ( $refund_amount ) {
$html .= $this->render_money_row(
__( 'PayPal Refunded:', 'woocommerce-paypal-payments' ),
__( 'The net amount that was refunded.', 'woocommerce-paypal-payments' ),
$refund_amount['value'],
$refund_amount['currency_code'],
true,
'refunded-total'
);
}
$net = $breakdown['net_amount'] ?? null; $net = $breakdown['net_amount'] ?? null;
if ( is_array( $net ) ) { if ( is_array( $net ) ) {
$html .= $this->render_money_row( $html .= $this->render_money_row(
@ -50,6 +79,15 @@ class FeesRenderer {
$net['value'], $net['value'],
$net['currency_code'] $net['currency_code']
); );
if ( ( $refund_total > 0.0 && $refund_currency === $net['currency_code'] ) ) {
$html .= $this->render_money_row(
__( 'PayPal Net Total:', 'woocommerce-paypal-payments' ),
__( 'The net total that will be credited to your PayPal account minus the refunds.', 'woocommerce-paypal-payments' ),
$net['value'] - $refund_total,
$net['currency_code']
);
}
} }
return $html; return $html;
@ -63,9 +101,10 @@ class FeesRenderer {
* @param string|float $value The money value. * @param string|float $value The money value.
* @param string $currency The currency code. * @param string $currency The currency code.
* @param bool $negative Whether to add the minus sign. * @param bool $negative Whether to add the minus sign.
* @param string $html_class Html class to add to the elements.
* @return string * @return string
*/ */
private function render_money_row( string $title, string $tooltip, $value, string $currency, bool $negative = false ): string { private function render_money_row( string $title, string $tooltip, $value, string $currency, bool $negative = false, string $html_class = '' ): string {
/** /**
* Bad type hint in WC phpdoc. * Bad type hint in WC phpdoc.
* *
@ -73,10 +112,10 @@ class FeesRenderer {
*/ */
return ' return '
<tr> <tr>
<td class="label">' . wc_help_tip( $tooltip ) . ' ' . esc_html( $title ) . ' <td class="' . trim( 'label ' . $html_class ) . '">' . wc_help_tip( $tooltip ) . ' ' . esc_html( $title ) . '
</td> </td>
<td width="1%"></td> <td width="1%"></td>
<td class="total"> <td class="' . trim( 'total ' . $html_class ) . '">
' . ' .
( $negative ? ' - ' : '' ) . ( $negative ? ' - ' : '' ) .
wc_price( $value, array( 'currency' => $currency ) ) . ' wc_price( $value, array( 'currency' => $currency ) ) . '

View file

@ -87,6 +87,13 @@ class SettingsPageAssets {
*/ */
protected $all_funding_sources; protected $all_funding_sources;
/**
* Whether it's a settings page of this plugin.
*
* @var bool
*/
private $is_settings_page;
/** /**
* Assets constructor. * Assets constructor.
* *
@ -100,6 +107,7 @@ class SettingsPageAssets {
* @param bool $is_pay_later_button_enabled Whether Pay Later button is enabled either for checkout, cart or product page. * @param bool $is_pay_later_button_enabled Whether Pay Later button is enabled either for checkout, cart or product page.
* @param array $disabled_sources The list of disabled funding sources. * @param array $disabled_sources The list of disabled funding sources.
* @param array $all_funding_sources The list of all existing funding sources. * @param array $all_funding_sources The list of all existing funding sources.
* @param bool $is_settings_page Whether it's a settings page of this plugin.
*/ */
public function __construct( public function __construct(
string $module_url, string $module_url,
@ -111,7 +119,8 @@ class SettingsPageAssets {
Environment $environment, Environment $environment,
bool $is_pay_later_button_enabled, bool $is_pay_later_button_enabled,
array $disabled_sources, array $disabled_sources,
array $all_funding_sources array $all_funding_sources,
bool $is_settings_page
) { ) {
$this->module_url = $module_url; $this->module_url = $module_url;
$this->version = $version; $this->version = $version;
@ -123,12 +132,15 @@ class SettingsPageAssets {
$this->is_pay_later_button_enabled = $is_pay_later_button_enabled; $this->is_pay_later_button_enabled = $is_pay_later_button_enabled;
$this->disabled_sources = $disabled_sources; $this->disabled_sources = $disabled_sources;
$this->all_funding_sources = $all_funding_sources; $this->all_funding_sources = $all_funding_sources;
$this->is_settings_page = $is_settings_page;
} }
/** /**
* Register assets provided by this module. * Register assets provided by this module.
*
* @return void
*/ */
public function register_assets() { public function register_assets(): void {
add_action( add_action(
'admin_enqueue_scripts', 'admin_enqueue_scripts',
function() { function() {
@ -136,11 +148,13 @@ class SettingsPageAssets {
return; return;
} }
if ( ! $this->is_paypal_payment_method_page() ) { if ( $this->is_settings_page ) {
return; $this->register_admin_assets();
} }
$this->register_admin_assets(); if ( $this->is_paypal_payment_method_page() ) {
$this->register_paypal_admin_assets();
}
} }
); );
@ -171,9 +185,9 @@ class SettingsPageAssets {
} }
/** /**
* Register assets for admin pages. * Register assets for PayPal admin pages.
*/ */
private function register_admin_assets(): void { private function register_paypal_admin_assets(): void {
wp_enqueue_style( wp_enqueue_style(
'ppcp-gateway-settings', 'ppcp-gateway-settings',
trailingslashit( $this->module_url ) . 'assets/css/gateway-settings.css', trailingslashit( $this->module_url ) . 'assets/css/gateway-settings.css',
@ -210,4 +224,18 @@ class SettingsPageAssets {
) )
); );
} }
/**
* Register assets for PayPal admin pages.
*/
private function register_admin_assets(): void {
wp_enqueue_script(
'ppcp-admin-common',
trailingslashit( $this->module_url ) . 'assets/js/common.js',
array(),
$this->version,
true
);
}
} }

View file

@ -48,6 +48,7 @@ class PayPalGateway extends \WC_Payment_Gateway {
const ORDER_PAYMENT_MODE_META_KEY = '_ppcp_paypal_payment_mode'; const ORDER_PAYMENT_MODE_META_KEY = '_ppcp_paypal_payment_mode';
const ORDER_PAYMENT_SOURCE_META_KEY = '_ppcp_paypal_payment_source'; const ORDER_PAYMENT_SOURCE_META_KEY = '_ppcp_paypal_payment_source';
const FEES_META_KEY = '_ppcp_paypal_fees'; const FEES_META_KEY = '_ppcp_paypal_fees';
const REFUND_FEES_META_KEY = '_ppcp_paypal_refund_fees';
const REFUNDS_META_KEY = '_ppcp_refunds'; const REFUNDS_META_KEY = '_ppcp_refunds';
/** /**
@ -289,9 +290,11 @@ class PayPalGateway extends \WC_Payment_Gateway {
// in the constructor, so must do it here. // in the constructor, so must do it here.
global $theorder; global $theorder;
if ( $theorder instanceof WC_Order ) { if ( $theorder instanceof WC_Order ) {
$payment_method_title = $theorder->get_payment_method_title(); if ( $theorder->get_payment_method() === self::ID ) {
if ( $payment_method_title ) { $payment_method_title = $theorder->get_payment_method_title();
$this->title = $payment_method_title; if ( $payment_method_title ) {
$this->title = $payment_method_title;
}
} }
} }
} }
@ -525,7 +528,7 @@ class PayPalGateway extends \WC_Payment_Gateway {
$order = $this->session_handler->order(); $order = $this->session_handler->order();
$this->add_paypal_meta( $wc_order, $order, $this->environment ); $this->add_paypal_meta( $wc_order, $order, $this->environment );
$subscriptions = wcs_get_subscriptions_for_order( $order_id ); $subscriptions = function_exists( 'wcs_get_subscriptions_for_order' ) ? wcs_get_subscriptions_for_order( $order_id ) : array();
foreach ( $subscriptions as $subscription ) { foreach ( $subscriptions as $subscription ) {
$subscription->update_meta_data( 'ppcp_subscription', $paypal_subscription_id ); $subscription->update_meta_data( 'ppcp_subscription', $paypal_subscription_id );
$subscription->save(); $subscription->save();

View file

@ -40,7 +40,7 @@ trait ProcessPaymentTrait {
* @param int $customer_id The customer ID. * @param int $customer_id The customer ID.
*/ */
protected function schedule_saved_payment_check( int $wc_order_id, int $customer_id ): void { protected function schedule_saved_payment_check( int $wc_order_id, int $customer_id ): void {
$timestamp = 1 * MINUTE_IN_SECONDS; $timestamp = 3 * MINUTE_IN_SECONDS;
if ( if (
$this->config->has( 'subscription_behavior_when_vault_fails' ) $this->config->has( 'subscription_behavior_when_vault_fails' )
&& $this->config->get( 'subscription_behavior_when_vault_fails' ) === 'capture_auth' && $this->config->get( 'subscription_behavior_when_vault_fails' ) === 'capture_auth'

View file

@ -0,0 +1,195 @@
<?php
/**
* The RefundFeesUpdater helper.
*
* @package WooCommerce\PayPalCommerce\WcGateway\Helper;
*/
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\WcGateway\Helper;
use Psr\Log\LoggerInterface;
use WC_Order;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Money;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway;
use WP_Comment;
/**
* CheckoutHelper class.
*/
class RefundFeesUpdater {
/**
* The Order Endpoint.
*
* @var OrderEndpoint
*/
private $order_endpoint;
/**
* The logger.
*
* @var LoggerInterface
*/
private $logger;
/**
* RefundFeesUpdater constructor.
*
* @param OrderEndpoint $order_endpoint The Order Endpoint.
* @param LoggerInterface $logger The logger.
*/
public function __construct( OrderEndpoint $order_endpoint, LoggerInterface $logger ) {
$this->order_endpoint = $order_endpoint;
$this->logger = $logger;
}
/**
* Updates the fees meta for a given order.
*
* @param WC_Order $wc_order The WooCommerce order.
* @return void
*/
public function update( WC_Order $wc_order ): void {
$paypal_order_id = $wc_order->get_meta( PayPalGateway::ORDER_ID_META_KEY );
if ( ! $paypal_order_id ) {
$this->logger->error(
sprintf( 'Failed to update order paypal refund fees. No PayPal order_id. [wc_order: %s]', $wc_order->get_id() )
);
return;
}
$this->logger->debug(
sprintf( 'Updating order paypal refund fees. [wc_order: %s, paypal_order: %s]', $wc_order->get_id(), $paypal_order_id )
);
$paypal_order = $this->order_endpoint->order( $paypal_order_id );
$purchase_units = $paypal_order->purchase_units();
$gross_amount_total = 0.0;
$fee_total = 0.0;
$net_amount_total = 0.0;
$currency_codes = array();
$refunds_ids = array();
foreach ( $purchase_units as $purchase_unit ) {
$payments = $purchase_unit->payments();
if ( ! $payments ) {
continue;
}
$refunds = $payments->refunds();
foreach ( $refunds as $refund ) {
$breakdown = $refund->seller_payable_breakdown();
$refunds_ids[] = $refund->id();
if ( ! $breakdown ) {
continue;
}
$gross_amount = $breakdown->gross_amount();
if ( $gross_amount ) {
$gross_amount_total += $gross_amount->value();
$currency_codes[] = $gross_amount->currency_code();
}
$paypal_fee = $breakdown->paypal_fee();
if ( $paypal_fee ) {
$fee_total += $paypal_fee->value();
$currency_codes[] = $paypal_fee->currency_code();
}
$net_amount = $breakdown->net_amount();
if ( $net_amount ) {
$net_amount_total += $net_amount->value();
$currency_codes[] = $net_amount->currency_code();
}
}
}
$currency_codes = array_unique( $currency_codes );
if ( count( $currency_codes ) > 1 ) {
// There are multiple different currencies codes in the refunds.
$this->logger->warning(
sprintf(
'Updating order paypal refund fees. Multiple currencies detected. [wc_order: %s, paypal_order: %s, currencies: %s]',
$wc_order->get_id(),
$paypal_order_id,
implode( ',', $currency_codes )
)
);
$wc_order->update_meta_data( PayPalGateway::REFUND_FEES_META_KEY, array() );
return;
}
$currency_code = current( $currency_codes ) ?: '';
$meta_data = array(
'gross_amount' => ( new Money( $gross_amount_total, $currency_code ) )->to_array(),
'paypal_fee' => ( new Money( $fee_total, $currency_code ) )->to_array(),
'net_amount' => ( new Money( $net_amount_total, $currency_code ) )->to_array(),
);
$wc_order->update_meta_data( PayPalGateway::REFUND_FEES_META_KEY, $meta_data );
$wc_order->save();
$order_notes = $this->get_order_notes( $wc_order );
foreach ( $refunds_ids as $refund_id ) {
$has_note = false;
foreach ( $order_notes as $order_note ) {
if ( strpos( $order_note->comment_content, $refund_id ) !== false ) {
$has_note = true;
}
}
if ( ! $has_note ) {
$wc_order->add_order_note( sprintf( 'PayPal refund ID: %s', $refund_id ) );
}
}
$this->logger->debug(
sprintf( 'Updated order paypal refund fees. [wc_order: %s, paypal_order: %s]', $wc_order->get_id(), $paypal_order_id )
);
}
/**
* Returns all order notes
* Based on WC_Order::get_customer_order_notes
*
* @param WC_Order $wc_order The WooCommerce order.
* @return WP_Comment[]
*/
private function get_order_notes( WC_Order $wc_order ): array {
$notes = array();
$args = array(
'post_id' => $wc_order->get_id(),
);
// By default, WooCommerce excludes comments of the comment_type order_note.
// We need to remove this filter to get the order notes.
remove_filter( 'comments_clauses', array( 'WC_Comments', 'exclude_order_comments' ) );
$comments = get_comments( $args );
if ( is_array( $comments ) ) {
foreach ( $comments as $comment ) {
if ( $comment instanceof WP_Comment ) {
$notes[] = $comment;
}
}
}
add_filter( 'comments_clauses', array( 'WC_Comments', 'exclude_order_comments' ) );
return $notes;
}
}

View file

@ -40,17 +40,42 @@ class UnsupportedCurrencyAdminNotice {
*/ */
private $shop_currency; private $shop_currency;
/**
* Indicates if we're on the WooCommerce gateways list page.
*
* @var bool
*/
private $is_wc_gateways_list_page;
/**
* Indicates if we're on a PPCP Settings page.
*
* @var bool
*/
private $is_ppcp_settings_page;
/** /**
* UnsupportedCurrencyAdminNotice constructor. * UnsupportedCurrencyAdminNotice constructor.
* *
* @param State $state The state. * @param State $state The state.
* @param string $shop_currency The shop currency. * @param string $shop_currency The shop currency.
* @param array $supported_currencies The supported currencies. * @param array $supported_currencies The supported currencies.
* @param bool $is_wc_gateways_list_page Indicates if we're on the WooCommerce gateways list page.
* @param bool $is_ppcp_settings_page Indicates if we're on a PPCP Settings page.
*/ */
public function __construct( State $state, string $shop_currency, array $supported_currencies ) { public function __construct(
$this->state = $state; State $state,
$this->shop_currency = $shop_currency; string $shop_currency,
$this->supported_currencies = $supported_currencies; array $supported_currencies,
bool $is_wc_gateways_list_page,
bool $is_ppcp_settings_page
) {
$this->state = $state;
$this->shop_currency = $shop_currency;
$this->supported_currencies = $supported_currencies;
$this->is_wc_gateways_list_page = $is_wc_gateways_list_page;
$this->is_ppcp_settings_page = $is_ppcp_settings_page;
} }
/** /**
@ -63,16 +88,19 @@ class UnsupportedCurrencyAdminNotice {
return null; return null;
} }
$paypal_currency_support_url = 'https://developer.paypal.com/api/rest/reference/currency-codes/';
$message = sprintf( $message = sprintf(
/* translators: %1$s the shop currency, 2$s the gateway name. */ /* translators: %1$s the shop currency, %2$s the PayPal currency support page link opening HTML tag, %3$s the link ending HTML tag. */
__( __(
'Attention: Your current WooCommerce store currency (%1$s) is not supported by PayPal. Please update your store currency to one that is supported by PayPal to ensure smooth transactions. Visit the <a href="%2$s">PayPal currency support page</a> for more information on supported currencies.', 'Attention: Your current WooCommerce store currency (%1$s) is not supported by PayPal. Please update your store currency to one that is supported by PayPal to ensure smooth transactions. Visit the %2$sPayPal currency support page%3$s for more information on supported currencies.',
'woocommerce-paypal-payments' 'woocommerce-paypal-payments'
), ),
$this->shop_currency, $this->shop_currency,
'https://developer.paypal.com/api/rest/reference/currency-codes/' '<a href="' . esc_url( $paypal_currency_support_url ) . '">',
'</a>'
); );
return new Message( $message, 'warning' ); return new Message( $message, 'warning', true, 'ppcp-notice-wrapper' );
} }
/** /**
@ -81,7 +109,9 @@ class UnsupportedCurrencyAdminNotice {
* @return bool * @return bool
*/ */
protected function should_display(): bool { protected function should_display(): bool {
return $this->state->current_state() === State::STATE_ONBOARDED && ! $this->currency_supported(); return $this->state->current_state() === State::STATE_ONBOARDED
&& ! $this->currency_supported()
&& ( $this->is_wc_gateways_list_page || $this->is_ppcp_settings_page );
} }
/** /**

View file

@ -11,6 +11,7 @@ namespace WooCommerce\PayPalCommerce\WcGateway\Processor;
use WC_Order; use WC_Order;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Order; use WooCommerce\PayPalCommerce\ApiClient\Entity\Order;
use WooCommerce\PayPalCommerce\ApiClient\Helper\OrderTransient;
use WooCommerce\PayPalCommerce\Onboarding\Environment; use WooCommerce\PayPalCommerce\Onboarding\Environment;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway; use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway;
@ -22,14 +23,16 @@ trait OrderMetaTrait {
/** /**
* Adds common metadata to the order. * Adds common metadata to the order.
* *
* @param WC_Order $wc_order The WC order to which metadata will be added. * @param WC_Order $wc_order The WC order to which metadata will be added.
* @param Order $order The PayPal order. * @param Order $order The PayPal order.
* @param Environment $environment The environment. * @param Environment $environment The environment.
* @param OrderTransient|null $order_transient The order transient helper.
*/ */
protected function add_paypal_meta( protected function add_paypal_meta(
WC_Order $wc_order, WC_Order $wc_order,
Order $order, Order $order,
Environment $environment Environment $environment,
OrderTransient $order_transient = null
): void { ): void {
$wc_order->update_meta_data( PayPalGateway::ORDER_ID_META_KEY, $order->id() ); $wc_order->update_meta_data( PayPalGateway::ORDER_ID_META_KEY, $order->id() );
$wc_order->update_meta_data( PayPalGateway::INTENT_META_KEY, $order->intent() ); $wc_order->update_meta_data( PayPalGateway::INTENT_META_KEY, $order->intent() );
@ -43,6 +46,8 @@ trait OrderMetaTrait {
} }
$wc_order->save(); $wc_order->save();
do_action( 'woocommerce_paypal_payments_woocommerce_order_created', $wc_order, $order );
} }
/** /**

View file

@ -20,9 +20,10 @@ use WooCommerce\PayPalCommerce\ApiClient\Entity\AuthorizationStatus;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Money; use WooCommerce\PayPalCommerce\ApiClient\Entity\Money;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Order; use WooCommerce\PayPalCommerce\ApiClient\Entity\Order;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Payments; use WooCommerce\PayPalCommerce\ApiClient\Entity\Payments;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Refund; use WooCommerce\PayPalCommerce\ApiClient\Entity\RefundCapture;
use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException; use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway; use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway;
use WooCommerce\PayPalCommerce\WcGateway\Helper\RefundFeesUpdater;
/** /**
* Class RefundProcessor * Class RefundProcessor
@ -55,18 +56,27 @@ class RefundProcessor {
*/ */
private $logger; private $logger;
/**
* The refund fees updater.
*
* @var RefundFeesUpdater
*/
private $refund_fees_updater;
/** /**
* RefundProcessor constructor. * RefundProcessor constructor.
* *
* @param OrderEndpoint $order_endpoint The order endpoint. * @param OrderEndpoint $order_endpoint The order endpoint.
* @param PaymentsEndpoint $payments_endpoint The payments endpoint. * @param PaymentsEndpoint $payments_endpoint The payments endpoint.
* @param LoggerInterface $logger The logger. * @param RefundFeesUpdater $refund_fees_updater The refund fees updater.
* @param LoggerInterface $logger The logger.
*/ */
public function __construct( OrderEndpoint $order_endpoint, PaymentsEndpoint $payments_endpoint, LoggerInterface $logger ) { public function __construct( OrderEndpoint $order_endpoint, PaymentsEndpoint $payments_endpoint, RefundFeesUpdater $refund_fees_updater, LoggerInterface $logger ) {
$this->order_endpoint = $order_endpoint; $this->order_endpoint = $order_endpoint;
$this->payments_endpoint = $payments_endpoint; $this->payments_endpoint = $payments_endpoint;
$this->logger = $logger; $this->refund_fees_updater = $refund_fees_updater;
$this->logger = $logger;
} }
/** /**
@ -106,6 +116,7 @@ class RefundProcessor {
$refund_id = $this->refund( $order, $wc_order, $amount, $reason ); $refund_id = $this->refund( $order, $wc_order, $amount, $reason );
$this->add_refund_to_meta( $wc_order, $refund_id ); $this->add_refund_to_meta( $wc_order, $refund_id );
$this->refund_fees_updater->update( $wc_order );
break; break;
case self::REFUND_MODE_VOID: case self::REFUND_MODE_VOID:
@ -151,7 +162,7 @@ class RefundProcessor {
} }
$capture = $captures[0]; $capture = $captures[0];
$refund = new Refund( $refund = new RefundCapture(
$capture, $capture,
$capture->invoice_id(), $capture->invoice_id(),
$reason, $reason,

View file

@ -11,6 +11,7 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\WcGateway\Settings; namespace WooCommerce\PayPalCommerce\WcGateway\Settings;
use WooCommerce\PayPalCommerce\ApiClient\Helper\PurchaseUnitSanitizer;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
use WooCommerce\PayPalCommerce\ApiClient\Helper\DccApplies; use WooCommerce\PayPalCommerce\ApiClient\Helper\DccApplies;
use WooCommerce\PayPalCommerce\Onboarding\Environment; use WooCommerce\PayPalCommerce\Onboarding\Environment;
@ -496,6 +497,55 @@ return function ( ContainerInterface $container, array $fields ): array {
'requirements' => array(), 'requirements' => array(),
'gateway' => Settings::CONNECTION_TAB_ID, 'gateway' => Settings::CONNECTION_TAB_ID,
), ),
'subtotal_mismatch_behavior' => array(
'title' => __( 'Subtotal mismatch behavior', 'woocommerce-paypal-payments' ),
'type' => 'select',
'input_class' => array( 'wc-enhanced-select' ),
'default' => 'vertical',
'desc_tip' => true,
'description' => __(
'Differences between WooCommerce and PayPal roundings may cause mismatch in order items subtotal calculations. If not handled, these mismatches will cause the PayPal transaction to fail.',
'woocommerce-paypal-payments'
),
'options' => array(
PurchaseUnitSanitizer::MODE_DITCH => __( 'Do not send line items to PayPal', 'woocommerce-paypal-payments' ),
PurchaseUnitSanitizer::MODE_EXTRA_LINE => __( 'Add another line item', 'woocommerce-paypal-payments' ),
),
'screens' => array(
State::STATE_START,
State::STATE_ONBOARDED,
),
'requirements' => array(),
'gateway' => Settings::CONNECTION_TAB_ID,
'custom_attributes' => array(
'data-ppcp-handlers' => wp_json_encode(
array(
array(
'handler' => 'SubElementsHandler',
'options' => array(
'values' => array( PurchaseUnitSanitizer::MODE_EXTRA_LINE ),
'elements' => array( '#field-subtotal_mismatch_line_name' ),
),
),
)
),
),
),
'subtotal_mismatch_line_name' => array(
'title' => __( 'Subtotal mismatch line name', 'woocommerce-paypal-payments' ),
'type' => 'text',
'desc_tip' => true,
'description' => __( 'The name of the extra line that will be sent to PayPal to correct the subtotal mismatch.', 'woocommerce-paypal-payments' ),
'maxlength' => 22,
'default' => '',
'screens' => array(
State::STATE_START,
State::STATE_ONBOARDED,
),
'requirements' => array(),
'placeholder' => PurchaseUnitSanitizer::EXTRA_LINE_NAME,
'gateway' => Settings::CONNECTION_TAB_ID,
),
); );
return array_merge( $fields, $connection_fields ); return array_merge( $fields, $connection_fields );

View file

@ -77,6 +77,8 @@ class HeaderRenderer {
'</a> '</a>
</span> </span>
</div> </div>
<div class="ppcp-notice-wrapper"></div>
'; ';
} }
} }

View file

@ -183,7 +183,8 @@ class WCGatewayModule implements ModuleInterface {
$c->get( 'onboarding.environment' ), $c->get( 'onboarding.environment' ),
$settings_status->is_pay_later_button_enabled(), $settings_status->is_pay_later_button_enabled(),
$settings->has( 'disable_funding' ) ? $settings->get( 'disable_funding' ) : array(), $settings->has( 'disable_funding' ) ? $settings->get( 'disable_funding' ) : array(),
$c->get( 'wcgateway.settings.funding-sources' ) $c->get( 'wcgateway.settings.funding-sources' ),
$c->get( 'wcgateway.is-ppcp-settings-page' )
); );
$assets->register_assets(); $assets->register_assets();
} }

View file

@ -6,6 +6,7 @@ module.exports = {
mode: isProduction ? 'production' : 'development', mode: isProduction ? 'production' : 'development',
target: 'web', target: 'web',
entry: { entry: {
'common': path.resolve('./resources/js/common.js'),
'gateway-settings': path.resolve('./resources/js/gateway-settings.js'), 'gateway-settings': path.resolve('./resources/js/gateway-settings.js'),
'fraudnet': path.resolve('./resources/js/fraudnet.js'), 'fraudnet': path.resolve('./resources/js/fraudnet.js'),
'oxxo': path.resolve('./resources/js/oxxo.js'), 'oxxo': path.resolve('./resources/js/oxxo.js'),

View file

@ -80,6 +80,7 @@ return array(
$order_endpoint = $container->get( 'api.endpoint.order' ); $order_endpoint = $container->get( 'api.endpoint.order' );
$authorized_payments_processor = $container->get( 'wcgateway.processor.authorized-payments' ); $authorized_payments_processor = $container->get( 'wcgateway.processor.authorized-payments' );
$payment_token_factory = $container->get( 'vaulting.payment-token-factory' ); $payment_token_factory = $container->get( 'vaulting.payment-token-factory' );
$refund_fees_updater = $container->get( 'wcgateway.helper.refund-fees-updater' );
return array( return array(
new CheckoutOrderApproved( new CheckoutOrderApproved(
@ -91,14 +92,14 @@ return array(
), ),
new CheckoutOrderCompleted( $logger ), new CheckoutOrderCompleted( $logger ),
new CheckoutPaymentApprovalReversed( $logger ), new CheckoutPaymentApprovalReversed( $logger ),
new PaymentCaptureRefunded( $logger ), new PaymentCaptureRefunded( $logger, $refund_fees_updater ),
new PaymentCaptureReversed( $logger ), new PaymentCaptureReversed( $logger ),
new PaymentCaptureCompleted( $logger, $order_endpoint ), new PaymentCaptureCompleted( $logger, $order_endpoint ),
new VaultPaymentTokenCreated( $logger, $prefix, $authorized_payments_processor, $payment_token_factory ), new VaultPaymentTokenCreated( $logger, $prefix, $authorized_payments_processor, $payment_token_factory ),
new VaultPaymentTokenDeleted( $logger ), new VaultPaymentTokenDeleted( $logger ),
new PaymentCapturePending( $logger ), new PaymentCapturePending( $logger ),
new PaymentSaleCompleted( $logger ), new PaymentSaleCompleted( $logger ),
new PaymentSaleRefunded( $logger ), new PaymentSaleRefunded( $logger, $refund_fees_updater ),
new BillingSubscriptionCancelled( $logger ), new BillingSubscriptionCancelled( $logger ),
new BillingPlanPricingChangeActivated( $logger ), new BillingPlanPricingChangeActivated( $logger ),
new CatalogProductUpdated( $logger ), new CatalogProductUpdated( $logger ),

View file

@ -81,7 +81,7 @@ class BillingSubscriptionCancelled implements RequestHandler {
), ),
), ),
); );
$subscriptions = wcs_get_subscriptions( $args ); $subscriptions = function_exists( 'wcs_get_subscriptions' ) ? wcs_get_subscriptions( $args ) : array();
foreach ( $subscriptions as $subscription ) { foreach ( $subscriptions as $subscription ) {
$subscription->update_status( 'cancelled' ); $subscription->update_status( 'cancelled' );
} }

View file

@ -11,6 +11,7 @@ namespace WooCommerce\PayPalCommerce\Webhooks\Handler;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use WC_Order; use WC_Order;
use WooCommerce\PayPalCommerce\WcGateway\Helper\RefundFeesUpdater;
use WooCommerce\PayPalCommerce\WcGateway\Processor\RefundMetaTrait; use WooCommerce\PayPalCommerce\WcGateway\Processor\RefundMetaTrait;
use WooCommerce\PayPalCommerce\WcGateway\Processor\TransactionIdHandlingTrait; use WooCommerce\PayPalCommerce\WcGateway\Processor\TransactionIdHandlingTrait;
use WP_Error; use WP_Error;
@ -31,13 +32,22 @@ class PaymentCaptureRefunded implements RequestHandler {
*/ */
private $logger; private $logger;
/**
* The refund fees updater.
*
* @var RefundFeesUpdater
*/
private $refund_fees_updater;
/** /**
* PaymentCaptureRefunded constructor. * PaymentCaptureRefunded constructor.
* *
* @param LoggerInterface $logger The logger. * @param LoggerInterface $logger The logger.
* @param RefundFeesUpdater $refund_fees_updater The refund fees updater.
*/ */
public function __construct( LoggerInterface $logger ) { public function __construct( LoggerInterface $logger, RefundFeesUpdater $refund_fees_updater ) {
$this->logger = $logger; $this->logger = $logger;
$this->refund_fees_updater = $refund_fees_updater;
} }
/** /**
@ -68,9 +78,11 @@ class PaymentCaptureRefunded implements RequestHandler {
* @return WP_REST_Response * @return WP_REST_Response
*/ */
public function handle_request( WP_REST_Request $request ): WP_REST_Response { public function handle_request( WP_REST_Request $request ): WP_REST_Response {
$order_id = isset( $request['resource']['custom_id'] ) ? $resource = ( $request['resource'] ?? array() ) ?: array();
$request['resource']['custom_id'] : 0;
$refund_id = (string) ( $request['resource']['id'] ?? '' ); $order_id = $resource['custom_id'] ?? 0;
$refund_id = (string) ( $resource['id'] ?? '' );
if ( ! $order_id ) { if ( ! $order_id ) {
$message = sprintf( $message = sprintf(
'No order for webhook event %s was found.', 'No order for webhook event %s was found.',
@ -127,6 +139,7 @@ class PaymentCaptureRefunded implements RequestHandler {
if ( $refund_id ) { if ( $refund_id ) {
$this->update_transaction_id( $refund_id, $wc_order, $this->logger ); $this->update_transaction_id( $refund_id, $wc_order, $this->logger );
$this->add_refund_to_meta( $wc_order, $refund_id ); $this->add_refund_to_meta( $wc_order, $refund_id );
$this->refund_fees_updater->update( $wc_order );
} }
return $this->success_response(); return $this->success_response();

View file

@ -71,10 +71,13 @@ class PaymentSaleCompleted implements RequestHandler {
return $this->failure_response(); return $this->failure_response();
} }
if ( ! function_exists( 'wcs_get_subscriptions' ) ) {
return $this->failure_response( 'WooCommerce Subscriptions plugin is not active.' );
}
$billing_agreement_id = wc_clean( wp_unslash( $request['resource']['billing_agreement_id'] ?? '' ) ); $billing_agreement_id = wc_clean( wp_unslash( $request['resource']['billing_agreement_id'] ?? '' ) );
if ( ! $billing_agreement_id ) { if ( ! $billing_agreement_id ) {
$message = 'Could not retrieve billing agreement id for subscription.'; return $this->failure_response( 'Could not retrieve billing agreement id for subscription.' );
return $this->failure_response( $message );
} }
$args = array( $args = array(
@ -89,10 +92,24 @@ class PaymentSaleCompleted implements RequestHandler {
); );
$subscriptions = wcs_get_subscriptions( $args ); $subscriptions = wcs_get_subscriptions( $args );
foreach ( $subscriptions as $subscription ) { foreach ( $subscriptions as $subscription ) {
$parent_order = wc_get_order( $subscription->get_parent() );
$transaction_id = wc_clean( wp_unslash( $request['resource']['id'] ?? '' ) ); $transaction_id = wc_clean( wp_unslash( $request['resource']['id'] ?? '' ) );
if ( $transaction_id && is_string( $transaction_id ) && is_a( $parent_order, WC_Order::class ) ) { if ( $transaction_id && is_string( $transaction_id ) ) {
$this->update_transaction_id( $transaction_id, $parent_order, $this->logger ); $is_renewal = $subscription->get_meta( '_ppcp_is_subscription_renewal' ) ?? '';
if ( $is_renewal ) {
$renewal_order = wcs_create_renewal_order( $subscription );
if ( is_a( $renewal_order, WC_Order::class ) ) {
$renewal_order->payment_complete();
$this->update_transaction_id( $transaction_id, $renewal_order, $this->logger );
break;
}
}
$parent_order = wc_get_order( $subscription->get_parent() );
if ( is_a( $parent_order, WC_Order::class ) ) {
$subscription->update_meta_data( '_ppcp_is_subscription_renewal', 'true' );
$subscription->save_meta_data();
$this->update_transaction_id( $transaction_id, $parent_order, $this->logger );
}
} }
} }

View file

@ -10,6 +10,7 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Webhooks\Handler; namespace WooCommerce\PayPalCommerce\Webhooks\Handler;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use WooCommerce\PayPalCommerce\WcGateway\Helper\RefundFeesUpdater;
use WooCommerce\PayPalCommerce\WcGateway\Processor\RefundMetaTrait; use WooCommerce\PayPalCommerce\WcGateway\Processor\RefundMetaTrait;
use WooCommerce\PayPalCommerce\WcGateway\Processor\TransactionIdHandlingTrait; use WooCommerce\PayPalCommerce\WcGateway\Processor\TransactionIdHandlingTrait;
use WP_Error; use WP_Error;
@ -30,13 +31,22 @@ class PaymentSaleRefunded implements RequestHandler {
*/ */
private $logger; private $logger;
/**
* The refund fees updater.
*
* @var RefundFeesUpdater
*/
private $refund_fees_updater;
/** /**
* PaymentSaleRefunded constructor. * PaymentSaleRefunded constructor.
* *
* @param LoggerInterface $logger The logger. * @param LoggerInterface $logger The logger.
* @param RefundFeesUpdater $refund_fees_updater The refund fees updater.
*/ */
public function __construct( LoggerInterface $logger ) { public function __construct( LoggerInterface $logger, RefundFeesUpdater $refund_fees_updater ) {
$this->logger = $logger; $this->logger = $logger;
$this->refund_fees_updater = $refund_fees_updater;
} }
/** /**
@ -120,6 +130,7 @@ class PaymentSaleRefunded implements RequestHandler {
$this->update_transaction_id( $refund_id, $wc_order, $this->logger ); $this->update_transaction_id( $refund_id, $wc_order, $this->logger );
$this->add_refund_to_meta( $wc_order, $refund_id ); $this->add_refund_to_meta( $wc_order, $refund_id );
$this->refund_fees_updater->update( $wc_order );
} }
return $this->success_response(); return $this->success_response();

View file

@ -107,12 +107,18 @@ class VaultPaymentTokenCreated implements RequestHandler {
$customer_id = null !== $request['resource'] && isset( $request['resource']['customer_id'] ) $customer_id = null !== $request['resource'] && isset( $request['resource']['customer_id'] )
? $request['resource']['customer_id'] ? $request['resource']['customer_id']
: ''; : '';
if ( ! $customer_id ) { if ( ! $customer_id ) {
$message = 'No customer id was found.'; $message = 'No customer id was found.';
return $this->failure_response( $message ); return $this->failure_response( $message );
} }
$wc_customer_id = (int) str_replace( $this->prefix, '', $customer_id ); $wc_customer_id = $this->wc_customer_id_from( $customer_id );
if ( ! $wc_customer_id ) {
$message = "No WC customer id was found from PayPal customer id {$customer_id}";
return $this->failure_response( $message );
}
$this->authorized_payments_processor->capture_authorized_payments_for_customer( $wc_customer_id ); $this->authorized_payments_processor->capture_authorized_payments_for_customer( $wc_customer_id );
if ( ! is_null( $request['resource'] ) && isset( $request['resource']['id'] ) ) { if ( ! is_null( $request['resource'] ) && isset( $request['resource']['id'] ) ) {
@ -149,4 +155,29 @@ class VaultPaymentTokenCreated implements RequestHandler {
return $this->success_response(); return $this->success_response();
} }
/**
* Returns WC customer id from PayPal customer id.
*
* @param string $customer_id The customer ID from PayPal.
* @return int
*/
private function wc_customer_id_from( string $customer_id ): int {
$customers = get_users(
array(
'meta_key' => 'ppcp_guest_customer_id', //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'meta_value' => $customer_id, //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
'fields' => 'ids',
'number' => 1,
)
);
$wc_customer_id = $customers[0] ?? '';
if ( $wc_customer_id ) {
return (int) $wc_customer_id;
}
$id = str_replace( $this->prefix, '', $customer_id );
return is_numeric( $id ) ? (int) $id : 0;
}
} }

View file

@ -208,6 +208,15 @@ class IncomingWebhookEndpoint {
public function handle_request( \WP_REST_Request $request ): \WP_REST_Response { public function handle_request( \WP_REST_Request $request ): \WP_REST_Response {
$event = $this->event_from_request( $request ); $event = $this->event_from_request( $request );
$this->logger->debug(
sprintf(
'Webhook %s received of type %s and by resource "%s"',
$event->id(),
$event->event_type(),
$event->resource_type()
)
);
$this->last_webhook_event_storage->save( $event ); $this->last_webhook_event_storage->save( $event );
if ( $this->simulation->is_simulation_event( $event ) ) { if ( $this->simulation->is_simulation_event( $event ) ) {
@ -218,11 +227,21 @@ class IncomingWebhookEndpoint {
foreach ( $this->handlers as $handler ) { foreach ( $this->handlers as $handler ) {
if ( $handler->responsible_for_request( $request ) ) { if ( $handler->responsible_for_request( $request ) ) {
$event_type = ( $handler->event_types() ? current( $handler->event_types() ) : '' ) ?: '';
$this->logger->debug(
sprintf(
'Webhook is going to be handled by %s on %s',
$event_type,
get_class( $handler )
)
);
$response = $handler->handle_request( $request ); $response = $handler->handle_request( $request );
$this->logger->info( $this->logger->info(
sprintf( sprintf(
'Webhook has been handled by %s', 'Webhook has been handled by %s on %s',
( $handler->event_types() ) ? current( $handler->event_types() ) : '' $event_type,
get_class( $handler )
) )
); );
return $response; return $response;

View file

@ -1,6 +1,6 @@
{ {
"name": "woocommerce-paypal-payments", "name": "woocommerce-paypal-payments",
"version": "2.2.1", "version": "2.2.2",
"description": "WooCommerce PayPal Payments", "description": "WooCommerce PayPal Payments",
"repository": "https://github.com/woocommerce/woocommerce-paypal-payments", "repository": "https://github.com/woocommerce/woocommerce-paypal-payments",
"license": "GPL-2.0", "license": "GPL-2.0",

View file

@ -259,6 +259,11 @@
<code>DAY_IN_SECONDS</code> <code>DAY_IN_SECONDS</code>
</UndefinedConstant> </UndefinedConstant>
</file> </file>
<file src="modules/ppcp-api-client/src/Helper/OrderTransient.php">
<UndefinedConstant occurrences="1">
<code>DAY_IN_SECONDS</code>
</UndefinedConstant>
</file>
<file src="modules/ppcp-button/services.php"> <file src="modules/ppcp-button/services.php">
<PossiblyFalseArgument occurrences="1"> <PossiblyFalseArgument occurrences="1">
<code>realpath( __FILE__ )</code> <code>realpath( __FILE__ )</code>

View file

@ -4,7 +4,7 @@ Tags: woocommerce, paypal, payments, ecommerce, e-commerce, store, sales, sell,
Requires at least: 5.3 Requires at least: 5.3
Tested up to: 6.3 Tested up to: 6.3
Requires PHP: 7.2 Requires PHP: 7.2
Stable tag: 2.2.1 Stable tag: 2.2.2
License: GPLv2 License: GPLv2
License URI: http://www.gnu.org/licenses/gpl-2.0.html License URI: http://www.gnu.org/licenses/gpl-2.0.html
@ -81,7 +81,12 @@ Follow the steps below to connect the plugin to your PayPal account:
== Changelog == == Changelog ==
= 2.2.1 - xxxx-xx-xx = = 2.2.2 - 2023-08-29 =
* Fix - High rate of auth voids on vaulted subscriptions for guest users #1529
* Enhancement - HPOS compatibility issues #1594
* Feature preview - PayPal Subscriptions API fixes and improvements #1600 #1607
= 2.2.1 - 2023-08-24 =
* Fix - One-page checkout causes mini cart not showing the PP button on certain pages #1536 * Fix - One-page checkout causes mini cart not showing the PP button on certain pages #1536
* Fix - When onboarding loading the return_url too fast may cause the onboarding to fail #1565 * Fix - When onboarding loading the return_url too fast may cause the onboarding to fail #1565
* Fix - PayPal button doesn't work for variable products on product page after recent 2.2.0 release #1533 * Fix - PayPal button doesn't work for variable products on product page after recent 2.2.0 release #1533

View file

@ -0,0 +1,130 @@
<?php
namespace WooCommerce\PayPalCommerce\Api;
use Mockery;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Money;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Order;
use WooCommerce\PayPalCommerce\ModularTestCase;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway;
use WooCommerce\PayPalCommerce\WcGateway\Helper\RefundFeesUpdater;
use Psr\Log\LoggerInterface;
use WC_Order;
use function Brain\Monkey\Functions\when;
class OrderRefundFeesUpdateTest extends ModularTestCase
{
private $order_endpoint;
private $logger;
private $refundFeesUpdater;
public function setUp(): void
{
$this->order_endpoint = $this->createMock(OrderEndpoint::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->refundFeesUpdater = new RefundFeesUpdater($this->order_endpoint, $this->logger);
}
public function testUpdateWithoutPaypalOrderId(): void
{
$wc_order_id = 123;
$wc_order = Mockery::mock(WC_Order::class);
$wc_order->expects('get_meta')
->with(PayPalGateway::ORDER_ID_META_KEY)
->andReturn(null);
$wc_order->expects('get_id')->andReturn($wc_order_id);
$this->logger->expects($this->once())
->method('error');
$this->refundFeesUpdater->update($wc_order);
}
public function testUpdateWithValidData(): void
{
$wc_order_id = 123;
$paypal_order_id = 'test_order_id';
$refund_id = 'XYZ123';
$meta_data = [
'gross_amount' => ['value' => 10.0, 'currency_code' => 'USD'],
'paypal_fee' => ['value' => 7.0, 'currency_code' => 'USD'],
'net_amount' => ['value' => 3.0, 'currency_code' => 'USD'],
];
when('get_comments')->justReturn([]);
$wc_order = Mockery::mock(WC_Order::class);
$wc_order->expects('get_meta')
->with(PayPalGateway::ORDER_ID_META_KEY)
->andReturn($paypal_order_id);
$wc_order->expects('get_id')
->times(3)
->andReturn($wc_order_id);
$wc_order->expects('update_meta_data')
->once()
->with('_ppcp_paypal_refund_fees', $meta_data);
$wc_order->expects('add_order_note')
->once()
->withArgs(function ($arg) use ($refund_id) {
return strpos($arg, $refund_id) !== false;
});
$wc_order->expects('save')->once();
$moneyGross = Mockery::mock(Money::class);
$moneyGross->expects('value')->once()->andReturn($meta_data['gross_amount']['value']);
$moneyGross->expects('currency_code')->once()->andReturn($meta_data['gross_amount']['currency_code']);
$moneyFee = Mockery::mock(Money::class);
$moneyFee->expects('value')->once()->andReturn($meta_data['paypal_fee']['value']);
$moneyFee->expects('currency_code')->once()->andReturn($meta_data['paypal_fee']['currency_code']);
$moneyNet = Mockery::mock(Money::class);
$moneyNet->expects('value')->once()->andReturn($meta_data['net_amount']['value']);
$moneyNet->expects('currency_code')->once()->andReturn($meta_data['net_amount']['currency_code']);
$breakdown = $this->getMockBuilder(\stdClass::class)
->addMethods(['gross_amount', 'paypal_fee', 'net_amount'])
->getMock();
$breakdown->method('gross_amount')->willReturn($moneyGross);
$breakdown->method('paypal_fee')->willReturn($moneyFee);
$breakdown->method('net_amount')->willReturn($moneyNet);
$refund = $this->getMockBuilder(\stdClass::class)
->addMethods(['id', 'seller_payable_breakdown'])
->getMock();
$refund->method('id')->willReturn($refund_id);
$refund->method('seller_payable_breakdown')->willReturn($breakdown);
$payments = $this->getMockBuilder(\stdClass::class)
->addMethods(['refunds'])
->getMock();
$payments->method('refunds')->willReturn([$refund]);
$purchase_unit = $this->getMockBuilder(\stdClass::class)
->addMethods(['payments'])
->getMock();
$purchase_unit->method('payments')->willReturn($payments);
$paypal_order = Mockery::mock(Order::class);
$paypal_order->expects('purchase_units')->andReturn([$purchase_unit]);
$this->order_endpoint->method('order')->with($paypal_order_id)->willReturn($paypal_order);
$this->logger->expects($this->exactly(2))
->method('debug')
->withConsecutive(
[$this->stringContains('Updating order paypal refund fees.')],
[$this->stringContains('Updated order paypal refund fees.')]
);
$this->refundFeesUpdater->update($wc_order);
}
}

View file

@ -43,10 +43,19 @@ class PaymentsTest extends TestCase
'status' => 'CREATED', 'status' => 'CREATED',
] ]
); );
$captures = [$capture]; $refund = \Mockery::mock(Refund::class);
$authorizations = [$authorization]; $refund->shouldReceive('to_array')->andReturn(
[
'id' => 'refund',
'status' => 'CREATED',
]
);
$testee = new Payments($authorizations, $captures); $authorizations = [$authorization];
$captures = [$capture];
$refunds = [$refund];
$testee = new Payments($authorizations, $captures, $refunds);
$this->assertEquals( $this->assertEquals(
[ [
@ -62,6 +71,12 @@ class PaymentsTest extends TestCase
'status' => 'CREATED', 'status' => 'CREATED',
], ],
], ],
'refunds' => [
[
'id' => 'refund',
'status' => 'CREATED',
],
],
], ],
$testee->to_array() $testee->to_array()
); );

View file

@ -3,6 +3,7 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\ApiClient\Entity; namespace WooCommerce\PayPalCommerce\ApiClient\Entity;
use WooCommerce\PayPalCommerce\ApiClient\Helper\PurchaseUnitSanitizer;
use WooCommerce\PayPalCommerce\TestCase; use WooCommerce\PayPalCommerce\TestCase;
use Mockery; use Mockery;
@ -75,24 +76,43 @@ class PurchaseUnitTest extends TestCase
$this->assertEquals($expected, $testee->to_array()); $this->assertEquals($expected, $testee->to_array());
} }
/** /**
* @dataProvider dataForDitchTests * @dataProvider dataForDitchTests
* @param array $items * @param array $items
* @param Amount $amount * @param Amount $amount
* @param bool $doDitch * @param bool|array $doDitch
*/ * @param string $message
public function testDitchMethod(array $items, Amount $amount, bool $doDitch, string $message) */
public function testDitchMethod(array $items, Amount $amount, $doDitch, string $message)
{ {
if (is_array($doDitch)) {
$doDitchItems = $doDitch['items'];
$doDitchBreakdown = $doDitch['breakdown'];
$doDitchTax = $doDitch['tax'];
} else {
$doDitchItems = $doDitch;
$doDitchBreakdown = $doDitch;
$doDitchTax = $doDitch;
}
$testee = new PurchaseUnit( $testee = new PurchaseUnit(
$amount, $amount,
$items $items
); );
$testee->set_sanitizer(new PurchaseUnitSanitizer(PurchaseUnitSanitizer::MODE_DITCH));
$array = $testee->to_array(); $array = $testee->to_array();
$resultItems = $doDitch === ! array_key_exists('items', $array); $resultItems = $doDitchItems === ! array_key_exists('items', $array);
$resultBreakdown = $doDitch === ! array_key_exists('breakdown', $array['amount']);
$resultBreakdown = $doDitchBreakdown === ! array_key_exists('breakdown', $array['amount']);
$this->assertTrue($resultItems, $message); $this->assertTrue($resultItems, $message);
$this->assertTrue($resultBreakdown, $message); $this->assertTrue($resultBreakdown, $message);
foreach ($array['items'] ?? [] as $item) {
$resultTax = $doDitchTax === ! array_key_exists('tax', $item);
$this->assertTrue($resultTax, $message);
}
} }
public function dataForDitchTests() : array public function dataForDitchTests() : array
@ -406,6 +426,58 @@ class PurchaseUnitTest extends TestCase
'insurance' => null, 'insurance' => null,
], ],
], ],
'ditch_items_total_but_not_breakdown' => [
'message' => 'Items should be ditched because the item total does not add up. But not breakdown because it adds up.',
'ditch' => [
'items' => true,
'breakdown' => false,
'tax' => true,
],
'items' => [
[
'value' => 11,
'quantity' => 2,
'tax' => 3,
'category' => Item::PHYSICAL_GOODS,
],
],
'amount' => 26,
'breakdown' => [
'item_total' => 20,
'tax_total' => 6,
'shipping' => null,
'discount' => null,
'shipping_discount' => null,
'handling' => null,
'insurance' => null,
],
],
'ditch_items_tax_with_incorrect_tax_total' => [
'message' => 'Ditch tax from items. Items should not be ditched because the mismatch is on the tax.',
'ditch' => [
'items' => false,
'breakdown' => false,
'tax' => true,
],
'items' => [
[
'value' => 10,
'quantity' => 2,
'tax' => 4,
'category' => Item::PHYSICAL_GOODS,
],
],
'amount' => 26,
'breakdown' => [
'item_total' => 20,
'tax_total' => 6,
'shipping' => null,
'discount' => null,
'shipping_discount' => null,
'handling' => null,
'insurance' => null,
],
],
]; ];
$values = []; $values = [];
@ -421,10 +493,16 @@ class PurchaseUnitTest extends TestCase
'tax' => $tax, 'tax' => $tax,
'quantity'=> $item['quantity'], 'quantity'=> $item['quantity'],
'category' => $item['category'], 'category' => $item['category'],
'to_array' => [], 'to_array' => [
'unit_amount' => $unitAmount->to_array(),
'tax' => $tax->to_array(),
'quantity'=> $item['quantity'],
'category' => $item['category'],
],
] ]
); );
} }
$breakdown = null; $breakdown = null;
if ($test['breakdown']) { if ($test['breakdown']) {
$breakdown = Mockery::mock(AmountBreakdown::class); $breakdown = Mockery::mock(AmountBreakdown::class);
@ -438,10 +516,29 @@ class PurchaseUnitTest extends TestCase
return $money; return $money;
}); });
} }
$breakdown
->shouldReceive('to_array')
->andReturn(
array_map(
function ($value) {
return $value ? (new Money($value, 'EUR'))->to_array() : null;
},
$test['breakdown']
)
);
} }
$amountMoney = new Money($test['amount'], 'EUR');
$amount = Mockery::mock(Amount::class); $amount = Mockery::mock(Amount::class);
$amount->shouldReceive('to_array')->andReturn(['value' => number_format( $test['amount'], 2, '.', '' ), 'breakdown' => []]); $amount
$amount->shouldReceive('value_str')->andReturn(number_format( $test['amount'], 2, '.', '' )); ->shouldReceive('to_array')
->andReturn([
'value' => $amountMoney->value_str(),
'currency_code' => $amountMoney->currency_code(),
'breakdown' => $breakdown ? $breakdown->to_array() : [],
]);
$amount->shouldReceive('value_str')->andReturn($amountMoney->value_str());
$amount->shouldReceive('currency_code')->andReturn('EUR'); $amount->shouldReceive('currency_code')->andReturn('EUR');
$amount->shouldReceive('breakdown')->andReturn($breakdown); $amount->shouldReceive('breakdown')->andReturn($breakdown);
@ -456,6 +553,262 @@ class PurchaseUnitTest extends TestCase
return $values; return $values;
} }
/**
* @dataProvider dataForExtraLineTests
* @param array $items
* @param Amount $amount
* @param array $expected
* @param string $message
*/
public function testExtraLineMethod(array $items, Amount $amount, array $expected, string $message)
{
$testee = new PurchaseUnit(
$amount,
$items
);
$testee->set_sanitizer(new PurchaseUnitSanitizer(PurchaseUnitSanitizer::MODE_EXTRA_LINE, $expected['extra_line_name'] ?? null));
$countItemsBefore = count($items);
$array = $testee->to_array();
$countItemsAfter = count($array['items']);
$extraItem = array_pop($array['items']);
$this->assertEquals($countItemsBefore + 1, $countItemsAfter, $message);
$this->assertEquals($expected['extra_line_value'], $extraItem['unit_amount']['value'], $message);
$this->assertEquals($expected['extra_line_name'] ?? PurchaseUnitSanitizer::EXTRA_LINE_NAME, $extraItem['name'], $message);
foreach ($array['items'] as $i => $item) {
$this->assertEquals($expected['item_value'][$i], $item['unit_amount']['value'], $message);
}
}
public function dataForExtraLineTests() : array
{
$data = [
'default' => [
'message' => 'Extra line should be added with price 0.01 and line amount 10.',
'expected' => [
'item_value' => [10],
'extra_line_value' => 0.01,
],
'items' => [
[
'value' => 10,
'quantity' => 2,
'tax' => 3,
'category' => Item::PHYSICAL_GOODS,
],
],
'amount' => 26.01,
'breakdown' => [
'item_total' => 20.01,
'tax_total' => 6,
'shipping' => null,
'discount' => null,
'shipping_discount' => null,
'handling' => null,
'insurance' => null,
],
],
'with_custom_name' => [
'message' => 'Extra line should be added with price 0.01 and line amount 10.',
'expected' => [
'item_value' => [10],
'extra_line_value' => 0.01,
'extra_line_name' => 'My custom line name',
],
'items' => [
[
'value' => 10,
'quantity' => 2,
'tax' => 3,
'category' => Item::PHYSICAL_GOODS,
],
],
'amount' => 26.01,
'breakdown' => [
'item_total' => 20.01,
'tax_total' => 6,
'shipping' => null,
'discount' => null,
'shipping_discount' => null,
'handling' => null,
'insurance' => null,
],
],
'with_rounding_down' => [
'message' => 'Extra line should be added with price 0.01 and line amount 10.00.',
'expected' => [
'item_value' => [10.00],
'extra_line_value' => 0.01
],
'items' => [
[
'value' => 10.005,
'quantity' => 2,
'tax' => 3,
'category' => Item::PHYSICAL_GOODS,
],
],
'amount' => 26.01,
'breakdown' => [
'item_total' => 20.01,
'tax_total' => 6,
'shipping' => null,
'discount' => null,
'shipping_discount' => null,
'handling' => null,
'insurance' => null,
],
],
'with_two_rounding_down' => [
'message' => 'Extra line should be added with price 0.03 and lines amount 10.00 and 4.99.',
'expected' => [
'item_value' => [10.00, 4.99],
'extra_line_value' => 0.03
],
'items' => [
[
'value' => 10.005,
'quantity' => 2,
'tax' => 3,
'category' => Item::PHYSICAL_GOODS,
],
[
'value' => 5,
'quantity' => 2,
'tax' => 3,
'category' => Item::PHYSICAL_GOODS,
],
],
'amount' => 36.01,
'breakdown' => [
'item_total' => 30.01,
'tax_total' => 6,
'shipping' => null,
'discount' => null,
'shipping_discount' => null,
'handling' => null,
'insurance' => null,
],
],
'with_many_roundings_down' => [
'message' => 'Extra line should be added with price 0.01 and lines amount 10.00, 5.00 and 6.66.',
'expected' => [
'item_value' => [10.00, 4.99, 6.66],
'extra_line_value' => 0.02
],
'items' => [
[
'value' => 10.005,
'quantity' => 1,
'tax' => 3,
'category' => Item::PHYSICAL_GOODS,
],
[
'value' => 5.001,
'quantity' => 1,
'tax' => 3,
'category' => Item::PHYSICAL_GOODS,
],
[
'value' => 6.666,
'quantity' => 1,
'tax' => 3,
'category' => Item::PHYSICAL_GOODS,
],
],
'amount' => 27.67,
'breakdown' => [
'item_total' => 21.67,
'tax_total' => 6,
'shipping' => null,
'discount' => null,
'shipping_discount' => null,
'handling' => null,
'insurance' => null,
],
]
];
$values = [];
foreach ($data as $testKey => $test) {
$items = [];
foreach ($test['items'] as $key => $item) {
$unitAmount = new Money($item['value'], 'EUR');
$tax = new Money($item['tax'], 'EUR');
$items[$key] = Mockery::mock(
Item::class,
[
'unit_amount' => $unitAmount,
'tax' => $tax,
'quantity'=> $item['quantity'],
'category' => $item['category'],
]
);
$items[$key]->shouldReceive('to_array')->andReturnUsing(function (bool $roundToFloor = false) use ($unitAmount, $tax, $item) {
return [
'unit_amount' => $unitAmount->to_array($roundToFloor),
'tax' => $tax->to_array(),
'quantity'=> $item['quantity'],
'category' => $item['category'],
];
});
}
$breakdown = null;
if ($test['breakdown']) {
$breakdown = Mockery::mock(AmountBreakdown::class);
foreach ($test['breakdown'] as $method => $value) {
$breakdown->shouldReceive($method)->andReturnUsing(function () use ($value) {
if (! is_numeric($value)) {
return null;
}
$money = new Money($value, 'EUR');
return $money;
});
}
$breakdown
->shouldReceive('to_array')
->andReturn(
array_map(
function ($value) {
return $value ? (new Money($value, 'EUR'))->to_array() : null;
},
$test['breakdown']
)
);
}
$amountMoney = new Money($test['amount'], 'EUR');
$amount = Mockery::mock(Amount::class);
$amount
->shouldReceive('to_array')
->andReturn([
'value' => $amountMoney->value_str(),
'currency_code' => $amountMoney->currency_code(),
'breakdown' => $breakdown ? $breakdown->to_array() : [],
]);
$amount->shouldReceive('value_str')->andReturn($amountMoney->value_str());
$amount->shouldReceive('currency_code')->andReturn('EUR');
$amount->shouldReceive('breakdown')->andReturn($breakdown);
$values[$testKey] = [
$items,
$amount,
$test['expected'],
$test['message'],
];
}
return $values;
}
public function testPayee() public function testPayee()
{ {
$amount = Mockery::mock(Amount::class); $amount = Mockery::mock(Amount::class);

View file

@ -7,6 +7,7 @@ namespace WooCommerce\PayPalCommerce\ApiClient\Factory;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Authorization; use WooCommerce\PayPalCommerce\ApiClient\Entity\Authorization;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Capture; use WooCommerce\PayPalCommerce\ApiClient\Entity\Capture;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Payments; use WooCommerce\PayPalCommerce\ApiClient\Entity\Payments;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Refund;
use WooCommerce\PayPalCommerce\TestCase; use WooCommerce\PayPalCommerce\TestCase;
use Mockery; use Mockery;
@ -18,11 +19,15 @@ class PaymentsFactoryTest extends TestCase
$authorization->shouldReceive('to_array')->andReturn(['id' => 'foo', 'status' => 'CREATED']); $authorization->shouldReceive('to_array')->andReturn(['id' => 'foo', 'status' => 'CREATED']);
$capture = Mockery::mock(Capture::class); $capture = Mockery::mock(Capture::class);
$capture->shouldReceive('to_array')->andReturn(['id' => 'capture', 'status' => 'CREATED']); $capture->shouldReceive('to_array')->andReturn(['id' => 'capture', 'status' => 'CREATED']);
$refund = Mockery::mock(Refund::class);
$refund->shouldReceive('to_array')->andReturn(['id' => 'refund', 'status' => 'CREATED']);
$authorizationsFactory = Mockery::mock(AuthorizationFactory::class); $authorizationsFactory = Mockery::mock(AuthorizationFactory::class);
$authorizationsFactory->shouldReceive('from_paypal_response')->andReturn($authorization); $authorizationsFactory->shouldReceive('from_paypal_response')->andReturn($authorization);
$captureFactory = Mockery::mock(CaptureFactory::class); $captureFactory = Mockery::mock(CaptureFactory::class);
$captureFactory->shouldReceive('from_paypal_response')->andReturn($capture); $captureFactory->shouldReceive('from_paypal_response')->andReturn($capture);
$refundFactory = Mockery::mock(RefundFactory::class);
$refundFactory->shouldReceive('from_paypal_response')->andReturn($refund);
$response = (object)[ $response = (object)[
'authorizations' => [ 'authorizations' => [
(object)['id' => 'foo', 'status' => 'CREATED'], (object)['id' => 'foo', 'status' => 'CREATED'],
@ -30,9 +35,12 @@ class PaymentsFactoryTest extends TestCase
'captures' => [ 'captures' => [
(object)['id' => 'capture', 'status' => 'CREATED'], (object)['id' => 'capture', 'status' => 'CREATED'],
], ],
'refunds' => [
(object)['id' => 'refund', 'status' => 'CREATED'],
],
]; ];
$testee = new PaymentsFactory($authorizationsFactory, $captureFactory); $testee = new PaymentsFactory($authorizationsFactory, $captureFactory, $refundFactory);
$result = $testee->from_paypal_response($response); $result = $testee->from_paypal_response($response);
$this->assertInstanceOf(Payments::class, $result); $this->assertInstanceOf(Payments::class, $result);
@ -44,6 +52,9 @@ class PaymentsFactoryTest extends TestCase
'captures' => [ 'captures' => [
['id' => 'capture', 'status' => 'CREATED'], ['id' => 'capture', 'status' => 'CREATED'],
], ],
'refunds' => [
['id' => 'refund', 'status' => 'CREATED'],
],
]; ];
$this->assertEquals($expectedToArray, $result->to_array()); $this->assertEquals($expectedToArray, $result->to_array());
} }

View file

@ -25,6 +25,7 @@ use WooCommerce\PayPalCommerce\Vaulting\PaymentTokenRepository;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway; use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway;
use WooCommerce\PayPalCommerce\WcGateway\Processor\AuthorizedPaymentsProcessor; use WooCommerce\PayPalCommerce\WcGateway\Processor\AuthorizedPaymentsProcessor;
use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings; use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings;
use function Brain\Monkey\Functions\when;
class RenewalHandlerTest extends TestCase class RenewalHandlerTest extends TestCase
{ {
@ -115,6 +116,9 @@ class RenewalHandlerTest extends TestCase
->shouldReceive('payment_source') ->shouldReceive('payment_source')
->andReturn(null); ->andReturn(null);
$wcOrder
->shouldReceive('get_meta')
->andReturn('');
$wcOrder $wcOrder
->shouldReceive('get_id') ->shouldReceive('get_id')
->andReturn(1); ->andReturn(1);
@ -154,6 +158,8 @@ class RenewalHandlerTest extends TestCase
->with([$purchaseUnit], 'no_shipping', $payer, $token) ->with([$purchaseUnit], 'no_shipping', $payer, $token)
->andReturn($order); ->andReturn($order);
when('wcs_get_subscriptions_for_order')->justReturn(array());
$wcOrder->shouldReceive('update_status'); $wcOrder->shouldReceive('update_status');
$wcOrder->shouldReceive('save'); $wcOrder->shouldReceive('save');

View file

@ -38,6 +38,7 @@ class TestCase extends \PHPUnit\Framework\TestCase
when('wc_clean')->returnArg(); when('wc_clean')->returnArg();
when('get_transient')->returnArg(); when('get_transient')->returnArg();
when('delete_transient')->returnArg(); when('delete_transient')->returnArg();
when('wcs_get_subscription')->returnArg();
setUp(); setUp();
} }

View file

@ -43,11 +43,32 @@ class FeesRendererTest extends TestCase
], ],
]); ]);
$wcOrder->expects('get_meta')
->with(PayPalGateway::REFUND_FEES_META_KEY)
->andReturn([
'gross_amount' => [
'currency_code' => 'USD',
'value' => '20.52',
],
'paypal_fee' => [
'currency_code' => 'USD',
'value' => '0.51',
],
'net_amount' => [
'currency_code' => 'USD',
'value' => '50.01',
],
]);
$result = $this->renderer->render($wcOrder); $result = $this->renderer->render($wcOrder);
$this->assertStringContainsString('Fee', $result); $this->assertStringContainsString('Fee', $result);
$this->assertStringContainsString('0.41', $result); $this->assertStringContainsString('0.41', $result);
$this->assertStringContainsString('Payout', $result); $this->assertStringContainsString('Payout', $result);
$this->assertStringContainsString('10.01', $result); $this->assertStringContainsString('10.01', $result);
$this->assertStringContainsString('PayPal Refund Fee', $result);
$this->assertStringContainsString('0.51', $result);
$this->assertStringContainsString('PayPal Refund', $result);
$this->assertStringContainsString('50.01', $result);
} }
public function testRenderWithoutNet() { public function testRenderWithoutNet() {
@ -62,6 +83,10 @@ class FeesRendererTest extends TestCase
], ],
]); ]);
$wcOrder->expects('get_meta')
->with(PayPalGateway::REFUND_FEES_META_KEY)
->andReturn([]);
$result = $this->renderer->render($wcOrder); $result = $this->renderer->render($wcOrder);
$this->assertStringContainsString('Fee', $result); $this->assertStringContainsString('Fee', $result);
$this->assertStringContainsString('0.41', $result); $this->assertStringContainsString('0.41', $result);
@ -78,6 +103,10 @@ class FeesRendererTest extends TestCase
->with(PayPalGateway::FEES_META_KEY) ->with(PayPalGateway::FEES_META_KEY)
->andReturn($meta); ->andReturn($meta);
$wcOrder->expects('get_meta')
->with(PayPalGateway::REFUND_FEES_META_KEY)
->andReturn([]);
$this->assertSame('', $this->renderer->render($wcOrder)); $this->assertSame('', $this->renderer->render($wcOrder));
} }

View file

@ -27,8 +27,9 @@ class SettingsPagesAssetsTest extends TestCase
Mockery::mock(Environment::class), Mockery::mock(Environment::class),
true, true,
array(), array(),
array() array(),
); true
);
when('is_admin') when('is_admin')
->justReturn(true); ->justReturn(true);

View file

@ -364,6 +364,11 @@ class PurchaseUnitTest extends TestCase
], ],
self::adaptAmountFormat([ self::adaptAmountFormat([
'value' => 10.69, 'value' => 10.69,
'breakdown' => [
'item_total' => 10.69,
'tax_total' => 0,
'shipping' => 0,
],
]), ]),
]; ];
} }
@ -432,6 +437,11 @@ class PurchaseUnitTest extends TestCase
], ],
self::adaptAmountFormat([ self::adaptAmountFormat([
'value' => 10.69, 'value' => 10.69,
'breakdown' => [
'item_total' => 10.69,
'tax_total' => 0,
'shipping' => 0,
],
], get_woocommerce_currency()), ], get_woocommerce_currency()),
]; ];
} }

View file

@ -3,7 +3,7 @@
* Plugin Name: WooCommerce PayPal Payments * Plugin Name: WooCommerce PayPal Payments
* Plugin URI: https://woocommerce.com/products/woocommerce-paypal-payments/ * Plugin URI: https://woocommerce.com/products/woocommerce-paypal-payments/
* Description: PayPal's latest complete payments processing solution. Accept PayPal, Pay Later, credit/debit cards, alternative digital wallets local payment types and bank accounts. Turn on only PayPal options or process a full suite of payment methods. Enable global transaction with extensive currency and country coverage. * Description: PayPal's latest complete payments processing solution. Accept PayPal, Pay Later, credit/debit cards, alternative digital wallets local payment types and bank accounts. Turn on only PayPal options or process a full suite of payment methods. Enable global transaction with extensive currency and country coverage.
* Version: 2.2.1 * Version: 2.2.2
* Author: WooCommerce * Author: WooCommerce
* Author URI: https://woocommerce.com/ * Author URI: https://woocommerce.com/
* License: GPL-2.0 * License: GPL-2.0