Merge trunk

This commit is contained in:
Emili Castells Guasch 2025-08-11 11:32:42 +02:00
commit dad1d4bb35
No known key found for this signature in database
251 changed files with 8543 additions and 2882 deletions

View file

@ -12,7 +12,7 @@ jobs:
name: PHP ${{ matrix.php-versions }} WC ${{ matrix.wc-versions }}
steps:
- uses: ddev/github-action-setup-ddev@v1
- uses: ddev/github-action-setup-ddev@1c7ef18595da42355373cb6d9417a6f44d758b93 # v1.10.1
with:
autostart: false

View file

@ -27,7 +27,7 @@ jobs:
create_archive:
needs: check_version
uses: inpsyde/reusable-workflows/.github/workflows/build-plugin-archive.yml@main
uses: inpsyde/reusable-workflows/.github/workflows/build-plugin-archive.yml@a9af34f34e95cbe18703198c7e972e97ebcd7473
with:
PHP_VERSION: 7.4
NODE_VERSION: 22

View file

@ -25,7 +25,7 @@ jobs:
- uses: actions/checkout@v2
- name: Setup PHP
uses: shivammathur/setup-php@v2
uses: shivammathur/setup-php@0f7f1d08e3e32076e51cae65eb0b0c871405b16e # v2.34.1
with:
php-version: 7.4

View file

@ -14,7 +14,7 @@ jobs:
- uses: actions/checkout@v2
- name: Setup PHP
uses: shivammathur/setup-php@v2
uses: shivammathur/setup-php@0f7f1d08e3e32076e51cae65eb0b0c871405b16e # v2.34.1
with:
php-version: ${{ matrix.php-versions }}
@ -22,7 +22,7 @@ jobs:
run: composer validate
- name: Install dependencies
uses: ramsey/composer-install@v1
uses: ramsey/composer-install@a7320a0581dcd0432930c48a0e7ced67e6ec17e8 # v1.3.0
with:
composer-options: "--prefer-dist"

View file

@ -14,7 +14,7 @@ jobs:
- name: Check spelling
id: spelling
uses: crate-ci/typos@v1.30.2
uses: crate-ci/typos@7bc041cbb7ca9167c9e0e4ccbb26f48eb0f9d4e0 # v1.30.2
with:
# Path to config file
config: .github/workflows-config/typos.toml

View file

@ -32,7 +32,7 @@ return function (
$modules = apply_filters( 'woocommerce_paypal_payments_modules', $modules );
// Initialize plugin.
$properties = PluginProperties::new( __FILE__ );
$properties = PluginProperties::new( "$root_dir/woocommerce-paypal-payments.php" );
$bootstrap = Package::new( $properties );
foreach ( $modules as $module ) {

View file

@ -1,5 +1,39 @@
*** Changelog ***
= 3.0.9 - 2025-07-31 =
* Fix - Payment via "Proceed to PayPal" may result in a redirect loop #3570
= 3.0.8 - 2025-07-28 =
* Enhancement - Migration from Legacy Settings to New Settings as opt-in via banner & button #3491
* Enhancement - Replace call to `billing-agreements/agreement-tokens` with checking the capabilities for Reference Transactions #3495
* Enhancement - Add Fastlane 3D Secure support #3493
* Enhancement - Improved PHP 8.4 compatibility #3534
* Fix - `INVALID_REQUEST` error due to wrong `landing_page` value after upgrade to 3.0.7 #3521
* Fix - Incorrect Amount via Express Payment for WooCommerce Product Bundles #3516
* Fix - Onboarding failed via "Connect to PayPal" in new UI due to race condition #3385
* Fix - Fatal error when PayPal Payments is active without WooCommerce #3502
* Fix - PayPal Subscription transaction failed in various scenarios #3515
* Fix - Rounding differences potentially lead to order failure (author @luzat) #3373
* Fix - Google Pay payment on block checkout may fail when ACDC is default payment selection #3506
* Fix - Product Prices Disappear in some cases when WooCommerce Subscriptions is active #3519
= 3.0.7 - 2025-07-01 =
* Enhancement - Remove `application_context` in favor of `experience_context` object #3431
**NOTE**: If you were modifying the `application_context` object programmatically, you may need to update your code to utilize `experience_context` for your customizations.
* Enhancement - Add Contact Module feature
* Enhancement - Add WooCommerce Tracks integration
* Enhancement - Onboarding notification for Firefox browser #3433
* Enhancement - Reset BN code on plugin uninstall #3471
* Enhancement - Add "Stay updated with PayPal" option in the old and new settings UI #3430
* Enhancement - Add French Territories to the supported ACDC countries list #3438
* Enhancement - Auto-enable logging during onboarding #3369
* Fix - DUPLICATE_INVOICE_ID in Sandbox due to missing invoice prefix #3435
* Fix - Subscription product could not be unlinked from PayPal Subscription #3429
* Fix - PayPal button greyed out on single product page for variable products with >2 attributes #3395
* Fix - APMs automatically enabled despite selecting "No, ..." during onboarding #3362
* Fix - Ditch items logic does not work when using saved card payment #3476
* Fix - billing-agreements endpoint called too frequently when not enabled for Reference Transactions #3459
= 3.0.6 - 2025-05-27 =
* Enhancement - Implement 3D secure check for Google Pay #3163
* Enhancement - Add options for "Disable Credit Cards" and "Language" #3226

View file

@ -26,7 +26,8 @@
"php-stubs/wordpress-stubs": "^5.0@stable",
"php-stubs/woocommerce-stubs": "^8.0@stable",
"vimeo/psalm": "^4.0",
"vlucas/phpdotenv": "^5"
"vlucas/phpdotenv": "^5",
"coenjacobs/mozart": "^0.7.1"
},
"autoload": {
"psr-4": {

787
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -64,6 +64,8 @@ class AliasingContainer implements ContainerInterface
*/
public function has($key)
{
$key = (string) $key;
return $this->inner->has($this->getInnerKey($key));
}

View file

@ -64,7 +64,7 @@ class CachingContainer implements ContainerInterface
throw new NotFoundException($this->__('Key "%1$s" not found in inner container', [$key]), 0, $e);
} catch (Exception $e) {
throw new ContainerException(
$this->__('Could not retrieve value for key "%1$s from inner container', [$key]),
$this->__('Could not retrieve value for key "%1$s" from inner container', [$key]),
0,
$e
);

View file

@ -38,7 +38,8 @@ class CompositeCachingServiceProvider implements ServiceProviderInterface
}
/**
* {@inheritDoc}
* @inheritDoc
*
* @psalm-suppress InvalidNullableReturnType
* It isn't actually going to return null ever, because $factories will be filled during indexing.
*/
@ -56,7 +57,8 @@ class CompositeCachingServiceProvider implements ServiceProviderInterface
}
/**
* {@inheritDoc}
* @inheritDoc
*
* @psalm-suppress InvalidNullableReturnType
* It isn't actually going to return null ever, because $factories will be filled during indexing.
*/
@ -78,7 +80,7 @@ class CompositeCachingServiceProvider implements ServiceProviderInterface
*
* Caches them internally.
*
* @param iterable|ServiceProviderInterface[] $providers The providers to index.
* @param iterable<ServiceProviderInterface> $providers The providers to index.
*/
protected function indexProviderDefinitions(iterable $providers): void
{

View file

@ -89,6 +89,7 @@ class DelegatingContainer implements ContainerInterface
public function has($id)
{
$services = $this->provider->getFactories();
$id = (string) $id;
return array_key_exists($id, $services);
}

View file

@ -84,7 +84,10 @@ class DeprefixingContainer implements ContainerInterface
*/
public function has($key)
{
return $this->inner->has($this->getInnerKey($key)) || (!$this->strict && $this->inner->has($key));
$key = (string) $key;
$realKey = $this->getInnerKey($key);
return $this->inner->has($realKey) || (!$this->strict && $this->inner->has($key));
}
/**

View file

@ -5,13 +5,13 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Vendor\Dhii\Container;
use ArrayIterator;
use Traversable;
use WooCommerce\PayPalCommerce\Vendor\Dhii\Collection\WritableContainerInterface;
use WooCommerce\PayPalCommerce\Vendor\Dhii\Collection\WritableMapInterface;
use WooCommerce\PayPalCommerce\Vendor\Dhii\Container\Exception\NotFoundException;
use WooCommerce\PayPalCommerce\Vendor\Dhii\Container\Util\StringTranslatingTrait;
use IteratorAggregate;
use RangeException;
use Traversable;
/**
* A simple mutable dictionary, i.e. an enumerable key-value map.
@ -54,6 +54,8 @@ class Dictionary implements
*/
public function has($key)
{
$key = (string) $key;
$isHas = array_key_exists($key, $this->data);
return $isHas;

View file

@ -61,6 +61,8 @@ class FlashContainer implements
*/
public function has($key)
{
$key = (string) $key;
return array_key_exists($key, $this->flashData);
}

View file

@ -88,6 +88,8 @@ class HierarchyContainer implements ContainerInterface
*/
public function has($key)
{
$key = (string) $key;
return array_key_exists($key, $this->data);
}
}

View file

@ -85,6 +85,8 @@ class MappingContainer implements ContainerInterface
*/
public function has($key)
{
$key = (string) $key;
return $this->inner->has($key);
}
}

View file

@ -80,6 +80,8 @@ class MaskingContainer implements ContainerInterface
*/
public function has($key)
{
$key = (string) $key;
return $this->isExposed($key) && $this->inner->has($key);
}

View file

@ -12,6 +12,7 @@ use WooCommerce\PayPalCommerce\Vendor\Dhii\Collection\WritableMapInterface;
use WooCommerce\PayPalCommerce\Vendor\Dhii\Container\Exception\ContainerException;
use WooCommerce\PayPalCommerce\Vendor\Dhii\Container\Exception\NotFoundException;
use IteratorAggregate;
use Traversable;
/**
* A container that does nothing.
@ -92,7 +93,7 @@ class NoOpContainer implements
/**
* @inheritDoc
*/
public function getIterator()
public function getIterator(): Traversable
{
return new ArrayIterator([]);
}

View file

@ -127,6 +127,8 @@ class PathContainer implements ContainerInterface
*/
public function has($key)
{
$key = (string) $key;
/**
* @psalm-suppress InvalidCatch
* The base interface does not extend Throwable, but in fact everything that is possible

View file

@ -5,9 +5,12 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Vendor\Dhii\Container;
use WooCommerce\PayPalCommerce\Vendor\Dhii\Collection\ContainerInterface;
use WooCommerce\PayPalCommerce\Vendor\Dhii\Container\Exception\ContainerException;
use WooCommerce\PayPalCommerce\Vendor\Dhii\Container\Exception\NotFoundException;
use Exception;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface as PsrContainerInterface;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\NotFoundExceptionInterface;
use RuntimeException;
/**
* A container implementation that wraps around an inner container and prefixes its keys, requiring consumers to
@ -89,11 +92,18 @@ class PrefixingContainer implements ContainerInterface
*/
public function has($key)
{
$key = (string) $key;
if (!$this->isPrefixed($key) && $this->strict) {
return false;
}
return $this->inner->has($this->unprefix($key)) || (!$this->strict && $this->inner->has($key));
try {
$realKey = $this->unprefix($key);
} catch (Exception $e) {
throw new ContainerException(sprintf('Could not unprefix key "%1$s"', $key), 0, $e);
}
return $this->inner->has($realKey) || (!$this->strict && $this->inner->has($key));
}
/**
@ -108,7 +118,7 @@ class PrefixingContainer implements ContainerInterface
protected function unprefix(string $key): string
{
return $this->isPrefixed($key)
? substr($key, strlen($this->prefix))
? $this->substring($key, strlen($this->prefix))
: $key;
}
@ -125,4 +135,35 @@ class PrefixingContainer implements ContainerInterface
{
return strlen($this->prefix) > 0 && strpos($key, $this->prefix) === 0;
}
/**
* Extracts a substring from the specified string.
*
* @see substr()
*
* @param string $string The string to extract from.
* @param int $offset The char position, at which to start extraction.
* @param int|null $length The char position, at which to end extraction; unlimited if `null`.
*
* @return string The extracted substring.
*
* @throws RuntimeException If unable to extract.
*/
protected function substring(string $string, int $offset = 0, ?int $length = null): string
{
$length = $length ?? strlen($string) - $offset;
$substring = substr($string, $offset, $length);
if ($substring === false) {
throw new RuntimeException(
sprintf(
'Could not extract substring starting at %1$d of length %2$s from string "%3$s"',
$offset,
$length ?: 'null',
$string
)
);
}
return $substring;
}
}

View file

@ -114,6 +114,8 @@ class SegmentingContainer implements ContainerInterface
*/
public function has($key)
{
$key = (string) $key;
return $this->inner->has($key);
}
}

View file

@ -5,22 +5,22 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Vendor\Dhii\Container;
use WooCommerce\PayPalCommerce\Vendor\Interop\Container\ServiceProviderInterface;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
/**
* A value object capable of providing services.
*
* @package Dhii\Di
* @psalm-type Factory = callable(ContainerInterface): mixed
* @psalm-type Extension = callable(ContainerInterface, mixed): mixed
*/
class ServiceProvider implements ServiceProviderInterface
{
/** @var callable[] */
protected array $factories;
/**
* @var callable[]
*/
protected $factories;
/**
* @var callable[]
*/
protected $extensions;
protected array $extensions;
/**
* @param callable[] $factories A map of service name to service factory.

View file

@ -55,6 +55,7 @@ class SimpleCacheContainer implements
*/
public function has($id)
{
$id = (string) $id;
$storage = $this->storage;
try {
@ -77,7 +78,11 @@ class SimpleCacheContainer implements
try {
$storage->set($key, $value, $ttl);
} catch (Exception $e) {
throw new ContainerException(sprintf('Could not set key "%1$s" with value "%2$s"', $key, $value), 0, $e);
throw new ContainerException(
sprintf('Could not set key "%1$s" with value "%2$s"', $key, (string) $value),
0,
$e
);
}
}
@ -105,7 +110,7 @@ class SimpleCacheContainer implements
try {
$storage->clear();
} catch (Exception $e) {
throw new ContainerException(sprintf('Could not clear container'), 0, $e);
throw new ContainerException('Could not clear container', 0, $e);
}
}
}

View file

@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Vendor\Dhii\Container;
use WooCommerce\PayPalCommerce\Vendor\Interop\Container\ServiceProviderInterface;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
use ReflectionException;
use ReflectionFunction;
use ReflectionObject;
/**
* A service provider that detects tags in factory docBlocks, and exposes them as services.
*
* A service may have a docBlock. The docBlock may contain various docBlock tags, such as `@param` or `@return`.
* This class will detect `@tag {tagname}` tags in service docBlocks. `tagname` may be anything that a service
* key may be - they exist in the same namespace. In fact, a `tagname` corresponds to a service
* that returns a list of tagged services. To retrieve them, just resolve the tagname as a service.
*
* For each unique `tagname` in factory docBlocks, this service provider will create an extension with
* an identical name. This extension at resolution time will resolve each tagged service by key,
* and add resulting services to the list it is extending. To ensure there's always a list to extend,
* this service provider will also add a service with an identical name, which resolves to an empty list.
* All such "tag" services are empty list in the beginning of their resolution, so it doesn't matter
* if it gets overwritten by another module's identical empty list.
*
* @psalm-import-type Factory from ServiceProvider
* @psalm-import-type Extension from ServiceProvider
*/
class TaggingServiceProvider implements ServiceProviderInterface
{
/** @var array<Factory> */
protected array $factories;
/** @var array<Extension> */
protected array $extensions;
public function __construct(ServiceProviderInterface $inner)
{
$this->factories = $inner->getFactories();
$this->extensions = $inner->getExtensions();
$this->indexTags();
}
/**
* @inheritDoc
*/
public function getFactories()
{
return $this->factories;
}
/**
* @inheritDoc
*/
public function getExtensions()
{
return $this->extensions;
}
/**
* Indexes tagged factories, and creates factories and extensions for tags.
*
* @throws ReflectionException If problem obtaining factory reflection.
*/
protected function indexTags(): void
{
$tags = [];
foreach ($this->factories as $serviceName => $factory) {
if (is_string($factory)) {
continue;
}
$reflection = is_object($factory) && get_class($factory) === 'Closure'
? new ReflectionFunction($factory)
: new ReflectionObject($factory);
$docBlock = $reflection->getDocComment();
// No docblock
if ($docBlock === false) {
continue;
}
$factoryTags = $this->getTagsFromDocBlock($docBlock);
foreach ($factoryTags as $tag) {
if (!isset($tags[$tag]) || !is_array($tags[$tag])) {
$tags[$tag] = [];
}
$tags[$tag][] = $serviceName;
}
}
foreach ($tags as $tag => $taggedServiceNames) {
$this->factories[$tag] = fn (): array => [];
$this->extensions[$tag] = function (ContainerInterface $c, array $prev) use ($taggedServiceNames): array {
return array_merge(
$prev,
array_map(fn (string $serviceName) => $c->get($serviceName), $taggedServiceNames)
);
};
}
}
/**
* Retrieves tags names that are part of a docBlock.
*
* @link https://www.php.net/manual/en/reflectionclass.getdoccomment.php#118606
*
* @param string $docBlock The docBlock.
*
* @return array<string> A list of tag names.
*/
protected function getTagsFromDocBlock(string $docBlock): array
{
$regex = '#^\s*/?\**\s*(@tag\s*(?P<tags>[^\s]+))#m';
preg_match_all($regex, $docBlock, $matches);
return $matches['tags'];
}
}

View file

