Skip to content

Commit

Permalink
PAYOSWXP-158: Add PayPal v2 payment methods (#331)
Browse files Browse the repository at this point in the history
* PAYOSWXP-158: Add PayPal v2 payment methods

* PAYOSWXP-158: Add keys to service arguments

* PAYOSWXP-158: Add check for missing options

* PAYOSWXP-158: Set 'commit' parameter of paypal script to 'false'

* PAYOSWXP-158: Simplify the tests

* PAYOSWXP-158: Fix creation of query string

* PAYOSWXP-158: Remove 'v2' from name and description and add it to 'distinguishableName' via subscriber

* PAYOSWXP-158: Improve customer registration process

* PAYOSWXP-158: make methods more abstract (#341)

* PAYOSWXP-158: Fix check for redirectUrl

* PAYOSWXP-158: Improve address validation

* PAYOSWXP-158: Fix extended class

* PAYOSWXP-158: Improve logging of address violations

* PAYOSWXP-158: Hide PayPal v1 if v2 is active

---------

Co-authored-by: Frederik Rommel <[email protected]>
Co-authored-by: Frederik Rommel <[email protected]>
  • Loading branch information
3 people authored Jan 13, 2025
1 parent f3a0ada commit b535f27
Show file tree
Hide file tree
Showing 51 changed files with 2,067 additions and 282 deletions.
125 changes: 81 additions & 44 deletions src/Components/GenericExpressCheckout/CustomerRegistrationUtil.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,81 +4,115 @@

namespace PayonePayment\Components\GenericExpressCheckout;

use PayonePayment\Core\Utils\AddressCompare;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Shopware\Core\Checkout\Customer\CustomerEntity;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
use Shopware\Core\Framework\Validation\DataBag\RequestDataBag;
use Shopware\Core\Framework\Validation\DataValidationFactoryInterface;
use Shopware\Core\Framework\Validation\DataValidator;
use Shopware\Core\System\Country\CountryEntity;
use Shopware\Core\System\SalesChannel\SalesChannelContext;
use Shopware\Core\System\Salutation\SalutationEntity;
use Symfony\Component\Validator\ConstraintViolationList;
use Symfony\Contracts\Translation\TranslatorInterface;

class CustomerRegistrationUtil
{
public function __construct(
private readonly EntityRepository $salutationRepository,
private readonly EntityRepository $countryRepository,
private readonly TranslatorInterface $translator
private readonly TranslatorInterface $translator,
private readonly DataValidationFactoryInterface $addressValidationFactory,
private readonly DataValidator $validator,
private readonly LoggerInterface $logger
) {
}

public function getCustomerDataBagFromGetCheckoutSessionResponse(array $response, Context $context): RequestDataBag
public function getCustomerDataBagFromGetCheckoutSessionResponse(array $response, SalesChannelContext $salesChannelContext): RequestDataBag
{
$salutationId = $this->getSalutationId($context);
$salutationId = $this->getSalutationId($salesChannelContext->getContext());

$billingAddress = [
'salutationId' => $salutationId,
'company' => $this->extractBillingData($response, 'company'),
'firstName' => $this->extractBillingData($response, 'firstname'),
'lastName' => $this->extractBillingData($response, 'lastname'),
'street' => $this->extractBillingData($response, 'street'),
'additionalAddressLine1' => $this->extractBillingData($response, 'addressaddition'),
'zipcode' => $this->extractBillingData($response, 'zip'),
'city' => $this->extractBillingData($response, 'city'),
'countryId' => $this->getCountryIdByCode($this->extractBillingData($response, 'country') ?? '', $salesChannelContext->getContext()),
'phone' => $this->extractBillingData($response, 'telephonenumber'),
];

$shippingAddress = [
'salutationId' => $salutationId,
'company' => $this->extractShippingData($response, 'company'),
'firstName' => $this->extractShippingData($response, 'firstname'),
'lastName' => $this->extractShippingData($response, 'lastname'),
'street' => $this->extractShippingData($response, 'street'),
'additionalAddressLine1' => $this->extractShippingData($response, 'addressaddition'),
'zipcode' => $this->extractShippingData($response, 'zip'),
'city' => $this->extractShippingData($response, 'city'),
'countryId' => $this->getCountryIdByCode($this->extractShippingData($response, 'country') ?? '', $salesChannelContext->getContext()),
'phone' => $this->extractShippingData($response, 'telephonenumber'),
];

$billingAddressViolations = $this->validateAddress($billingAddress, $salesChannelContext);
$shippingAddressViolations = $this->validateAddress($shippingAddress, $salesChannelContext);

$isBillingAddressComplete = $billingAddressViolations->count() === 0;
$isShippingAddressComplete = $shippingAddressViolations->count() === 0;

if (!$isBillingAddressComplete && !$isShippingAddressComplete) {
$this->logger->error('PAYONE Express Checkout: The delivery and billing address is incomplete', [
'billingAddress' => $billingAddress,
'shippingAddress' => $shippingAddress,
'billingAddressViolations' => $billingAddressViolations->__toString(),
'shippingAddressViolations' => $shippingAddressViolations->__toString(),
]);

throw new RuntimeException($this->translator->trans('PayonePayment.errorMessages.genericError'));
}

if (!$isBillingAddressComplete && $isShippingAddressComplete) {
$billingAddress = $shippingAddress;
}

$customerData = new RequestDataBag([
'guest' => true,
'salutationId' => $salutationId,
'email' => $response['addpaydata']['email'],
'firstName' => $this->extractBillingData($response, 'firstname'),
'lastName' => $this->extractBillingData($response, 'lastname'),
'firstName' => $billingAddress['firstName'],
'lastName' => $billingAddress['lastName'],
'acceptedDataProtection' => true,
'billingAddress' => array_filter([
'salutationId' => $salutationId,
'company' => $this->extractBillingData($response, 'company'),
'firstName' => $this->extractBillingData($response, 'firstname'),
'lastName' => $this->extractBillingData($response, 'lastname'),
'street' => $this->extractBillingData($response, 'street'),
'additionalAddressLine1' => $this->extractBillingData($response, 'addressaddition'),
'zipcode' => $this->extractBillingData($response, 'zip'),
'city' => $this->extractBillingData($response, 'city'),
'countryId' => $this->getCountryIdByCode($this->extractBillingData($response, 'country') ?? '', $context),
'phone' => $this->extractBillingData($response, 'telephonenumber'),
]),
'shippingAddress' => array_filter([
'salutationId' => $salutationId,
'company' => $this->extractShippingData($response, 'company'),
'firstName' => $this->extractShippingData($response, 'firstname'),
'lastName' => $this->extractShippingData($response, 'lastname'),
'street' => $this->extractShippingData($response, 'street'),
'additionalAddressLine1' => $this->extractShippingData($response, 'addressaddition'),
'zipcode' => $this->extractShippingData($response, 'zip'),
'city' => $this->extractShippingData($response, 'city'),
'countryId' => $this->getCountryIdByCode($this->extractShippingData($response, 'country') ?? '', $context),
'phone' => $this->extractShippingData($response, 'telephonenumber'),
]),
'billingAddress' => $billingAddress,
'shippingAddress' => $shippingAddress,
]);

if ($this->extractBillingData($response, 'company') !== null) {
if ($customerData->get('billingAddress')?->get('company') !== null) {
$customerData->set('accountType', CustomerEntity::ACCOUNT_TYPE_BUSINESS);
} else {
$customerData->set('accountType', CustomerEntity::ACCOUNT_TYPE_PRIVATE);
}

$billingAddress = $customerData->get('billingAddress')?->all() ?: [];
$shippingAddress = $customerData->get('shippingAddress')?->all() ?: [];
if (array_diff($billingAddress, $shippingAddress) === []) {
if (!$isShippingAddressComplete || AddressCompare::areRawAddressesIdentical($billingAddress, $shippingAddress)) {
$customerData->remove('shippingAddress');
}

return $customerData;
}

private function extractBillingData(array $response, string $key, string|null $alternateKey = null): ?string
private function extractBillingData(array $response, string $key): ?string
{
// special case: PayPal express: PayPal does not return firstname. so we need to take the firstname from the shipping-data
// special case: PayPal v1 express: PayPal does not return firstname. so we need to take the firstname from the shipping-data
if (($key === 'firstname' || $key === 'lastname')
&& !\array_key_exists('firstname', $response['addpaydata'])
&& isset(
Expand All @@ -92,20 +126,16 @@ private function extractBillingData(array $response, string $key, string|null $a
}
}

if ($alternateKey === null
&& !\array_key_exists('billing_lastname', $response['addpaydata'])
&& !\array_key_exists('lastname', $response['addpaydata'])
) {
// there are no explicit billing-address-details. We assume that there are only shipping details. So we use the shipping details for the billing details too.
$alternateKey = 'shipping_' . $key;
}

return $response['addpaydata']['billing_' . $key] ?? $response['addpaydata'][$key] ?? ($alternateKey ? $response['addpaydata'][$alternateKey] : null);
// Do not take any values from the shipping address as a fallback for individual fields.
// If mandatory fields are missing from the billing address, the complete shipping address is used
return $response['addpaydata']['billing_' . $key] ?? $response['addpaydata'][$key] ?? null;
}

private function extractShippingData(array $response, string $key, ?string $alternateKey = null): ?string
private function extractShippingData(array $response, string $key): ?string
{
return $response['addpaydata']['shipping_' . $key] ?? $response['addpaydata'][$key] ?? $response['addpaydata'][$alternateKey] ?? $this->extractBillingData($response, $key);
// Do not take any values from the billing address as a fallback for individual fields.
// If mandatory fields are missing from the shipping address, the complete shipping address is removed
return $response['addpaydata']['shipping_' . $key] ?? null;
}

private function getSalutationId(Context $context): string
Expand Down Expand Up @@ -145,4 +175,11 @@ private function getCountryIdByCode(string $code, Context $context): ?string

return $country->getId();
}

private function validateAddress(array $address, SalesChannelContext $salesChannelContext): ConstraintViolationList
{
$validation = $this->addressValidationFactory->create($salesChannelContext);

return $this->validator->getViolations($address, $validation);
}
}
72 changes: 72 additions & 0 deletions src/Components/Helper/ActivePaymentMethodsLoader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

declare(strict_types=1);

namespace PayonePayment\Components\Helper;

use Psr\Cache\CacheItemPoolInterface;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\ContainsFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
use Shopware\Core\System\SalesChannel\Entity\SalesChannelRepository;
use Shopware\Core\System\SalesChannel\SalesChannelContext;

class ActivePaymentMethodsLoader implements ActivePaymentMethodsLoaderInterface
{
public function __construct(
private readonly CacheItemPoolInterface $cachePool,
private readonly SalesChannelRepository $paymentMethodRepository,
private readonly EntityRepository $salesChannelRepository
) {
}

public function getActivePaymentMethodIds(SalesChannelContext $salesChannelContext): array
{
$cacheKey = $this->generateCacheKey($salesChannelContext->getSalesChannelId());

$cacheItem = $this->cachePool->getItem($cacheKey);

if ($cacheItem->get() === null) {
$cacheItem->set($this->collectActivePayonePaymentMethodIds($salesChannelContext));

$this->cachePool->save($cacheItem);
}

return $cacheItem->get();
}

public function clearCache(Context $context): void
{
$cacheKeys = [];

/** @var string[] $salesChannelIds */
$salesChannelIds = $this->salesChannelRepository->searchIds(new Criteria(), $context)->getIds();

foreach ($salesChannelIds as $salesChannelId) {
$cacheKeys[] = $this->generateCacheKey($salesChannelId);
}

if ($cacheKeys === []) {
return;
}

$this->cachePool->deleteItems($cacheKeys);
}

private function collectActivePayonePaymentMethodIds(SalesChannelContext $salesChannelContext): array
{
$criteria = new Criteria();

$criteria->addFilter(new ContainsFilter('handlerIdentifier', 'PayonePayment'));
$criteria->addFilter(new EqualsFilter('active', true));

return $this->paymentMethodRepository->searchIds($criteria, $salesChannelContext)->getIds();
}

private function generateCacheKey(string $salesChannelId): string
{
return 'payone_payment.active_payment_methods.' . $salesChannelId;
}
}
15 changes: 15 additions & 0 deletions src/Components/Helper/ActivePaymentMethodsLoaderInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace PayonePayment\Components\Helper;

use Shopware\Core\Framework\Context;
use Shopware\Core\System\SalesChannel\SalesChannelContext;

interface ActivePaymentMethodsLoaderInterface
{
public function getActivePaymentMethodIds(SalesChannelContext $salesChannelContext): array;

public function clearCache(Context $context): void;
}
24 changes: 24 additions & 0 deletions src/Components/PaymentFilter/PaypalPaymentMethodFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace PayonePayment\Components\PaymentFilter;

use PayonePayment\Components\PaymentFilter\Exception\PaymentMethodNotAllowedException;
use PayonePayment\PaymentMethod\PayonePaypal;
use PayonePayment\PaymentMethod\PayonePaypalV2;
use Shopware\Core\Checkout\Payment\PaymentMethodCollection;
use Shopware\Core\Checkout\Payment\PaymentMethodEntity;

class PaypalPaymentMethodFilter extends DefaultPaymentFilterService
{
protected function additionalChecks(PaymentMethodCollection $methodCollection, PaymentFilterContext $filterContext): void
{
$paypalV1 = $methodCollection->get(PayonePaypal::UUID);
$paypalV2 = $methodCollection->get(PayonePaypalV2::UUID);

if ($paypalV1 instanceof PaymentMethodEntity && $paypalV2 instanceof PaymentMethodEntity) {
throw new PaymentMethodNotAllowedException('PayPal: PayPal v1 is not allowed if v2 is active.');
}
}
}
4 changes: 4 additions & 0 deletions src/Configuration/ConfigurationPrefixes.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ interface ConfigurationPrefixes
public const CONFIGURATION_PREFIX_DEBIT = 'debit';
public const CONFIGURATION_PREFIX_PAYPAL = 'paypal';
public const CONFIGURATION_PREFIX_PAYPAL_EXPRESS = 'paypalExpress';
public const CONFIGURATION_PREFIX_PAYPAL_V2 = 'paypalV2';
public const CONFIGURATION_PREFIX_PAYPAL_V2_EXPRESS = 'paypalV2Express';
public const CONFIGURATION_PREFIX_PAYOLUTION_INVOICING = 'payolutionInvoicing';
public const CONFIGURATION_PREFIX_PAYOLUTION_INSTALLMENT = 'payolutionInstallment';
public const CONFIGURATION_PREFIX_PAYOLUTION_DEBIT = 'payolutionDebit';
Expand Down Expand Up @@ -48,6 +50,8 @@ interface ConfigurationPrefixes
Handler\PayoneDebitPaymentHandler::class => self::CONFIGURATION_PREFIX_DEBIT,
Handler\PayonePaypalPaymentHandler::class => self::CONFIGURATION_PREFIX_PAYPAL,
Handler\PayonePaypalExpressPaymentHandler::class => self::CONFIGURATION_PREFIX_PAYPAL_EXPRESS,
Handler\PayonePaypalV2PaymentHandler::class => self::CONFIGURATION_PREFIX_PAYPAL_V2,
Handler\PayonePaypalV2ExpressPaymentHandler::class => self::CONFIGURATION_PREFIX_PAYPAL_V2_EXPRESS,
Handler\PayonePayolutionInvoicingPaymentHandler::class => self::CONFIGURATION_PREFIX_PAYOLUTION_INVOICING,
Handler\PayonePayolutionInstallmentPaymentHandler::class => self::CONFIGURATION_PREFIX_PAYOLUTION_INSTALLMENT,
Handler\PayonePayolutionDebitPaymentHandler::class => self::CONFIGURATION_PREFIX_PAYOLUTION_DEBIT,
Expand Down
23 changes: 23 additions & 0 deletions src/Controller/SettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,29 @@ private function getPaymentParameters(string $paymentClass): array
'successurl' => 'https://www.payone.com',
];

case Handler\PayonePaypalV2ExpressPaymentHandler::class:
case Handler\PayonePaypalV2PaymentHandler::class:
return [
'request' => 'preauthorization',
'clearingtype' => 'wlt',
'wallettype' => 'PAL',
'amount' => 100,
'currency' => 'EUR',
'reference' => sprintf('%s%d', self::REFERENCE_PREFIX_TEST, random_int(1_000_000_000_000, 9_999_999_999_999)),
'firstname' => 'Test',
'lastname' => 'Test',
'country' => 'DE',
'successurl' => 'https://www.payone.com',
'errorurl' => 'https://www.payone.com',
'backurl' => 'https://www.payone.com',
'shipping_city' => 'Berlin',
'shipping_country' => 'DE',
'shipping_firstname' => 'Test',
'shipping_lastname' => 'Test',
'shipping_street' => 'Mustergasse 5',
'shipping_zip' => '10969',
];

case Handler\PayoneSofortBankingPaymentHandler::class:
return [
'request' => 'preauthorization',
Expand Down
Loading

0 comments on commit b535f27

Please sign in to comment.