Merge branch 'trunk' into PCP-1968-subscriptions-api-renewals

This commit is contained in:
Emili Castells Guasch 2023-09-05 14:06:35 +02:00
commit a5cd04352f
36 changed files with 1457 additions and 157 deletions

View file

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

View file

@ -1,5 +1,10 @@
## packages
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).
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.
## 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;
/**
* The wrapper selector that will contain the notice.
*
* @var string
*/
private $wrapper;
/**
* Message constructor.
*
* @param string $message The message text.
* @param string $type The message type.
* @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->message = $message;
$this->dismissable = $dismissable;
$this->wrapper = $wrapper;
}
/**
@ -74,4 +83,13 @@ class Message {
public function is_dismissable(): bool {
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();
foreach ( $messages as $message ) {
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->is_dismissable() ) ? 'is-dismissible' : '',
( $message->wrapper() ? sprintf( 'data-ppcp-wrapper="%s"', esc_attr( $message->wrapper() ) ) : '' ),
wp_kses_post( $message->message() )
);
}

View file

@ -9,6 +9,7 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\ApiClient;
use WooCommerce\PayPalCommerce\Common\Pattern\SingletonDecorator;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\BillingSubscriptions;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\CatalogProducts;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\BillingPlans;
@ -62,6 +63,8 @@ use WooCommerce\PayPalCommerce\ApiClient\Factory\WebhookFactory;
use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache;
use WooCommerce\PayPalCommerce\ApiClient\Helper\DccApplies;
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\CustomerRepository;
use WooCommerce\PayPalCommerce\ApiClient\Repository\OrderRepository;
@ -311,6 +314,7 @@ return array(
$payments_factory = $container->get( 'api.factory.payments' );
$prefix = $container->get( 'api.prefix' );
$soft_descriptor = $container->get( 'wcgateway.soft-descriptor' );
$sanitizer = $container->get( 'api.helper.purchase-unit-sanitizer' );
return new PurchaseUnitFactory(
$amount_factory,
@ -320,7 +324,8 @@ return array(
$shipping_factory,
$payments_factory,
$prefix,
$soft_descriptor
$soft_descriptor,
$sanitizer
);
},
'api.factory.patch-collection-factory' => static function ( ContainerInterface $container ): PatchCollectionFactory {
@ -836,4 +841,19 @@ return array(
'api.order-helper' => static function( ContainerInterface $container ): 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;
use WC_Order;
use WooCommerce\PayPalCommerce\ApiClient\Helper\OrderTransient;
use WooCommerce\PayPalCommerce\Vendor\Dhii\Container\ServiceProvider;
use WooCommerce\PayPalCommerce\Vendor\Dhii\Modular\Module\ModuleInterface;
use WooCommerce\PayPalCommerce\Vendor\Interop\Container\ServiceProviderInterface;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Order;
/**
* Class ApiModule
@ -40,6 +43,30 @@ class ApiModule implements ModuleInterface {
WC()->session->set( 'ppcp_fees', $fees );
}
);
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

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

View file

@ -112,7 +112,7 @@ class PayUponInvoiceOrderEndpoint {
'processing_instruction' => 'ORDER_COMPLETE_ON_PAYMENT_APPROVAL',
'purchase_units' => array_map(
static function ( PurchaseUnit $item ): array {
return $item->to_array( false );
return $item->to_array( true, false );
},
$items
),
@ -166,8 +166,11 @@ class PayUponInvoiceOrderEndpoint {
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

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

View file

@ -9,6 +9,8 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\ApiClient\Entity;
use WooCommerce\PayPalCommerce\ApiClient\Helper\PurchaseUnitSanitizer;
/**
* Class PurchaseUnit
*/
@ -91,6 +93,13 @@ class PurchaseUnit {
*/
private $contains_physical_goods = false;
/**
* The sanitizer for this purchase unit output.
*
* @var PurchaseUnitSanitizer|null
*/
private $sanitizer;
/**
* PurchaseUnit constructor.
*
@ -220,6 +229,16 @@ class PurchaseUnit {
$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.
*
@ -277,11 +296,12 @@ class PurchaseUnit {
/**
* 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
*/
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(
'reference_id' => $this->reference_id(),
'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() ) {
$purchase_unit['payee'] = $this->payee()->to_array();
}
@ -325,101 +334,45 @@ class PurchaseUnit {
if ( $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
* 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 cases,
* we can not send the line items.
* Applies the ppcp_ditch_items_breakdown filter.
* If true purchase_unit items and breakdown are ditched from PayPal.
*
* @param Amount $amount The amount.
* @param Item ...$items The items.
* @return bool
* @param bool $ditched_items_breakdown If the breakdown and items were already ditched.
* @param array $purchase_unit The purchase_unit array.
* @return array
*/
private function ditch_items_when_mismatch( Amount $amount, Item ...$items ): bool {
$breakdown = $amount->breakdown();
if ( ! $breakdown ) {
return false;
}
public function apply_ditch_items_mismatch_filter( bool $ditched_items_breakdown, array $purchase_unit ): array {
/**
* 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', $ditched_items_breakdown, $this );
$item_total = $breakdown->item_total();
if ( $item_total ) {
$remaining_item_total = array_reduce(
$items,
function ( float $total, Item $item ): float {
return $total - (float) $item->unit_amount()->value_str() * (float) $item->quantity();
},
(float) $item_total->value_str()
);
if ( $ditch ) {
unset( $purchase_unit['items'] );
unset( $purchase_unit['amount']['breakdown'] );
$remaining_item_total = round( $remaining_item_total, 2 );
if ( 0.0 !== $remaining_item_total ) {
return true;
if ( isset( $this->sanitizer ) && ( $ditch !== $ditched_items_breakdown ) ) {
$this->sanitizer->set_last_message(
__( 'Ditch items breakdown filter. Items and breakdown ditched.', 'woocommerce-paypal-payments' )
);
}
}
$tax_total = $breakdown->tax_total();
$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;
return $purchase_unit;
}
}

View file

@ -13,6 +13,7 @@ use WC_Session_Handler;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Item;
use WooCommerce\PayPalCommerce\ApiClient\Entity\PurchaseUnit;
use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException;
use WooCommerce\PayPalCommerce\ApiClient\Helper\PurchaseUnitSanitizer;
use WooCommerce\PayPalCommerce\ApiClient\Repository\PayeeRepository;
use WooCommerce\PayPalCommerce\Webhooks\CustomIds;
@ -77,17 +78,25 @@ class PurchaseUnitFactory {
*/
private $soft_descriptor;
/**
* The sanitizer for purchase unit output data.
*
* @var PurchaseUnitSanitizer|null
*/
private $sanitizer;
/**
* PurchaseUnitFactory constructor.
*
* @param AmountFactory $amount_factory The amount factory.
* @param PayeeRepository $payee_repository The Payee repository.
* @param PayeeFactory $payee_factory The Payee factory.
* @param ItemFactory $item_factory The item factory.
* @param ShippingFactory $shipping_factory The shipping factory.
* @param PaymentsFactory $payments_factory The payments factory.
* @param string $prefix The prefix.
* @param string $soft_descriptor The soft descriptor.
* @param AmountFactory $amount_factory The amount factory.
* @param PayeeRepository $payee_repository The Payee repository.
* @param PayeeFactory $payee_factory The Payee factory.
* @param ItemFactory $item_factory The item factory.
* @param ShippingFactory $shipping_factory The shipping factory.
* @param PaymentsFactory $payments_factory The payments factory.
* @param string $prefix The prefix.
* @param string $soft_descriptor The soft descriptor.
* @param ?PurchaseUnitSanitizer $sanitizer The purchase unit to_array sanitizer.
*/
public function __construct(
AmountFactory $amount_factory,
@ -97,7 +106,8 @@ class PurchaseUnitFactory {
ShippingFactory $shipping_factory,
PaymentsFactory $payments_factory,
string $prefix = 'WC-',
string $soft_descriptor = ''
string $soft_descriptor = '',
PurchaseUnitSanitizer $sanitizer = null
) {
$this->amount_factory = $amount_factory;
@ -108,6 +118,7 @@ class PurchaseUnitFactory {
$this->payments_factory = $payments_factory;
$this->prefix = $prefix;
$this->soft_descriptor = $soft_descriptor;
$this->sanitizer = $sanitizer;
}
/**
@ -151,6 +162,9 @@ class PurchaseUnitFactory {
$invoice_id,
$soft_descriptor
);
$this->init_purchase_unit( $purchase_unit );
/**
* Returns PurchaseUnit for the WC order.
*/
@ -221,6 +235,8 @@ class PurchaseUnitFactory {
$soft_descriptor
);
$this->init_purchase_unit( $purchase_unit );
return $purchase_unit;
}
@ -283,6 +299,9 @@ class PurchaseUnitFactory {
$soft_descriptor,
$payments
);
$this->init_purchase_unit( $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' );
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

@ -33,4 +33,16 @@ class MoneyFormatter {
? (string) round( $value, 0 )
: 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

@ -155,7 +155,7 @@ return array(
$session_handler = $container->get( 'session.handler' );
$settings = $container->get( 'wcgateway.settings' );
$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' );
return new CreateOrderEndpoint(
$request_data,

View file

@ -25,6 +25,7 @@ use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException;
use WooCommerce\PayPalCommerce\ApiClient\Factory\PayerFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\PurchaseUnitFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\ShippingPreferenceFactory;
use WooCommerce\PayPalCommerce\ApiClient\Helper\OrderTransient;
use WooCommerce\PayPalCommerce\Button\Exception\ValidationException;
use WooCommerce\PayPalCommerce\Button\Validation\CheckoutFormValidator;
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::INTENT_META_KEY, $order->intent() );
$wc_order->save_meta_data();
do_action( 'woocommerce_paypal_payments_woocommerce_order_created', $wc_order, $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\Exception\PayPalApiException;
use WooCommerce\PayPalCommerce\ApiClient\Helper\OrderTransient;
use WooCommerce\PayPalCommerce\Onboarding\State;
use WooCommerce\PayPalCommerce\Session\SessionHandler;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway;
@ -163,6 +164,10 @@ class EarlyOrderHandler {
/**
* 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

@ -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 {setVisibleByClass, isVisible} from "../../../ppcp-button/resources/js/modules/Helper/Hiding";
import widgetBuilder from "../../../ppcp-button/resources/js/modules/Renderer/WidgetBuilder";
import SubElementsHandler from "./SettingsHandler/SubElementsHandler";
document.addEventListener(
'DOMContentLoaded',
@ -307,5 +308,16 @@ document.addEventListener(
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

@ -212,10 +212,18 @@ return array(
return new ConnectAdminNotice( $state, $settings );
},
'wcgateway.notice.currency-unsupported' => static function ( ContainerInterface $container ): UnsupportedCurrencyAdminNotice {
$state = $container->get( 'onboarding.state' );
$shop_currency = $container->get( 'api.shop.currency' );
$supported_currencies = $container->get( 'api.supported-currencies' );
return new UnsupportedCurrencyAdminNotice( $state, $shop_currency, $supported_currencies );
$state = $container->get( 'onboarding.state' );
$shop_currency = $container->get( 'api.shop.currency' );
$supported_currencies = $container->get( 'api.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 {
return new GatewayWithoutPayPalAdminNotice(

View file

@ -87,6 +87,13 @@ class SettingsPageAssets {
*/
protected $all_funding_sources;
/**
* Whether it's a settings page of this plugin.
*
* @var bool
*/
private $is_settings_page;
/**
* 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 array $disabled_sources The list of disabled 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(
string $module_url,
@ -111,7 +119,8 @@ class SettingsPageAssets {
Environment $environment,
bool $is_pay_later_button_enabled,
array $disabled_sources,
array $all_funding_sources
array $all_funding_sources,
bool $is_settings_page
) {
$this->module_url = $module_url;
$this->version = $version;
@ -123,6 +132,7 @@ class SettingsPageAssets {
$this->is_pay_later_button_enabled = $is_pay_later_button_enabled;
$this->disabled_sources = $disabled_sources;
$this->all_funding_sources = $all_funding_sources;
$this->is_settings_page = $is_settings_page;
}
/**
@ -138,11 +148,13 @@ class SettingsPageAssets {
return;
}
if ( ! $this->is_paypal_payment_method_page() ) {
return;
if ( $this->is_settings_page ) {
$this->register_admin_assets();
}
$this->register_admin_assets();
if ( $this->is_paypal_payment_method_page() ) {
$this->register_paypal_admin_assets();
}
}
);
@ -173,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(
'ppcp-gateway-settings',
trailingslashit( $this->module_url ) . 'assets/css/gateway-settings.css',
@ -212,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

@ -290,9 +290,11 @@ class PayPalGateway extends \WC_Payment_Gateway {
// in the constructor, so must do it here.
global $theorder;
if ( $theorder instanceof WC_Order ) {
$payment_method_title = $theorder->get_payment_method_title();
if ( $payment_method_title ) {
$this->title = $payment_method_title;
if ( $theorder->get_payment_method() === self::ID ) {
$payment_method_title = $theorder->get_payment_method_title();
if ( $payment_method_title ) {
$this->title = $payment_method_title;
}
}
}
}

View file

@ -40,17 +40,42 @@ class UnsupportedCurrencyAdminNotice {
*/
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.
*
* @param State $state The state.
* @param string $shop_currency The shop currency.
* @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 ) {
$this->state = $state;
$this->shop_currency = $shop_currency;
$this->supported_currencies = $supported_currencies;
public function __construct(
State $state,
string $shop_currency,
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;
}
$paypal_currency_support_url = 'https://developer.paypal.com/api/rest/reference/currency-codes/';
$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'
),
$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
*/
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 WooCommerce\PayPalCommerce\ApiClient\Entity\Order;
use WooCommerce\PayPalCommerce\ApiClient\Helper\OrderTransient;
use WooCommerce\PayPalCommerce\Onboarding\Environment;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway;
@ -22,14 +23,16 @@ trait OrderMetaTrait {
/**
* Adds common metadata to the order.
*
* @param WC_Order $wc_order The WC order to which metadata will be added.
* @param Order $order The PayPal order.
* @param Environment $environment The environment.
* @param WC_Order $wc_order The WC order to which metadata will be added.
* @param Order $order The PayPal order.
* @param Environment $environment The environment.
* @param OrderTransient|null $order_transient The order transient helper.
*/
protected function add_paypal_meta(
WC_Order $wc_order,
Order $order,
Environment $environment
Environment $environment,
OrderTransient $order_transient = null
): void {
$wc_order->update_meta_data( PayPalGateway::ORDER_ID_META_KEY, $order->id() );
$wc_order->update_meta_data( PayPalGateway::INTENT_META_KEY, $order->intent() );
@ -43,6 +46,8 @@ trait OrderMetaTrait {
}
$wc_order->save();
do_action( 'woocommerce_paypal_payments_woocommerce_order_created', $wc_order, $order );
}
/**

View file

@ -11,6 +11,7 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\WcGateway\Settings;
use WooCommerce\PayPalCommerce\ApiClient\Helper\PurchaseUnitSanitizer;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
use WooCommerce\PayPalCommerce\ApiClient\Helper\DccApplies;
use WooCommerce\PayPalCommerce\Onboarding\Environment;
@ -496,6 +497,55 @@ return function ( ContainerInterface $container, array $fields ): array {
'requirements' => array(),
'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 );

View file

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

View file

@ -183,7 +183,8 @@ class WCGatewayModule implements ModuleInterface {
$c->get( 'onboarding.environment' ),
$settings_status->is_pay_later_button_enabled(),
$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();
}

View file

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

View file

@ -259,6 +259,11 @@
<code>DAY_IN_SECONDS</code>
</UndefinedConstant>
</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">
<PossiblyFalseArgument occurrences="1">
<code>realpath( __FILE__ )</code>

View file

@ -3,6 +3,7 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\ApiClient\Entity;
use WooCommerce\PayPalCommerce\ApiClient\Helper\PurchaseUnitSanitizer;
use WooCommerce\PayPalCommerce\TestCase;
use Mockery;
@ -75,24 +76,43 @@ class PurchaseUnitTest extends TestCase
$this->assertEquals($expected, $testee->to_array());
}
/**
* @dataProvider dataForDitchTests
* @param array $items
* @param Amount $amount
* @param bool $doDitch
*/
public function testDitchMethod(array $items, Amount $amount, bool $doDitch, string $message)
/**
* @dataProvider dataForDitchTests
* @param array $items
* @param Amount $amount
* @param bool|array $doDitch
* @param 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(
$amount,
$items
);
$testee->set_sanitizer(new PurchaseUnitSanitizer(PurchaseUnitSanitizer::MODE_DITCH));
$array = $testee->to_array();
$resultItems = $doDitch === ! array_key_exists('items', $array);
$resultBreakdown = $doDitch === ! array_key_exists('breakdown', $array['amount']);
$resultItems = $doDitchItems === ! array_key_exists('items', $array);
$resultBreakdown = $doDitchBreakdown === ! array_key_exists('breakdown', $array['amount']);
$this->assertTrue($resultItems, $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
@ -406,6 +426,58 @@ class PurchaseUnitTest extends TestCase
'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 = [];
@ -421,10 +493,16 @@ class PurchaseUnitTest extends TestCase
'tax' => $tax,
'quantity'=> $item['quantity'],
'category' => $item['category'],
'to_array' => [],
'to_array' => [
'unit_amount' => $unitAmount->to_array(),
'tax' => $tax->to_array(),
'quantity'=> $item['quantity'],
'category' => $item['category'],
],
]
);
}
$breakdown = null;
if ($test['breakdown']) {
$breakdown = Mockery::mock(AmountBreakdown::class);
@ -438,10 +516,29 @@ class PurchaseUnitTest extends TestCase
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' => number_format( $test['amount'], 2, '.', '' ), 'breakdown' => []]);
$amount->shouldReceive('value_str')->andReturn(number_format( $test['amount'], 2, '.', '' ));
$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);
@ -456,6 +553,262 @@ class PurchaseUnitTest extends TestCase
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()
{
$amount = Mockery::mock(Amount::class);

View file

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

View file

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