@ -27,6 +27,7 @@ trait StringTranslatingTrait
*/
protected function __(string $string, array $args = array(), $context = null): string
{
$context = (string) $context;
$string = $this->_translate($string, $context);
array_unshift($args, $string);
return call_user_func_array('sprintf', $args);

View file

@ -1,52 +1,38 @@
<?php
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Container;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerExceptionInterface;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
/**
* @phpstan-import-type Service from \WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ServiceModule
* @phpstan-import-type ExtendingService from \WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExtendingModule
*/
class ContainerConfigurator
{
/**
* @var array<string, callable(ContainerInterface $container):mixed>
*/
private $services = [];
/** @var array<string, Service> */
private array $services = [];
/** @var array<string, bool> */
private array $factoryIds = [];
private ServiceExtensions $extensions;
private ?ContainerInterface $compiledContainer = null;
/** @var ContainerInterface[] */
private array $containers = [];
/**
* @var array<string, bool>
*/
private $factoryIds = [];
/**
* @var array<string, array<callable(mixed $service, ContainerInterface $container):mixed>>
*/
private $extensions = [];
/**
* @var ContainerInterface[]
*/
private $containers = [];
/**
* @var null|ContainerInterface
*/
private $compiledContainer;
/**
* ContainerConfigurator constructor.
*
* @param ContainerInterface[] $containers
*/
public function __construct(array $containers = [])
public function __construct(array $containers = [], ?ServiceExtensions $extensions = null)
{
array_map([$this, 'addContainer'], $containers);
$this->extensions = $extensions ?? new ServiceExtensions();
}
/**
* Allowing to add child containers.
*
* @param ContainerInterface $container
* @return void
*/
public function addContainer(ContainerInterface $container): void
{
@ -55,7 +41,7 @@ class ContainerConfigurator
/**
* @param string $id
* @param callable(ContainerInterface $container):mixed $factory
* @param Service $factory
*/
public function addFactory(string $id, callable $factory): void
{
@ -67,8 +53,7 @@ class ContainerConfigurator
/**
* @param string $id
* @param callable(ContainerInterface $container):mixed $service
*
* @param Service $service
* @return void
*/
public function addService(string $id, callable $service): void
@ -89,7 +74,6 @@ class ContainerConfigurator
/**
* @param string $id
*
* @return bool
*/
public function hasService(string $id): bool
@ -109,37 +93,31 @@ class ContainerConfigurator
/**
* @param string $id
* @param callable(mixed $service, ContainerInterface $container):mixed $extender
*
* @param ExtendingService $extender
* @return void
*/
public function addExtension(string $id, callable $extender): void
{
if (!isset($this->extensions[$id])) {
$this->extensions[$id] = [];
}
$this->extensions[$id][] = $extender;
$this->extensions->add($id, $extender);
}
/**
* @param string $id
*
* @return bool
*/
public function hasExtension(string $id): bool
{
return isset($this->extensions[$id]);
return $this->extensions->has($id);
}
/**
* Returns a read only version of this Container.
*
* @return ContainerInterface
*
* @phpstan-assert ContainerInterface $this->compiledContainer
*/
public function createReadOnlyContainer(): ContainerInterface
{
if (!$this->compiledContainer) {
if ($this->compiledContainer === null) {
$this->compiledContainer = new ReadOnlyContainer(
$this->services,
$this->factoryIds,

View file

@ -10,15 +10,8 @@ use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
class PackageProxyContainer implements ContainerInterface
{
/**
* @var Package
*/
private $package;
/**
* @var ContainerInterface|null
*/
private $container;
private Package $package;
private ?ContainerInterface $container = null;
/**
* @param Package $package
@ -31,8 +24,6 @@ class PackageProxyContainer implements ContainerInterface
/**
* @param string $id
* @return mixed
*
* @throws \Exception
*/
public function get(string $id)
{
@ -44,8 +35,6 @@ class PackageProxyContainer implements ContainerInterface
/**
* @param string $id
* @return bool
*
* @throws \Exception
*/
public function has(string $id): bool
{
@ -55,33 +44,30 @@ class PackageProxyContainer implements ContainerInterface
/**
* @return bool
*
* @throws \Exception
* @psalm-assert-if-true ContainerInterface $this->container
* @phpstan-assert-if-true ContainerInterface $this->container
* @phpstan-assert-if-false null $this->container
*/
private function tryContainer(): bool
{
if ($this->container) {
if ($this->container !== null) {
return true;
}
/** TODO: We need a better way to deal with status checking besides equality */
if (
$this->package->statusIs(Package::STATUS_READY)
|| $this->package->statusIs(Package::STATUS_BOOTED)
$this->package->hasContainer()
|| $this->package->hasReachedStatus(Package::STATUS_INITIALIZED)
) {
$this->container = $this->package->container();
}
return (bool)$this->container;
return $this->container !== null;
}
/**
* @param string $id
* @return void
*
* @throws \Exception
*
* @psalm-assert ContainerInterface $this->container
* @phpstan-assert ContainerInterface $this->container
*/
private function assertPackageBooted(string $id): void
{
@ -90,13 +76,11 @@ class PackageProxyContainer implements ContainerInterface
}
$name = $this->package->name();
$status = $this->package->statusIs(Package::STATUS_FAILED)
? 'is errored'
: 'is not ready yet';
$status = $this->package->hasFailed() ? 'is errored' : 'is not ready yet';
throw new class ("Error retrieving service {$id} because package {$name} {$status}.")
extends \Exception
implements ContainerExceptionInterface {
$error = "Error retrieving service {$id} because package {$name} {$status}.";
throw new class (esc_html($error)) extends \Exception implements ContainerExceptionInterface
{
};
}
}

View file

@ -7,58 +7,43 @@ namespace WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Container;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\NotFoundExceptionInterface;
/**
* @phpstan-import-type Service from \WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ServiceModule
* @phpstan-import-type ExtendingService from \WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExtendingModule
*/
class ReadOnlyContainer implements ContainerInterface
{
/**
* @var array<string, callable(\WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface $container):mixed>
*/
private $services;
/** @var array<string, Service> */
private array $services;
/** @var array<string, bool> */
private array $factoryIds;
private ServiceExtensions $extensions;
/** @var ContainerInterface[] */
private array $containers;
/** @var array<string, mixed> */
private array $resolvedServices = [];
/**
* @var array<string, bool>
*/
private $factoryIds;
/**
* @var array<string, array<callable(mixed, ContainerInterface $container):mixed>>
*/
private $extensions;
/**
* Resolved factories.
*
* @var array<string, mixed>
*/
private $resolvedServices = [];
/**
* @var ContainerInterface[]
*/
private $containers;
/**
* ReadOnlyContainer constructor.
*
* @param array<string, callable(ContainerInterface $container):mixed> $services
* @param array<string, Service> $services
* @param array<string, bool> $factoryIds
* @param array<string, array<callable(mixed, ContainerInterface $container):mixed>> $extensions
* @param ServiceExtensions|array<string, ExtendingService> $extensions
* @param ContainerInterface[] $containers
*/
public function __construct(
array $services,
array $factoryIds,
array $extensions,
$extensions,
array $containers
) {
$this->services = $services;
$this->factoryIds = $factoryIds;
$this->extensions = $extensions;
$this->extensions = $this->configureServiceExtensions($extensions);
$this->containers = $containers;
}
/**
* @param string $id
*
* @return mixed
*/
public function get(string $id)
@ -69,7 +54,7 @@ class ReadOnlyContainer implements ContainerInterface
if (array_key_exists($id, $this->services)) {
$service = $this->services[$id]($this);
$resolved = $this->resolveExtensions($id, $service);
$resolved = $this->extensions->resolve($service, $id, $this);
if (!isset($this->factoryIds[$id])) {
$this->resolvedServices[$id] = $resolved;
@ -83,19 +68,18 @@ class ReadOnlyContainer implements ContainerInterface
if ($container->has($id)) {
$service = $container->get($id);
return $this->resolveExtensions($id, $service);
return $this->extensions->resolve($service, $id, $this);
}
}
throw new class ("Service with ID {$id} not found.")
extends \Exception
implements NotFoundExceptionInterface {
$error = "Service with ID {$id} not found.";
throw new class (esc_html($error)) extends \Exception implements NotFoundExceptionInterface
{
};
}
/**
* @param string $id
*
* @return bool
*/
public function has(string $id): bool
@ -118,21 +102,43 @@ class ReadOnlyContainer implements ContainerInterface
}
/**
* @param string $id
* @param mixed $service
* Support extensions as array or ServiceExtensions instance for backward compatibility.
*
* @return mixed
* With PHP 8+ we could use an actual union type, but when we bump to PHP 8 as min supported
* version, we will probably bump major version as well, so we can just get rid of support
* for array.
*
* @param mixed $extensions
* @return ServiceExtensions
*/
private function resolveExtensions(string $id, $service)
private function configureServiceExtensions($extensions): ServiceExtensions
{
if (!isset($this->extensions[$id])) {
return $service;
if ($extensions instanceof ServiceExtensions) {
return $extensions;
}
foreach ($this->extensions[$id] as $extender) {
$service = $extender($service, $this);
if (!is_array($extensions)) {
$type = is_object($extensions) ? get_class($extensions) : gettype($extensions);
throw new \TypeError(
sprintf(
'%s::%s(): Argument #3 ($extensions) must be of type %s|array, %s given',
__CLASS__,
'__construct',
ServiceExtensions::class,
esc_html($type)
)
);
}
return $service;
$servicesExtensions = new ServiceExtensions();
foreach ($extensions as $id => $callback) {
/**
* @var string $id
* @var ExtendingService $callback
*/
$servicesExtensions->add($id, $callback);
}
return $servicesExtensions;
}
}

View file

@ -0,0 +1,194 @@
<?php
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Container;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface as Container;
/**
* @phpstan-import-type ExtendingService from \WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExtendingModule
*/
class ServiceExtensions
{
private const SERVICE_TYPE_NOT_CHANGED = 1;
private const SERVICE_TYPE_CHANGED = 2;
private const SERVICE_TYPE_NOT_OBJECT = 0;
/** @var array<string, list<ExtendingService>> */
protected array $extensions = [];
/**
* @param string $type
*
* @return string
*/
final public static function typeId(string $type): string
{
return "@instanceof<{$type}>";
}
/**
* @param string $extensionId
* @param ExtendingService $extender
*
* @return static
*/
public function add(string $extensionId, callable $extender): ServiceExtensions
{
if (!isset($this->extensions[$extensionId])) {
$this->extensions[$extensionId] = [];
}
$this->extensions[$extensionId][] = $extender;
return $this;
}
/**
* @param string $extensionId
*
* @return bool
*/
public function has(string $extensionId): bool
{
return isset($this->extensions[$extensionId]);
}
/**
* @param mixed $service
* @param string $id
* @param Container $container
*
* @return mixed
*/
final public function resolve($service, string $id, Container $container)
{
$service = $this->resolveById($id, $service, $container);
return is_object($service)
? $this->resolveByType(get_class($service), $service, $container)
: $service;
}
/**
* @param string $id
* @param mixed $service
* @param Container $container
*
* @return mixed
*/
protected function resolveById(string $id, $service, Container $container)
{
foreach ($this->extensions[$id] ?? [] as $extender) {
$service = $extender($service, $container);
}
return $service;
}
/**
* @param string $className
* @param object $service
* @param Container $container
* @param string[] $extendedClasses
*
* @return mixed
*
* phpcs:disable SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh
* phpcs:disable Syde.Functions.ReturnTypeDeclaration.NoReturnType
*/
protected function resolveByType(
string $className,
object $service,
Container $container,
array $extendedClasses = []
) {
// phpcs:enable SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh
// phpcs:enable Syde.Functions.ReturnTypeDeclaration.NoReturnType
$extendedClasses[] = $className;
/** @var array<class-string, list<ExtendingService>> $allCallbacks */
$allCallbacks = [];
// 1st group of extensions: targeting exact class
$byClass = $this->extensions[self::typeId($className)] ?? null;
if (($byClass !== null) && ($byClass !== [])) {
$allCallbacks[$className] = $byClass;
}
// 2nd group of extensions: targeting parent classes
$parents = class_parents($service, false) ?: [];
foreach ($parents as $parentName) {
$byParent = $this->extensions[self::typeId($parentName)] ?? null;
if (($byParent !== null) && ($byParent !== [])) {
$allCallbacks[$parentName] = $byParent;
}
}
// 3rd group of extensions: targeting implemented interfaces
$interfaces = class_implements($service, false) ?: [];
foreach ($interfaces as $interfaceName) {
$byInterface = $this->extensions[self::typeId($interfaceName)] ?? null;
if (($byInterface !== null) && ($byInterface !== [])) {
$allCallbacks[$interfaceName] = $byInterface;
}
}
$resultType = self::SERVICE_TYPE_NOT_CHANGED;
/** @var class-string $type */
foreach ($allCallbacks as $type => $extenders) {
// When the previous group of callbacks resulted in a type change, we need to check
// type before processing next group.
if (($resultType === self::SERVICE_TYPE_CHANGED) && !is_a($service, $type)) {
continue;
}
/** @var object $service */
[$service, $resultType] = $this->extendByType($type, $service, $container, $extenders);
if ($resultType === self::SERVICE_TYPE_NOT_OBJECT) {
// Service is not an object anymore, let's return it.
return $service;
}
}
// If type changed since beginning, let's start over.
// We check if class was already extended to avoid infinite recursion. E.g. instead of:
// `-> extend(A): B -> extend(B): A -> *loop* ->`
// we have:
// `-> extend(A): B -> extend(B): A -> return A`.
$newClassName = get_class($service);
if (!in_array($newClassName, $extendedClasses, true)) {
return $this->resolveByType($newClassName, $service, $container, $extendedClasses);
}
return $service;
}
/**
* @param class-string $type
* @param object $service
* @param Container $container
* @param list<ExtendingService> $extenders
*
* @return list{mixed, int}
*/
private function extendByType(
string $type,
object $service,
Container $container,
array $extenders
): array {
foreach ($extenders as $extender) {
$service = $extender($service, $container);
if (!is_object($service)) {
return [$service, self::SERVICE_TYPE_NOT_OBJECT];
}
if (!is_a($service, $type)) {
return [$service, self::SERVICE_TYPE_CHANGED];
}
}
return [$service, self::SERVICE_TYPE_NOT_CHANGED];
}
}

View file

@ -8,7 +8,6 @@ use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
interface ExecutableModule extends Module
{
/**
* Perform actions with objects retrieved from the container. Usually, adding WordPress hooks.
* Return true to signal a success, false to signal a failure.

View file

@ -4,9 +4,13 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
/**
* @phpstan-type ExtendingService callable(mixed $service, ContainerInterface $container): mixed
*/
interface ExtendingModule extends Module
{
/**
* Return application services' extensions.
*
@ -18,7 +22,7 @@ interface ExtendingModule extends Module
* That is done by using as ID (array key in the `extensions` method) the target module ID
* and the service ID.
*
* @return array<string, callable(mixed $service, \WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface $container):mixed>
* @return array<string, ExtendingService>
*/
public function extensions(): array;
}

View file

@ -4,6 +4,9 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module;
/**
* @phpstan-import-type Service from ServiceModule
*/
interface FactoryModule extends Module
{
/**
@ -12,7 +15,7 @@ interface FactoryModule extends Module
* Similar to `services`, but object created by given factories are not "cached", but a *new*
* instance is returned everytime `get()` is called in the container.
*
* @return array<string, callable(\WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface $container):mixed>
* @return array<string, Service>
*/
public function factories(): array;
}

View file

@ -9,12 +9,10 @@ namespace WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module;
*/
interface Module
{
/**
* Unique identifier for your Module.
*
* @return string
*/
public function id(): string;
}

View file

@ -4,16 +4,11 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module;
/**
* Trait ModuleClassNameIdTrait
*
* @package WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module
*/
trait ModuleClassNameIdTrait
{
/**
* @return string
*
* @see Module::id()
*/
public function id(): string

View file

@ -4,9 +4,13 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
/**
* @phpstan-type Service callable(ContainerInterface $container): mixed
*/
interface ServiceModule extends Module
{
/**
* Return application services' factories.
*
@ -15,7 +19,7 @@ interface ServiceModule extends Module
* Services are "cached", so the given factory is called once the first time `get()` is called
* in the container, and on subsequent `get()` the same instance is returned again and again.
*
* @return array<string, callable(\WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface $container):mixed>
* @return array<string, Service>
*/
public function services(): array;
}

View file

@ -6,19 +6,22 @@ namespace WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Container\ContainerConfigurator;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Container\PackageProxyContainer;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExtendingModule;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExecutableModule;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExtendingModule;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\FactoryModule;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\Module;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ServiceModule;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Properties\Properties;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
/**
* @phpstan-import-type Service from \WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ServiceModule
* @phpstan-import-type ExtendingService from \WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExtendingModule
*/
class Package
{
/**
* All the hooks fired in this class use this prefix.
* @var string
*/
private const HOOK_PREFIX = 'inpsyde.modularity.';
@ -34,14 +37,13 @@ class Package
* $container->has(Package::PROPERTIES);
* $container->get(Package::PROPERTIES);
* </code>
*
* @var string
*/
public const PROPERTIES = 'properties';
/**
* Custom action to be used to add Modules to the package.
* Custom action to be used to add modules and connect other packages.
* It might also be used to access package properties.
* Access container is not possible at this stage.
*
* @example
* <code>
@ -49,67 +51,64 @@ class Package
*
* add_action(
* $package->hookName(Package::ACTION_INIT),
* $callback
* fn (Package $package) => // do something,
* );
* </code>
*/
public const ACTION_INIT = 'init';
/**
* Custom action which is triggered after the application
* is booted to access container and properties.
* Very similar to `ACTION_INIT`, but it is static, so not dependent on package name.
* It passes package name as first argument.
*
* @example
* <code>
* $package = Package::new();
*
* add_action(
* $package->hookName(Package::ACTION_READY),
* $callback
* );
* </code>
* <code>
* add_action(
* Package::ACTION_MODULARITY_INIT,
* fn (string $packageName, Package $package) => // do something,
* 10,
* 2
* );
* </code>
*/
public const ACTION_READY = 'ready';
public const ACTION_MODULARITY_INIT = self::HOOK_PREFIX . self::ACTION_INIT;
/**
* Custom action which is triggered when a failure happens during the building stage.
*
* @example
* <code>
* $package = Package::new();
*
* add_action(
* $package->hookName(Package::ACTION_FAILED_BUILD),
* $callback
* );
* </code>
* Action fired when it is safe to access container.
* Add more modules is not anymore possible at this stage.
*/
public const ACTION_INITIALIZED = 'initialized';
/**
* Action fired when plugin finished its bootstrapping process, all its hooks are added.
* Add more modules is not anymore possible at this stage.
*/
public const ACTION_BOOTED = 'ready';
/**
* Action fired when anything went wrong during the "build" procedure.
*/
public const ACTION_FAILED_BUILD = 'failed-build';
/**
* Custom action which is triggered when a failure happens during the booting stage.
*
* @example
* <code>
* $package = Package::new();
*
* add_action(
* $package->hookName(Package::ACTION_FAILED_BOOT),
* $callback
* );
* </code>
* Action fired when anything went wrong during the "boot" procedure.
*/
public const ACTION_FAILED_BOOT = 'failed-boot';
/**
* Custom action which is triggered when a package is connected.
* Action fired when adding a module failed.
*/
public const ACTION_PACKAGE_CONNECTED = 'package-connected';
public const ACTION_FAILED_ADD_MODULE = 'failed-add-module';
/**
* Custom action which is triggered when a package cannot be connected.
* Action fired when a package connection failed.
*/
public const ACTION_FAILED_CONNECTION = 'failed-connection';
public const ACTION_FAILED_CONNECT = 'failed-connection';
/**
* Action fired when a package is connected successfully.
*/
public const ACTION_PACKAGE_CONNECTED = 'package-connected';
/**
* Module states can be used to get information about your module.
@ -118,7 +117,7 @@ class Package
* <code>
* $package = Package::new();
* $package->moduleIs(SomeModule::class, Package::MODULE_ADDED); // false
* $package->boot(new SomeModule());
* $package->addModule(new SomeModule());
* $package->moduleIs(SomeModule::class, Package::MODULE_ADDED); // true
* </code>
*/
@ -137,90 +136,81 @@ class Package
* @example
* <code>
* $package = Package::new();
* $package->statusIs(Package::IDLE); // true
* $package->statusIs(Package::STATUS_IDLE); // true
* $package->build();
* $package->statusIs(Package::STATUS_INITIALIZED); // true
* $package->boot();
* $package->statusIs(Package::BOOTED); // true
* $package->statusIs(Package::STATUS_DONE); // true
* </code>
*/
public const STATUS_IDLE = 2;
public const STATUS_INITIALIZING = 3;
public const STATUS_INITIALIZED = 4;
public const STATUS_MODULES_ADDED = 5;
public const STATUS_READY = 7;
public const STATUS_BOOTED = 8;
public const STATUS_BOOTING = 5;
public const STATUS_BOOTED = 7;
public const STATUS_DONE = 8;
public const STATUS_FAILED = -8;
/**
* Current state of the application.
*
* @see Package::STATUS_*
*
* @var int
*/
private $status = self::STATUS_IDLE;
// Deprecated flags
/** @deprecated */
public const STATUS_MODULES_ADDED = self::STATUS_BOOTING;
/** @deprecated */
public const ACTION_READY = self::ACTION_BOOTED;
/** @deprecated */
public const ACTION_FAILED_CONNECTION = self::ACTION_FAILED_CONNECT;
/**
* Contains the progress of all modules.
*
* @see Package::moduleProgress()
*
* @var array<string, list<string>>
*/
private $moduleStatus = [self::MODULES_ALL => []];
// Map of status to package-specific and global hook, both optional (i..e, null).
private const STATUSES_ACTIONS_MAP = [
self::STATUS_INITIALIZING => [self::ACTION_INIT, self::ACTION_MODULARITY_INIT],
self::STATUS_INITIALIZED => [self::ACTION_INITIALIZED, null],
self::STATUS_BOOTED => [self::ACTION_BOOTED, null],
];
/**
* Hashmap of where keys are names of connected packages, and values are boolean, true
* if connection was successful.
*
* @see Package::connect()
*
* @var array<string, bool>
*/
private $connectedPackages = [];
private const SUCCESS_STATUSES = [
self::STATUS_IDLE => self::STATUS_IDLE,
self::STATUS_INITIALIZING => self::STATUS_INITIALIZING,
self::STATUS_INITIALIZED => self::STATUS_INITIALIZED,
self::STATUS_BOOTING => self::STATUS_BOOTING,
self::STATUS_BOOTED => self::STATUS_BOOTED,
self::STATUS_DONE => self::STATUS_DONE,
];
/**
* @var list<ExecutableModule>
*/
private $executables = [];
private const OPERATORS = [
'<' => '<',
'<=' => '<=',
'>' => '>',
'>=' => '>=',
'==' => '==',
'!=' => '!=',
];
/**
* @var Properties
*/
private $properties;
/**
* @var ContainerConfigurator
*/
private $containerConfigurator;
/**
* @var bool
*/
private $built = false;
/**
* @var bool
*/
private $hasContainer = false;
/**
* @var \Throwable|null
*/
private $lastError = null;
/** @var Package::STATUS_* */
private int $status = self::STATUS_IDLE;
/** @var array<string, list<string>> */
private array $moduleStatus = [self::MODULES_ALL => []];
/** @var array<string, bool> */
private array $connectedPackages = [];
/** @var list<ExecutableModule> */
private array $executables = [];
private Properties $properties;
private ContainerConfigurator $containerConfigurator;
private bool $built = false;
private bool $hasContainer = false;
private ?\Throwable $lastError = null;
/**
* @param Properties $properties
* @param ContainerInterface[] $containers
*
* @param ContainerInterface ...$containers
* @return Package
*/
public static function new(Properties $properties, ContainerInterface ...$containers): Package
public static function new(Properties $properties, ContainerInterface ...$containers): Package
{
return new self($properties, ...$containers);
}
/**
* @param Properties $properties
* @param ContainerInterface[] $containers
* @param ContainerInterface ...$containers
*/
private function __construct(Properties $properties, ContainerInterface ...$containers)
{
@ -229,7 +219,7 @@ class Package
$this->containerConfigurator = new ContainerConfigurator($containers);
$this->containerConfigurator->addService(
self::PROPERTIES,
static function () use ($properties) {
static function () use ($properties): Properties {
return $properties;
}
);
@ -237,14 +227,14 @@ class Package
/**
* @param Module $module
*
* @return static
* @throws \Exception
*/
public function addModule(Module $module): Package
{
try {
$this->assertStatus(self::STATUS_IDLE, sprintf('add module %s', $module->id()));
$reason = sprintf('add module %s', $module->id());
$this->assertStatus(self::STATUS_FAILED, $reason, '!=');
$this->assertStatus(self::STATUS_INITIALIZING, $reason, '<=');
$registeredServices = $this->addModuleServices(
$module,
@ -271,7 +261,7 @@ class Package
$status = $added ? self::MODULE_ADDED : self::MODULE_NOT_ADDED;
$this->moduleProgress($module->id(), $status);
} catch (\Throwable $throwable) {
$this->handleFailure($throwable, self::ACTION_FAILED_BUILD);
$this->handleFailure($throwable, self::ACTION_FAILED_ADD_MODULE);
}
return $this;
@ -280,7 +270,6 @@ class Package
/**
* @param Package $package
* @return bool
* @throws \Exception
*/
public function connect(Package $package): bool
{
@ -290,33 +279,17 @@ class Package
}
$packageName = $package->name();
$errorData = ['package' => $packageName, 'status' => $this->status];
$errorMessage = "Failed connecting package {$packageName}";
// Don't connect, if already connected
if (array_key_exists($packageName, $this->connectedPackages)) {
$error = "{$errorMessage} because it was already connected.";
do_action(
$this->hookName(self::ACTION_FAILED_CONNECTION),
$packageName,
new \WP_Error('already_connected', $error, $errorData)
);
throw new \Exception($error, 0, $this->lastError);
return $this->handleConnectionFailure($packageName, 'already connected', false);
}
// Don't connect, if already booted or boot failed
$failed = $this->statusIs(self::STATUS_FAILED);
if ($failed || $this->statusIs(self::STATUS_BOOTED)) {
$status = $failed ? 'errored' : 'booted';
$error = "{$errorMessage} to a {$status} package.";
do_action(
$this->hookName(self::ACTION_FAILED_CONNECTION),
$packageName,
new \WP_Error("no_connect_on_{$status}", $error, $errorData)
);
throw new \Exception($error, 0, $this->lastError);
$failed = $this->hasFailed();
if ($failed || $this->hasReachedStatus(self::STATUS_INITIALIZED)) {
$reason = $failed ? 'is errored' : 'has a built container already';
$this->handleConnectionFailure($packageName, "current package {$reason}", true);
}
$this->connectedPackages[$packageName] = true;
@ -330,9 +303,10 @@ class Package
}
);
// If the other package is booted, we can obtain a container, otherwise
// we build a proxy container
$container = $package->statusIs(self::STATUS_BOOTED)
// If we can obtain a container we do, otherwise we build a proxy container
$packageHasContainer = $package->hasReachedStatus(self::STATUS_INITIALIZED)
|| $package->hasContainer();
$container = $packageHasContainer
? $package->container()
: new PackageProxyContainer($package);
@ -347,7 +321,10 @@ class Package
return true;
} catch (\Throwable $throwable) {
if (isset($packageName)) {
if (
isset($packageName)
&& (($this->connectedPackages[$packageName] ?? false) !== true)
) {
$this->connectedPackages[$packageName] = false;
}
$this->handleFailure($throwable, self::ACTION_FAILED_BUILD);
@ -362,17 +339,26 @@ class Package
public function build(): Package
{
try {
// Don't allow building the application multiple times.
// Be tolerant about things like `$package->build()->build()`.
// Sometimes, from the extern, we might want to call `build()` to ensure the container
// is ready before accessing a service. And in that case we don't want to throw an
// exception if the container is already built.
if ($this->built && $this->statusIs(self::STATUS_INITIALIZED)) {
return $this;
}
// We expect `build` to be called only after `addModule()` or `connect()` which do
// not change the status, so we expect status to be still "IDLE".
// This will prevent invalid things like calling `build()` from inside something
// hooking ACTION_INIT OR ACTION_INITIALIZED.
$this->assertStatus(self::STATUS_IDLE, 'build package');
do_action(
$this->hookName(self::ACTION_INIT),
$this
);
// Changing the status here ensures we can not call this method again, and also we can not
// add new modules, because both this and `addModule()` methods check for idle status.
// For backward compatibility, adding new modules via `boot()` will still be possible, even
// if deprecated, at the condition that the container was not yet accessed at that point.
// This will change the status to "INITIALIZING" then fire the action that allow other
// packages to add modules or connect packages.
$this->progress(self::STATUS_INITIALIZING);
// This will change the status to "INITIALIZED" then fire an action when it is safe to
// access the container, because from this moment on, container is locked from change.
$this->progress(self::STATUS_INITIALIZED);
} catch (\Throwable $throwable) {
$this->handleFailure($throwable, self::ACTION_FAILED_BUILD);
@ -386,37 +372,47 @@ class Package
/**
* @param Module ...$defaultModules Deprecated, use `addModule()` to add default modules.
* @return bool
*
* @throws \Throwable
*/
public function boot(Module ...$defaultModules): bool
{
try {
// When package is done, nothing should happen to it calling boot again, but we call
// false to signal something is off.
if ($this->statusIs(self::STATUS_DONE)) {
return false;
}
// Call build() if not called yet, and ensure any new module passed here is added
// as well, throwing if the container was already built.
$this->doBuild(...$defaultModules);
// Don't allow booting the application multiple times.
$this->assertStatus(self::STATUS_MODULES_ADDED, 'boot application', '<');
$this->assertStatus(self::STATUS_FAILED, 'boot application', '!=');
// Make sure we call boot() on a non-failed instance, and also make a sanity check
// on the status flow, e.g. prevent calling boot() from an action hook.
$this->assertStatus(self::STATUS_INITIALIZED, 'boot application');
$this->progress(self::STATUS_MODULES_ADDED);
// This will change status to STATUS_BOOTING "locking" subsequent call to `boot()`, but
// no hook is fired here, because at this point we can not do anything more or less than
// what can be done on the ACTION_INITIALIZED hook, so that hook is sufficient.
$this->progress(self::STATUS_BOOTING);
$this->doExecute();
$this->progress(self::STATUS_READY);
do_action(
$this->hookName(self::ACTION_READY),
$this
);
// This will change status to STATUS_BOOTED and then fire an action that make it
// possible to hook on a package that has finished its bootstrapping process, so all its
// "executable" modules have been executed.
$this->progress(self::STATUS_BOOTED);
} catch (\Throwable $throwable) {
$this->handleFailure($throwable, self::ACTION_FAILED_BOOT);
return false;
}
$this->progress(self::STATUS_BOOTED);
// This will change the status to DONE and will not fire any action.
// This is a status that proves that everything went well, not only the Package itself,
// but also anything hooking Package's hooks.
// The only way to move out of this status is a failure that might only happen directly
// calling `addModule()`, `connect()` or `build()`.
$this->progress(self::STATUS_DONE);
return true;
}
@ -439,39 +435,67 @@ class Package
);
}
// We expect `boot()` to be called either:
// 1. Directly after `addModule()`/`connect()`, without any `build()` call in between, so
// status is IDLE and `$this->built` is `false`.
// 2. After `build()` is called, so status is INITIALIZED and `$this->built` is `true`.
// Any other usage is not allowed (e.g. calling `boot()` from an hook callback) and in that
// case we return here, giving back control to `boot()` which will throw.
$validFlows = (!$this->built && $this->statusIs(self::STATUS_IDLE))
|| ($this->built && $this->statusIs(self::STATUS_INITIALIZED));
if (!$validFlows) {
// If none of the two supported flows happened, we just return handling control back
// to `boot()`, that will throw.
return;
}
if (!$this->built) {
array_map([$this, 'addModule'], $defaultModules);
// First valid flow: `boot()` was called directly after `addModule()`/`connect()`
// without any call to `build()`. We can call `build()` and return, handing control
// back to `boot()`. Before returning, if we had default modules passed to `boot()` we
// already have fired a deprecation, so here we just add them dealing with back-compat.
foreach ($defaultModules as $defaultModule) {
$this->addModule($defaultModule);
}
$this->build();
return;
}
if (
!$defaultModules
|| ($this->status >= self::STATUS_MODULES_ADDED)
|| ($this->statusIs(self::STATUS_FAILED))
) {
// if we don't have default modules, there's nothing to do, and if the status is beyond
// "modules added" or is failed, we do nothing as well and let `boot()` throw.
// Second valid flow: we have called `boot()` after `build()`. If we did it correctly,
// without default modules passed to `boot()`, we can just return handing control back
// to `boot()`.
if (!$defaultModules) {
return;
}
// If here, we have done something like: `$package->build()->boot($module1, $module2)`.
// Passing modules to `boot()` was deprecated when `build()` was introduced, so whoever
// added `build()` should have removed modules passed to `boot()`.
// But we want to keep 100% backward compatibility so we still support this behavior
// until the next major is released. To do that, we simulate IDLE status to prevent
// `addModule()` from throwing when adding default modules.
// But we can do that only if we don't have a compiled container yet.
// If anything hooking ACTION_INIT called `container()` we have a compiled container
// already, and we can't add modules, so we not going to simulate INIT status, which mean
// the `$this->addModule()` call below will throw.
$backup = $this->status;
try {
// simulate idle status to prevent `addModule()` from throwing
// only if we don't have a container yet
$this->hasContainer or $this->status = self::STATUS_IDLE;
if (!$this->hasContainer()) {
$this->status = self::STATUS_IDLE;
}
foreach ($defaultModules as $defaultModule) {
// If a module was added by `build()` or `addModule()` we can skip it, a
// deprecation was trigger to make it noticeable without breakage
// If a module was already added via `addModule()` we can skip it, reducing the
// chances of throwing an exception if not needed.
if (!$this->moduleIs($defaultModule->id(), self::MODULE_ADDED)) {
$this->addModule($defaultModule);
}
}
} finally {
$this->status = $backup;
if (!$this->hasFailed()) {
$this->status = $backup;
}
}
}
@ -482,7 +506,9 @@ class Package
*/
private function addModuleServices(Module $module, string $status): bool
{
/** @var null|array<string, Service|ExtendingService> $services */
$services = null;
/** @var null|callable(string, Service|ExtendingService): void $addCallback */
$addCallback = null;
switch ($status) {
case self::MODULE_REGISTERED:
@ -499,21 +525,16 @@ class Package
break;
}
if (!$services) {
if (($services === null) || ($services === []) || ($addCallback === null)) {
return false;
}
$ids = [];
array_walk(
$services,
static function (callable $service, string $id) use ($addCallback, &$ids) {
/** @var callable(string, callable) $addCallback */
$addCallback($id, $service);
/** @var list<string> $ids */
$ids[] = $id;
}
);
/** @var list<string> $ids */
foreach ($services as $id => $service) {
$addCallback($id, $service);
$ids[] = $id;
}
$this->moduleProgress($module->id(), $status, $ids);
return true;
@ -521,8 +542,6 @@ class Package
/**
* @return void
*
* @throws \Throwable
*/
private function doExecute(): void
{
@ -530,9 +549,7 @@ class Package
$success = $executable->run($this->container());
$this->moduleProgress(
$executable->id(),
$success
? self::MODULE_EXECUTED
: self::MODULE_EXECUTION_FAILED
$success ? self::MODULE_EXECUTED : self::MODULE_EXECUTION_FAILED,
);
}
}
@ -541,15 +558,20 @@ class Package
* @param string $moduleId
* @param string $status
* @param list<string>|null $serviceIds
*
* @return void
* @return void
*/
private function moduleProgress(string $moduleId, string $status, ?array $serviceIds = null)
{
isset($this->moduleStatus[$status]) or $this->moduleStatus[$status] = [];
private function moduleProgress(
string $moduleId,
string $status,
?array $serviceIds = null
): void {
if (!isset($this->moduleStatus[$status])) {
$this->moduleStatus[$status] = [];
}
$this->moduleStatus[$status][] = $moduleId;
if (!$serviceIds || !$this->properties->isDebug()) {
if (($serviceIds === null) || ($serviceIds === []) || !$this->properties->isDebug()) {
$this->moduleStatus[self::MODULES_ALL][] = "{$moduleId} {$status}";
return;
@ -605,10 +627,9 @@ class Package
* `inpsyde.modularity.my-plugin` anyway, so the file name is not relevant.
*
* @param string $suffix
*
* @return string
* @see Package::name()
*
* @see Package::name()
*/
public function hookName(string $suffix = ''): string
{
@ -631,8 +652,6 @@ class Package
/**
* @return ContainerInterface
*
* @throws \Exception
*/
public function container(): ContainerInterface
{
@ -642,6 +661,14 @@ class Package
return $this->containerConfigurator->createReadOnlyContainer();
}
/**
* @return bool
*/
public function hasContainer(): bool
{
return $this->hasContainer;
}
/**
* @return string
*/
@ -652,27 +679,94 @@ class Package
/**
* @param int $status
*/
private function progress(int $status): void
{
$this->status = $status;
}
/**
* @param int $status
*
* @return bool
*/
public function statusIs(int $status): bool
{
return $this->status === $status;
return $this->checkStatus($status);
}
/**
* @return bool
*/
public function hasFailed(): bool
{
return $this->status === self::STATUS_FAILED;
}
/**
* @param int $status
* @return bool
*/
public function hasReachedStatus(int $status): bool
{
if ($this->hasFailed()) {
return false;
}
return isset(self::SUCCESS_STATUSES[$status]) && $this->checkStatus($status, '>=');
}
/**
* @param int $status
* @param value-of<Package::OPERATORS> $operator
* @return bool
*/
private function checkStatus(int $status, string $operator = '=='): bool
{
assert(isset(self::OPERATORS[$operator]));
return version_compare((string) $this->status, (string) $status, $operator);
}
/**
* @param Package::STATUS_* $status
*/
private function progress(int $status): void
{
$this->status = $status;
[$packageHookSuffix, $globalHook] = self::STATUSES_ACTIONS_MAP[$status] ?? [null, null];
if ($packageHookSuffix !== null) {
do_action($this->hookName($packageHookSuffix), $this);
}
if ($globalHook !== null) {
do_action($globalHook, $this->name(), $this);
}
}
/**
* @param string $packageName
* @param string $reason
* @param bool $throw
* @return bool
*/
private function handleConnectionFailure(string $packageName, string $reason, bool $throw): bool
{
$errorData = ['package' => $packageName, 'status' => $this->status];
$message = "Failed connecting package {$packageName} because {$reason}.";
do_action(
$this->hookName(self::ACTION_FAILED_CONNECT),
$packageName,
new \WP_Error('failed_connection', $message, $errorData)
);
if ($throw) {
throw new \Exception(
esc_html($message),
0,
$this->lastError // phpcs:ignore WordPress.Security.EscapeOutput
);
}
return false;
}
/**
* @param \Throwable $throwable
* @param Package::ACTION_FAILED_* $action
* @return void
* @throws \Throwable
*/
private function handleFailure(\Throwable $throwable, string $action): void
{
@ -690,18 +784,15 @@ class Package
/**
* @param int $status
* @param string $action
* @param string $operator
*
* @throws \Exception
* @psalm-suppress ArgumentTypeCoercion
* @param value-of<Package::OPERATORS> $operator
*/
private function assertStatus(int $status, string $action, string $operator = '=='): void
{
if (!version_compare((string) $this->status, (string) $status, $operator)) {
if (!$this->checkStatus($status, $operator)) {
throw new \Exception(
sprintf("Can't %s at this point of application.", $action),
sprintf("Can't %s at this point of application.", esc_html($action)),
0,
$this->lastError
$this->lastError // phpcs:ignore WordPress.Security.EscapeOutput
);
}
}
@ -713,7 +804,6 @@ class Package
* @param string $message
* @param string $function
* @param string $version
*
* @return void
*/
private function deprecatedArgument(string $message, string $function, string $version): void
@ -721,7 +811,9 @@ class Package
do_action('deprecated_argument_run', $function, $message, $version);
if (apply_filters('deprecated_argument_trigger_error', true)) {
trigger_error($message, \E_USER_DEPRECATED);
do_action('wp_trigger_error_run', $function, $message, \E_USER_DEPRECATED);
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error
trigger_error(esc_html($message), \E_USER_DEPRECATED);
}
}
}

View file

@ -6,47 +6,30 @@ namespace WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Properties;
class BaseProperties implements Properties
{
/**
* @var null|bool
*/
protected $isDebug = null;
/**
* @var string
*/
protected $baseName;
/**
* @var string
*/
protected $basePath;
/**
* @var string|null
*/
protected $baseUrl;
/**
* @var array
*/
protected $properties;
protected ?bool $isDebug = null;
protected string $baseName;
protected string $basePath;
protected ?string $baseUrl;
/** @var array<string, mixed> */
protected array $properties;
/**
* @param string $baseName
* @param string $basePath
* @param string|null $baseUrl
* @param array $properties
* @param array<string, mixed> $properties
*/
protected function __construct(
string $baseName,
string $basePath,
string $baseUrl = null,
?string $baseUrl = null,
array $properties = []
) {
$baseName = $this->sanitizeBaseName($baseName);
$basePath = (string) trailingslashit($basePath);
if ($baseUrl) {
$baseUrl = (string) trailingslashit($baseUrl);
$basePath = trailingslashit($basePath);
if ($baseUrl !== null) {
$baseUrl = trailingslashit($baseUrl);
}
$this->baseName = $baseName;
@ -58,11 +41,13 @@ class BaseProperties implements Properties
/**
* @param string $name
*
* @return string
* @return lowercase-string
*/
protected function sanitizeBaseName(string $name): string
{
substr_count($name, '/') and $name = dirname($name);
if (substr_count($name, '/')) {
$name = dirname($name);
}
return strtolower(pathinfo($name, PATHINFO_FILENAME));
}
@ -162,7 +147,9 @@ class BaseProperties implements Properties
{
$value = $this->get(self::PROP_REQUIRES_WP);
return $value && is_string($value) ? $value : null;
return (($value !== '') && is_string($value))
? $value
: null;
}
/**
@ -172,11 +159,13 @@ class BaseProperties implements Properties
{
$value = $this->get(self::PROP_REQUIRES_PHP);
return $value && is_string($value) ? $value : null;
return (($value !== '') && is_string($value))
? $value
: null;
}
/**
* @return array
* @return string[]
*/
public function tags(): array
{
@ -185,7 +174,8 @@ class BaseProperties implements Properties
/**
* @param string $key
* @param null $default
* @param mixed $default
*
* @return mixed
*/
public function get(string $key, $default = null)
@ -195,6 +185,7 @@ class BaseProperties implements Properties
/**
* @param string $key
*
* @return bool
*/
public function has(string $key): bool

View file

@ -5,17 +5,26 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Properties;
/**
* Class LibraryProperties
*
* @package WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Properties
* @phpstan-type ComposerAuthor array{
* name: string,
* email?: string,
* homepage?: string,
* role?: string,
* }
* @phpstan-type ComposerData array{
* name: string,
* version?: string,
* require?: array<string, string>,
* require-dev?: array<string, string>,
* description?: string,
* keywords?: string[],
* authors?: ComposerAuthor[],
* extra?: array{modularity?: array<string, string>},
* }
*/
class LibraryProperties extends BaseProperties
{
/**
* Allowed configuration in composer.json "extra.modularity".
*
* @var array
*/
/** Allowed configuration in composer.json "extra.modularity" */
public const EXTRA_KEYS = [
self::PROP_DOMAIN_PATH,
self::PROP_NAME,
@ -31,32 +40,33 @@ class LibraryProperties extends BaseProperties
*
* @return LibraryProperties
*
* @throws \Exception
* @psalm-suppress MixedArrayAccess
* phpcs:disable SlevomatCodingStandard.Complexity
*/
public static function new(string $composerJsonFile, ?string $baseUrl = null): LibraryProperties
{
if (!\is_file($composerJsonFile) || !\is_readable($composerJsonFile)) {
throw new \Exception("File {$composerJsonFile} does not exist or is not readable.");
}
// phpcs:enable SlevomatCodingStandard.Complexity
$content = (string) file_get_contents($composerJsonFile);
/** @var array $composerJsonData */
$composerJsonData = json_decode($content, true);
$composerJsonData = self::readComposerJsonData($composerJsonFile);
$properties = Properties::DEFAULT_PROPERTIES;
$properties[self::PROP_DESCRIPTION] = $composerJsonData['description'] ?? '';
$properties[self::PROP_TAGS] = $composerJsonData['keywords'] ?? [];
$authors = $composerJsonData['authors'] ?? [];
if (!is_array($authors)) {
$authors = [];
}
$names = [];
foreach ((array) $authors as $author) {
$name = $author['name'] ?? null;
if ($name && is_string($name)) {
foreach ($authors as $author) {
if (!is_array($author)) {
continue;
}
$name = $author['name'] ?? '';
if (($name !== '') && is_string($name)) {
$names[] = $name;
}
$url = $author['homepage'] ?? null;
if ($url && !$properties['authorUri'] && is_string($url)) {
$url = $author['homepage'] ?? '';
if (($url !== '') && ($properties[self::PROP_AUTHOR_URI] === '') && is_string($url)) {
$properties[self::PROP_AUTHOR_URI] = $url;
}
}
@ -66,6 +76,9 @@ class LibraryProperties extends BaseProperties
// Custom settings which can be stored in composer.json "extra.modularity"
$extra = $composerJsonData['extra']['modularity'] ?? [];
if (!is_array($extra)) {
$extra = [];
}
foreach (self::EXTRA_KEYS as $key) {
$properties[$key] = $extra[$key] ?? '';
}
@ -74,39 +87,50 @@ class LibraryProperties extends BaseProperties
$properties[self::PROP_REQUIRES_PHP] = self::extractPhpVersion($composerJsonData);
// composer.json might have "version" in root
$version = $composerJsonData['version'] ?? null;
if ($version && is_string($version)) {
$version = $composerJsonData['version'] ?? '';
if (($version !== '') && is_string($version)) {
$properties[self::PROP_VERSION] = $version;
}
[$baseName, $name] = static::buildNames($composerJsonData);
$basePath = dirname($composerJsonFile);
if (empty($properties[self::PROP_NAME])) {
if (($properties[self::PROP_NAME] === '') || !is_string($properties[self::PROP_NAME])) {
$properties[self::PROP_NAME] = $name;
}
return new self(
$baseName,
$basePath,
$baseUrl,
$properties
);
return new self($baseName, $basePath, $baseUrl, $properties);
}
/**
* @param array $composerJsonData
* @param string $url
*
* @return static
*/
public function withBaseUrl(string $url): LibraryProperties
{
if ($this->baseUrl !== null) {
throw new \Exception(sprintf('%s::$baseUrl property is not overridable.', __CLASS__));
}
$this->baseUrl = trailingslashit($url);
return $this;
}
/**
* @param ComposerData $composerJsonData
*
* @return array{string, string}
*/
private static function buildNames(array $composerJsonData): array
protected static function buildNames(array $composerJsonData): array
{
$composerName = (string) ($composerJsonData['name'] ?? '');
$packageNamePieces = explode('/', $composerName, 2);
$basename = implode('-', $packageNamePieces);
// "inpsyde/foo-bar-baz" => "Inpsyde Foo Bar Baz"
// From "syde/foo-bar-baz" to "Syde Foo Bar Baz"
$name = mb_convert_case(
str_replace(['-', '_', '.'], ' ', implode(' ', $packageNamePieces)),
MB_CASE_TITLE
MB_CASE_TITLE,
);
return [$basename, $name];
@ -122,88 +146,111 @@ class LibraryProperties extends BaseProperties
* `5.6 || >= 7.1` returns `5.6`
* `>= 7.1 < 8` returns `7.1`
*
* @param array $composerData
* @param ComposerData $composerData
* @param string $key
*
* @return string|null
* @return string
*/
private static function extractPhpVersion(array $composerData, string $key = 'require'): ?string
{
protected static function extractPhpVersion(
array $composerData,
string $key = 'require'
): string {
$nextKey = ($key === 'require')
? 'require-dev'
: null;
$base = (array) ($composerData[$key] ?? []);
$requirement = $base['php'] ?? null;
$version = ($requirement && is_string($requirement))
$base = $composerData[$key] ?? null;
$requirement = is_array($base)
? ($base['php'] ?? '')
: '';
$version = (($requirement !== '') && is_string($requirement))
? trim($requirement)
: null;
if (!$version) {
return $nextKey
: '';
if ($version === '') {
return ($nextKey !== null)
? static::extractPhpVersion($composerData, $nextKey)
: null;
: '';
}
static $matcher;
$matcher or $matcher = static function (string $version): ?string {
$version = trim($version);
if (!$version) {
return null;
}
// versions range like `>= 7.2.4 < 8`
if (preg_match('{>=?([\s0-9\.]+)<}', $version, $matches)) {
return trim($matches[1], " \t\n\r\0\x0B.");
}
// aliases like `dev-src#abcde as 7.4`
if (preg_match('{as\s*([\s0-9\.]+)}', $version, $matches)) {
return trim($matches[1], " \t\n\r\0\x0B.");
}
// Basic requirements like 7.2, >=7.2, ^7.2, ~7.2
if (preg_match('{^(?:[>=\s~\^]+)?([0-9\.]+)}', $version, $matches)) {
return trim($matches[1], " \t\n\r\0\x0B.");
}
return null;
};
// support for simpler requirements like `7.3`, `>=7.4` or alternative like `5.6 || >=7`
$alternatives = explode('||', $version);
/** @var non-empty-string|null $found */
$found = null;
foreach ($alternatives as $alternative) {
/** @var callable(string):?string $matcher */
$itemFound = $matcher($alternative);
if ($itemFound && (!$found || version_compare($itemFound, $found, '<'))) {
$itemFound = static::parseVersion($alternative);
if (
($itemFound !== '')
&& (($found === null) || version_compare($itemFound, $found, '<'))
) {
$found = $itemFound;
}
}
if ($found) {
if ($found !== null) {
return $found;
}
return $nextKey
return ($nextKey !== null)
? static::extractPhpVersion($composerData, $nextKey)
: null;
: '';
}
/**
* @param string $url
* @param string $version
*
* @return static
*
* @throws \Exception
* @return string
*/
public function withBaseUrl(string $url): LibraryProperties
protected static function parseVersion(string $version): string
{
if ($this->baseUrl !== null) {
throw new \Exception(sprintf('%s::$baseUrl property is not overridable.', __CLASS__));
$version = trim($version);
if ($version === '') {
return '';
}
$this->baseUrl = trailingslashit($url);
// versions range like `>= 7.2.4 < 8`
if (preg_match('{>=?([\s0-9\.]+)<}', $version, $matches)) {
return trim($matches[1], " \t\n\r\0\x0B.");
}
return $this;
// aliases like `dev-src#abcde as 7.4`
if (preg_match('{as\s*([\s0-9\.]+)}', $version, $matches)) {
return trim($matches[1], " \t\n\r\0\x0B.");
}
// Basic requirements like 7.2, >=7.2, ^7.2, ~7.2
if (preg_match('{^(?:[>=\s~\^]+)?([0-9\.]+)}', $version, $matches)) {
return trim($matches[1], " \t\n\r\0\x0B.");
}
return '';
}
/**
* @param string $composerJsonFile
*
* @return ComposerData
* @throws \Exception
*/
private static function readComposerJsonData(string $composerJsonFile): array
{
if (!\is_file($composerJsonFile) || !\is_readable($composerJsonFile)) {
throw new \Exception(
esc_html("File {$composerJsonFile} does not exist or is not readable."),
);
}
$content = (string) file_get_contents($composerJsonFile);
/** @var ComposerData $composerJsonData */
$composerJsonData = json_decode($content, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \Exception(
esc_html("Error reading file {$composerJsonFile}: " . json_last_error_msg()),
);
}
return $composerJsonData;
}
}

View file

@ -4,24 +4,14 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Properties;
/**
* Class PluginProperties
*
* @package WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Properties
*
* @psalm-suppress PossiblyFalseArgument, InvalidArgument
*/
class PluginProperties extends BaseProperties
{
/**
* Custom properties for Plugins.
*/
// Custom properties for Plugins
public const PROP_NETWORK = 'network';
public const PROP_REQUIRES_PLUGINS = 'requiresPlugins';
/**
* Available methods of Properties::__call()
* from plugin headers.
*
* @link https://developer.wordpress.org/reference/functions/get_plugin_data/
* @see https://developer.wordpress.org/reference/functions/get_plugin_data/
*/
protected const HEADERS = [
self::PROP_AUTHOR => 'Author',
@ -37,36 +27,17 @@ class PluginProperties extends BaseProperties
// additional headers
self::PROP_NETWORK => 'Network',
self::PROP_REQUIRES_PLUGINS => 'RequiresPlugins',
];
/**
* @var string
*/
private $pluginMainFile;
/**
* @var string
*/
private $pluginBaseName;
/**
* @var bool|null
*/
protected $isMu;
/**
* @var bool|null
*/
protected $isActive;
/**
* @var bool|null
*/
protected $isNetworkActive;
private string $pluginMainFile;
private string $pluginBaseName;
protected ?bool $isMu = null;
protected ?bool $isActive = null;
protected ?bool $isNetworkActive = null;
/**
* @param string $pluginMainFile
*
* @return PluginProperties
*/
public static function new(string $pluginMainFile): PluginProperties
@ -75,8 +46,6 @@ class PluginProperties extends BaseProperties
}
/**
* PluginProperties constructor.
*
* @param string $pluginMainFile
*/
protected function __construct(string $pluginMainFile)
@ -85,7 +54,11 @@ class PluginProperties extends BaseProperties
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$pluginData = get_plugin_data($pluginMainFile);
// $markup = false, to avoid an incorrect early wptexturize call.
// $translate = false, to avoid loading translations too early
// @see https://core.trac.wordpress.org/ticket/49965
// @see https://core.trac.wordpress.org/ticket/34114
$pluginData = (array) get_plugin_data($pluginMainFile, false, false);
$properties = Properties::DEFAULT_PROPERTIES;
// Map pluginData to internal structure.
@ -93,6 +66,7 @@ class PluginProperties extends BaseProperties
$properties[$key] = $pluginData[$pluginDataKey] ?? '';
unset($pluginData[$pluginDataKey]);
}
/** @var array<string, mixed> $properties */
$properties = array_merge($properties, $pluginData);
$this->pluginMainFile = wp_normalize_path($pluginMainFile);
@ -119,14 +93,22 @@ class PluginProperties extends BaseProperties
/**
* @return bool
*
* @psalm-suppress PossiblyFalseArgument
*/
public function network(): bool
{
return (bool) $this->get(self::PROP_NETWORK, false);
}
/**
* @return string[]
*/
public function requiresPlugins(): array
{
$value = $this->get(self::PROP_REQUIRES_PLUGINS);
return $value && is_string($value) ? explode(',', $value) : [];
}
/**
* @return bool
*/
@ -163,10 +145,6 @@ class PluginProperties extends BaseProperties
public function isMuPlugin(): bool
{
if ($this->isMu === null) {
/**
* @psalm-suppress UndefinedConstant
* @psalm-suppress MixedArgument
*/
$muPluginDir = wp_normalize_path(WPMU_PLUGIN_DIR);
$this->isMu = strpos($this->pluginMainFile, $muPluginDir) === 0;
}

View file

@ -17,9 +17,7 @@ interface Properties
public const PROP_REQUIRES_WP = 'requiresWp';
public const PROP_REQUIRES_PHP = 'requiresPhp';
public const PROP_TAGS = 'tags';
/**
* @var array
*/
public const DEFAULT_PROPERTIES = [
self::PROP_AUTHOR => '',
self::PROP_AUTHOR_URI => '',
@ -36,15 +34,13 @@ interface Properties
/**
* @param string $key
* @param null $default
*
* @param mixed $default
* @return mixed
*/
public function get(string $key, $default = null);
/**
* @param string $key
*
* @return bool
*/
public function has(string $key): bool;
@ -103,6 +99,7 @@ interface Properties
/**
* The home page of the plugin, theme or library.
*
* @return string
*/
public function uri(): string;
@ -122,7 +119,7 @@ interface Properties
/**
* Optional. Specify the minimum required PHP version.
*
* @return string
* @return string|null
*/
public function requiresPhp(): ?string;
@ -130,10 +127,10 @@ interface Properties
* Optional. Currently, only available for Theme and Library.
* Plugins do not have support for "tags"/"keywords" in header.
*
* @link https://developer.wordpress.org/reference/classes/wp_theme/#properties
* @link https://getcomposer.org/doc/04-schema.md#keywords
* @return string[]
*
* @return array
* @see https://developer.wordpress.org/reference/classes/wp_theme/#properties
* @see https://getcomposer.org/doc/04-schema.md#keywords
*/
public function tags(): array;
}

View file

@ -4,26 +4,12 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Properties;
/**
* Class ThemeProperties
*
* @package WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Properties
*
* @psalm-suppress PossiblyFalseArgument, InvalidArgument
*/
class ThemeProperties extends BaseProperties
{
/**
* Additional properties specific for themes.
*/
public const PROP_STATUS = 'status';
public const PROP_TEMPLATE = 'template';
/**
* Available methods of Properties::__call()
* from theme headers.
*
* @link https://developer.wordpress.org/reference/classes/wp_theme/
*/
/** @see https://developer.wordpress.org/reference/classes/wp_theme/ */
protected const HEADERS = [
self::PROP_AUTHOR => 'Author',
self::PROP_AUTHOR_URI => 'AuthorURI',
@ -53,8 +39,6 @@ class ThemeProperties extends BaseProperties
}
/**
* ThemeProperties constructor.
*
* @param string $themeDirectory
*/
protected function __construct(string $themeDirectory)
@ -67,13 +51,15 @@ class ThemeProperties extends BaseProperties
$properties = Properties::DEFAULT_PROPERTIES;
foreach (self::HEADERS as $key => $themeKey) {
/** @psalm-suppress DocblockTypeContradiction */
$properties[$key] = $theme->get($themeKey) ?? '';
$property = $theme->get($themeKey);
if (is_string($property) || is_array($property)) {
$properties[$key] = $property;
}
}
$baseName = $theme->get_stylesheet();
$basePath = $theme->get_stylesheet_directory();
$baseUrl = (string) trailingslashit($theme->get_stylesheet_directory_uri());
$baseUrl = trailingslashit($theme->get_stylesheet_directory_uri());
parent::__construct(
$baseName,
@ -84,8 +70,6 @@ class ThemeProperties extends BaseProperties
}
/**
* If the theme is published.
*
* @return string
*/
public function status(): string
@ -93,6 +77,9 @@ class ThemeProperties extends BaseProperties
return (string) $this->get(self::PROP_STATUS);
}
/**
* @return string
*/
public function template(): string
{
return (string) $this->get(self::PROP_TEMPLATE);
@ -120,7 +107,7 @@ class ThemeProperties extends BaseProperties
public function parentThemeProperties(): ?ThemeProperties
{
$template = $this->template();
if (!$template) {
if ($template === '') {
return null;
}

View file

@ -2,9 +2,11 @@
namespace WooCommerce\PayPalCommerce\Vendor\Psr\Container;
use Throwable;
/**
* Base interface representing a generic exception in a container.
*/
interface ContainerExceptionInterface
interface ContainerExceptionInterface extends Throwable
{
}

View file

@ -33,6 +33,7 @@ return function ( string $root_dir ): iterable {
( require "$modules_dir/ppcp-blocks/module.php" )(),
( require "$modules_dir/ppcp-paypal-subscriptions/module.php" )(),
( require "$modules_dir/ppcp-local-alternative-payment-methods/module.php" )(),
( require "$modules_dir/ppcp-settings/module.php" )(),
);
// phpcs:disable WordPress.NamingConventions.ValidHookName.UseUnderscores
@ -91,15 +92,5 @@ return function ( string $root_dir ): iterable {
$modules[] = ( require "$modules_dir/ppcp-axo-block/module.php" )();
}
$show_new_ux = '1' === get_option( 'woocommerce-ppcp-is-new-merchant' );
$preview_new_ux = '1' === getenv( 'PCP_SETTINGS_ENABLED' );
if ( apply_filters(
'woocommerce.feature-flags.woocommerce_paypal_payments.settings_enabled',
$show_new_ux || $preview_new_ux
) ) {
$modules[] = ( require "$modules_dir/ppcp-settings/module.php" )();
}
return $modules;
};

View file

@ -18,14 +18,7 @@ use WooCommerce\PayPalCommerce\AdminNotices\Endpoint\MuteMessageEndpoint;
return array(
'admin-notices.url' => static function ( ContainerInterface $container ): string {
$path = realpath( __FILE__ );
if ( false === $path ) {
return '';
}
return plugins_url(
'/modules/ppcp-admin-notices/',
dirname( $path, 3 ) . '/woocommerce-paypal-payments.php'
);
return plugins_url( '/modules/ppcp-admin-notices/', $container->get( 'ppcp.path-to-plugin-main-file' ) );
},
'admin-notices.renderer' => static function ( ContainerInterface $container ): RendererInterface {
return new Renderer(

View file

@ -80,7 +80,7 @@ class Renderer implements RendererInterface {
printf(
'<div class="notice notice-%s %s" %s%s><p>%s</p></div>',
$message->type(),
esc_attr( $message->type() ),
( $message->is_dismissible() ) ? 'is-dismissible' : '',
( $message->wrapper() ? sprintf( 'data-ppcp-wrapper="%s"', esc_attr( $message->wrapper() ) ) : '' ),
// Use `empty()` in condition, to avoid false phpcs warning.

View file

@ -9,45 +9,33 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\ApiClient;
use WooCommerce\PayPalCommerce\ApiClient\Authentication\Bearer;
use WooCommerce\PayPalCommerce\ApiClient\Authentication\ClientCredentials;
use WooCommerce\PayPalCommerce\ApiClient\Authentication\ConnectBearer;
use WooCommerce\PayPalCommerce\ApiClient\Authentication\PayPalBearer;
use WooCommerce\PayPalCommerce\ApiClient\Authentication\SdkClientToken;
use WooCommerce\PayPalCommerce\ApiClient\Authentication\UserIdToken;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\Orders;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PaymentMethodTokensEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PaymentTokensEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Factory\CardAuthenticationResultFactory;
use WooCommerce\PayPalCommerce\ApiClient\Helper\CurrencyGetter;
use WooCommerce\PayPalCommerce\ApiClient\Helper\FailureRegistry;
use WooCommerce\PayPalCommerce\ApiClient\Helper\PartnerAttribution;
use WooCommerce\PayPalCommerce\Common\Pattern\SingletonDecorator;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\BillingPlans;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\BillingSubscriptions;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\CatalogProducts;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\BillingPlans;
use WooCommerce\PayPalCommerce\ApiClient\Factory\BillingCycleFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\PaymentPreferencesFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\RefundFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\PlanFactory;
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\Session\SessionHandler;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
use WooCommerce\PayPalCommerce\ApiClient\Authentication\Bearer;
use WooCommerce\PayPalCommerce\ApiClient\Authentication\PayPalBearer;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\BillingAgreementsEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\IdentityToken;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\LoginSeller;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\Orders;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PartnerReferrals;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PartnersEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PaymentMethodTokensEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PaymentsEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PaymentTokenEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PaymentTokensEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\WebhookEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Factory\AddressFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\AmountFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\AuthorizationFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\BillingCycleFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\CaptureFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\CardAuthenticationResultFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\ContactPreferenceFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\ExchangeRateFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\FraudProcessorResponseFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\ItemFactory;
@ -56,33 +44,46 @@ use WooCommerce\PayPalCommerce\ApiClient\Factory\OrderFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\PatchCollectionFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\PayeeFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\PayerFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\PaymentPreferencesFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\PaymentsFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\PaymentTokenActionLinksFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\PaymentTokenFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\PlanFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\PlatformFeeFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\ProductFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\PurchaseUnitFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\RefundFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\RefundPayerFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\SellerPayableBreakdownFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\SellerReceivableBreakdownFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\SellerStatusFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\ShippingFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\ShippingOptionFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\ReturnUrlFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\ShippingPreferenceFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\WebhookEventFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\WebhookFactory;
use WooCommerce\PayPalCommerce\ApiClient\Helper\ReferenceTransactionStatus;
use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache;
use WooCommerce\PayPalCommerce\ApiClient\Helper\CurrencyGetter;
use WooCommerce\PayPalCommerce\ApiClient\Helper\DccApplies;
use WooCommerce\PayPalCommerce\ApiClient\Helper\FailureRegistry;
use WooCommerce\PayPalCommerce\ApiClient\Helper\OrderHelper;
use WooCommerce\PayPalCommerce\ApiClient\Helper\OrderTransient;
use WooCommerce\PayPalCommerce\ApiClient\Helper\PartnerAttribution;
use WooCommerce\PayPalCommerce\ApiClient\Helper\PurchaseUnitSanitizer;
use WooCommerce\PayPalCommerce\ApiClient\Repository\CustomerRepository;
use WooCommerce\PayPalCommerce\ApiClient\Repository\OrderRepository;
use WooCommerce\PayPalCommerce\ApiClient\Repository\PartnerReferralsData;
use WooCommerce\PayPalCommerce\ApiClient\Repository\PayeeRepository;
use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings;
use WooCommerce\PayPalCommerce\ApiClient\Authentication\ConnectBearer;
use WooCommerce\PayPalCommerce\WcGateway\Helper\EnvironmentConfig;
use WooCommerce\PayPalCommerce\WcGateway\Helper\Environment;
use WooCommerce\PayPalCommerce\Settings\Enum\InstallationPathEnum;
use WooCommerce\PayPalCommerce\ApiClient\Factory\ContactPreferenceFactory;
use WooCommerce\PayPalCommerce\Common\Pattern\SingletonDecorator;
use WooCommerce\PayPalCommerce\Session\SessionHandler;
use WooCommerce\PayPalCommerce\Settings\Data\SettingsModel;
use WooCommerce\PayPalCommerce\Settings\Enum\InstallationPathEnum;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
use WooCommerce\PayPalCommerce\WcGateway\Helper\Environment;
use WooCommerce\PayPalCommerce\WcGateway\Helper\EnvironmentConfig;
use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings;
return array(
'api.host' => static function( ContainerInterface $container ) : string {
@ -274,13 +275,10 @@ return array(
$container->get( 'woocommerce.logger.woocommerce' )
);
},
'api.endpoint.billing-agreements' => static function ( ContainerInterface $container ): BillingAgreementsEndpoint {
return new BillingAgreementsEndpoint(
$container->get( 'api.host' ),
$container->get( 'api.bearer' ),
$container->get( 'woocommerce.logger.woocommerce' )
);
},
'api.reference-transaction-status' => static fn ( ContainerInterface $container ): ReferenceTransactionStatus => new ReferenceTransactionStatus(
$container->get( 'api.endpoint.partners' ),
$container->get( 'api.reference-transaction-status-cache' )
),
'api.endpoint.catalog-products' => static function ( ContainerInterface $container ): CatalogProducts {
return new CatalogProducts(
$container->get( 'api.host' ),
@ -413,6 +411,9 @@ return array(
$container->get( 'api.factory.shipping-option' )
);
},
'api.factory.return-url' => static function ( ContainerInterface $container ): ReturnUrlFactory {
return new ReturnUrlFactory();
},
'api.factory.shipping-preference' => static function ( ContainerInterface $container ): ShippingPreferenceFactory {
return new ShippingPreferenceFactory();
},
@ -783,6 +784,11 @@ return array(
'amex' => array( 'JPY' ),
'jcb' => array( 'JPY' ),
),
'YT' => $mastercard_visa_amex, // Mayotte.
'RE' => $mastercard_visa_amex, // Reunion.
'GP' => $mastercard_visa_amex, // Guadelope.
'GF' => $mastercard_visa_amex, // French Guiana.
'MQ' => $mastercard_visa_amex, // Martinique.
)
);
},
@ -870,6 +876,9 @@ return array(
'api.user-id-token-cache' => static function( ContainerInterface $container ): Cache {
return new Cache( 'ppcp-id-token-cache' );
},
'api.reference-transaction-status-cache' => static function( ContainerInterface $container ): Cache {
return new Cache( 'ppcp-reference-transaction-status-cache' );
},
'api.user-id-token' => static function( ContainerInterface $container ): UserIdToken {
return new UserIdToken(
$container->get( 'api.host' ),

View file

@ -1,147 +0,0 @@
<?php
/**
* The billing agreements endpoint.
*
* @package WooCommerce\PayPalCommerce\ApiClient\Endpoint
*/
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\ApiClient\Endpoint;
use Exception;
use stdClass;
use WooCommerce\PayPalCommerce\ApiClient\Authentication\Bearer;
use WooCommerce\PayPalCommerce\ApiClient\Exception\PayPalApiException;
use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException;
use Psr\Log\LoggerInterface;
/**
* Class BillingAgreementsEndpoint
*/
class BillingAgreementsEndpoint {
use RequestTrait;
/**
* The host.
*
* @var string
*/
private $host;
/**
* The bearer.
*
* @var Bearer
*/
private $bearer;
/**
* The logger.
*
* @var LoggerInterface
*/
private $logger;
/**
* BillingAgreementsEndpoint constructor.
*
* @param string $host The host.
* @param Bearer $bearer The bearer.
* @param LoggerInterface $logger The logger.
*/
public function __construct(
string $host,
Bearer $bearer,
LoggerInterface $logger
) {
$this->host = $host;
$this->bearer = $bearer;
$this->logger = $logger;
}
/**
* Creates a billing agreement token.
*
* @param string $description The description.
* @param string $return_url The return URL.
* @param string $cancel_url The cancel URL.
*
* @throws RuntimeException If the request fails.
* @throws PayPalApiException If the request fails.
*/
public function create_token( string $description, string $return_url, string $cancel_url ): stdClass {
$data = array(
'description' => $description,
'payer' => array(
'payment_method' => 'PAYPAL',
),
'plan' => array(
'type' => 'MERCHANT_INITIATED_BILLING',
'merchant_preferences' => array(
'return_url' => $return_url,
'cancel_url' => $cancel_url,
'skip_shipping_address' => true,
),
),
);
$bearer = $this->bearer->bearer();
$url = trailingslashit( $this->host ) . 'v1/billing-agreements/agreement-tokens';
$args = array(
'method' => 'POST',
'headers' => array(
'Authorization' => 'Bearer ' . $bearer->token(),
'Content-Type' => 'application/json',
),
'body' => wp_json_encode( $data ),
);
$response = $this->request( $url, $args );
if ( is_wp_error( $response ) || ! is_array( $response ) ) {
throw new RuntimeException( 'Not able to create a billing agreement token.' );
}
$json = json_decode( $response['body'] );
$status_code = (int) wp_remote_retrieve_response_code( $response );
if ( 201 !== $status_code ) {
throw new PayPalApiException(
$json,
$status_code
);
}
return $json;
}
/**
* Checks if reference transactions are enabled in account.
*
* @throws RuntimeException If the request fails (no auth, no connection, etc.).
*/
public function reference_transaction_enabled(): bool {
try {
if ( wc_string_to_bool( get_transient( 'ppcp_reference_transaction_enabled' ) ) === true ) {
return true;
}
$this->is_request_logging_enabled = false;
try {
$this->create_token(
'Checking if reference transactions are enabled',
'https://example.com/return',
'https://example.com/cancel'
);
} finally {
$this->is_request_logging_enabled = true;
}
set_transient( 'ppcp_reference_transaction_enabled', true, MONTH_IN_SECONDS );
return true;
} catch ( Exception $exception ) {
set_transient( 'ppcp_reference_transaction_enabled', false, HOUR_IN_SECONDS );
return false;
}
}
}

View file

@ -176,10 +176,10 @@ class OrderEndpoint {
public function create(
array $items,
string $shipping_preference,
Payer $payer = null,
?Payer $payer = null,
string $payment_method = '',
array $request_data = array(),
PaymentSource $payment_source = null
?PaymentSource $payment_source = null
): Order {
$bearer = $this->bearer->bearer();
$data = array(
@ -444,9 +444,7 @@ class OrderEndpoint {
}
$response = $this->request( $url, $args );
if ( is_wp_error( $response ) ) {
$error = new RuntimeException(
__( 'Could not retrieve order.', 'woocommerce-paypal-payments' )
);
$error = new RuntimeException( 'Could not retrieve order.' );
$this->logger->warning( $error->getMessage() );
throw $error;
@ -456,7 +454,7 @@ class OrderEndpoint {
if ( 404 === $status_code || empty( $response['body'] ) ) {
$error = new RuntimeException(
__( 'Could not retrieve order.', 'woocommerce-paypal-payments' ),
'Could not retrieve order.',
404
);
$this->logger->warning(
@ -585,9 +583,6 @@ class OrderEndpoint {
$data = array(
'payment_source' => $payment_source,
'processing_instruction' => 'ORDER_COMPLETE_ON_PAYMENT_APPROVAL',
'application_context' => array(
'locale' => 'es-MX',
),
);
$args = array(

View file

@ -113,9 +113,7 @@ class WebhookEndpoint {
$response = $this->request( $url, $args );
if ( is_wp_error( $response ) ) {
throw new RuntimeException(
__( 'Not able to create a webhook.', 'woocommerce-paypal-payments' )
);
throw new RuntimeException( 'Not able to create a webhook.' );
}
$json = json_decode( $response['body'] );
@ -151,9 +149,7 @@ class WebhookEndpoint {
$response = $this->request( $url, $args );
if ( is_wp_error( $response ) ) {
throw new RuntimeException(
__( 'Not able to load webhooks list.', 'woocommerce-paypal-payments' )
);
throw new RuntimeException( 'Not able to load webhooks list.' );
}
$json = json_decode( $response['body'] );
@ -195,9 +191,7 @@ class WebhookEndpoint {
$response = $this->request( $url, $args );
if ( $response instanceof WP_Error ) {
throw new RuntimeException(
__( 'Not able to delete the webhook.', 'woocommerce-paypal-payments' )
);
throw new RuntimeException( 'Not able to delete the webhook.' );
}
$status_code = (int) wp_remote_retrieve_response_code( $response );
@ -250,9 +244,7 @@ class WebhookEndpoint {
$response = $this->request( $url, $args );
if ( is_wp_error( $response ) ) {
throw new RuntimeException(
__( 'Not able to simulate webhook.', 'woocommerce-paypal-payments' )
);
throw new RuntimeException( 'Not able to simulate webhook.' );
}
$json = json_decode( $response['body'] );
$status_code = (int) wp_remote_retrieve_response_code( $response );
@ -312,9 +304,7 @@ class WebhookEndpoint {
);
$response = $this->request( $url, $args );
if ( is_wp_error( $response ) ) {
$error = new RuntimeException(
__( 'Not able to verify webhook event.', 'woocommerce-paypal-payments' )
);
$error = new RuntimeException( 'Not able to verify webhook event.' );
$this->logger->log(
'warning',
$error->getMessage(),
@ -340,9 +330,7 @@ class WebhookEndpoint {
public function verify_current_request_for_webhook( Webhook $webhook ): bool {
if ( ! $webhook->id() ) {
$error = new RuntimeException(
__( 'Not a valid webhook to verify.', 'woocommerce-paypal-payments' )
);
$error = new RuntimeException( 'Not a valid webhook to verify.' );
$this->logger->log( 'warning', $error->getMessage(), array( 'webhook' => $webhook ) );
throw $error;
}
@ -369,11 +357,7 @@ class WebhookEndpoint {
$error = new RuntimeException(
sprintf(
// translators: %s is the headers key.
__(
'Not a valid webhook event. Header %s is missing',
'woocommerce-paypal-payments'
),
'Not a valid webhook event. Header %s is missing',
$key
)
);

View file

@ -41,7 +41,7 @@ class Amount {
* @param Money $money The money.
* @param AmountBreakdown|null $breakdown The breakdown.
*/
public function __construct( Money $money, AmountBreakdown $breakdown = null ) {
public function __construct( Money $money, ?AmountBreakdown $breakdown = null ) {
$this->money = $money;
$this->breakdown = $breakdown;
}

View file

@ -62,8 +62,7 @@ class AuthorizationStatus {
if ( ! in_array( $status, self::VALID_STATUS, true ) ) {
throw new RuntimeException(
sprintf(
// translators: %s is the current status.
__( '%s is not a valid status', 'woocommerce-paypal-payments' ),
'%s is not a valid status',
$status
)
);

View file

@ -175,6 +175,10 @@ class ExperienceContext {
* @param string|null $new_value The value to set.
*/
public function with_landing_page( ?string $new_value ): ExperienceContext {
if ( $new_value && strtoupper( $new_value ) === 'BILLING' ) {
$new_value = self::LANDING_PAGE_GUEST_CHECKOUT;
}
$obj = clone $this;
$obj->landing_page = $new_value;

View file

@ -114,13 +114,13 @@ class Item {
Money $unit_amount,
int $quantity,
string $description = '',
Money $tax = null,
?Money $tax = null,
string $sku = '',
string $category = 'PHYSICAL_GOODS',
string $url = '',
string $image_url = '',
float $tax_rate = 0,
string $cart_item_key = null
?string $cart_item_key = null
) {
$this->name = $name;

View file

@ -9,6 +9,8 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\ApiClient\Entity;
use DateTime;
/**
* Class Order
*/
@ -25,7 +27,7 @@ class Order {
/**
* The create time.
*
* @var \DateTime|null
* @var DateTime|null
*/
private $create_time;
@ -60,7 +62,7 @@ class Order {
/**
* The update time.
*
* @var \DateTime|null
* @var DateTime|null
*/
private $update_time;
@ -70,6 +72,10 @@ class Order {
* @var PaymentSource|null
*/
private $payment_source;
/**
* @var mixed|null
*/
private $links;
/**
* Order constructor.
@ -82,18 +88,19 @@ class Order {
* @param PaymentSource|null $payment_source The payment source.
* @param Payer|null $payer The payer.
* @param string $intent The intent.
* @param \DateTime|null $create_time The create time.
* @param \DateTime|null $update_time The update time.
* @param DateTime|null $create_time The create time.
* @param DateTime|null $update_time The update time.
*/
public function __construct(
string $id,
array $purchase_units,
OrderStatus $order_status,
PaymentSource $payment_source = null,
Payer $payer = null,
?PaymentSource $payment_source = null,
?Payer $payer = null,
string $intent = 'CAPTURE',
\DateTime $create_time = null,
\DateTime $update_time = null
?DateTime $create_time = null,
?DateTime $update_time = null,
$links = null
) {
$this->id = $id;
@ -104,6 +111,7 @@ class Order {
$this->create_time = $create_time;
$this->update_time = $update_time;
$this->payment_source = $payment_source;
$this->links = $links;
}
/**
@ -118,7 +126,7 @@ class Order {
/**
* Returns the create time.
*
* @return \DateTime|null
* @return DateTime|null
*/
public function create_time() {
return $this->create_time;
@ -127,7 +135,7 @@ class Order {
/**
* Returns the update time.
*
* @return \DateTime|null
* @return DateTime|null
*/
public function update_time() {
return $this->update_time;
@ -179,6 +187,15 @@ class Order {
return $this->payment_source;
}
/**
* Returns the links.
*
* @return mixed|null
*/
public function links() {
return $this->links;
}
/**
* Returns the object as array.
*
@ -206,6 +223,10 @@ class Order {
$order['update_time'] = $this->update_time()->format( 'Y-m-d\TH:i:sO' );
}
if ( $this->links ) {
$order['links'] = $this->links();
}
return $order;
}
}

View file

@ -51,8 +51,7 @@ class OrderStatus {
if ( ! in_array( $status, self::VALID_STATUS, true ) ) {
throw new RuntimeException(
sprintf(
// translators: %s is the current status.
__( '%s is not a valid status', 'woocommerce-paypal-payments' ),
'%s is not a valid status',
$status
)
);

View file

@ -9,6 +9,8 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\ApiClient\Entity;
use DateTime;
/**
* Class Payer
* The customer who sends the money.
@ -39,7 +41,7 @@ class Payer {
/**
* The birth date.
*
* @var \DateTime|null
* @var DateTime|null
*/
private $birthdate;
@ -71,7 +73,7 @@ class Payer {
* @param string $email_address The email.
* @param string $payer_id The payer id.
* @param Address|null $address The address.
* @param \DateTime|null $birthdate The birth date.
* @param DateTime|null $birthdate The birth date.
* @param PhoneWithType|null $phone The phone.
* @param PayerTaxInfo|null $tax_info The tax info.
*/
@ -79,10 +81,10 @@ class Payer {
?PayerName $name,
string $email_address,
string $payer_id,
Address $address = null,
\DateTime $birthdate = null,
PhoneWithType $phone = null,
PayerTaxInfo $tax_info = null
?Address $address = null,
?DateTime $birthdate = null,
?PhoneWithType $phone = null,
?PayerTaxInfo $tax_info = null
) {
$this->name = $name;
@ -133,7 +135,7 @@ class Payer {
/**
* Returns the birth date.
*
* @return \DateTime|null
* @return DateTime|null
*/
public function birthdate() {
return $this->birthdate;

View file

@ -51,8 +51,7 @@ class PayerTaxInfo {
if ( ! in_array( $type, self::VALID_TYPES, true ) ) {
throw new RuntimeException(
sprintf(
// translators: %s is the current type.
__( '%s is not a valid tax type.', 'woocommerce-paypal-payments' ),
'%s is not a valid tax type.',
$type
)
);

View file

@ -50,9 +50,7 @@ class PaymentToken {
*/
public function __construct( string $id, stdClass $source, string $type = self::TYPE_PAYMENT_METHOD_TOKEN ) {
if ( ! in_array( $type, self::get_valid_types(), true ) ) {
throw new RuntimeException(
__( 'Not a valid payment source type.', 'woocommerce-paypal-payments' )
);
throw new RuntimeException( 'Not a valid payment source type.' );
}
$this->id = $id;
$this->type = $type;

View file

@ -109,13 +109,13 @@ class PurchaseUnit {
public function __construct(
Amount $amount,
array $items = array(),
Shipping $shipping = null,
?Shipping $shipping = null,
string $reference_id = 'default',
string $description = '',
string $custom_id = '',
string $invoice_id = '',
string $soft_descriptor = '',
Payments $payments = null
?Payments $payments = null
) {
$this->amount = $amount;

View file

@ -54,7 +54,7 @@ class RefundCapture {
Capture $capture,
string $invoice_id,
string $note_to_payer = '',
Amount $amount = null
?Amount $amount = null
) {
$this->capture = $capture;
$this->invoice_id = $invoice_id;

View file

@ -17,44 +17,50 @@ class Shipping {
/**
* The name.
*
* @var string
* @var string|null
*/
private $name;
private ?string $name;
/**
* The address.
*
* @var Address
* @var Address|null
*/
private $address;
private ?Address $address;
/**
* Custom contact email address, usually added via the Contact Module.
*/
private ?string $email_address = null;
private ?string $email_address;
/**
* Custom contact phone number, usually added via the Contact Module.
*/
private ?Phone $phone_number = null;
private ?Phone $phone_number;
/**
* Shipping methods.
*
* @var ShippingOption[]
*/
private $options;
private array $options;
/**
* Shipping constructor.
*
* @param string $name The name.
* @param Address $address The address.
* @param string|null $name The name.
* @param Address|null $address The address.
* @param string|null $email_address Contact email.
* @param Phone|null $phone_number Contact phone.
* @param ShippingOption[] $options Shipping methods.
*/
public function __construct( string $name, Address $address, string $email_address = null, Phone $phone_number = null, array $options = array() ) {
public function __construct(
string $name = null,
Address $address = null,
?string $email_address = null,
?Phone $phone_number = null,
array $options = array()
) {
$this->name = $name;
$this->address = $address;
$this->email_address = $email_address;
@ -65,18 +71,18 @@ class Shipping {
/**
* Returns the name.
*
* @return string
* @return null|string
*/
public function name(): string {
public function name(): ?string {
return $this->name;
}
/**
* Returns the shipping address.
*
* @return Address
* @return null|Address
*/
public function address(): Address {
public function address(): ?Address {
return $this->address;
}
@ -113,19 +119,26 @@ class Shipping {
* @return array
*/
public function to_array(): array {
$result = array(
'name' => array(
'full_name' => $this->name(),
),
'address' => $this->address()->to_array(),
);
$result = array();
$name = $this->name();
if ( $name ) {
$result['name'] = array(
'full_name' => $name,
);
}
$address = $this->address();
if ( $address ) {
$result['address'] = $address->to_array();
}
$contact_email = $this->email_address();
$contact_phone = $this->phone_number();
if ( $contact_email ) {
$result['email_address'] = $contact_email;
}
$contact_phone = $this->phone_number();
if ( $contact_phone ) {
$result['phone_number'] = $contact_phone->to_array();
}
@ -138,6 +151,7 @@ class Shipping {
$this->options
);
}
return $result;
}
}

View file

@ -36,9 +36,9 @@ class PayPalApiException extends RuntimeException {
* @param stdClass|null $response The JSON object.
* @param int $status_code The HTTP status code.
*/
public function __construct( stdClass $response = null, int $status_code = 0 ) {
public function __construct( ?stdClass $response = null, int $status_code = 0 ) {
if ( is_null( $response ) ) {
$response = new \stdClass();
$response = new stdClass();
}
if ( ! isset( $response->message ) ) {
$response->message = sprintf(
@ -63,7 +63,7 @@ class PayPalApiException extends RuntimeException {
/**
* The JSON response object.
*
* @var \stdClass $response
* @var stdClass $response
*/
$this->response = $response;
$this->status_code = $status_code;

View file

@ -198,12 +198,15 @@ class AmountFactory {
/**
* Returns an Amount object based off a PayPal Response.
*
* @param \stdClass $data The JSON object.
* @param mixed $data The JSON object.
*
* @return Amount
* @throws RuntimeException When JSON object is malformed.
* @return Amount|null
*/
public function from_paypal_response( \stdClass $data ): Amount {
public function from_paypal_response( $data ) {
if ( null === $data || ! $data instanceof \stdClass ) {
return null;
}
$money = $this->money_factory->from_paypal_response( $data );
$breakdown = ( isset( $data->breakdown ) ) ? $this->break_down( $data->breakdown ) : null;
return new Amount( $money, $breakdown );
@ -242,8 +245,7 @@ class AmountFactory {
if ( ! isset( $item->value ) || ! is_numeric( $item->value ) ) {
throw new RuntimeException(
sprintf(
// translators: %s is the current breakdown key.
__( 'No value given for breakdown %s', 'woocommerce-paypal-payments' ),
'No value given for breakdown %s',
$key
)
);
@ -251,8 +253,7 @@ class AmountFactory {
if ( ! isset( $item->currency_code ) ) {
throw new RuntimeException(
sprintf(
// translators: %s is the current breakdown key.
__( 'No currency given for breakdown %s', 'woocommerce-paypal-payments' ),
'No currency given for breakdown %s',
$key
)
);

View file

@ -45,15 +45,11 @@ class AuthorizationFactory {
*/
public function from_paypal_response( \stdClass $data ): Authorization {
if ( ! isset( $data->id ) ) {
throw new RuntimeException(
__( 'Does not contain an id.', 'woocommerce-paypal-payments' )
);
throw new RuntimeException( 'Does not contain an id.' );
}
if ( ! isset( $data->status ) ) {
throw new RuntimeException(
__( 'Does not contain status.', 'woocommerce-paypal-payments' )
);
throw new RuntimeException( 'Does not contain status.' );
}
$reason = $data->status_details->reason ?? null;

View file

@ -12,6 +12,7 @@ namespace WooCommerce\PayPalCommerce\ApiClient\Factory;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Capture;
use WooCommerce\PayPalCommerce\ApiClient\Entity\CaptureStatus;
use WooCommerce\PayPalCommerce\ApiClient\Entity\CaptureStatusDetails;
use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException;
/**
* Class CaptureFactory
@ -63,6 +64,7 @@ class CaptureFactory {
* @param \stdClass $data The PayPal response.
*
* @return Capture
* @throws RuntimeException When capture amount data is invalid.
*/
public function from_paypal_response( \stdClass $data ) : Capture {
$reason = $data->status_details->reason ?? null;
@ -74,13 +76,18 @@ class CaptureFactory {
$this->fraud_processor_response_factory->from_paypal_response( $data->processor_response )
: null;
$amount = $this->amount_factory->from_paypal_response( $data->amount );
if ( null === $amount ) {
throw new RuntimeException( 'Invalid capture amount data.' );
}
return new Capture(
(string) $data->id,
new CaptureStatus(
(string) $data->status,
$reason ? new CaptureStatusDetails( $reason ) : null
),
$this->amount_factory->from_paypal_response( $data->amount ),
$amount,
(bool) $data->final_capture,
(string) $data->seller_protection->status,
(string) $data->invoice_id,

View file

@ -100,6 +100,34 @@ class ExperienceContextBuilder {
return $builder;
}
/**
* Uses a custom return URL.
*
* @param string $url The return URL.
*/
public function with_custom_return_url( string $url ): ExperienceContextBuilder {
$builder = clone $this;
$builder->experience_context = $builder->experience_context
->with_return_url( $url );
return $builder;
}
/**
* Uses a custom cancel URL.
*
* @param string $url The cancel URL.
*/
public function with_custom_cancel_url( string $url ): ExperienceContextBuilder {
$builder = clone $this;
$builder->experience_context = $builder->experience_context
->with_cancel_url( $url );
return $builder;
}
/**
* Uses the current brand name from the settings.
*/

View file

@ -179,19 +179,13 @@ class ItemFactory {
*/
public function from_paypal_response( \stdClass $data ): Item {
if ( ! isset( $data->name ) ) {
throw new RuntimeException(
__( 'No name for item given', 'woocommerce-paypal-payments' )
);
throw new RuntimeException( 'No name for item given' );
}
if ( ! isset( $data->quantity ) || ! is_numeric( $data->quantity ) ) {
throw new RuntimeException(
__( 'No quantity for item given', 'woocommerce-paypal-payments' )
);
throw new RuntimeException( 'No quantity for item given' );
}
if ( ! isset( $data->unit_amount->value ) || ! isset( $data->unit_amount->currency_code ) ) {
throw new RuntimeException(
__( 'No money values for item given', 'woocommerce-paypal-payments' )
);
throw new RuntimeException( 'No money values for item given' );
}
$unit_amount = new Money( (float) $data->unit_amount->value, $data->unit_amount->currency_code );

View file

@ -68,7 +68,8 @@ class OrderFactory {
$order->payer(),
$order->intent(),
$order->create_time(),
$order->update_time()
$order->update_time(),
$order->links()
);
}
@ -81,70 +82,153 @@ class OrderFactory {
* @throws RuntimeException When JSON object is malformed.
*/
public function from_paypal_response( \stdClass $order_data ): Order {
if ( ! isset( $order_data->id ) ) {
throw new RuntimeException(
__( 'Order does not contain an id.', 'woocommerce-paypal-payments' )
);
}
if ( ! isset( $order_data->purchase_units ) || ! is_array( $order_data->purchase_units ) ) {
throw new RuntimeException(
__( 'Order does not contain items.', 'woocommerce-paypal-payments' )
);
}
if ( ! isset( $order_data->status ) ) {
throw new RuntimeException(
__( 'Order does not contain status.', 'woocommerce-paypal-payments' )
);
}
if ( ! isset( $order_data->intent ) ) {
throw new RuntimeException(
__( 'Order does not contain intent.', 'woocommerce-paypal-payments' )
);
}
$this->validate_order_id( $order_data );
$purchase_units = array_map(
function ( \stdClass $data ): PurchaseUnit {
return $this->purchase_unit_factory->from_paypal_response( $data );
},
$order_data->purchase_units
);
$create_time = ( isset( $order_data->create_time ) ) ?
\DateTime::createFromFormat( 'Y-m-d\TH:i:sO', $order_data->create_time )
: null;
$update_time = ( isset( $order_data->update_time ) ) ?
\DateTime::createFromFormat( 'Y-m-d\TH:i:sO', $order_data->update_time )
: null;
$payer = ( isset( $order_data->payer ) ) ?
$this->payer_factory->from_paypal_response( $order_data->payer )
: null;
$payment_source = null;
if ( isset( $order_data->payment_source ) ) {
$json_encoded_payment_source = wp_json_encode( $order_data->payment_source );
if ( $json_encoded_payment_source ) {
$payment_source_as_array = json_decode( $json_encoded_payment_source, true );
if ( $payment_source_as_array ) {
$name = array_key_first( $payment_source_as_array );
if ( $name ) {
$payment_source = new PaymentSource(
$name,
$order_data->payment_source->$name
);
}
}
}
}
$purchase_units = $this->create_purchase_units( $order_data );
$status = $this->create_order_status( $order_data );
$intent = $this->get_intent( $order_data );
$timestamps = $this->create_timestamps( $order_data );
$payer = $this->create_payer( $order_data );
$payment_source = $this->create_payment_source( $order_data );
$links = $order_data->links ?? null;
return new Order(
$order_data->id,
$purchase_units,
new OrderStatus( $order_data->status ),
$status,
$payment_source,
$payer,
$order_data->intent,
$create_time,
$update_time
$intent,
$timestamps['create_time'],
$timestamps['update_time'],
$links
);
}
/**
* Validates that the order data contains a required ID.
*
* @param \stdClass $order_data The order data.
*
* @throws RuntimeException When ID is missing.
*/
private function validate_order_id( \stdClass $order_data ): void {
if ( ! isset( $order_data->id ) ) {
throw new RuntimeException( 'Order does not contain an id.' );
}
}
/**
* Creates purchase units from order data.
*
* @param \stdClass $order_data The order data.
*
* @return array Array of PurchaseUnit objects.
*/
private function create_purchase_units( \stdClass $order_data ): array {
if ( ! isset( $order_data->purchase_units ) || ! is_array( $order_data->purchase_units ) ) {
return array();
}
$purchase_units = array();
foreach ( $order_data->purchase_units as $data ) {
$purchase_unit = $this->purchase_unit_factory->from_paypal_response( $data );
if ( null !== $purchase_unit ) {
$purchase_units[] = $purchase_unit;
}
}
return $purchase_units;
}
/**
* Creates order status from order data.
*
* @param \stdClass $order_data The order data.
*
* @return OrderStatus
*/
private function create_order_status( \stdClass $order_data ): OrderStatus {
$status_value = $order_data->status ?? 'PAYER_ACTION_REQUIRED';
return new OrderStatus( $status_value );
}
/**
* Gets the intent from order data.
*
* @param \stdClass $order_data The order data.
*
* @return string
*/
private function get_intent( \stdClass $order_data ): string {
return $order_data->intent ?? 'CAPTURE';
}
/**
* Creates timestamps from order data.
*
* @param \stdClass $order_data The order data.
*
* @return array Array with 'create_time' and 'update_time' keys.
*/
private function create_timestamps( \stdClass $order_data ): array {
$create_time = isset( $order_data->create_time ) ?
\DateTime::createFromFormat( 'Y-m-d\TH:i:sO', $order_data->create_time ) :
null;
$update_time = isset( $order_data->update_time ) ?
\DateTime::createFromFormat( 'Y-m-d\TH:i:sO', $order_data->update_time ) :
null;
return array(
'create_time' => $create_time,
'update_time' => $update_time,
);
}
/**
* Creates payer from order data.
*
* @param \stdClass $order_data The order data.
*
* @return mixed Payer object or null.
*/
private function create_payer( \stdClass $order_data ) {
return isset( $order_data->payer ) ?
$this->payer_factory->from_paypal_response( $order_data->payer ) :
null;
}
/**
* Creates payment source from order data.
*
* @param \stdClass $order_data The order data.
*
* @return PaymentSource|null
*/
private function create_payment_source( \stdClass $order_data ): ?PaymentSource {
if ( ! isset( $order_data->payment_source ) ) {
return null;
}
$json_encoded_payment_source = wp_json_encode( $order_data->payment_source );
if ( ! $json_encoded_payment_source ) {
return null;
}
$payment_source_as_array = json_decode( $json_encoded_payment_source, true );
if ( ! $payment_source_as_array ) {
return null;
}
$source_name = array_key_first( $payment_source_as_array );
if ( ! $source_name ) {
return null;
}
return new PaymentSource(
$source_name,
$order_data->payment_source->$source_name
);
}
}

View file

@ -27,9 +27,7 @@ class PaymentTokenFactory {
*/
public function from_paypal_response( $data ): PaymentToken {
if ( ! isset( $data->id ) ) {
throw new RuntimeException(
__( 'No id for payment token given', 'woocommerce-paypal-payments' )
);
throw new RuntimeException( 'No id for payment token given' );
}
return new PaymentToken(

View file

@ -57,24 +57,16 @@ class PlanFactory {
*/
public function from_paypal_response( stdClass $data ): Plan {
if ( ! isset( $data->id ) ) {
throw new RuntimeException(
__( 'No id for given plan', 'woocommerce-paypal-payments' )
);
throw new RuntimeException( 'No id for given plan' );
}
if ( ! isset( $data->name ) ) {
throw new RuntimeException(
__( 'No name for plan given', 'woocommerce-paypal-payments' )
);
throw new RuntimeException( 'No name for plan given' );
}
if ( ! isset( $data->product_id ) ) {
throw new RuntimeException(
__( 'No product id for given plan', 'woocommerce-paypal-payments' )
);
throw new RuntimeException( 'No product id for given plan' );
}
if ( ! isset( $data->billing_cycles ) ) {
throw new RuntimeException(
__( 'No billing cycles for given plan', 'woocommerce-paypal-payments' )
);
throw new RuntimeException( 'No billing cycles for given plan' );
}
$billing_cycles = array();

View file

@ -28,14 +28,10 @@ class ProductFactory {
*/
public function from_paypal_response( stdClass $data ): Product {
if ( ! isset( $data->id ) ) {
throw new RuntimeException(
__( 'No id for product given', 'woocommerce-paypal-payments' )
);
throw new RuntimeException( 'No id for product given' );
}
if ( ! isset( $data->name ) ) {
throw new RuntimeException(
__( 'No name for product given', 'woocommerce-paypal-payments' )
);
throw new RuntimeException( 'No name for product given' );
}
return new Product(

View file

@ -15,6 +15,7 @@ use WooCommerce\PayPalCommerce\ApiClient\Entity\PurchaseUnit;
use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException;
use WooCommerce\PayPalCommerce\ApiClient\Helper\PurchaseUnitSanitizer;
use WooCommerce\PayPalCommerce\Webhooks\CustomIds;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Address;
/**
* Class PurchaseUnitFactory
@ -88,7 +89,7 @@ class PurchaseUnitFactory {
PaymentsFactory $payments_factory,
string $prefix = 'WC-',
string $soft_descriptor = '',
PurchaseUnitSanitizer $sanitizer = null
?PurchaseUnitSanitizer $sanitizer = null
) {
$this->amount_factory = $amount_factory;
@ -108,21 +109,20 @@ class PurchaseUnitFactory {
* @return PurchaseUnit
*/
public function from_wc_order( \WC_Order $order ): PurchaseUnit {
$amount = $this->amount_factory->from_wc_order( $order );
$items = array_filter(
$amount = $this->amount_factory->from_wc_order( $order );
$items = array_filter(
$this->item_factory->from_wc_order( $order ),
function ( Item $item ): bool {
return $item->unit_amount()->value() >= 0;
}
);
$shipping = $this->shipping_factory->from_wc_order( $order );
if (
! $this->shipping_needed( ... array_values( $items ) ) ||
empty( $shipping->address()->country_code() ) ||
( ! $shipping->address()->postal_code() && ! $this->country_without_postal_code( $shipping->address()->country_code() ) )
) {
$shipping = $this->shipping_factory->from_wc_order( $order );
$shipping_address = $shipping->address();
if ( $this->should_disable_shipping( $items, $shipping_address ) ) {
$shipping = null;
}
$reference_id = 'default';
$description = '';
$custom_id = (string) $order->get_id();
@ -176,10 +176,12 @@ class PurchaseUnitFactory {
$shipping = null;
$customer = \WC()->customer;
if ( $this->shipping_needed( ... array_values( $items ) ) && is_a( $customer, \WC_Customer::class ) ) {
$shipping = $this->shipping_factory->from_wc_customer( \WC()->customer, $with_shipping_options );
$shipping = $this->shipping_factory->from_wc_customer( \WC()->customer, $with_shipping_options );
$shipping_address = $shipping->address();
if (
2 !== strlen( $shipping->address()->country_code() ) ||
( ! $shipping->address()->postal_code() && ! $this->country_without_postal_code( $shipping->address()->country_code() ) )
! $shipping_address ||
2 !== strlen( $shipping_address->country_code() ) ||
( ! $shipping_address->postal_code() && ! $this->country_without_postal_code( $shipping_address->country_code() ) )
) {
$shipping = null;
}
@ -219,17 +221,20 @@ class PurchaseUnitFactory {
*
* @param \stdClass $data The JSON object.
*
* @return PurchaseUnit
* @return ?PurchaseUnit
* @throws RuntimeException When JSON object is malformed.
*/
public function from_paypal_response( \stdClass $data ): PurchaseUnit {
public function from_paypal_response( \stdClass $data ): ?PurchaseUnit {
if ( ! isset( $data->reference_id ) || ! is_string( $data->reference_id ) ) {
throw new RuntimeException(
__( 'No reference ID given.', 'woocommerce-paypal-payments' )
);
throw new RuntimeException( 'No reference ID given.' );
}
$amount_data = $data->amount ?? null;
$amount = $this->amount_factory->from_paypal_response( $amount_data );
if ( null === $amount ) {
return null;
}
$amount = $this->amount_factory->from_paypal_response( $data->amount );
$description = ( isset( $data->description ) ) ? $data->description : '';
$custom_id = ( isset( $data->custom_id ) ) ? $data->custom_id : '';
$invoice_id = ( isset( $data->invoice_id ) ) ? $data->invoice_id : '';
@ -245,7 +250,7 @@ class PurchaseUnitFactory {
}
$shipping = null;
try {
if ( isset( $data->shipping ) ) {
if ( isset( $data->shipping ) && ! empty( (array) $data->shipping ) ) {
$shipping = $this->shipping_factory->from_paypal_response( $data->shipping );
}
} catch ( RuntimeException $error ) {
@ -334,4 +339,19 @@ class PurchaseUnitFactory {
return substr( $sanitized, 0, 22 ) ?: '';
}
/**
* Determines whether shipping should be disabled for a purchase unit.
*
* @param array $items Purchase unit items.
* @param Address|null $shipping_address The shipping address to validate.
*
* @return bool
*/
private function should_disable_shipping( array $items, ?Address $shipping_address ): bool {
return ! $this->shipping_needed( ... array_values( $items ) ) ||
! $shipping_address ||
empty( $shipping_address->country_code() ) ||
( ! $shipping_address->postal_code() && ! $this->country_without_postal_code( $shipping_address->country_code() ) );
}
}

View file

@ -12,6 +12,7 @@ namespace WooCommerce\PayPalCommerce\ApiClient\Factory;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Refund;
use WooCommerce\PayPalCommerce\ApiClient\Entity\RefundStatus;
use WooCommerce\PayPalCommerce\ApiClient\Entity\RefundStatusDetails;
use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException;
/**
* Class RefundFactory
@ -62,6 +63,7 @@ class RefundFactory {
* @param \stdClass $data The PayPal response.
*
* @return Refund
* @throws RuntimeException When refund amount data is invalid.
*/
public function from_paypal_response( \stdClass $data ) : Refund {
$reason = $data->status_details->reason ?? null;
@ -73,13 +75,18 @@ class RefundFactory {
$this->refund_payer_factory->from_paypal_response( $data->payer )
: null;
$amount = $this->amount_factory->from_paypal_response( $data->amount );
if ( null === $amount ) {
throw new RuntimeException( 'Invalid refund amount data.' );
}
return new Refund(
(string) $data->id,
new RefundStatus(
(string) $data->status,
$reason ? new RefundStatusDetails( $reason ) : null
),
$this->amount_factory->from_paypal_response( $data->amount ),
$amount,
(string) ( $data->invoice_id ?? '' ),
(string) ( $data->custom_id ?? '' ),
$seller_payable_breakdown,

View file

@ -0,0 +1,53 @@
<?php
/**
* Factory for determining the appropriate return URL based on context.
*
* @package WooCommerce\PayPalCommerce\ApiClient\Factory
*/
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\ApiClient\Factory;
use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException;
/**
* Class ReturnUrlFactory
*/
class ReturnUrlFactory {
/**
* @throws RuntimeException When required data is missing for the context.
*/
public function from_context( string $context, array $request_data = array() ): string {
switch ( $context ) {
case 'cart':
case 'cart-block':
case 'mini-cart':
return wc_get_cart_url();
case 'product':
if ( ! empty( $request_data['purchase_units'] ) && is_array( $request_data['purchase_units'] ) ) {
$first_unit = reset( $request_data['purchase_units'] );
if ( ! empty( $first_unit['items'] ) && is_array( $first_unit['items'] ) ) {
$first_item = reset( $first_unit['items'] );
if ( ! empty( $first_item['url'] ) ) {
return $first_item['url'];
}
}
}
throw new RuntimeException( 'Product URL is required but not provided in the request data.' );
case 'pay-now':
if ( ! empty( $request_data['order_id'] ) ) {
$order = wc_get_order( $request_data['order_id'] );
if ( $order instanceof \WC_Order ) {
return $order->get_checkout_payment_url();
}
}
throw new RuntimeException( 'The order ID is invalid.' );
default:
return wc_get_checkout_url();
}
}
}

View file

@ -96,34 +96,25 @@ class ShippingFactory {
* @throws RuntimeException When JSON object is malformed.
*/
public function from_paypal_response( \stdClass $data ): Shipping {
if ( ! isset( $data->name->full_name ) ) {
throw new RuntimeException(
__( 'No name was given for shipping.', 'woocommerce-paypal-payments' )
);
}
if ( ! isset( $data->address ) ) {
throw new RuntimeException(
__( 'No address was given for shipping.', 'woocommerce-paypal-payments' )
);
}
$contact_phone = null;
$contact_email = null;
$address = $this->address_factory->from_paypal_response( $data->address );
$name = $data->name->full_name ?? null;
$address = isset( $data->address )
? $this->address_factory->from_paypal_response( $data->address )
: null;
$contact_email = $data->email_address ?? null;
$contact_phone = isset( $data->phone_number->national_number )
? new Phone( $data->phone_number->national_number )
: null;
$options = array_map(
array( $this->shipping_option_factory, 'from_paypal_response' ),
$data->options ?? array()
);
if ( isset( $data->phone_number->national_number ) ) {
$contact_phone = new Phone( $data->phone_number->national_number );
}
if ( isset( $data->email_address ) ) {
$contact_email = $data->email_address;
}
return new Shipping(
$data->name->full_name,
$name,
$address,
$contact_email,
$contact_phone,

View file

@ -40,14 +40,10 @@ class WebhookEventFactory {
*/
public function from_paypal_response( $data ): WebhookEvent {
if ( ! isset( $data->id ) ) {
throw new RuntimeException(
__( 'ID for webhook event not found.', 'woocommerce-paypal-payments' )
);
throw new RuntimeException( 'ID for webhook event not found.' );
}
if ( ! isset( $data->event_type ) ) {
throw new RuntimeException(
__( 'Event type for webhook event not found.', 'woocommerce-paypal-payments' )
);
throw new RuntimeException( 'Event type for webhook event not found.' );
}
$create_time = ( isset( $data->create_time ) ) ?

View file

@ -59,19 +59,13 @@ class WebhookFactory {
*/
public function from_paypal_response( $data ): Webhook {
if ( ! isset( $data->id ) ) {
throw new RuntimeException(
__( 'No id for webhook given.', 'woocommerce-paypal-payments' )
);
throw new RuntimeException( 'No id for webhook given.' );
}
if ( ! isset( $data->url ) ) {
throw new RuntimeException(
__( 'No URL for webhook given.', 'woocommerce-paypal-payments' )
);
throw new RuntimeException( 'No URL for webhook given.' );
}
if ( ! isset( $data->event_types ) ) {
throw new RuntimeException(
__( 'No event types for webhook given.', 'woocommerce-paypal-payments' )
);
throw new RuntimeException( 'No event types for webhook given.' );
}
return new Webhook(

View file

@ -114,7 +114,7 @@ abstract class ProductStatus {
* @param Settings|null $settings See description in {@see self::clear()}.
* @return void
*/
abstract protected function clear_state( Settings $settings = null ) : void;
abstract protected function clear_state( ?Settings $settings = null ) : void;
/**
* Whether the merchant has access to the feature.
@ -199,7 +199,7 @@ abstract class ProductStatus {
* @param Settings|null $settings The settings object.
* @return void
*/
public function clear( Settings $settings = null ) : void {
public function clear( ?Settings $settings = null ) : void {
$this->is_eligible = null;
$this->has_request_failure = false;

View file

@ -81,7 +81,7 @@ class PurchaseUnitSanitizer {
* @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 ) {
public function __construct( ?string $mode = null, ?string $extra_line_name = null ) {
if ( ! in_array( $mode, self::VALID_MODES, true ) ) {
$mode = self::MODE_DITCH;
@ -193,10 +193,16 @@ class PurchaseUnitSanitizer {
$item_mismatch = $this->calculate_item_mismatch();
if ( $item_mismatch > 0 ) {
// Use appropriate category to preserve purely digital or physical goods baskets.
$rounding_item_category = $this->determine_rounding_item_category();
// 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();
$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, '', null, '', $rounding_item_category )
)->to_array();
$this->set_last_message(
__( 'Item amount mismatch. Extra line added.', 'woocommerce-paypal-payments' )
@ -217,6 +223,24 @@ class PurchaseUnitSanitizer {
}
}
/**
* Determines the appropriate category for rounding items based on existing items.
*
* @return string The category (Item::DIGITAL_GOODS or Item::PHYSICAL_GOODS)
*/
private function determine_rounding_item_category(): string {
// Check if all items are digital goods.
foreach ( $this->items() as $item ) {
$category = $item['category'] ?? Item::PHYSICAL_GOODS;
if ( $category !== Item::DIGITAL_GOODS ) {
return Item::PHYSICAL_GOODS;
}
}
// All items are digital goods.
return Item::DIGITAL_GOODS;
}
/**
* The sanitizes the purchase_unit items tax.
*

View file

@ -0,0 +1,64 @@
<?php
/**
* Reference transaction status helper class.
*
* @package WooCommerce\PayPalCommerce\ApiClient\Helper
*/
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\ApiClient\Helper;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PartnersEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException;
/**
* Class ReferenceTransactionStatus
*
* Helper class to check reference transaction capabilities for PayPal merchant accounts.
*/
class ReferenceTransactionStatus {
public const CACHE_KEY = 'ppcp_reference_transaction_enabled';
protected PartnersEndpoint $partners_endpoint;
protected Cache $cache;
public function __construct( PartnersEndpoint $partners_endpoint, Cache $cache ) {
$this->partners_endpoint = $partners_endpoint;
$this->cache = $cache;
}
/**
* Checks if reference transactions are enabled in the merchant account.
*
* This method verifies if the merchant has the PAYPAL_WALLET_VAULTING_ADVANCED
* capability active, which is required for processing reference transactions.
*
* @return bool True if reference transactions are enabled, false otherwise.
*/
public function reference_transaction_enabled(): bool {
if ( $this->cache->has( self::CACHE_KEY ) ) {
return (bool) $this->cache->get( self::CACHE_KEY );
}
try {
foreach ( $this->partners_endpoint->seller_status()->capabilities() as $capability ) {
if (
$capability->name() === 'PAYPAL_WALLET_VAULTING_ADVANCED' &&
$capability->status() === 'ACTIVE'
) {
$this->cache->set( self::CACHE_KEY, true, MONTH_IN_SECONDS );
return true;
}
}
} catch ( RuntimeException $exception ) {
$this->cache->set( self::CACHE_KEY, false, HOUR_IN_SECONDS );
return false;
}
$this->cache->set( self::CACHE_KEY, false, HOUR_IN_SECONDS );
return false;
}
}

View file

@ -54,7 +54,12 @@ class PartnerReferralsData {
* @param bool $use_card_payments If the merchant wants to process credit card payments.
* @return array
*/
public function data( array $products = array(), string $onboarding_token = '', bool $use_subscriptions = null, bool $use_card_payments = true ) : array {
public function data(
array $products = array(),
string $onboarding_token = '',
?bool $use_subscriptions = null,
bool $use_card_payments = true
) : array {
$in_acdc_country = $this->dcc_applies->for_country_currency();
if ( ! $products ) {

View file

@ -334,12 +334,22 @@ class ApplePayButton extends PaymentButton {
this.checkEligibility();
}
reinit() {
async reinit() {
// Missing (invalid) configuration indicates, that the first `init()` call did not happen yet.
if ( ! this.validateConfiguration( true ) ) {
return;
}
// Ensures transaction info is updated when cart or checkout update events are triggered.
await this.contextHandler
.transactionInfo()
.then( ( transactionInfo ) => {
this.transactionInfo = transactionInfo;
} )
.catch( ( error ) => {
console.error( 'Failed to get transaction info:', error );
} );
super.reinit();
this.init();

View file

@ -94,20 +94,23 @@ if (
features.push( 'subscriptions' );
}
registerExpressPaymentMethod( {
name: buttonData.id,
title: `PayPal - ${ buttonData.title }`,
description: __(
'Eligible users will see the PayPal button.',
'woocommerce-paypal-payments'
),
label: <div dangerouslySetInnerHTML={ { __html: buttonData.title } } />,
content: <ApplePayComponent isEditing={ false } />,
edit: <ApplePayComponent isEditing={ true } />,
ariaLabel: buttonData.title,
canMakePayment: () => buttonData.enabled,
supports: {
features,
style: [ 'height', 'borderRadius' ],
},
} );
if ( buttonConfig?.is_enabled ) {
registerExpressPaymentMethod( {
name: buttonData.id,
title: `PayPal - ${ buttonData.title }`,
description: __(
'Eligible users will see the PayPal button.',
'woocommerce-paypal-payments'
),
label: <div dangerouslySetInnerHTML={ { __html: buttonData.title } } />,
content: <ApplePayComponent isEditing={ false } />,
edit: <ApplePayComponent isEditing={ true } />,
ariaLabel: buttonData.title,
canMakePayment: () =>
buttonData.enabled && window.ApplePaySession?.canMakePayments(),
supports: {
features,
style: [ 'height', 'borderRadius' ],
},
} );
}

View file

@ -136,14 +136,7 @@ return array(
return false;
},
'applepay.url' => static function ( ContainerInterface $container ): string {
$path = realpath( __FILE__ );
if ( false === $path ) {
return '';
}
return plugins_url(
'/modules/ppcp-applepay/',
dirname( $path, 3 ) . '/woocommerce-paypal-payments.php'
);
return plugins_url( '/modules/ppcp-applepay/', $container->get( 'ppcp.path-to-plugin-main-file' ) );
},
'applepay.sdk_script_url' => static function ( ContainerInterface $container ): string {
return 'https://applepay.cdn-apple.com/jsapi/v1/apple-pay-sdk.js';

View file

@ -18,6 +18,7 @@ use WooCommerce\PayPalCommerce\Applepay\Assets\PropertiesDictionary;
use WooCommerce\PayPalCommerce\Button\Assets\ButtonInterface;
use WooCommerce\PayPalCommerce\Button\Assets\SmartButtonInterface;
use WooCommerce\PayPalCommerce\Applepay\Helper\AvailabilityNotice;
use WooCommerce\PayPalCommerce\Settings\SettingsModule;
use WooCommerce\PayPalCommerce\WcGateway\Helper\Environment;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExecutableModule;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExtendingModule;
@ -55,7 +56,7 @@ class ApplepayModule implements ServiceModule, ExtendingModule, ExecutableModule
// Clears product status when appropriate.
add_action(
'woocommerce_paypal_payments_clear_apm_product_status',
function( Settings $settings = null ) use ( $c ): void {
function( ?Settings $settings = null ) use ( $c ): void {
$apm_status = $c->get( 'applepay.apple-product-status' );
assert( $apm_status instanceof AppleProductStatus );
$apm_status->clear( $settings );
@ -93,15 +94,17 @@ class ApplepayModule implements ServiceModule, ExtendingModule, ExecutableModule
return;
}
if ( $apple_payment_method->is_enabled() ) {
$module->load_assets( $c, $apple_payment_method );
$module->handle_validation_file( $c, $apple_payment_method );
$module->render_buttons( $c, $apple_payment_method );
$apple_payment_method->bootstrap_ajax_request();
}
$module->load_admin_assets( $c, $apple_payment_method );
$module->load_block_editor_assets( $c, $apple_payment_method );
if ( SettingsModule::should_use_the_old_ui() && ! $apple_payment_method->is_enabled() ) {
return;
}
$module->load_assets( $c, $apple_payment_method );
$module->handle_validation_file( $c, $apple_payment_method );
$module->render_buttons( $c, $apple_payment_method );
$apple_payment_method->bootstrap_ajax_request();
},
1
);
@ -241,7 +244,8 @@ class ApplepayModule implements ServiceModule, ExtendingModule, ExecutableModule
$validation_string = $this->validation_string( $is_sandbox );
nocache_headers();
header( 'Content-Type: text/plain', true, 200 );
echo $validation_string;// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $validation_string;
exit;
}
}
@ -254,13 +258,13 @@ class ApplepayModule implements ServiceModule, ExtendingModule, ExecutableModule
* @return void
*/
public function load_assets( ContainerInterface $c, ApplePayButton $button ): void {
if ( ! $button->is_enabled() ) {
return;
}
add_action(
'wp_enqueue_scripts',
function () use ( $c, $button ) {
if ( ! $button->is_enabled() ) {
return;
}
$smart_button = $c->get( 'button.smart-button' );
assert( $smart_button instanceof SmartButtonInterface );
if ( $smart_button->should_load_ppcp_script() ) {
@ -281,6 +285,9 @@ class ApplepayModule implements ServiceModule, ExtendingModule, ExecutableModule
add_action(
'enqueue_block_editor_assets',
function () use ( $c, $button ) {
if ( ! $button->is_enabled() ) {
return;
}
$button->enqueue_admin_styles();
}
);
@ -361,9 +368,6 @@ class ApplepayModule implements ServiceModule, ExtendingModule, ExecutableModule
* @return void
*/
public function render_buttons( ContainerInterface $c, ApplePayButton $button ): void {
if ( ! $button->is_enabled() ) {
return;
}
add_action(
'wp',
@ -371,8 +375,13 @@ class ApplepayModule implements ServiceModule, ExtendingModule, ExecutableModule
if ( is_admin() ) {
return;
}
$button = $c->get( 'applepay.button' );
if ( ! $button->is_enabled() ) {
return;
}
/**
* The Button.
*
@ -391,9 +400,6 @@ class ApplepayModule implements ServiceModule, ExtendingModule, ExecutableModule
* @return void
*/
public function handle_validation_file( ContainerInterface $c, ApplePayButton $button ): void {
if ( ! $button->is_enabled() ) {
return;
}
$env = $c->get( 'settings.environment' );
assert( $env instanceof Environment );
$is_sandbox = $env->current_environment_is( Environment::SANDBOX );

View file

@ -160,7 +160,7 @@ class ApplePayDataObjectHttp {
* @return void
*/
public function validation_data(): void {
$data = filter_input( INPUT_POST, 'validation', FILTER_VALIDATE_BOOL );
$data = filter_input( INPUT_POST, 'validation', FILTER_VALIDATE_BOOLEAN );
if ( ! $data ) {
return;
}

View file

@ -101,7 +101,7 @@ class AppleProductStatus extends ProductStatus {
}
/** {@inheritDoc} */
protected function clear_state( Settings $settings = null ) : void {
protected function clear_state( ?Settings $settings = null ) : void {
if ( null === $settings ) {
$settings = $this->settings;
}

View file

@ -42,40 +42,56 @@ $fast-transition-duration: 0.5s;
display: flex;
flex-direction: column;
align-items: center;
max-width: 300px;
max-width: 340px;
width: 100%;
}
&__content {
position: relative;
box-sizing: border-box;
aspect-ratio: 1.586;
display: flex;
flex-direction: column;
justify-content: space-between;
border: 1px solid $border-color;
font-size: 0.875em;
font-family: monospace;
padding: 1em;
margin: 1em 0;
border-radius: 4px;
border-radius: 10px;
width: 100%;
box-shadow: 0 3px 10px -3px rgba(0, 0, 0, .2666666667);
background-image: linear-gradient(60deg, rgba(0, 0, 0, 0.0666666667), rgba(204, 204, 204, 0.0666666667) 65%, rgba(255, 255, 255, 0.4) 68%, rgba(255, 255, 255, 0));
border: 2px solid #ccc;
background-color: #f6f6f6;
}
&__meta {
@include flex-space-between;
width: 100%;
text-align: left;
&-digits {
letter-spacing: 2px;
margin-top: 76px;
font-size: 24px;
text-shadow: 0 -1px 1px #fff, 0 1px 1px rgba(0, 0, 0, .2666666667);
color: #666;
text-align: center;
font-family: monospace;
}
&:last-child {
align-self: flex-end;
&-logo {
position: absolute;
right: 32px;
top: 32px;
height: 40px;
}
}
&__watermark {
align-self: flex-end;
&-expiry {
text-align: right;
font-size: 14px;
padding-right: 32px;
}
&-name {
text-transform: uppercase;
position: absolute;
left: 24px;
bottom: 20px;
line-height: 1em;
}
}
&__edit {

View file

@ -45,24 +45,19 @@ const Card = ( { fastlaneSdk, showWatermark = true } ) => {
<div className="wc-block-checkout-axo-block-card__inner">
<div className="wc-block-checkout-axo-block-card__content">
<div className="wc-block-checkout-axo-block-card__meta">
<span className="wc-block-checkout-axo-block-card__meta-digits">
<div className="wc-block-checkout-axo-block-card__meta-logo">
{ cardLogo }
</div>
<div className="wc-block-checkout-axo-block-card__meta-digits">
{ `**** **** **** ${ lastDigits }` }
</span>
{ cardLogo }
</div>
<div className="wc-block-checkout-axo-block-card__meta-expiry">
{ formattedExpiry }
</div>
<div className="wc-block-checkout-axo-block-card__meta-name">
{ name }
</div>
</div>
<div className="wc-block-checkout-axo-block-card__meta">
<span>{ name }</span>
<span>{ formattedExpiry }</span>{ ' ' }
</div>
</div>
<div className="wc-block-checkout-axo-block-card__watermark">
{ showWatermark && (
<Watermark
fastlaneSdk={ fastlaneSdk }
name="wc-block-checkout-axo-card-watermark"
includeAdditionalInfo={ false }
/>
) }
</div>
</div>
</div>

View file

@ -121,7 +121,7 @@ export const populateWooFields = (
address_1: address.addressLine1,
address_2: address.addressLine2 || '',
city: address.adminArea2,
state: address.adminArea1,
state: address.adminArea1 || '',
postcode: address.postalCode,
country: address.countryCode,
phone: phoneNumber.nationalNumber,

View file

@ -27,8 +27,38 @@ const usePayPalScript = ( namespace, ppcpConfig, isConfigLoaded ) => {
useEffect( () => {
const loadScript = async () => {
if ( ! isPayPalLoaded && isConfigLoaded ) {
const axoConfig = window.wc_ppcp_axo;
try {
await loadPayPalScript( namespace, ppcpConfig );
const res = await fetch(
axoConfig.ajax.axo_script_attributes.endpoint,
{
method: 'POST',
credentials: 'same-origin',
body: JSON.stringify( {
nonce: axoConfig.ajax.axo_script_attributes
.nonce,
} ),
}
);
const json = await res.json();
if ( ! json.success ) {
log(
`Failed to load axo script attributes: ${ json.data.message }`,
'error'
);
return;
}
await loadPayPalScript( namespace, {
...ppcpConfig,
script_attributes: {
...ppcpConfig.script_attributes,
'data-sdk-client-token': json.data.sdk_client_token,
},
} );
setIsPayPalLoaded( true );
} catch ( error ) {
log(

View file

@ -32,7 +32,7 @@ export const useShippingAddressChange = ( fastlaneSdk, setShippingAddress ) => {
address_1: address.addressLine1,
address_2: address.addressLine2 || '',
city: address.adminArea2,
state: address.adminArea1,
state: address.adminArea1 || '',
postcode: address.postalCode,
country: address.countryCode,
phone: phoneNumber.nationalNumber,

View file

@ -18,15 +18,7 @@ return array(
return true;
},
'axoblock.url' => static function ( ContainerInterface $container ) : string {
/**
* The path cannot be false.
*
* @psalm-suppress PossiblyFalseArgument
*/
return plugins_url(
'/modules/ppcp-axo-block/',
dirname( realpath( __FILE__ ), 3 ) . '/woocommerce-paypal-payments.php'
);
return plugins_url( '/modules/ppcp-axo-block/', $container->get( 'ppcp.path-to-plugin-main-file' ) );
},
'axoblock.method' => static function ( ContainerInterface $container ) : AxoBlockPaymentMethod {
return new AxoBlockPaymentMethod(

View file

@ -67,24 +67,6 @@ class AxoBlockModule implements ServiceModule, ExtendingModule, ExecutableModule
add_action(
'wp_loaded',
function () use ( $c ) {
add_filter(
'woocommerce_paypal_payments_localized_script_data',
function( array $localized_script_data ) use ( $c ) {
if ( ! $c->has( 'axo.available' ) || ! $c->get( 'axo.available' ) ) {
return $localized_script_data;
}
$module = $this;
$api = $c->get( 'api.sdk-client-token' );
assert( $api instanceof SdkClientToken );
$logger = $c->get( 'woocommerce.logger.woocommerce' );
assert( $logger instanceof LoggerInterface );
return $module->add_sdk_client_token_to_script_data( $api, $logger, $localized_script_data );
}
);
/**
* Param types removed to avoid third-party issues.
*
@ -146,37 +128,6 @@ class AxoBlockModule implements ServiceModule, ExtendingModule, ExecutableModule
return true;
}
/**
* Adds id token to localized script data.
*
* @param SdkClientToken $api User id token api.
* @param LoggerInterface $logger The logger.
* @param array $localized_script_data The localized script data.
* @return array
*/
private function add_sdk_client_token_to_script_data(
SdkClientToken $api,
LoggerInterface $logger,
array $localized_script_data
): array {
try {
$sdk_client_token = $api->sdk_client_token();
$localized_script_data['axo'] = array(
'sdk_client_token' => $sdk_client_token,
);
} catch ( RuntimeException $exception ) {
$error = $exception->getMessage();
if ( is_a( $exception, PayPalApiException::class ) ) {
$error = $exception->get_details( $error );
}
$logger->error( $error );
}
return $localized_script_data;
}
/**
* Enqueues PayPal Insights analytics script for the Checkout block.
*

View file

@ -11,7 +11,8 @@ namespace WooCommerce\PayPalCommerce\AxoBlock;
use WC_Payment_Gateway;
use Automattic\WooCommerce\Blocks\Payments\Integrations\AbstractPaymentMethodType;
use WooCommerce\PayPalCommerce\Axo\FrontendLoggerEndpoint;
use WooCommerce\PayPalCommerce\Axo\Endpoint\AxoScriptAttributes;
use WooCommerce\PayPalCommerce\Axo\Endpoint\FrontendLogger;
use WooCommerce\PayPalCommerce\Button\Assets\SmartButtonInterface;
use WooCommerce\PayPalCommerce\WcGateway\Helper\Environment;
use WooCommerce\PayPalCommerce\Axo\Gateway\AxoGateway;
@ -257,9 +258,13 @@ class AxoBlockPaymentMethod extends AbstractPaymentMethodType {
'icons_directory' => esc_url( $this->wcgateway_module_url ) . 'assets/images/axo/',
'module_url' => untrailingslashit( $this->module_url ),
'ajax' => array(
'frontend_logger' => array(
'endpoint' => \WC_AJAX::get_endpoint( FrontendLoggerEndpoint::ENDPOINT ),
'nonce' => wp_create_nonce( FrontendLoggerEndpoint::nonce() ),
'frontend_logger' => array(
'endpoint' => \WC_AJAX::get_endpoint( FrontendLogger::ENDPOINT ),
'nonce' => wp_create_nonce( FrontendLogger::nonce() ),
),
'axo_script_attributes' => array(
'endpoint' => \WC_AJAX::get_endpoint( AxoScriptAttributes::ENDPOINT ),
'nonce' => wp_create_nonce( AxoScriptAttributes::nonce() ),
),
),
'logging_enabled' => $this->settings->has( 'logging_enabled' ) ? $this->settings->get( 'logging_enabled' ) : '',

View file

@ -0,0 +1,863 @@
<!DOCTYPE html>
<html lang='en'>
<!--
URL: /wp-content/plugins/woocommerce-paypal-payments/modules/ppcp-axo/docs/payment-test.html
-->
<head>
<meta charset='UTF-8'>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
<title>PayPal Fastlane Checkout - Minimal POC</title>
</head>
<body>
<section>
<h3>PayPal Fastlane Test</h3>
<div id='test-form'>
<p>
PayPal client ID: <span id='client-id'></span>
<button id='clear-credentials'>Clear</button>
</p>
<div id='fastlane-container'>
<div class='form-group'>
<label for='sca-method'>3DS Verification:</label>
<select id='sca-method' style='width: 100%;'>
<option value=''>NEVER - Do not ask for 3DS verification</option>
<option value='SCA_WHEN_REQUIRED'>WHEN REQUIRED - Let PayPal decide if to show
3DS
</option>
<option value='SCA_ALWAYS'>ALWAYS - Ask for 3DS verification</option>
</select>
</div>
<div class='form-group'>
<label for='email'>Email Address</label>
<input type='email' id='email' placeholder='Enter your email address'>
</div>
<button id='lookupButton'>Continue</button>
<!-- Fastlane Watermark will be rendered here -->
<div id='fastlaneWatermark'></div>
<!-- Shipping Address Container -->
<div id='shippingAddressContainer' style='display: none;'>
<h3>Shipping Address</h3>
<div id='shippingAddressDetails'></div>
<button id='changeShippingButton' style='display: none;'>Change Address</button>
</div>
<!-- Payment Method Container -->
<div id='paymentMethodContainer' style='display: none;'>
<h3>Payment Method</h3>
<div id='paymentMethodDetails'></div>
<button id='changePaymentButton' style='display: none;'>Change Payment Method
</button>
<!-- Fastlane Card Component will be rendered here for guests or members without cards -->
<div id='fastlaneCardComponent' style='display: none;'></div>
</div>
<!-- Checkout Container -->
<div id='checkout-container' style='display: none;'>
<h3>Order Summary</h3>
<p>Product: Test Item</p>
<p>Price: $10.00</p>
<button id='placeOrderButton'>Place Order</button>
</div>
</div>
</div>
<div class='logger' id='response-log'></div>
</section>
<script>
// Check if credentials exist in localStorage
const PAYPAL_CLIENT_ID = localStorage.getItem('PAYPAL_CLIENT_ID');
const PAYPAL_CLIENT_SECRET = localStorage.getItem('PAYPAL_CLIENT_SECRET');
const SCA_METHOD = localStorage.getItem('SCA_METHOD') || '';
// Variables to store the Fastlane instance and other data
let fastlane;
let identity;
let customerContextId;
let renderFastlaneMemberExperience = false;
let profileData = null;
let sdkLoaded = false;
// If credentials don't exist, show input form
if (!PAYPAL_CLIENT_ID || !PAYPAL_CLIENT_SECRET) {
document.getElementById('test-form').innerHTML = `
<div id='credentials-form'>
<h4>Enter PayPal API Credentials</h4>
<div style='margin-bottom: 10px;'>
<label for='client-id-input'>Client ID:</label>
<input type='text' id='client-id-input' style='width: 100%;'>
</div>
<div style='margin-bottom: 10px;'>
<label for='client-secret'>Client Secret:</label>
<input type='text' id='client-secret' style='width: 100%;'>
</div>
<button id='save-credentials' style='padding: 8px 16px;'>Save Credentials</button>
</div>
`;
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('save-credentials').addEventListener('click', () => {
const clientId = document.getElementById('client-id-input').value.trim();
const clientSecret = document.getElementById('client-secret').value.trim();
if (!clientId || !clientSecret) {
alert('Please enter both Client ID and Client Secret');
return;
}
localStorage.setItem('PAYPAL_CLIENT_ID', clientId);
localStorage.setItem('PAYPAL_CLIENT_SECRET', clientSecret);
window.location.reload();
});
});
} else {
const clientIdElement = document.getElementById('client-id');
clientIdElement.textContent = PAYPAL_CLIENT_ID;
document.getElementById('sca-method').value = SCA_METHOD;
// Add event listener for SCA method change
document.getElementById('sca-method').addEventListener('change', () => {
const scaMethod = document.getElementById('sca-method').value;
localStorage.setItem('SCA_METHOD', scaMethod);
logResponse(`3DS verification method changed to: ${scaMethod || 'NEVER'}`);
});
document.getElementById('clear-credentials').addEventListener('click', () => {
if (confirm('Are you sure you want to clear your PayPal credentials?')) {
localStorage.removeItem('PAYPAL_CLIENT_ID');
localStorage.removeItem('PAYPAL_CLIENT_SECRET');
localStorage.removeItem('SCA_METHOD');
window.location.reload();
}
});
// Initialize the app since we have credentials
document.addEventListener('DOMContentLoaded', initializeApp);
}
// Logging function for better debugging
function logResponse(message, data) {
const logger = document.getElementById('response-log');
const timestamp = new Date().toLocaleTimeString();
if (undefined === data) {
logger.innerHTML += `<div>[${timestamp}] ${message}</div>`;
console.log(message);
} else {
logger.innerHTML +=
`<div>[${timestamp}] ${message}<pre>${JSON.stringify(data, null, 2)}</pre></div>`;
console.log(message, data);
}
logger.scrollTop = logger.scrollHeight;
}
// Main initialization function
async function initializeApp() {
try {
logResponse('[Mock Server] Generating client token...');
// Generate access token
const tokenResponse = await fetch('https://api-m.sandbox.paypal.com/v1/oauth2/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${btoa(`${PAYPAL_CLIENT_ID}:${PAYPAL_CLIENT_SECRET}`)}`,
},
body: 'grant_type=client_credentials',
});
if (!tokenResponse.ok) {
throw new Error('Failed to get PayPal access token');
}
const tokenData = await tokenResponse.json();
const accessToken = tokenData.access_token;
logResponse('[Mock Server] Access token obtained');
const clientTokenBody = {
customer_type: 'MERCHANT',
features: ['FASTLANE', 'VAULT'],
domains: [location.hostname],
};
logResponse('[Mock Server] Generate client token...', clientTokenBody);
// Generate client token for SDK
const clientTokenResponse = await fetch('https://api-m.sandbox.paypal.com/v1/identity/generate-token',
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'Accept-Language': 'en_US',
},
body: JSON.stringify(clientTokenBody),
},
);
if (!clientTokenResponse.ok) {
throw new Error('Failed to generate client token');
}
const clientTokenData = await clientTokenResponse.json();
const clientToken = clientTokenData.client_token;
logResponse('[Mock Server] Client token generated', clientTokenData);
// Load the PayPal SDK
loadPayPalSdk(clientToken);
} catch (error) {
logResponse('[Mock Server] Error:', error);
}
}
// Load PayPal SDK with the generated token
function loadPayPalSdk(clientToken) {
logResponse('Loading the PayPal JS SDK...');
const script = document.createElement('script');
script.src =
`https://www.paypal.com/sdk/js?client-id=${PAYPAL_CLIENT_ID}&components=fastlane`;
script.setAttribute('data-sdk-client-token', clientToken);
script.setAttribute('data-client-token', clientToken);
script.onload = async function() {
logResponse('PayPal SDK loaded');
sdkLoaded = true;
try {
// Initialize Fastlane
logResponse('Initialize Fastlane...');
fastlane = await window.paypal.Fastlane();
// Set locale if needed (en_us is default)
fastlane.setLocale('en_us');
} catch (error) {
logResponse('Failed to initialize Fastlane:', error);
logResponse('-- Maybe it\'s not available/enabled for this account --');
return;
}
try {
// Get identity module for customer lookup and authentication
logResponse('Get identity module and add watermark...');
identity = fastlane.Identity();
// Render Fastlane watermark for transparency
const watermarkElement = document.getElementById('fastlaneWatermark');
identity.renderWatermark(watermarkElement);
// Add event listener for lookup button
setupEventListeners();
logResponse('Fastlane initialized successfully');
} catch (error) {
logResponse('Failed to configure Fastlane:', error);
}
};
document.head.appendChild(script);
}
// Setup all event listeners
function setupEventListeners() {
// Lookup button click handler
document.getElementById('lookupButton').addEventListener('click', handleLookupButtonClick);
// Place order button handler
document.getElementById('placeOrderButton')
.addEventListener('click', handlePlaceOrderButtonClick);
}
// Handle lookup button click
async function handleLookupButtonClick() {
const email = document.getElementById('email').value;
if (!email) {
alert('Please enter your email address');
return;
}
logResponse(`Looking up customer email: ${email}`);
try {
// Look up customer by email
const lookupResult = await identity.lookupCustomerByEmail(email);
customerContextId = lookupResult.customerContextId;
if (customerContextId) {
// Email is associated with a Fastlane profile or PayPal member
logResponse('Customer found with ID:', customerContextId);
// Trigger authentication flow (OTP)
logResponse('Triggering authentication flow...');
const authResult = await identity.triggerAuthenticationFlow(customerContextId);
logResponse('Authentication result:', authResult);
if (authResult.authenticationState === 'succeeded') {
// Authentication successful - we have profile data
renderFastlaneMemberExperience = true;
profileData = authResult.profileData;
// Display member checkout experience
displayMemberCheckout(profileData);
} else {
// Authentication failed or was canceled
logResponse('Authentication failed or was canceled');
renderFastlaneMemberExperience = false;
displayGuestCheckout();
}
} else {
// No profile found - this is a guest
logResponse('No Fastlane profile found - processing as guest');
renderFastlaneMemberExperience = false;
displayGuestCheckout();
}
} catch (error) {
logResponse('Error during customer lookup:', error);
displayGuestCheckout();
}
}
// Display checkout for authenticated Fastlane members
function displayMemberCheckout(profile) {
logResponse('Displaying member checkout with profile:', profile);
// Show shipping address if available
if (profile.shippingAddress) {
const shippingContainer = document.getElementById('shippingAddressContainer');
const shippingDetails = document.getElementById('shippingAddressDetails');
const changeShippingBtn = document.getElementById('changeShippingButton');
// Format and display address
const address = profile.shippingAddress;
shippingDetails.innerHTML = `
<p>${profile.name || ''}</p>
<p>${address.line1 || ''}</p>
${address.line2 ? `<p>${address.line2}</p>` : ''}
<p>${address.city || ''}, ${address.state || ''} ${address.postal_code || ''}</p>
<p>${address.country_code || ''}</p>
`;
// Show container and change button
shippingContainer.style.display = 'block';
changeShippingBtn.style.display = 'block';
// Handle change address button
changeShippingBtn.onclick = function() {
try {
logResponse('Opening shipping address selector');
// Show shipping address selector
fastlane.Address().showShippingAddressSelector();
} catch (error) {
logResponse('Error showing address selector:', error);
}
};
}
// Show payment method if available
if (profile.card) {
const paymentContainer = document.getElementById('paymentMethodContainer');
const paymentDetails = document.getElementById('paymentMethodDetails');
const changePaymentBtn = document.getElementById('changePaymentButton');
// Format and display card info
const card = profile.card;
paymentDetails.innerHTML = `
<p>Card: ${card.brand || ''} ending in ${card.last_digits || ''}</p>
<p>Expires: ${card.expiry || ''}</p>
`;
// Show container and change button
paymentContainer.style.display = 'block';
changePaymentBtn.style.display = 'block';
// Handle change payment button
changePaymentBtn.onclick = function() {
try {
logResponse('Opening card selector');
// Show card selector
fastlane.Payment().showCardSelector();
} catch (error) {
logResponse('Error showing card selector:', error);
}
};
} else {
// No card available, show card component
displayCardComponent();
}
// Show checkout container
document.getElementById('checkout-container').style.display = 'block';
}
// Display checkout for guests or unauthenticated users
function displayGuestCheckout() {
logResponse('Displaying guest checkout');
// Show shipping container with empty form
const shippingContainer = document.getElementById('shippingAddressContainer');
const shippingDetails = document.getElementById('shippingAddressDetails');
// Create shipping form for guest
shippingDetails.innerHTML = `
<div class='form-group'>
<label for='name'>Full Name</label>
<input type='text' id='name' placeholder='Enter your full name'>
</div>
<div class='form-group'>
<label for='line1'>Address Line 1</label>
<input type='text' id='line1' placeholder='Street address'>
</div>
<div class='form-group'>
<label for='line2'>Address Line 2</label>
<input type='text' id='line2' placeholder='Apt, suite, etc. (optional)'>
</div>
<div class='form-group'>
<label for='city'>City</label>
<input type='text' id='city' placeholder='City'>
</div>
<div class='form-group'>
<label for='state'>State</label>
<input type='text' id='state' placeholder='State'>
</div>
<div class='form-group'>
<label for='postal_code'>Postal Code</label>
<input type='text' id='postal_code' placeholder='Postal code'>
</div>
<div class='form-group'>
<label for='country_code'>Country Code</label>
<input type='text' id='country_code' placeholder='Country code (e.g., US)'>
</div>
`;
shippingContainer.style.display = 'block';
// Show card component for payment
displayCardComponent();
// Show checkout container
document.getElementById('checkout-container').style.display = 'block';
}
// Display Fastlane card component
function displayCardComponent() {
try {
const paymentContainer = document.getElementById('paymentMethodContainer');
const cardComponent = document.getElementById('fastlaneCardComponent');
// Show containers
paymentContainer.style.display = 'block';
cardComponent.style.display = 'block';
logResponse('Initializing Fastlane card component');
// Create and render the card component
const payment = fastlane.Payment();
// Customize the card component (optional)
const cardStyle = {
input: {
color: '#333333',
fontSize: '16px',
fontFamily: 'Arial, sans-serif',
},
invalid: {
color: '#e5424d',
},
base: {
backgroundColor: '#ffffff',
color: '#333333',
},
};
// Initialize the card component with options
payment.initCardComponent(cardComponent, {
style: cardStyle, // Set fields that should be collected
fields: {
name: {
required: true,
},
number: {
required: true,
},
cvv: {
required: true,
},
expiry: {
required: true,
},
}, // Callbacks
onReady: function() {
logResponse('Card component is ready');
},
onChange: function(state) {
logResponse('Card component state changed:', state);
},
onError: function(error) {
logResponse('Card component error:', error);
},
});
} catch (error) {
logResponse('Error initializing card component:', error);
}
}
// Handle place order button click
async function handlePlaceOrderButtonClick() {
logResponse('Processing order...');
// Get the selected SCA method
const scaMethod = document.getElementById('sca-method').value;
logResponse(`3DS verification method: ${scaMethod || 'NEVER'}`);
try {
if (renderFastlaneMemberExperience && profileData && profileData.card) {
// For an authenticated Fastlane member with a saved card
logResponse('Processing order for Fastlane member with saved card');
// Get access token for server call
const tokenResponse = await fetch(
'https://api-m.sandbox.paypal.com/v1/oauth2/token',
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${btoa(`${PAYPAL_CLIENT_ID}:${PAYPAL_CLIENT_SECRET}`)}`,
},
body: 'grant_type=client_credentials',
},
);
if (!tokenResponse.ok) {
throw new Error('Failed to get PayPal access token');
}
const tokenData = await tokenResponse.json();
const accessToken = tokenData.access_token;
// Get payment token
const payment = fastlane.Payment();
const tokenizeResult = await payment.tokenizeCard();
if (!tokenizeResult || !tokenizeResult.token) {
throw new Error('Failed to tokenize saved card');
}
logResponse('Card tokenized:', tokenizeResult);
// Create the order request body
const orderRequestBody = {
intent: 'CAPTURE',
payment_source: {
card: {
single_use_token: tokenizeResult.token,
},
},
purchase_units: [
{
amount: {
currency_code: 'USD',
value: '10.00',
},
description: 'Test Item',
},
],
};
// Add 3DS verification if specified
if (scaMethod) {
orderRequestBody.payment_source.card.attributes = {
verification: {
method: scaMethod,
},
};
}
logResponse('Creating order with request:', orderRequestBody);
// Create order with Orders V2 API
const orderResponse = await fetch('https://api-m.sandbox.paypal.com/v2/checkout/orders',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
body: JSON.stringify(orderRequestBody),
},
);
if (!orderResponse.ok) {
const errorData = await orderResponse.json();
throw new Error(`Failed to create order: ${JSON.stringify(errorData)}`);
}
const orderData = await orderResponse.json();
logResponse('Order created successfully:', orderData);
// Check if 3DS verification is required (PAYER_ACTION_REQUIRED)
if (orderData.status === 'PAYER_ACTION_REQUIRED') {
logResponse('3DS verification required');
// Find the payer-action link for 3DS verification
const payerActionLink = orderData.links.find(link => link.rel === 'payer-action');
if (payerActionLink) {
// In a real implementation, you would redirect the buyer to this URL
logResponse('3DS verification URL:', payerActionLink.href);
// For this POC, we'll just show an alert with the URL
alert(`3DS verification required. In a real implementation, you would redirect to: ${payerActionLink.href}`);
// After 3DS verification is complete, capture the payment
logResponse('3DS verification complete. Capturing payment...');
alert(
'After 3DS verification, you would need to capture the payment using the Orders API');
}
} else {
// No 3DS required, capture the payment directly
alert('Order created successfully! Order ID: ' + orderData.id);
}
} else {
// For guests or members without a saved card
logResponse('Processing order for guest or member without saved card');
// Get payment token
const payment = fastlane.Payment();
const tokenizeResult = await payment.tokenizeCard();
if (!tokenizeResult || !tokenizeResult.token) {
throw new Error('Failed to tokenize card');
}
logResponse('Card tokenized:', tokenizeResult);
// Get access token for server call
const tokenResponse = await fetch(
'https://api-m.sandbox.paypal.com/v1/oauth2/token',
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${btoa(`${PAYPAL_CLIENT_ID}:${PAYPAL_CLIENT_SECRET}`)}`,
},
body: 'grant_type=client_credentials',
},
);
if (!tokenResponse.ok) {
throw new Error('Failed to get PayPal access token');
}
const tokenData = await tokenResponse.json();
const accessToken = tokenData.access_token;
// Collect shipping address from form
const shippingAddress = {
name: document.getElementById('name').value,
address_line_1: document.getElementById('line1').value,
address_line_2: document.getElementById('line2').value || '',
admin_area_2: document.getElementById('city').value,
admin_area_1: document.getElementById('state').value,
postal_code: document.getElementById('postal_code').value,
country_code: document.getElementById('country_code').value,
};
// Create the order request body
const orderRequestBody = {
intent: 'CAPTURE',
payment_source: {
card: {
single_use_token: tokenizeResult.token,
store_in_vault: true, // This enables creating a Fastlane profile
},
},
purchase_units: [
{
amount: {
currency_code: 'USD',
value: '10.00',
},
description: 'Test Item',
shipping: {
name: {
full_name: shippingAddress.name,
},
address: {
address_line_1: shippingAddress.address_line_1,
address_line_2: shippingAddress.address_line_2,
admin_area_2: shippingAddress.admin_area_2,
admin_area_1: shippingAddress.admin_area_1,
postal_code: shippingAddress.postal_code,
country_code: shippingAddress.country_code,
},
},
},
],
};
// Add 3DS verification if specified
if (scaMethod) {
orderRequestBody.payment_source.card.attributes = {
verification: {
method: scaMethod,
},
};
}
logResponse('Creating order with request:', orderRequestBody);
// Create order with Orders V2 API
const orderResponse = await fetch('https://api-m.sandbox.paypal.com/v2/checkout/orders',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
body: JSON.stringify(orderRequestBody),
},
);
if (!orderResponse.ok) {
const errorData = await orderResponse.json();
throw new Error(`Failed to create order: ${JSON.stringify(errorData)}`);
}
const orderData = await orderResponse.json();
logResponse('Order created successfully:', orderData);
// Check if 3DS verification is required (PAYER_ACTION_REQUIRED)
if (orderData.status === 'PAYER_ACTION_REQUIRED') {
logResponse('3DS verification required');
// Find the payer-action link for 3DS verification
const payerActionLink = orderData.links.find(link => link.rel === 'payer-action');
if (payerActionLink) {
// In a real implementation, you would redirect the buyer to this URL
logResponse('3DS verification URL:', payerActionLink.href);
// For this POC, we'll just show an alert with the URL
alert(`3DS verification required. In a real implementation, you would redirect to: ${payerActionLink.href}`);
// After 3DS verification is complete, capture the payment
logResponse('3DS verification complete. Capturing payment...');
alert(
'After 3DS verification, you would need to capture the payment using the Orders API');
}
} else {
// No 3DS required, capture the payment directly
alert('Order created successfully! Order ID: ' + orderData.id);
}
}
} catch (error) {
logResponse('Error processing order:', error);
alert('Error processing order: ' + error.message);
}
}
</script>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
section {
border: 1px solid #ddd;
padding: 20px;
border-radius: 8px;
margin-top: 20px;
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
input[type="text"],
input[type="email"] {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
}
button {
background-color: #0070ba;
color: white;
padding: 10px 15px;
border: none;
border-radius: 4px;
cursor: pointer;
margin-top: 10px;
}
button:hover {
background-color: #005ea6;
}
.logger {
margin-top: 20px;
border: 1px solid #eee;
padding: 10px;
min-height: 100px;
max-height: calc(100vh - 260px);
overflow-y: auto;
background-color: #f9f9f9;
border-radius: 4px;
font-family: monospace;
> div {
border-bottom: 1px solid #eee;
padding: 0 0 10px 0;
margin: 0 0 10px 0;
&:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
}
}
#shippingAddressContainer,
#paymentMethodContainer,
#fastlaneWatermark,
#checkout-container {
margin-top: 20px;
}
#fastlaneCardComponent {
min-height: 150px;
border: 1px solid #eee;
padding: 10px;
margin-top: 10px;
}
#client-id {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 300px;
display: inline-block;
vertical-align: text-bottom;
font-size: 0.8em;
}
</style>
</body>
</html>

View file

@ -44,7 +44,7 @@ class ShippingView {
? this.states[ countryCode ][ stateCode ]
: stateCode;
if ( this.hasEmptyValues( data, stateName ) ) {
if ( this.hasEmptyValues( data ) ) {
return `
<div style="margin-bottom: 20px;">
<div class="axo-checkout-header-section">
@ -71,9 +71,9 @@ class ShippingView {
) }</div>
<div>${ data.value( 'street1' ) }</div>
<div>${ data.value( 'street2' ) }</div>
<div>${ data.value(
'city'
) }, ${ stateName } ${ data.value( 'postCode' ) }</div>
<div>${ data.value( 'city' ) }${
stateName ? ', ' + stateName : ''
} ${ data.value( 'postCode' ) }</div>
<div>${ valueOfSelect(
'#billing_country',
countryCode
@ -158,14 +158,13 @@ class ShippingView {
} );
}
hasEmptyValues( data, stateName ) {
hasEmptyValues( data ) {
return (
! data.value( 'email' ) ||
! data.value( 'firstName' ) ||
! data.value( 'lastName' ) ||
! data.value( 'street1' ) ||
! data.value( 'city' ) ||
! stateName
! data.value( 'city' )
);
}

View file

@ -2,20 +2,41 @@ import AxoManager from './AxoManager';
import { loadPayPalScript } from '../../../ppcp-button/resources/js/modules/Helper/PayPalScriptLoading';
import { log } from './Helper/Debug';
( function ( { axoConfig, ppcpConfig, jQuery } ) {
( function ( { axoConfig, ppcpConfig } ) {
const namespace = 'ppcpPaypalClassicAxo';
const bootstrap = () => {
new AxoManager( namespace, axoConfig, ppcpConfig );
};
document.addEventListener( 'DOMContentLoaded', () => {
document.addEventListener( 'DOMContentLoaded', async () => {
if ( typeof PayPalCommerceGateway === 'undefined' ) {
console.error( 'AXO could not be configured.' );
return;
}
// Load PayPal
loadPayPalScript( namespace, ppcpConfig )
const res = await fetch(
axoConfig.ajax.axo_script_attributes.endpoint,
{
method: 'POST',
credentials: 'same-origin',
body: JSON.stringify( {
nonce: axoConfig.ajax.axo_script_attributes.nonce,
} ),
}
);
const json = await res.json();
if ( ! json.success ) {
throw new Error( json.data.message );
}
loadPayPalScript( namespace, {
...ppcpConfig,
script_attributes: {
...ppcpConfig.script_attributes,
'data-sdk-client-token': json.data.sdk_client_token,
},
} )
.then( () => {
bootstrap();
} )
@ -26,5 +47,4 @@ import { log } from './Helper/Debug';
} )( {
axoConfig: window.wc_ppcp_axo,
ppcpConfig: window.PayPalCommerceGateway,
jQuery: window.jQuery,
} );

Some files were not shown because too many files have changed in this diff Show more