From 827d532438872300313bc04b7b7dfedc78d48e12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jib=C3=A9=20Barth?= Date: Tue, 28 Apr 2026 10:16:10 +0200 Subject: [PATCH 1/3] PPSYL-184 - Always add a notification url when create payment --- src/Command/Handler/CapturePaymentRequestHandler.php | 4 ---- src/Creator/PayPlugPaymentDataCreator.php | 5 +++++ src/Provider/Payment/ApplePayPaymentProvider.php | 4 ---- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/Command/Handler/CapturePaymentRequestHandler.php b/src/Command/Handler/CapturePaymentRequestHandler.php index 71bcf9dd..c6c91792 100644 --- a/src/Command/Handler/CapturePaymentRequestHandler.php +++ b/src/Command/Handler/CapturePaymentRequestHandler.php @@ -27,7 +27,6 @@ public function __construct( private PayPlugPaymentDataCreator $paymentDataCreator, #[Autowire(service: 'sylius_shop.provider.order_pay.after_pay_url')] private UrlProviderInterface $afterPayUrlProvider, - private UrlGeneratorInterface $urlGenerator, ) {} public function __invoke(CapturePaymentRequest $capturePaymentRequest): void @@ -67,9 +66,6 @@ public function __invoke(CapturePaymentRequest $capturePaymentRequest): void 'cancel_url' => $returnUrl . '?&' . http_build_query(['status' => PayPlugApiClientInterface::STATUS_CANCELED]), ]; - $notificationUrl = $this->urlGenerator->generate('sylius_payment_method_notify', ['code' => $payment->getMethod()?->getCode()], UrlGeneratorInterface::ABSOLUTE_URL); - $data['notification_url'] = $notificationUrl; - $paymentRequest->setPayload($data); try { diff --git a/src/Creator/PayPlugPaymentDataCreator.php b/src/Creator/PayPlugPaymentDataCreator.php index e9e32322..8ba4a9a7 100644 --- a/src/Creator/PayPlugPaymentDataCreator.php +++ b/src/Creator/PayPlugPaymentDataCreator.php @@ -27,6 +27,7 @@ use Sylius\Component\Core\Model\Shipment; use Sylius\Component\Resource\Repository\RepositoryInterface; use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; class PayPlugPaymentDataCreator { @@ -41,6 +42,7 @@ public function __construct( private RepositoryInterface $payplugCardRepository, private RequestStack $requestStack, private PayplugFeatureChecker $payplugFeatureChecker, + private UrlGeneratorInterface $urlGenerator, ) { } @@ -83,6 +85,9 @@ public function create( $paymentMethod = $payment->getMethod(); $gatewayFactoryName = $paymentMethod?->getGatewayConfig()?->getFactoryName(); + $notificationUrl = $this->urlGenerator->generate('sylius_payment_method_notify', ['code' => $payment->getMethod()?->getCode()], UrlGeneratorInterface::ABSOLUTE_URL); + $details['notification_url'] = $notificationUrl; + if ( PayPlugGatewayFactory::FACTORY_NAME === $gatewayFactoryName && $paymentMethod instanceof PaymentMethodInterface diff --git a/src/Provider/Payment/ApplePayPaymentProvider.php b/src/Provider/Payment/ApplePayPaymentProvider.php index 74f9b93c..1f195d9e 100644 --- a/src/Provider/Payment/ApplePayPaymentProvider.php +++ b/src/Provider/Payment/ApplePayPaymentProvider.php @@ -5,7 +5,6 @@ namespace PayPlug\SyliusPayPlugPlugin\Provider\Payment; use DateTimeImmutable; -use Doctrine\ORM\EntityManagerInterface; use LogicException; use Payplug\Resource\IVerifiableAPIResource; use Payplug\Resource\Payment; @@ -32,7 +31,6 @@ use Sylius\Component\Payment\PaymentTransitions; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\Routing\RouterInterface; use Webmozart\Assert\Assert; class ApplePayPaymentProvider @@ -45,7 +43,6 @@ public function __construct( #[Autowire('@payplug_sylius_payplug_plugin.api_client.apple_pay')] private PayPlugApiClientInterface $applePayClient, private OrderTokenAssignerInterface $orderTokenAssigner, - private RouterInterface $router, private LoggerInterface $logger, ) { } @@ -81,7 +78,6 @@ public function provide(Request $request, OrderInterface $order): PaymentInterfa ); $paymentData = $paymentDataObject->getArrayCopy(); - $paymentData['notification_url'] = $this->router->generate('sylius_payment_method_notify', ['code' => $payment->getMethod()?->getCode()], RouterInterface::ABSOLUTE_URL); $this->logger->notice('[Payplug] ApplePay payment data', ['data' => $paymentData]); $paymentResource = $this->applePayClient->createPayment($paymentData); From 0e33439945d20b7a946c33b84bd006e410045532 Mon Sep 17 00:00:00 2001 From: adumont-payplug Date: Wed, 29 Apr 2026 11:52:37 +0200 Subject: [PATCH 2/3] PRE-3278: adding scalapay APM and project configuration --- .github/workflows/analysis.yaml | 86 ---------------- .github/workflows/{sylius.yaml => ci.yml} | 99 ++++++++++++++++--- .github/workflows/pull_request_template.md | 35 ++++++- README.md | 5 +- composer.json | 11 ++- config/services.yaml | 21 ++++ config/services/client.xml | 9 ++ config/services/gateway.xml | 8 ++ config/twig_hooks/admin.yaml | 6 ++ config/twig_hooks/shop.yaml | 3 + ecs.php | 29 ++++++ grumphp.yml | 11 +++ public/assets/scalapay/logo.svg | 23 +++++ ruleset/phpstan-baseline.neon | 6 ++ ruleset/rector.php | 2 +- .../Auth/UnifiedAuthenticationController.php | 4 + src/ApiClient/PayPlugApiClientFactory.php | 13 +-- .../Handler/CapturePaymentRequestHandler.php | 9 +- .../Handler/StatusPaymentRequestHandler.php | 3 +- .../CapturePaymentRequestCommandProvider.php | 4 + .../NotifyPaymentRequestCommandProvider.php | 4 + .../StatusPaymentRequestCommandProvider.php | 4 + src/Controller/OneClickAction.php | 3 +- src/Controller/OrderController.php | 3 +- src/Creator/PayPlugPaymentDataCreator.php | 6 ++ .../RefundUnitsCommandCreatorDecorator.php | 3 +- src/Entity/Traits/CustomerTrait.php | 1 - src/Entity/Traits/PaymentMethodTrait.php | 1 - .../PostSavePaymentMethodEventListener.php | 3 +- src/Form/Extension/PaymentTypeExtension.php | 2 +- .../Type/AbstractGatewayConfigurationType.php | 1 + .../Type/ScalapayGatewayConfigurationType.php | 25 +++++ src/Gateway/ScalapayGatewayFactory.php | 21 ++++ .../Constraints/IsCanSavePaymentMethod.php | 1 + .../Constraints/IsOneyEnabledValidator.php | 2 +- src/Handler/PaymentNotificationHandler.php | 4 +- .../RefundPaymentGeneratedHandler.php | 4 +- .../Provider/CaptureHttpResponseProvider.php | 4 + .../AbortPaymentProcessor.php | 1 + .../RefundPaymentHandler.php | 2 +- .../RefundPaymentProcessor.php | 4 +- ...tSupportedRefundPaymentMethodsProvider.php | 2 +- .../OneySupportedPaymentChoiceProvider.php | 2 +- .../Payment/ApplePayPaymentProvider.php | 3 + ...dRefundPaymentMethodsProviderDecorator.php | 15 +++ src/Provider/SupportedMethodsProvider.php | 2 +- ...dRefundPaymentMethodsProviderDecorator.php | 2 +- src/Repository/PaymentRepository.php | 6 +- .../OneyPaymentMethodsResolverDecorator.php | 2 +- src/Resolver/PaymentStateResolver.php | 4 +- ...calapayPaymentMethodsResolverDecorator.php | 43 ++++++++ src/Validator/PaymentMethodValidator.php | 17 +++- .../shop/select_payment/_scalapay.html.twig | 7 ++ tests/TestApplication/config/bundles.php | 2 + .../src/Entity/PaymentMethod.php | 2 +- translations/messages.en.yml | 1 + translations/messages.fr.yml | 1 + translations/messages.it.yml | 1 + translations/validators.en.yml | 8 ++ translations/validators.fr.yml | 8 ++ translations/validators.it.yml | 8 ++ 61 files changed, 474 insertions(+), 148 deletions(-) delete mode 100644 .github/workflows/analysis.yaml rename .github/workflows/{sylius.yaml => ci.yml} (52%) create mode 100644 ecs.php create mode 100644 public/assets/scalapay/logo.svg create mode 100644 src/Gateway/Form/Type/ScalapayGatewayConfigurationType.php create mode 100644 src/Gateway/ScalapayGatewayFactory.php create mode 100644 src/Provider/ScalapaySupportedRefundPaymentMethodsProviderDecorator.php create mode 100644 src/Resolver/ScalapayPaymentMethodsResolverDecorator.php create mode 100644 templates/shop/select_payment/_scalapay.html.twig diff --git a/.github/workflows/analysis.yaml b/.github/workflows/analysis.yaml deleted file mode 100644 index 8411f1b8..00000000 --- a/.github/workflows/analysis.yaml +++ /dev/null @@ -1,86 +0,0 @@ -name: Analysis -'on': - push: - branches: - - develop - - qa - - master - paths-ignore: - - README.md - pull_request: - paths-ignore: - - README.md -jobs: - analysis: - name: 'PHP ${{ matrix.php }} Symfony ${{ matrix.symfony }}' - runs-on: ubuntu-latest - strategy: - matrix: - php: - - 8.2 - symfony: - - '6.4.*' - env: - APP_ENV: test - steps: - - - uses: actions/checkout@v2 - - - name: 'Setup PHP' - uses: shivammathur/setup-php@v2 - with: - php-version: '${{ matrix.php }}' - tools: symfony - coverage: none - - - name: 'Composer - Get Cache Directory' - id: composer-cache - run: 'echo "::set-output name=dir::$(composer config cache-files-dir)"' - - - name: 'Composer - Set cache' - uses: actions/cache@v4 - with: - path: '${{ steps.composer-cache.outputs.dir }}' - key: 'php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-${{ hashFiles(''**/composer.json'') }}' - restore-keys: "php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-\n" - - - name: 'Composer - Validate composer.json and composer.lock' - run: 'composer validate --strict' - - - name: 'Composer - Github Auth' - run: 'composer config -g github-oauth.github.com ${{ github.token }}' - - - name: 'Composer - Restrict Symfony version' - run: 'composer config extra.symfony.require "${{ matrix.symfony }}"' - - - name: 'Composer - Update dependencies' - run: 'composer update --no-progress' - id: end-of-setup - - - name: 'PHPStan - Run' - run: 'if [ -f ruleset/phpstan.neon ]; then vendor/bin/phpstan analyse -c ruleset/phpstan.neon src/ ; else echo PHPStan rulesets file does not exist, skipping step ; fi' - if: 'always() && steps.end-of-setup.outcome == ''success''' - # TODO: launch Grumphp - - sonarcloud: - if: github.event.repository.fork != true - runs-on: ubuntu-latest - continue-on-error: true - steps: - - uses: actions/checkout@v3 - with: - # Disabling shallow clone is recommended for improving relevancy of reporting - fetch-depth: 0 - - name: SonarCloud Scan - uses: sonarsource/sonarcloud-github-action@master - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - with: - projectBaseDir: . - args: > - -Dsonar.organization=${{ secrets.SONAR_ORGA }} - -Dsonar.projectKey=github-payplug-payplug-syliuspayplugplugin - -Dsonar.sources=src/ - -Dsonar.test.exclusions=tests/** - -Dsonar.tests=tests/ - -Dsonar.verbose=true diff --git a/.github/workflows/sylius.yaml b/.github/workflows/ci.yml similarity index 52% rename from .github/workflows/sylius.yaml rename to .github/workflows/ci.yml index 33dc6c31..a1217745 100644 --- a/.github/workflows/sylius.yaml +++ b/.github/workflows/ci.yml @@ -1,19 +1,76 @@ -name: Sylius +name: CI + 'on': - push: - branches: - - develop - - qa - - master - paths-ignore: - - README.md pull_request: + branches: + - test-develop-ci + - test-master-ci paths-ignore: - README.md + jobs: - sylius: + quality: + name: PHP Quality + runs-on: ubuntu-latest + env: + APP_ENV: test + steps: + - + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - + name: 'Setup PHP' + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + ini-values: date.timezone=UTC + extensions: intl + tools: symfony + coverage: none + - + name: 'Composer - Get Cache Directory' + id: composer-cache + run: 'echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT' + - + name: 'Composer - Set cache' + uses: actions/cache@v4 + with: + path: '${{ steps.composer-cache.outputs.dir }}' + key: 'php-8.2-symfony-6.4-composer-${{ hashFiles(''**/composer.json'') }}' + restore-keys: 'php-8.2-symfony-6.4-composer-' + - + name: 'Composer - Github Auth' + run: 'composer config -g github-oauth.github.com ${{ github.token }}' + - + name: 'Composer - Remove version field for validation' + run: | + jq 'del(.version)' composer.json > composer.json.tmp + mv composer.json.tmp composer.json + - + name: 'Composer - Validate' + run: 'composer validate --strict' + - + name: 'Composer - Restrict Symfony version' + run: 'composer config extra.symfony.require "^6.4"' + - + name: 'Composer - Install dependencies' + run: 'composer install --no-progress' + id: end-of-setup + - + name: 'ECS - Coding Standard' + run: 'composer ecs' + if: 'always() && steps.end-of-setup.outcome == ''success''' + - + name: 'PHPStan - Static Analysis' + run: 'composer phpstan' + if: 'always() && steps.end-of-setup.outcome == ''success''' + + sylius-matrix: name: 'PHPUnit-Behat (PHP ${{ matrix.php }} Sylius ${{ matrix.sylius }} Symfony ${{ matrix.symfony }})' runs-on: ubuntu-latest + needs: [quality] + if: github.base_ref == 'test-develop-ci' strategy: fail-fast: false matrix: @@ -43,11 +100,11 @@ jobs: coverage: none - name: 'Setup Node' - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: '${{ matrix.node }}' - - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: 'Composer - Get Cache Directory' id: composer-cache @@ -83,7 +140,7 @@ jobs: run: 'make install -e SYLIUS_VERSION=${{ matrix.sylius }} SYMFONY_VERSION=${{ matrix.symfony }}' id: end-of-setup-sylius - - name: 'Doctrine Schema Validate - Run' + name: 'Doctrine Schema Validate' run: 'vendor/bin/console doctrine:schema:validate --skip-sync' - name: 'Run PHPUnit' @@ -93,8 +150,8 @@ jobs: uses: actions/upload-artifact@v4 if: failure() with: - name: logs - path: ./tests/Application/etc/build + name: 'logs-php${{ matrix.php }}-sylius${{ matrix.sylius }}-sf${{ matrix.symfony }}' + path: ./tests/TestApplication/etc/build services: mariadb: image: 'mariadb:10.4.11' @@ -103,3 +160,17 @@ jobs: env: MYSQL_ALLOW_EMPTY_PASSWORD: true options: '--health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3' + + # ----------------------------------------------------------------------- + # 4. SONARCLOUD — code quality analysis (develop PRs only) + # ----------------------------------------------------------------------- + sonarcloud: + if: always() && !failure() && !cancelled() && github.base_ref == 'test-develop-ci' + needs: sylius-matrix + uses: payplug/template-ci/.github/workflows/sonarcloud.yml@main + with: + project-name: 'github-payplug-payplug-syliuspayplugplugin' + src-folder: 'src/' + secrets: + sonar-orga: ${{ secrets.SONAR_ORGA }} + sonar-token: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/pull_request_template.md b/.github/workflows/pull_request_template.md index c90cc2c8..6fed9975 100644 --- a/.github/workflows/pull_request_template.md +++ b/.github/workflows/pull_request_template.md @@ -1,5 +1,34 @@ -# ⚠️ Requirements -Reviewer, please take a look at those requirements: +## Description + + -- [ ] Check that plugin version has been upgrated and are identical in both `composer.json` and `src/PayPlugSyliusPayPlugPlugin.php` files +**Motivation:** +**Related issue(s):** Closes # + +--- + +## Type of Change + +- 🐛 Bug fix (non-breaking change that fixes an issue) [ ] +- ✨ New feature (non-breaking change that adds functionality) [ ] +- 💥 Breaking change (fix or feature that causes existing functionality to change and that could impact other libs) [ ] +- 🔧 Refactor (no functional changes, code improvement only) [ ] +- 📦 Dependency update [ ] +- 🔒 Security fix [ ] +- 📝 Documentation update [ ] + +--- + +## Checklist +### Code Quality +- [ ] Code is linted and formatted +- [ ] No unnecessary commented-out code or debug logs +- [ ] No hardcoded values (use env variables or config) + +### Testing +- [ ] Unit tests added / updated + +### Security & Ops +- [ ] No sensitive data or secrets introduced +- [ ] Logging and error handling are appropriate diff --git a/README.md b/README.md index 610a38d8..40b8025e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ [![License](https://img.shields.io/packagist/l/payplug/sylius-payplug-plugin.svg)](https://github.com/payplug/SyliusPayPlugPlugin/blob/master/LICENSE) -[![CI - Analysis](https://github.com/payplug/SyliusPayPlugPlugin/actions/workflows/analysis.yaml/badge.svg?branch=master)](https://github.com/payplug/SyliusPayPlugPlugin/actions/workflows/analysis.yaml) -[![CI - Sylius](https://github.com/payplug/SyliusPayPlugPlugin/actions/workflows/sylius.yaml/badge.svg?branch=master)](https://github.com/payplug/SyliusPayPlugPlugin/actions/workflows/sylius.yaml) +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=github-payplug-payplug-syliuspayplugplugin&metric=alert_status&token=af29f9f3fbb3a74caff4e4a4d168bddab858f4dc)](https://sonarcloud.io/summary/new_code?id=github-payplug-payplug-syliuspayplugplugin) +[![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=github-payplug-payplug-syliuspayplugplugin&metric=duplicated_lines_density&token=af29f9f3fbb3a74caff4e4a4d168bddab858f4dc)](https://sonarcloud.io/summary/new_code?id=github-payplug-payplug-syliuspayplugplugin) +[![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=github-payplug-payplug-syliuspayplugplugin&metric=code_smells&token=af29f9f3fbb3a74caff4e4a4d168bddab858f4dc)](https://sonarcloud.io/summary/new_code?id=github-payplug-payplug-syliuspayplugplugin) [![Version](https://img.shields.io/packagist/v/payplug/sylius-payplug-plugin.svg)](https://packagist.org/packages/payplug/sylius-payplug-plugin) [![Total Downloads](https://poser.pugx.org/payplug/sylius-payplug-plugin/downloads)](https://packagist.org/packages/payplug/sylius-payplug-plugin) diff --git a/composer.json b/composer.json index f7d338e1..059fce80 100755 --- a/composer.json +++ b/composer.json @@ -1,6 +1,7 @@ { "name": "payplug/sylius-payplug-plugin", "type": "sylius-plugin", + "version": "2.0.0.", "keywords": [ "sylius", "sylius-plugin", @@ -47,7 +48,7 @@ "phpstan/phpstan-webmozart-assert": "2.0.0", "phpunit/phpunit": "^9.6", "rector/rector": "2.0.4", - "sylius-labs/coding-standard": "4.4.0", + "sylius-labs/coding-standard": "^4.4", "sylius/test-application": "^2.1.0@alpha", "symfony/apache-pack": "*", "symfony/browser-kit": "^6.4|| ^7.2", @@ -80,10 +81,10 @@ } }, "scripts": { - "ecs": "ecs check -c rulesets/ecs.php --ansi --clear-cache .", - "fix-ecs": "@ecs --fix", - "phpmd": "phpmd src ansi rulesets/.php_md.xml", - "phpstan": "phpstan analyse src -c rulesets/phpstan.neon", + "ecs": "ecs check -c ruleset/ecs.php --ansi --clear-cache", + "fix-ecs": "@ecs --fix --memory-limit=4G", + "phpmd": "phpmd src ansi ruleset/.php_md.xml", + "phpstan": "phpstan analyse src -c ruleset/phpstan.neon", "phpunit": "phpunit tests/PHPUnit --colors=always", "tests": [ "@ecs", diff --git a/config/services.yaml b/config/services.yaml index 8b5dac42..116676f0 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -119,6 +119,27 @@ services: - name: sylius.payment_request.provider.http_response gateway_factory: !php/const PayPlug\SyliusPayPlugPlugin\Gateway\AmericanExpressGatewayFactory::FACTORY_NAME + ## Scalapay Payplug Gateway ## + payplug_sylius_payplug_plugin.command_provider.payplug_scalapay: + class: Sylius\Bundle\PaymentBundle\CommandProvider\ActionsCommandProvider + arguments: + - !tagged_locator + tag: payplug_sylius_payplug_plugin.command_provider.payplug_scalapay + index_by: 'action' + tags: + - name: sylius.payment_request.command_provider + gateway_factory: !php/const PayPlug\SyliusPayPlugPlugin\Gateway\ScalapayGatewayFactory::FACTORY_NAME + payplug_sylius_payplug_plugin.provider.order_pay.http_response.payplug_scalapay: + class: Sylius\Bundle\PaymentBundle\Provider\ActionsHttpResponseProvider + arguments: + - !tagged_locator + tag: payplug_sylius_payplug_plugin.http_response_provider.payplug_scalapay + index_by: action + tags: + - name: sylius.payment_request.provider.http_response + gateway_factory: !php/const PayPlug\SyliusPayPlugPlugin\Gateway\ScalapayGatewayFactory::FACTORY_NAME + + ## Apple Pay Payplug Gateway ## payplug_sylius_payplug_plugin.command_provider.payplug_apple_pay: class: Sylius\Bundle\PaymentBundle\CommandProvider\ActionsCommandProvider diff --git a/config/services/client.xml b/config/services/client.xml index 0ca32b00..22917bdf 100644 --- a/config/services/client.xml +++ b/config/services/client.xml @@ -53,5 +53,14 @@ method="create"/> american_express + + + + payplug_scalapay + diff --git a/config/services/gateway.xml b/config/services/gateway.xml index eeda89e7..dd174517 100644 --- a/config/services/gateway.xml +++ b/config/services/gateway.xml @@ -47,5 +47,13 @@ + + + + PayPlug\SyliusPayPlugPlugin\Gateway\ScalapayGatewayFactory + + diff --git a/config/twig_hooks/admin.yaml b/config/twig_hooks/admin.yaml index 931ecf69..ba1e2403 100644 --- a/config/twig_hooks/admin.yaml +++ b/config/twig_hooks/admin.yaml @@ -30,6 +30,9 @@ sylius_twig_hooks: 'sylius_admin.payment_method.create.content.form.sections.gateway_configuration.payplug_american_express': &amexGateway live_checkbox: *liveCheckbox + 'sylius_admin.payment_method.create.content.form.sections.gateway_configuration.payplug_scalapay': &scalapayGateway + live_checkbox: *liveCheckbox + 'sylius_admin.payment_method.update.content.form.sections.gateway_configuration.payplug': <<: *payplugGateway renew_oauth: &renewOAuth @@ -47,3 +50,6 @@ sylius_twig_hooks: 'sylius_admin.payment_method.update.content.form.sections.gateway_configuration.payplug_american_express': <<: *amexGateway renew_oauth: *renewOAuth + 'sylius_admin.payment_method.update.content.form.sections.gateway_configuration.payplug_scalapay': + <<: *scalapayGateway + renew_oauth: *renewOAuth diff --git a/config/twig_hooks/shop.yaml b/config/twig_hooks/shop.yaml index ed250d5f..5ffe2826 100644 --- a/config/twig_hooks/shop.yaml +++ b/config/twig_hooks/shop.yaml @@ -44,3 +44,6 @@ sylius_twig_hooks: 'sylius_shop.shared.form.select_payment.payment.choice.details#payplug_american_express': american_express: template: '@PayPlugSyliusPayPlugPlugin/shop/select_payment/_american_express.html.twig' + 'sylius_shop.shared.form.select_payment.payment.choice.details#payplug_scalapay': + scalapay: + template: '@PayPlugSyliusPayPlugPlugin/shop/select_payment/_scalapay.html.twig' diff --git a/ecs.php b/ecs.php new file mode 100644 index 00000000..50afbd67 --- /dev/null +++ b/ecs.php @@ -0,0 +1,29 @@ +withPaths([ + __DIR__ . '/ruleset', + __DIR__ . '/src', + __DIR__ . '/tests', + ]) + + // add a single rule + ->withRules([ + NoUnusedImportsFixer::class, + ]) + + // add sets - group of rules, from easiest to more complex ones + // uncomment one, apply one, commit, PR, merge and repeat + //->withPreparedSets( + // spaces: true, + // namespaces: true, + // docblocks: true, + // arrays: true, + // comments: true, + //) + ; diff --git a/grumphp.yml b/grumphp.yml index 1ae601d8..e8ea3a8c 100644 --- a/grumphp.yml +++ b/grumphp.yml @@ -3,6 +3,12 @@ grumphp: failed: ~ succeeded: ~ tasks: + git_commit_message: + matchers: + - '/^(PRE|SYL|SMP)-\d+: .+/' + git_branch_name: + whitelist: + - '/^(feature|fix|hotfix|refactor|release)\/(PRE|SYL|SMP)-\d+/' composer: no_check_all: true jsonlint: @@ -14,12 +20,17 @@ grumphp: level: ~ configuration: 'ruleset/phpstan.neon' use_grumphp_paths: false + memory_limit: '-1' securitychecker_symfony: ~ yamllint: parse_custom_tags: true ecs: config: 'ruleset/ecs.php' + clear-cache: true no-progress-bar: true + files_on_pre_commit: false + paths: ['src', 'tests/Behat', 'tests/PHPUnit'] + triggered_by: ['php', 'phtml', 'twig', 'yaml', 'yml', 'xml', 'json', 'neon', 'lock'] twigcs: path: 'src/' severity: error diff --git a/public/assets/scalapay/logo.svg b/public/assets/scalapay/logo.svg new file mode 100644 index 00000000..619520ad --- /dev/null +++ b/public/assets/scalapay/logo.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ruleset/phpstan-baseline.neon b/ruleset/phpstan-baseline.neon index 1d16fed3..2ebc3b8c 100644 --- a/ruleset/phpstan-baseline.neon +++ b/ruleset/phpstan-baseline.neon @@ -1360,6 +1360,12 @@ parameters: count: 1 path: ../src/Resolver/PayPlugPaymentMethodsResolverDecorator.php + - + message: '#^Method PayPlug\\SyliusPayPlugPlugin\\Resolver\\ScalapayPaymentMethodsResolverDecorator\:\:getSupportedMethods\(\) should return array\ but returns array\.$#' + identifier: return.type + count: 1 + path: ../src/Resolver/ScalapayPaymentMethodsResolverDecorator.php + - message: '#^Call to method apply\(\) on an unknown class SM\\StateMachine\\StateMachineInterface\.$#' identifier: class.notFound diff --git a/ruleset/rector.php b/ruleset/rector.php index c68c6f8f..90e1fac4 100644 --- a/ruleset/rector.php +++ b/ruleset/rector.php @@ -38,4 +38,4 @@ SymfonySetList::SYMFONY_72, SetList::CODE_QUALITY, SetList::DEAD_CODE, - ]); \ No newline at end of file + ]); diff --git a/src/Action/Admin/Auth/UnifiedAuthenticationController.php b/src/Action/Admin/Auth/UnifiedAuthenticationController.php index 603ad6aa..2449cc48 100644 --- a/src/Action/Admin/Auth/UnifiedAuthenticationController.php +++ b/src/Action/Admin/Auth/UnifiedAuthenticationController.php @@ -24,6 +24,7 @@ * This controller is used to authenticate the user with PayPlug * * The OAuth process start when creating a new payment method or updated it. + * * @see PayPlug\SyliusPayPlugPlugin\EventListener\PostSavePaymentMethodEventListener */ #[Route('/payplug/auth')] @@ -66,9 +67,11 @@ public function setupRedirection(Request $request): Response return new RedirectResponse(substr($header, 9)); } } + throw new \LogicException('No location header found'); } catch (\Throwable $e) { $this->logger->critical('Error while perform Payplug OAuth Setup redirection', ['message' => $e->getMessage(), 'exception' => $e]); + return $this->handleOAuthError($request); } } @@ -128,6 +131,7 @@ public function oauthCallback(Request $request): Response return new RedirectResponse($this->router->generate('sylius_admin_payment_method_update', ['id' => $paymentMethod->getId()])); } catch (\Throwable $e) { $this->logger->critical('Error while perform Payplug OAuth callback', ['message' => $e->getMessage(), 'exception' => $e]); + return $this->handleOAuthError($request); } } diff --git a/src/ApiClient/PayPlugApiClientFactory.php b/src/ApiClient/PayPlugApiClientFactory.php index 4d12c900..9b71b41e 100644 --- a/src/ApiClient/PayPlugApiClientFactory.php +++ b/src/ApiClient/PayPlugApiClientFactory.php @@ -9,7 +9,6 @@ use Sylius\Component\Payment\Model\GatewayConfigInterface; use Sylius\Component\Payment\Model\PaymentMethodInterface; use Sylius\Component\Resource\Repository\RepositoryInterface; -use Symfony\Config\SyliusPayment\GatewayConfigConfig; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; @@ -48,17 +47,14 @@ public function createForPaymentMethod(PaymentMethodInterface $paymentMethod): P private function getTokenForGatewayConfig(GatewayConfigInterface $gatewayConfig): string { $config = $gatewayConfig->getConfig(); - $clientConfig = $config['live_client']; - if (true !== $config['live']) { // The live mode is not enabled, use client config for test mode - $clientConfig = $config['test_client']; - } - if (!\is_array($clientConfig)) { + $rawClientConfig = true !== $config['live'] ? $config['test_client'] : $config['live_client']; + if (!\is_array($rawClientConfig)) { throw new GatewayConfigurationException('No client config found for ' . $gatewayConfig->getFactoryName() . '. Please renew your credentials in the PayPlug plugin configuration.'); } - + /** @var array $clientConfig */ + $clientConfig = $rawClientConfig; $cacheKey = sprintf('payplug_%s_api_key_%s', $gatewayConfig->getFactoryName(), $config['live'] === true ? 'live' : 'test'); - /** @var array $clientConfig */ return $this->cache->get($cacheKey, function (ItemInterface $item) use ($clientConfig) { $response = Authentication::generateJWT($clientConfig['client_id'] ?? '', $clientConfig['client_secret'] ?? ''); if ([] === $response || !is_array($response['httpResponse'])) { @@ -75,6 +71,7 @@ private function getTokenForGatewayConfig(GatewayConfigInterface $gatewayConfig) } $item->expiresAfter($expiresIn); + return $accessToken; }); } diff --git a/src/Command/Handler/CapturePaymentRequestHandler.php b/src/Command/Handler/CapturePaymentRequestHandler.php index c6c91792..cda95705 100644 --- a/src/Command/Handler/CapturePaymentRequestHandler.php +++ b/src/Command/Handler/CapturePaymentRequestHandler.php @@ -9,6 +9,7 @@ use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface; use PayPlug\SyliusPayPlugPlugin\Command\CapturePaymentRequest; use PayPlug\SyliusPayPlugPlugin\Creator\PayPlugPaymentDataCreator; +use Psr\Log\LoggerInterface; use Sylius\Abstraction\StateMachine\StateMachineInterface; use Sylius\Bundle\CoreBundle\OrderPay\Provider\UrlProviderInterface; use Sylius\Bundle\PaymentBundle\Provider\PaymentRequestProviderInterface; @@ -27,6 +28,7 @@ public function __construct( private PayPlugPaymentDataCreator $paymentDataCreator, #[Autowire(service: 'sylius_shop.provider.order_pay.after_pay_url')] private UrlProviderInterface $afterPayUrlProvider, + private LoggerInterface $logger, ) {} public function __invoke(CapturePaymentRequest $capturePaymentRequest): void @@ -40,7 +42,10 @@ public function __invoke(CapturePaymentRequest $capturePaymentRequest): void throw new \LogicException('Payment method is not set for the payment.'); } - if (PayPlugApiClientInterface::STATUS_CREATED === ($payment->getDetails()['status'] ?? null)) { + if ( + PayPlugApiClientInterface::STATUS_CREATED === ($payment->getDetails()['status'] ?? null) && + ($payment->getDetails()['factory_name'] ?? null) === $method->getGatewayConfig()?->getFactoryName() + ) { $paymentRequest->setResponseData([ 'retry' => true, 'message' => 'Payment already created', @@ -72,6 +77,7 @@ public function __invoke(CapturePaymentRequest $capturePaymentRequest): void $payplugPayment = $client->createPayment($data); } catch (HttpException $exception) { $paymentRequest->setResponseData(\json_decode($exception->getHttpResponse(), true)); // @phpstan-ignore-line + $this->logger->error('[PayPlug] Scalapay capture failed', ['response' => $exception->getHttpResponse()]); $this->stateMachine->apply( $paymentRequest, PaymentRequestTransitions::GRAPH, @@ -84,6 +90,7 @@ public function __invoke(CapturePaymentRequest $capturePaymentRequest): void $payment->setDetails([ ...$payment->getDetails(), 'status' => PayPlugApiClientInterface::STATUS_CREATED, + 'factory_name' => $method->getGatewayConfig()?->getFactoryName(), 'payment_id' => $payplugPayment->__get('id'), 'payplug_response' => $arrayPayplugPayment, 'redirect_url' => $payplugPayment->hosted_payment->payment_url, // @phpstan-ignore-line diff --git a/src/Command/Handler/StatusPaymentRequestHandler.php b/src/Command/Handler/StatusPaymentRequestHandler.php index 1f2c47fe..fcd0f67e 100644 --- a/src/Command/Handler/StatusPaymentRequestHandler.php +++ b/src/Command/Handler/StatusPaymentRequestHandler.php @@ -45,12 +45,13 @@ public function __invoke(StatusPaymentRequest $statusPaymentRequest): void // We don't have a forced status, so we retrieve the payment status from PayPlug $client = $this->apiClientFactory->createForPaymentMethod($method); - /** @var null|string $payplugPaymentId */ + /** @var string|null $payplugPaymentId */ $payplugPaymentId = $payment->getDetails()['payment_id'] ?? null; if (null === $payplugPaymentId) { $this->logger->warning('No PayPlug payment ID found in payment details.', ['payment_id' => $payment->getId(), 'order_id' => $payment->getOrder()?->getId()]); $payment->setDetails(['status' => PayPlugApiClientInterface::FAILED]); $this->updatePaymentState($payment); + return; } diff --git a/src/Command/Provider/CapturePaymentRequestCommandProvider.php b/src/Command/Provider/CapturePaymentRequestCommandProvider.php index 1627b281..8d0be621 100644 --- a/src/Command/Provider/CapturePaymentRequestCommandProvider.php +++ b/src/Command/Provider/CapturePaymentRequestCommandProvider.php @@ -31,6 +31,10 @@ 'payplug_sylius_payplug_plugin.command_provider.payplug_apple_pay', ['action' => PaymentRequestInterface::ACTION_CAPTURE], )] +#[AutoconfigureTag( + 'payplug_sylius_payplug_plugin.command_provider.payplug_scalapay', + ['action' => PaymentRequestInterface::ACTION_CAPTURE], +)] final class CapturePaymentRequestCommandProvider implements PaymentRequestCommandProviderInterface { public function supports(PaymentRequestInterface $paymentRequest): bool diff --git a/src/Command/Provider/NotifyPaymentRequestCommandProvider.php b/src/Command/Provider/NotifyPaymentRequestCommandProvider.php index 0311f69b..55f16fb8 100644 --- a/src/Command/Provider/NotifyPaymentRequestCommandProvider.php +++ b/src/Command/Provider/NotifyPaymentRequestCommandProvider.php @@ -29,6 +29,10 @@ 'payplug_sylius_payplug_plugin.command_provider.payplug_apple_pay', ['action' => PaymentRequestInterface::ACTION_NOTIFY], )] +#[AutoconfigureTag( + 'payplug_sylius_payplug_plugin.command_provider.payplug_scalapay', + ['action' => PaymentRequestInterface::ACTION_NOTIFY], +)] final class NotifyPaymentRequestCommandProvider implements PaymentRequestCommandProviderInterface { public function supports(PaymentRequestInterface $paymentRequest): bool diff --git a/src/Command/Provider/StatusPaymentRequestCommandProvider.php b/src/Command/Provider/StatusPaymentRequestCommandProvider.php index e75804b0..a0475641 100644 --- a/src/Command/Provider/StatusPaymentRequestCommandProvider.php +++ b/src/Command/Provider/StatusPaymentRequestCommandProvider.php @@ -30,6 +30,10 @@ 'payplug_sylius_payplug_plugin.command_provider.payplug_apple_pay', ['action' => PaymentRequestInterface::ACTION_STATUS], )] +#[AutoconfigureTag( + 'payplug_sylius_payplug_plugin.command_provider.payplug_scalapay', + ['action' => PaymentRequestInterface::ACTION_STATUS], +)] final class StatusPaymentRequestCommandProvider implements PaymentRequestCommandProviderInterface { public function __construct(private RequestStack $requestStack) diff --git a/src/Controller/OneClickAction.php b/src/Controller/OneClickAction.php index 166fc383..a1e27ab2 100644 --- a/src/Controller/OneClickAction.php +++ b/src/Controller/OneClickAction.php @@ -6,15 +6,14 @@ use PayPlug\SyliusPayPlugPlugin\Action\Api\ApiAwareTrait; use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactory; -use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; use Payum\Core\ApiAwareInterface; use Payum\Core\GatewayAwareInterface; use Payum\Core\GatewayAwareTrait; -use Sylius\Component\Payment\Model\GatewayConfigInterface; use Payum\Core\Payum; use Sylius\Component\Core\Model\PaymentInterface; use Sylius\Component\Core\Model\PaymentMethodInterface; use Sylius\Component\Core\Repository\PaymentRepositoryInterface; +use Sylius\Component\Payment\Model\GatewayConfigInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; diff --git a/src/Controller/OrderController.php b/src/Controller/OrderController.php index 7c70250e..0baf45c9 100644 --- a/src/Controller/OrderController.php +++ b/src/Controller/OrderController.php @@ -27,12 +27,12 @@ use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Contracts\Service\Attribute\Required; -use Webmozart\Assert\Assert; #[AsController] final class OrderController extends BaseOrderController { private const APPLE_ERROR_RESPONSE_CODE = 0; + private const APPLE_SUCCESS_RESPONSE_CODE = 1; #[Required] @@ -178,6 +178,7 @@ public function confirmApplePayPaymentAction(Request $request): Response if (null !== $eventResponse) { return $eventResponse; } + return new JsonResponse([], Response::HTTP_BAD_REQUEST); } diff --git a/src/Creator/PayPlugPaymentDataCreator.php b/src/Creator/PayPlugPaymentDataCreator.php index 8ba4a9a7..89d882c0 100644 --- a/src/Creator/PayPlugPaymentDataCreator.php +++ b/src/Creator/PayPlugPaymentDataCreator.php @@ -19,6 +19,7 @@ use PayPlug\SyliusPayPlugPlugin\Gateway\BancontactGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\OneyGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\Gateway\ScalapayGatewayFactory; use Sylius\Component\Core\Model\AddressInterface; use Sylius\Component\Core\Model\CustomerInterface; use Sylius\Component\Core\Model\OrderInterface; @@ -102,6 +103,10 @@ public function create( $details->offsetSet('payment_context', $this->getCartContext($order)); } + if (ScalapayGatewayFactory::FACTORY_NAME === $gatewayFactoryName) { + $details->offsetSet('payment_context', $this->getCartContext($order)); + } + $this->addPaymentMethodFieldToDetails($details, $gatewayFactoryName ?? ''); return $details; @@ -349,6 +354,7 @@ private function addPaymentMethodFieldToDetails(ArrayObject $details, string $ga BancontactGatewayFactory::FACTORY_NAME => BancontactGatewayFactory::PAYMENT_METHOD_BANCONTACT, ApplePayGatewayFactory::FACTORY_NAME => ApplePayGatewayFactory::PAYMENT_METHOD_APPLE_PAY, AmericanExpressGatewayFactory::FACTORY_NAME => AmericanExpressGatewayFactory::PAYMENT_METHOD_AMERICAN_EXPRESS, + ScalapayGatewayFactory::FACTORY_NAME => ScalapayGatewayFactory::PAYMENT_METHOD_SCALAPAY, ]; // match function is only supported by php 8. so can not use it here. foreach ($paymentMethods as $name => $method) { diff --git a/src/Creator/RefundUnitsCommandCreatorDecorator.php b/src/Creator/RefundUnitsCommandCreatorDecorator.php index 396ac3ac..1f509dc1 100644 --- a/src/Creator/RefundUnitsCommandCreatorDecorator.php +++ b/src/Creator/RefundUnitsCommandCreatorDecorator.php @@ -28,9 +28,10 @@ class RefundUnitsCommandCreatorDecorator implements RequestCommandCreatorInterface { private const MINIMUM_REFUND_AMOUNT = 10; + private const SUPPORTED_METHODS = [ PayPlugGatewayFactory::FACTORY_NAME, - OneyGatewayFactory::FACTORY_NAME + OneyGatewayFactory::FACTORY_NAME, ]; public function __construct( diff --git a/src/Entity/Traits/CustomerTrait.php b/src/Entity/Traits/CustomerTrait.php index 7d3ae3dd..fca7508d 100644 --- a/src/Entity/Traits/CustomerTrait.php +++ b/src/Entity/Traits/CustomerTrait.php @@ -6,7 +6,6 @@ use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; -use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface; use PayPlug\SyliusPayPlugPlugin\Entity\Card; trait CustomerTrait diff --git a/src/Entity/Traits/PaymentMethodTrait.php b/src/Entity/Traits/PaymentMethodTrait.php index a5a6c862..bcd2e66b 100644 --- a/src/Entity/Traits/PaymentMethodTrait.php +++ b/src/Entity/Traits/PaymentMethodTrait.php @@ -6,7 +6,6 @@ use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; -use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface; use PayPlug\SyliusPayPlugPlugin\Entity\Card; trait PaymentMethodTrait diff --git a/src/EventListener/PostSavePaymentMethodEventListener.php b/src/EventListener/PostSavePaymentMethodEventListener.php index 59657705..8a9a087c 100644 --- a/src/EventListener/PostSavePaymentMethodEventListener.php +++ b/src/EventListener/PostSavePaymentMethodEventListener.php @@ -13,7 +13,6 @@ use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Routing\RouterInterface; -use function Symfony\Component\Translation\t; #[AsEventListener(event: 'sylius.payment_method.post_create', method: 'onCreate')] #[AsEventListener(event: 'sylius.payment_method.post_update', method: 'onUpdate')] @@ -76,6 +75,7 @@ private function startOAuth(PaymentMethodInterface $paymentMethod, ResourceContr // Should never happen return; } + try { $request->getSession()->set('payplug_sylius_oauth_payment_method_id', $paymentMethod->getId()); $setupRedirection = $this->router->generate('payplug_sylius_admin_auth_setup_redirection', referenceType: RouterInterface::ABSOLUTE_URL); @@ -83,6 +83,7 @@ private function startOAuth(PaymentMethodInterface $paymentMethod, ResourceContr /** * @var string $payplugRedirectUrl + * * @phpstan-ignore-next-line -- Error of return type in Payplug SDK */ $payplugRedirectUrl = Authentication::getRegisterUrl($setupRedirection, $oauthCallback); diff --git a/src/Form/Extension/PaymentTypeExtension.php b/src/Form/Extension/PaymentTypeExtension.php index 9fce7b3c..dc1889f7 100644 --- a/src/Form/Extension/PaymentTypeExtension.php +++ b/src/Form/Extension/PaymentTypeExtension.php @@ -8,9 +8,9 @@ use PayPlug\SyliusPayPlugPlugin\Gateway\OneyGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Provider\OneySupportedPaymentChoiceProvider; -use Sylius\Component\Payment\Model\GatewayConfigInterface; use Sylius\Bundle\CoreBundle\Form\Type\Checkout\PaymentType; use Sylius\Component\Core\Model\OrderInterface; +use Sylius\Component\Payment\Model\GatewayConfigInterface; use Symfony\Component\Form\AbstractTypeExtension; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\TextType; diff --git a/src/Gateway/Form/Type/AbstractGatewayConfigurationType.php b/src/Gateway/Form/Type/AbstractGatewayConfigurationType.php index da325b82..80fce24a 100644 --- a/src/Gateway/Form/Type/AbstractGatewayConfigurationType.php +++ b/src/Gateway/Form/Type/AbstractGatewayConfigurationType.php @@ -24,6 +24,7 @@ class AbstractGatewayConfigurationType extends AbstractType public const VALIDATION_GROUPS = ['Default', 'sylius']; protected string $gatewayFactoryTitle = ''; + protected string $gatewayFactoryName = ''; protected string $gatewayBaseCurrencyCode = PayPlugGatewayFactory::BASE_CURRENCY_CODE; diff --git a/src/Gateway/Form/Type/ScalapayGatewayConfigurationType.php b/src/Gateway/Form/Type/ScalapayGatewayConfigurationType.php new file mode 100644 index 00000000..ec89fb96 --- /dev/null +++ b/src/Gateway/Form/Type/ScalapayGatewayConfigurationType.php @@ -0,0 +1,25 @@ + 'payplug_scalapay', + 'label' => 'payplug_sylius_payplug_plugin.ui.scalapay_gateway_label', + 'priority' => 80, + ], +)] +final class ScalapayGatewayConfigurationType extends AbstractGatewayConfigurationType +{ + protected string $gatewayFactoryTitle = ScalapayGatewayFactory::FACTORY_TITLE; + + protected string $gatewayFactoryName = ScalapayGatewayFactory::FACTORY_NAME; + + protected string $gatewayBaseCurrencyCode = ScalapayGatewayFactory::BASE_CURRENCY_CODE; +} diff --git a/src/Gateway/ScalapayGatewayFactory.php b/src/Gateway/ScalapayGatewayFactory.php new file mode 100644 index 00000000..e6a7b92a --- /dev/null +++ b/src/Gateway/ScalapayGatewayFactory.php @@ -0,0 +1,21 @@ + [ + 'min_amount' => 500, + 'max_amount' => 200000, + ], + ]; +} diff --git a/src/Gateway/Validator/Constraints/IsCanSavePaymentMethod.php b/src/Gateway/Validator/Constraints/IsCanSavePaymentMethod.php index 7bd79203..9de50f01 100644 --- a/src/Gateway/Validator/Constraints/IsCanSavePaymentMethod.php +++ b/src/Gateway/Validator/Constraints/IsCanSavePaymentMethod.php @@ -12,6 +12,7 @@ final class IsCanSavePaymentMethod extends Constraint { public string $noTestKeyMessage = 'payplug_sylius_payplug_plugin.%s.can_not_save_method_with_test_key'; + public string $noAccessMessage = 'payplug_sylius_payplug_plugin.%s.can_not_save_method_no_access'; public function validatedBy(): string diff --git a/src/Gateway/Validator/Constraints/IsOneyEnabledValidator.php b/src/Gateway/Validator/Constraints/IsOneyEnabledValidator.php index 86aefd6e..5ef51802 100644 --- a/src/Gateway/Validator/Constraints/IsOneyEnabledValidator.php +++ b/src/Gateway/Validator/Constraints/IsOneyEnabledValidator.php @@ -9,8 +9,8 @@ use PayPlug\SyliusPayPlugPlugin\Checker\OneyChecker; use PayPlug\SyliusPayPlugPlugin\Exception\GatewayConfigurationException; use PayPlug\SyliusPayPlugPlugin\Gateway\OneyGatewayFactory; -use Sylius\Component\Payment\Model\GatewayConfigInterface; use Sylius\Component\Core\Model\PaymentMethodInterface; +use Sylius\Component\Payment\Model\GatewayConfigInterface; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; use Symfony\Component\Validator\Exception\UnexpectedTypeException; diff --git a/src/Handler/PaymentNotificationHandler.php b/src/Handler/PaymentNotificationHandler.php index afcc8468..7e1c8851 100644 --- a/src/Handler/PaymentNotificationHandler.php +++ b/src/Handler/PaymentNotificationHandler.php @@ -48,7 +48,7 @@ public function treat( $this->entityManager->refresh($payment); - if ($details['status'] === PayPlugApiClientInterface::STATUS_ABORTED) { + if (($details['status'] ?? null) === PayPlugApiClientInterface::STATUS_ABORTED) { $lock->release(); return; @@ -92,7 +92,7 @@ public function treat( 'message' => $paymentResource->failure->message ?? '', ]; - if (PayPlugApiClientInterface::INTERNAL_STATUS_ONE_CLICK === $details['status']) { + if (PayPlugApiClientInterface::INTERNAL_STATUS_ONE_CLICK === ($details['status'] ?? null)) { $this->requestStack->getSession()->getFlashBag()->add('error', 'payplug_sylius_payplug_plugin.error.transaction_failed_1click'); } diff --git a/src/MessageHandler/RefundPaymentGeneratedHandler.php b/src/MessageHandler/RefundPaymentGeneratedHandler.php index 9bd4bc27..79e5b849 100644 --- a/src/MessageHandler/RefundPaymentGeneratedHandler.php +++ b/src/MessageHandler/RefundPaymentGeneratedHandler.php @@ -14,9 +14,9 @@ use PayPlug\SyliusPayPlugPlugin\Gateway\BancontactGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\OneyGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\Gateway\ScalapayGatewayFactory; use PayPlug\SyliusPayPlugPlugin\PaymentProcessing\RefundPaymentProcessor; use PayPlug\SyliusPayPlugPlugin\Repository\RefundHistoryRepositoryInterface; -use Sylius\Component\Payment\Model\GatewayConfigInterface; use Psr\Log\LoggerInterface; use Sylius\Abstraction\StateMachine\StateMachineInterface; use Sylius\Component\Core\Model\OrderInterface; @@ -24,6 +24,7 @@ use Sylius\Component\Core\Model\PaymentMethodInterface; use Sylius\Component\Core\Repository\OrderRepositoryInterface; use Sylius\Component\Core\Repository\PaymentRepositoryInterface; +use Sylius\Component\Payment\Model\GatewayConfigInterface; use Sylius\Component\Resource\Repository\RepositoryInterface; use Sylius\RefundPlugin\Entity\RefundPayment; use Sylius\RefundPlugin\Event\RefundPaymentGenerated; @@ -72,6 +73,7 @@ public function __invoke(RefundPaymentGenerated $message): void BancontactGatewayFactory::FACTORY_NAME, ApplePayGatewayFactory::FACTORY_NAME, AmericanExpressGatewayFactory::FACTORY_NAME, + ScalapayGatewayFactory::FACTORY_NAME, ], true) ) { return; diff --git a/src/OrderPay/Provider/CaptureHttpResponseProvider.php b/src/OrderPay/Provider/CaptureHttpResponseProvider.php index 6fe83347..e7d7e326 100644 --- a/src/OrderPay/Provider/CaptureHttpResponseProvider.php +++ b/src/OrderPay/Provider/CaptureHttpResponseProvider.php @@ -31,6 +31,10 @@ 'payplug_sylius_payplug_plugin.http_response_provider.payplug_american_express', ['action' => PaymentRequestInterface::ACTION_CAPTURE], )] +#[AutoconfigureTag( + 'payplug_sylius_payplug_plugin.http_response_provider.payplug_scalapay', + ['action' => PaymentRequestInterface::ACTION_CAPTURE], +)] class CaptureHttpResponseProvider implements HttpResponseProviderInterface { public function supports(RequestConfiguration $requestConfiguration, PaymentRequestInterface $paymentRequest): bool diff --git a/src/PaymentProcessing/AbortPaymentProcessor.php b/src/PaymentProcessing/AbortPaymentProcessor.php index a4f85d78..2b805aac 100644 --- a/src/PaymentProcessing/AbortPaymentProcessor.php +++ b/src/PaymentProcessing/AbortPaymentProcessor.php @@ -44,6 +44,7 @@ public function process(PaymentInterface $payment): void return; } $client = $this->payplugApiClientFactory->createForPaymentMethod($method); + try { // When a payment is failed on Sylius, also abort it on PayPlug. // This should prevent the case that if we are already on PayPlug payment page diff --git a/src/PaymentProcessing/RefundPaymentHandler.php b/src/PaymentProcessing/RefundPaymentHandler.php index 70d80e53..c2ebe143 100644 --- a/src/PaymentProcessing/RefundPaymentHandler.php +++ b/src/PaymentProcessing/RefundPaymentHandler.php @@ -56,7 +56,7 @@ public function fromRequest(Refund $refund, PaymentInterface $payment): RefundUn $payment->getOrder()->getNumber(), array_merge( $this->parseIdsToUnitRefunds($items, RefundType::orderItemUnit(), OrderItemUnitRefund::class), - $this->parseIdsToUnitRefunds($shipments, RefundType::shipment(), ShipmentRefund::class) + $this->parseIdsToUnitRefunds($shipments, RefundType::shipment(), ShipmentRefund::class), ), $payment->getMethod()->getId(), // @phpstan-ignore-line '', diff --git a/src/PaymentProcessing/RefundPaymentProcessor.php b/src/PaymentProcessing/RefundPaymentProcessor.php index d9c62382..54fe0d51 100644 --- a/src/PaymentProcessing/RefundPaymentProcessor.php +++ b/src/PaymentProcessing/RefundPaymentProcessor.php @@ -14,11 +14,10 @@ use PayPlug\SyliusPayPlugPlugin\Gateway\OneyGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Repository\RefundHistoryRepositoryInterface; -use Sylius\Component\Payment\Model\GatewayConfigInterface; -use Webmozart\Assert\Assert; use Psr\Log\LoggerInterface; use Sylius\Component\Core\Model\PaymentInterface; use Sylius\Component\Core\Model\PaymentMethodInterface; +use Sylius\Component\Payment\Model\GatewayConfigInterface; use Sylius\Component\Payment\PaymentTransitions; use Sylius\Component\Resource\Exception\UpdateHandlingException; use Sylius\Component\Resource\Repository\RepositoryInterface; @@ -28,6 +27,7 @@ use Symfony\Component\Workflow\Attribute\AsCompletedListener; use Symfony\Component\Workflow\Event\CompletedEvent; use Symfony\Contracts\Translation\TranslatorInterface; +use Webmozart\Assert\Assert; #[Autoconfigure(public: true)] final class RefundPaymentProcessor implements PaymentProcessorInterface diff --git a/src/Provider/AbstractSupportedRefundPaymentMethodsProvider.php b/src/Provider/AbstractSupportedRefundPaymentMethodsProvider.php index 2336059d..42e88191 100644 --- a/src/Provider/AbstractSupportedRefundPaymentMethodsProvider.php +++ b/src/Provider/AbstractSupportedRefundPaymentMethodsProvider.php @@ -4,11 +4,11 @@ namespace PayPlug\SyliusPayPlugPlugin\Provider; -use Sylius\Component\Payment\Model\GatewayConfigInterface; use Sylius\Component\Core\Model\OrderInterface; use Sylius\Component\Core\Model\PaymentInterface; use Sylius\Component\Core\Model\PaymentMethodInterface; use Sylius\Component\Core\Repository\OrderRepositoryInterface; +use Sylius\Component\Payment\Model\GatewayConfigInterface; use Sylius\RefundPlugin\Provider\RefundPaymentMethodsProviderInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; diff --git a/src/Provider/OneySupportedPaymentChoiceProvider.php b/src/Provider/OneySupportedPaymentChoiceProvider.php index 64309253..9635b7b3 100644 --- a/src/Provider/OneySupportedPaymentChoiceProvider.php +++ b/src/Provider/OneySupportedPaymentChoiceProvider.php @@ -6,8 +6,8 @@ use PayPlug\SyliusPayPlugPlugin\Gateway\OneyGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Repository\PaymentMethodRepositoryInterface; -use Sylius\Component\Payment\Model\GatewayConfigInterface; use Sylius\Component\Core\Model\PaymentMethodInterface; +use Sylius\Component\Payment\Model\GatewayConfigInterface; class OneySupportedPaymentChoiceProvider { diff --git a/src/Provider/Payment/ApplePayPaymentProvider.php b/src/Provider/Payment/ApplePayPaymentProvider.php index 1f195d9e..96592302 100644 --- a/src/Provider/Payment/ApplePayPaymentProvider.php +++ b/src/Provider/Payment/ApplePayPaymentProvider.php @@ -105,6 +105,7 @@ public function patch(Request $request, OrderInterface $order): PaymentInterface if (!$lastPayment instanceof PaymentInterface) { $this->logger->error('[Payplug] No new payment found for order', ['order' => $order]); + throw new LogicException(); } @@ -119,6 +120,7 @@ public function patch(Request $request, OrderInterface $order): PaymentInterface $paymentResource = $this->applePayClient->retrieve($lastPayment->getDetails()['payment_id']); $this->logger->notice('[Payplug] ApplePay payment resource', ['payment' => (array) $paymentResource]); + try { $token = $request->request->all('token'); if ([] === $token) { @@ -163,6 +165,7 @@ public function patch(Request $request, OrderInterface $order): PaymentInterface } catch (\Exception $exception) { $this->logger->error('[Payplug] ApplePay payment update failed', ['exception' => $exception, 'message' => $exception->getMessage()]); $this->applyRequiredPaymentTransition($lastPayment, PaymentInterface::STATE_FAILED); + try { $paymentResource->abort($this->applePayClient->getConfiguration()); } catch (\Throwable $throwable) { diff --git a/src/Provider/ScalapaySupportedRefundPaymentMethodsProviderDecorator.php b/src/Provider/ScalapaySupportedRefundPaymentMethodsProviderDecorator.php new file mode 100644 index 00000000..ae4c7ede --- /dev/null +++ b/src/Provider/ScalapaySupportedRefundPaymentMethodsProviderDecorator.php @@ -0,0 +1,15 @@ +createQueryBuilder('o') + /** @var PaymentInterface|null $result */ + $result = $this->createQueryBuilder('o') ->where('o.details LIKE :payplugPaymentId') ->setParameter('payplugPaymentId', '%' . $payplugPaymentId . '%') ->getQuery() ->setMaxResults(1) ->getOneOrNullResult() ; + + return $result; } /** diff --git a/src/Resolver/OneyPaymentMethodsResolverDecorator.php b/src/Resolver/OneyPaymentMethodsResolverDecorator.php index e555f96e..73802220 100644 --- a/src/Resolver/OneyPaymentMethodsResolverDecorator.php +++ b/src/Resolver/OneyPaymentMethodsResolverDecorator.php @@ -6,11 +6,11 @@ use PayPlug\SyliusPayPlugPlugin\Checker\OneyCheckerInterface; use PayPlug\SyliusPayPlugPlugin\Gateway\OneyGatewayFactory; -use Sylius\Component\Payment\Model\GatewayConfigInterface; use Sylius\Component\Core\Model\OrderInterface; use Sylius\Component\Core\Model\Payment; use Sylius\Component\Core\Model\PaymentMethodInterface; use Sylius\Component\Currency\Context\CurrencyContextInterface; +use Sylius\Component\Payment\Model\GatewayConfigInterface; use Sylius\Component\Payment\Model\PaymentInterface as BasePaymentInterface; use Sylius\Component\Payment\Resolver\PaymentMethodsResolverInterface; use Symfony\Component\DependencyInjection\Attribute\AsDecorator; diff --git a/src/Resolver/PaymentStateResolver.php b/src/Resolver/PaymentStateResolver.php index 1b338106..1c8de161 100644 --- a/src/Resolver/PaymentStateResolver.php +++ b/src/Resolver/PaymentStateResolver.php @@ -8,14 +8,12 @@ use Payplug\Resource\Payment; use Payplug\Resource\PaymentAuthorization; use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactory; -use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; -use Sylius\Component\Payment\Model\GatewayConfigInterface; use Sylius\Abstraction\StateMachine\StateMachineInterface; use Sylius\Component\Core\Model\PaymentInterface; use Sylius\Component\Core\Model\PaymentMethodInterface; +use Sylius\Component\Payment\Model\GatewayConfigInterface; use Sylius\Component\Payment\PaymentTransitions; -use Symfony\Component\DependencyInjection\Attribute\Autowire; final class PaymentStateResolver implements PaymentStateResolverInterface { diff --git a/src/Resolver/ScalapayPaymentMethodsResolverDecorator.php b/src/Resolver/ScalapayPaymentMethodsResolverDecorator.php new file mode 100644 index 00000000..7dc3a306 --- /dev/null +++ b/src/Resolver/ScalapayPaymentMethodsResolverDecorator.php @@ -0,0 +1,43 @@ +decorated->getSupportedMethods($subject); + + return $this->supportedMethodsProvider->provide( + $supportedMethods, + ScalapayGatewayFactory::FACTORY_NAME, + ScalapayGatewayFactory::AUTHORIZED_CURRENCIES, + $subject->getAmount() ?? 0, + ); + } + + public function supports(BasePaymentInterface $subject): bool + { + return $this->decorated->supports($subject); + } +} diff --git a/src/Validator/PaymentMethodValidator.php b/src/Validator/PaymentMethodValidator.php index bb88cf29..34b826e2 100644 --- a/src/Validator/PaymentMethodValidator.php +++ b/src/Validator/PaymentMethodValidator.php @@ -1,5 +1,7 @@ $this->processBancontact($paymentMethod), AmericanExpressGatewayFactory::FACTORY_NAME => $this->processAmex($paymentMethod), ApplePayGatewayFactory::FACTORY_NAME => $this->processApplePay($paymentMethod), - default => throw new \InvalidArgumentException("Unsupported payment method"), + ScalapayGatewayFactory::FACTORY_NAME => $this->processScalapay($paymentMethod), + default => throw new \InvalidArgumentException('Unsupported payment method'), }; foreach ($errors as $error) { @@ -78,24 +82,35 @@ private function processPayplug(PaymentMethodInterface $paymentMethod): Constrai private function processOney(PaymentMethodInterface $paymentMethod): ConstraintViolationListInterface { $constraintList = [new IsOneyEnabled()]; + return $this->validator->validate($paymentMethod, $constraintList, self::VALIDATION_GROUPS); } private function processBancontact(PaymentMethodInterface $paymentMethod): ConstraintViolationListInterface { $constraintList = [new IsCanSavePaymentMethod()]; + return $this->validator->validate($paymentMethod, $constraintList, self::VALIDATION_GROUPS); } private function processAmex(PaymentMethodInterface $paymentMethod): ConstraintViolationListInterface { $constraintList = [new IsCanSavePaymentMethod()]; + return $this->validator->validate($paymentMethod, $constraintList, self::VALIDATION_GROUPS); } private function processApplePay(PaymentMethodInterface $paymentMethod): ConstraintViolationListInterface { $constraintList = [new IsCanSavePaymentMethod()]; + + return $this->validator->validate($paymentMethod, $constraintList, self::VALIDATION_GROUPS); + } + + private function processScalapay(PaymentMethodInterface $paymentMethod): ConstraintViolationListInterface + { + $constraintList = [new IsCanSavePaymentMethod()]; + return $this->validator->validate($paymentMethod, $constraintList, self::VALIDATION_GROUPS); } } diff --git a/templates/shop/select_payment/_scalapay.html.twig b/templates/shop/select_payment/_scalapay.html.twig new file mode 100644 index 00000000..d5d83f02 --- /dev/null +++ b/templates/shop/select_payment/_scalapay.html.twig @@ -0,0 +1,7 @@ +{% set form = hookable_metadata.context.form %} + +
diff --git a/tests/TestApplication/config/bundles.php b/tests/TestApplication/config/bundles.php index 48514e52..39254569 100644 --- a/tests/TestApplication/config/bundles.php +++ b/tests/TestApplication/config/bundles.php @@ -1,5 +1,7 @@ ['all' => true], Knp\Bundle\SnappyBundle\KnpSnappyBundle::class => ['all' => true], diff --git a/tests/TestApplication/src/Entity/PaymentMethod.php b/tests/TestApplication/src/Entity/PaymentMethod.php index 7dad0a1c..4aa8c0cc 100644 --- a/tests/TestApplication/src/Entity/PaymentMethod.php +++ b/tests/TestApplication/src/Entity/PaymentMethod.php @@ -8,8 +8,8 @@ use Doctrine\ORM\Mapping as ORM; use PayPlug\SyliusPayPlugPlugin\Entity\Traits\PaymentMethodTrait; use Sylius\Component\Core\Model\PaymentMethod as BasePaymentMethod; -use Sylius\Component\Payment\Model\PaymentMethodTranslationInterface; use Sylius\Component\Payment\Model\PaymentMethodTranslation; +use Sylius\Component\Payment\Model\PaymentMethodTranslationInterface; #[ORM\Entity] #[ORM\Table(name: 'sylius_payment_method')] diff --git a/translations/messages.en.yml b/translations/messages.en.yml index c62ba008..97b5424c 100644 --- a/translations/messages.en.yml +++ b/translations/messages.en.yml @@ -24,6 +24,7 @@ payplug_sylius_payplug_plugin: confirmation_modal: confirm_card_deletion: Are you sure you want to delete this card? bancontact_gateway_label: Bancontact by Payplug + scalapay_gateway_label: Scalapay by PayPlug apple_pay_gateway_label: Apple Pay by Payplug american_express_gateway_label: American Express by Payplug apple_pay_not_available: Apple Pay is not available on this browser or device. diff --git a/translations/messages.fr.yml b/translations/messages.fr.yml index 9a9de68e..42193f9b 100644 --- a/translations/messages.fr.yml +++ b/translations/messages.fr.yml @@ -24,6 +24,7 @@ payplug_sylius_payplug_plugin: confirmation_modal: confirm_card_deletion: Êtes-vous sûr(e) de vouloir supprimer cette carte ? bancontact_gateway_label: Bancontact by Payplug + scalapay_gateway_label: Scalapay by PayPlug apple_pay_gateway_label: Apple Pay by Payplug american_express_gateway_label: American Express by Payplug apple_pay_not_available: Apple Pay n'est pas disponible sur ce navigateur ou appareil. diff --git a/translations/messages.it.yml b/translations/messages.it.yml index cc97e1ed..b9170f12 100644 --- a/translations/messages.it.yml +++ b/translations/messages.it.yml @@ -24,6 +24,7 @@ payplug_sylius_payplug_plugin: confirmation_modal: confirm_card_deletion: Desideri cancellare questa carta? bancontact_gateway_label: Bancontact by Payplug + scalapay_gateway_label: Scalapay by PayPlug apple_pay_gateway_label: Apple Pay by Payplug american_express_gateway_label: American Express by Payplug apple_pay_not_available: Apple Pay non è disponibile su questo browser o dispositivo. diff --git a/translations/validators.en.yml b/translations/validators.en.yml index 8ca73234..a5fc5a83 100644 --- a/translations/validators.en.yml +++ b/translations/validators.en.yml @@ -25,6 +25,14 @@ payplug_sylius_payplug_plugin: To activate Bancontact, please fill in this form and activate the LIVE mode. + payplug_scalapay: + can_not_save_method_with_test_key: | + The Scalapay payment method is not available for the TEST mode. + Please activate the LIVE mode. + can_not_save_method_no_access: | + You don't have access to this feature yet. + To activate Scalapay, please contact us at support@payplug.com + and activate the LIVE mode. payplug_apple_pay: can_not_save_method_with_test_key: | The Apple Pay payment method is not available for the TEST mode. diff --git a/translations/validators.fr.yml b/translations/validators.fr.yml index 4da2b61b..128c6ffb 100644 --- a/translations/validators.fr.yml +++ b/translations/validators.fr.yml @@ -24,6 +24,14 @@ payplug_sylius_payplug_plugin: Pour activer Bancontact, rendez-vous sur ce formulaire et activez le mode LIVE. + payplug_scalapay: + can_not_save_method_with_test_key: | + Le paiement par Scalapay n'est pas disponible en mode TEST. + Veuillez activer le mode LIVE. + can_not_save_method_no_access: | + Vous n'avez pas accès à cette fonctionnalité. + Pour activer Scalapay, contactez-nous à support@payplug.com + et activez le mode LIVE. payplug_apple_pay: can_not_save_method_with_test_key: | Le paiement par Apple Pay n’est pas disponible en mode TEST. diff --git a/translations/validators.it.yml b/translations/validators.it.yml index fdd6fe52..efaf49df 100644 --- a/translations/validators.it.yml +++ b/translations/validators.it.yml @@ -24,6 +24,14 @@ payplug_sylius_payplug_plugin: Per attivare Bancontact, compila questo modulo e attiva la modalità LIVE. + payplug_scalapay: + can_not_save_method_with_test_key: | + Il metodo di pagamento Scalapay non è disponibile in modalità TEST. + Attiva la modalità LIVE. + can_not_save_method_no_access: | + Non puoi ancora accedere a questa funzionalità. + Per attivare Scalapay, contattaci a support@payplug.com + e attiva la modalità LIVE. payplug_american_express: can_not_save_method_with_test_key: | Il pagamento Apple Pay non è disponibile in modalità TEST. From b5b845250f64b004eb0dc9f2e6babdbdce526621 Mon Sep 17 00:00:00 2001 From: adumont-payplug Date: Thu, 30 Apr 2026 13:55:48 +0200 Subject: [PATCH 3/3] PRE-3278: adding Unit testing for the plugin --- .phpunit.result.cache | 1 + .../AbortPaymentProcessor.php | 4 +- .../Action/ConvertPaymentActionTest.php | 146 +++++ tests/PHPUnit/Action/StatusActionTest.php | 331 ++++++++++ .../Creator/PayPlugPaymentDataCreatorTest.php | 598 ++++++++++++++++++ .../PaymentNotificationHandlerTest.php | 391 ++++++++++++ .../AbortPaymentProcessorTest.php | 132 ++++ .../RefundPaymentHandlerTest.php | 256 ++++++++ .../RefundPaymentProcessorTest.php | 222 +++++++ tests/PHPUnit/PhoneNumberFormatTest.php | 2 +- .../Provider/SupportedMethodsProviderTest.php | 213 +++++++ ...neyPaymentMethodsResolverDecoratorTest.php | 271 ++++++++ .../Validator/PaymentMethodValidatorTest.php | 226 +++++++ 13 files changed, 2790 insertions(+), 3 deletions(-) create mode 100644 .phpunit.result.cache create mode 100644 tests/PHPUnit/Action/ConvertPaymentActionTest.php create mode 100644 tests/PHPUnit/Action/StatusActionTest.php create mode 100644 tests/PHPUnit/Creator/PayPlugPaymentDataCreatorTest.php create mode 100644 tests/PHPUnit/Handler/PaymentNotificationHandlerTest.php create mode 100644 tests/PHPUnit/PaymentProcessing/AbortPaymentProcessorTest.php create mode 100644 tests/PHPUnit/PaymentProcessing/RefundPaymentHandlerTest.php create mode 100644 tests/PHPUnit/PaymentProcessing/RefundPaymentProcessorTest.php create mode 100644 tests/PHPUnit/Provider/SupportedMethodsProviderTest.php create mode 100644 tests/PHPUnit/Resolver/OneyPaymentMethodsResolverDecoratorTest.php create mode 100644 tests/PHPUnit/Validator/PaymentMethodValidatorTest.php diff --git a/.phpunit.result.cache b/.phpunit.result.cache new file mode 100644 index 00000000..3abff030 --- /dev/null +++ b/.phpunit.result.cache @@ -0,0 +1 @@ +{"version":1,"defects":{"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Action\\StatusActionTest::testExecute_missingStatusAndPaymentId_marksNew":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Action\\StatusActionTest::testExecute_missingPaymentId_marksNew":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Action\\StatusActionTest::testExecute_canceledStatus_marksCanceled":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Action\\StatusActionTest::testExecute_capturedStatus_marksCaptured":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Action\\StatusActionTest::testExecute_failedStatus_marksFailed":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Action\\StatusActionTest::testExecute_authorizedStatus_marksAuthorized":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Action\\StatusActionTest::testExecute_canceledByOney_marksCanceledAndAppliesOneyTransition":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Action\\StatusActionTest::testExecute_createdStatusWithPayumToken_retrievesFromApi":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Action\\StatusActionTest::testExecute_unknownStatus_marksUnknown":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_basicPayment_populatesAmountCurrencyMetadata":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_withContext_addsPaymentContextField":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_withoutContext_doesNotAddPaymentContextField":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_maleCustomer_billingTitleIsMr":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_femaleCustomer_billingTitleIsMrs":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_unknownGenderCustomer_billingTitleIsNull":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_frFrLocale_billingLanguageIsFr":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_enUsLocale_billingLanguageIsEn":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_sameShippingAndBillingId_deliveryTypeIsBilling":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_differentShippingAndBillingId_deliveryTypeIsNew":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_payplugGateway_setsAllowSaveCardFalseByDefault":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_payplugGatewayWithOneClickAllowed_sessionNull_setsAllowSaveCardTrue":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_payplugGatewayWithOneClickAllowed_sessionOther_setsAllowSaveCardTrue":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_payplugGatewayWithValidCardId_setsPaymentMethodFromCard":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_payplugGatewayWithInvalidCardId_skipsPaymentMethod":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_payplugGatewayWithDeferredCapture_convertsAmountToAuthorizedAmount":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_oneyGateway_setsOneySpecificFields":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_bancontactGateway_setsBancontactPaymentMethod":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_withMobilePhoneInBillingAddress_populatesMobilePhone":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_withLandlinePhoneInBillingAddress_populatesLandlinePhone":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_withNullPhone_doesNotPopulatePhoneFields":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Handler\\PaymentNotificationHandlerTest::testTreat_withIsPaidTrue_setsStatusCaptured":3,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Handler\\PaymentNotificationHandlerTest::testTreat_withOneyRefused_setsStatusCanceledByOney":3,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Handler\\PaymentNotificationHandlerTest::testTreat_withGenericFailure_setsStatusFailed":3,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Handler\\PaymentNotificationHandlerTest::testTreat_withIsPaidAndValidCardMetadata_savesCard":3,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Handler\\PaymentNotificationHandlerTest::testTreat_withIsPaidAndMissingCustomerId_doesNotSaveCard":3,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PaymentProcessing\\AbortPaymentProcessorTest::testProcess_withNoPaymentId_doesNotCallApi":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PaymentProcessing\\AbortPaymentProcessorTest::testProcess_withNullMethod_doesNotCallApi":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PaymentProcessing\\AbortPaymentProcessorTest::testProcess_withPaymentId_callsAbortPayment":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PaymentProcessing\\AbortPaymentProcessorTest::testProcess_httpException_isSilentlySwallowed":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PaymentProcessing\\RefundPaymentHandlerTest::testFromRequest_exactSingleItemMatch_returnsRefundUnitsForThatItem":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PaymentProcessing\\RefundPaymentHandlerTest::testFromRequest_exactShipmentMatch_returnsRefundUnitsForShipment":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PaymentProcessing\\RefundPaymentHandlerTest::testFromRequest_partialAllocation_spreadsAcrossItems":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PaymentProcessing\\RefundPaymentHandlerTest::testFromRequest_amountExceedsAvailable_throwsInvalidRefundAmount":3,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PaymentProcessing\\RefundPaymentHandlerTest::testFromRequest_itemWithZeroRemaining_isSkipped":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PaymentProcessing\\RefundPaymentProcessorTest::testProcess_missingPaymentId_doesNotCallApiAndFlashesMessage":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PaymentProcessing\\RefundPaymentProcessorTest::testProcess_nonPayplugGateway_skipsRefund":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Validator\\PaymentMethodValidatorTest::testProcess_noViolations_doesNotDisableOrFlashError":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Validator\\PaymentMethodValidatorTest::testProcess_withViolations_disablesMethodAndFlashesErrors":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Validator\\PaymentMethodValidatorTest::testProcess_payplugFactory_noFlags_validatesWithBaseConstraintOnly":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Validator\\PaymentMethodValidatorTest::testProcess_payplugFactory_allFlagsEnabled_validatesWithAllConstraints":4,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Handler\\PaymentNotificationHandlerTest::testTreat_withOneyPendingReview_setsStatusAuthorized":3,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PaymentProcessing\\RefundPaymentProcessorTest::testOnRefundCompleteTransitionEvent_withNonPaymentSubject_doesNothing":4},"times":{"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Action\\ConvertPaymentActionTest::testSupports_withConvertRequestAndPaymentSource_returnsTrue":0.023,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Action\\ConvertPaymentActionTest::testSupports_withConvertRequestButNonPaymentSource_returnsFalse":0.001,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Action\\ConvertPaymentActionTest::testSupports_withConvertRequestButWrongTo_returnsFalse":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Action\\ConvertPaymentActionTest::testExecute_delegatesToCreatorAndSetsResult":0.002,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Action\\ConvertPaymentActionTest::testExecute_passesAllCreatorFieldsToResult":0.001,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Action\\StatusActionTest::testSupports_withGetStatusAndPaymentModel_returnsTrue":0.003,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Action\\StatusActionTest::testSupports_withNonPaymentModel_returnsFalse":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Action\\StatusActionTest::testExecute_missingStatusAndPaymentId_marksNew":0.002,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Action\\StatusActionTest::testExecute_missingPaymentId_marksNew":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Action\\StatusActionTest::testExecute_canceledStatus_marksCanceled":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Action\\StatusActionTest::testExecute_capturedStatus_marksCaptured":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Action\\StatusActionTest::testExecute_failedStatus_marksFailed":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Action\\StatusActionTest::testExecute_authorizedStatus_marksAuthorized":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Action\\StatusActionTest::testExecute_canceledByOney_marksCanceledAndAppliesOneyTransition":0.005,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Action\\StatusActionTest::testExecute_createdStatusWithPayumToken_retrievesFromApi":0.002,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Action\\StatusActionTest::testExecute_unknownStatus_marksUnknown":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testFormatNumber_validMobileNumberFR_returnsMobileFlag":0.01,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testFormatNumber_validLandlineNumberFR_returnsNonMobileFlag":0.001,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testFormatNumber_invalidNumber_returnsNullValues":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testFormatNumber_unparseable_returnsNullValues":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testFormatNumber_validBelgianNumber_returnsFormattedE164":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_basicPayment_populatesAmountCurrencyMetadata":0.007,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_withContext_addsPaymentContextField":0.001,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_withoutContext_doesNotAddPaymentContextField":0.002,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_maleCustomer_billingTitleIsMr":0.001,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_femaleCustomer_billingTitleIsMrs":0.001,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_unknownGenderCustomer_billingTitleIsNull":0.001,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_frFrLocale_billingLanguageIsFr":0.001,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_enUsLocale_billingLanguageIsEn":0.001,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_sameShippingAndBillingId_deliveryTypeIsBilling":0.001,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_differentShippingAndBillingId_deliveryTypeIsNew":0.001,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_payplugGateway_setsAllowSaveCardFalseByDefault":0.014,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_payplugGatewayWithOneClickAllowed_sessionNull_setsAllowSaveCardTrue":0.002,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_payplugGatewayWithOneClickAllowed_sessionOther_setsAllowSaveCardTrue":0.001,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_payplugGatewayWithValidCardId_setsPaymentMethodFromCard":0.003,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_payplugGatewayWithInvalidCardId_skipsPaymentMethod":0.001,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_payplugGatewayWithDeferredCapture_convertsAmountToAuthorizedAmount":0.001,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_oneyGateway_setsOneySpecificFields":0.002,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_bancontactGateway_setsBancontactPaymentMethod":0.002,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_withMobilePhoneInBillingAddress_populatesMobilePhone":0.001,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_withLandlinePhoneInBillingAddress_populatesLandlinePhone":0.001,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Creator\\PayPlugPaymentDataCreatorTest::testCreate_withNullPhone_doesNotPopulatePhoneFields":0.001,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Handler\\PaymentNotificationHandlerTest::testTreat_withNonPaymentResource_doesNothing":0.005,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Handler\\PaymentNotificationHandlerTest::testTreat_withAbortedStatus_releasesLockAndReturns":0.001,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Handler\\PaymentNotificationHandlerTest::testTreat_withIsPaidTrue_setsStatusCaptured":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Handler\\PaymentNotificationHandlerTest::testTreat_withOneyPendingReview_setsStatusAuthorized":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Handler\\PaymentNotificationHandlerTest::testTreat_withOneyRefused_setsStatusCanceledByOney":0.001,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Handler\\PaymentNotificationHandlerTest::testTreat_withGenericFailure_setsStatusFailed":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Handler\\PaymentNotificationHandlerTest::testTreat_withIsPaidAndValidCardMetadata_savesCard":0.001,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Handler\\PaymentNotificationHandlerTest::testTreat_withIsPaidAndMissingCustomerId_doesNotSaveCard":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Handler\\PaymentNotificationHandlerTest::testTreat_withIsPaidAndCardAlreadyExists_doesNotSaveCardAgain":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PaymentProcessing\\AbortPaymentProcessorTest::testProcess_withNoPaymentId_doesNotCallApi":0.001,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PaymentProcessing\\AbortPaymentProcessorTest::testProcess_withNullMethod_doesNotCallApi":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PaymentProcessing\\AbortPaymentProcessorTest::testProcess_withPaymentId_callsAbortPayment":0.001,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PaymentProcessing\\AbortPaymentProcessorTest::testProcess_httpException_isSilentlySwallowed":0.002,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PaymentProcessing\\RefundPaymentHandlerTest::testFromRequest_exactSingleItemMatch_returnsRefundUnitsForThatItem":0.012,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PaymentProcessing\\RefundPaymentHandlerTest::testFromRequest_exactShipmentMatch_returnsRefundUnitsForShipment":0.001,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PaymentProcessing\\RefundPaymentHandlerTest::testFromRequest_partialAllocation_spreadsAcrossItems":0.001,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PaymentProcessing\\RefundPaymentHandlerTest::testFromRequest_amountExceedsAvailable_throwsInvalidRefundAmount":0.002,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PaymentProcessing\\RefundPaymentHandlerTest::testFromRequest_itemWithZeroRemaining_isSkipped":0.001,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PaymentProcessing\\RefundPaymentProcessorTest::testProcess_fullRefundSuccess_callsApiRefund":0.002,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PaymentProcessing\\RefundPaymentProcessorTest::testProcess_apiThrowsException_throwsUpdateHandlingException":0.002,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PaymentProcessing\\RefundPaymentProcessorTest::testProcess_missingPaymentId_doesNotCallApiAndFlashesMessage":0.003,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PaymentProcessing\\RefundPaymentProcessorTest::testProcess_nonPayplugGateway_skipsRefund":0.001,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PaymentProcessing\\RefundPaymentProcessorTest::testProcessWithAmount_success_createsRefundHistoryEntry":0.002,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PaymentProcessing\\RefundPaymentProcessorTest::testProcessWithAmount_apiThrowsException_throwsUpdateHandlingException":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\phoneNumberFormatTest::testFormatNumberMethod with data set #0":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\phoneNumberFormatTest::testFormatNumberMethod with data set #1":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\phoneNumberFormatTest::testFormatNumberMethod with data set #2":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\phoneNumberFormatTest::testFormatNumberMethod with data set #3":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\phoneNumberFormatTest::testFormatNumberMethod with data set #4":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\phoneNumberFormatTest::testFormatNumberMethod with data set #5":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\phoneNumberFormatTest::testFormatNumberMethod with data set #6":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\phoneNumberFormatTest::testFormatNumberMethod with data set #7":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\phoneNumberFormatTest::testFormatNumberMethod with data set #8":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\phoneNumberFormatTest::testFormatNumberMethod with data set #9":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\phoneNumberFormatTest::testFormatNumberMethod with data set #10":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\phoneNumberFormatTest::testFormatNumberMethod with data set #11":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\phoneNumberFormatTest::testFormatNumberMethod with data set #12":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\phoneNumberFormatTest::testFormatNumberMethod with data set #13":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\phoneNumberFormatTest::testFormatNumberMethod with data set #14":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\phoneNumberFormatTest::testFormatNumberMethod with data set #15":0.001,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\phoneNumberFormatTest::testFormatNumberMethod with data set #16":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\phoneNumberFormatTest::testFormatNumberMethod with data set #17":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\phoneNumberFormatTest::testFormatNumberMethod with data set #18":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\phoneNumberFormatTest::testFormatNumberMethod with data set #19":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\phoneNumberFormatTest::testFormatNumberMethod with data set #20":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\phoneNumberFormatTest::testFormatNumberMethod with data set #21":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\phoneNumberFormatTest::testFormatNumberMethod with data set #22":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Provider\\SupportedMethodsProviderTest::testProvide_withDifferentFactory_doesNotFilter":0.001,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Provider\\SupportedMethodsProviderTest::testProvide_withUnauthorizedCurrency_removesMethod":0.001,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Provider\\SupportedMethodsProviderTest::testProvide_withAmountBelowMin_removesMethod":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Provider\\SupportedMethodsProviderTest::testProvide_withAmountAboveMax_removesMethod":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Provider\\SupportedMethodsProviderTest::testProvide_withValidCurrencyAndAmount_keepsMethod":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Provider\\SupportedMethodsProviderTest::testProvide_withAmountAtMinBoundary_keepsMethod":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Provider\\SupportedMethodsProviderTest::testProvide_withAmountAtMaxBoundary_keepsMethod":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Provider\\SupportedMethodsProviderTest::testProvide_withMixedList_filtersCorrectly":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Resolver\\OneyPaymentMethodsResolverDecoratorTest::testGetSupportedMethods_nonOneyMethod_isNotFiltered":0.003,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Resolver\\OneyPaymentMethodsResolverDecoratorTest::testGetSupportedMethods_oneyDisabled_removesOneyMethod":0.001,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Resolver\\OneyPaymentMethodsResolverDecoratorTest::testGetSupportedMethods_priceIneligible_removesOneyMethod":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Resolver\\OneyPaymentMethodsResolverDecoratorTest::testGetSupportedMethods_tooManyItems_removesOneyMethod":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Resolver\\OneyPaymentMethodsResolverDecoratorTest::testGetSupportedMethods_countryIneligible_removesOneyMethod":0.001,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Resolver\\OneyPaymentMethodsResolverDecoratorTest::testGetSupportedMethods_allConditionsMet_keepsOneyMethod":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Resolver\\OneyPaymentMethodsResolverDecoratorTest::testGetSupportedMethods_mixedList_onlyOneyRemoved":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Resolver\\OneyPaymentMethodsResolverDecoratorTest::testSupports_delegatesToDecorated":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Validator\\PaymentMethodValidatorTest::testProcess_nullGatewayConfig_returnsEarlyWithoutFlush":0.002,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Validator\\PaymentMethodValidatorTest::testProcess_unsupportedFactory_throwsInvalidArgumentException":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Validator\\PaymentMethodValidatorTest::testProcess_noViolations_doesNotDisableOrFlashError":0.005,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Validator\\PaymentMethodValidatorTest::testProcess_withViolations_disablesMethodAndFlashesErrors":0.001,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Validator\\PaymentMethodValidatorTest::testProcess_payplugFactory_noFlags_validatesWithBaseConstraintOnly":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\Validator\\PaymentMethodValidatorTest::testProcess_payplugFactory_allFlagsEnabled_validatesWithAllConstraints":0.001,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PaymentProcessing\\RefundPaymentProcessorTest::testOnRefundCompleteTransitionEvent_withNonPaymentSubject_doesNothing":0.002,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PaymentProcessing\\RefundPaymentProcessorTest::testProcess_nullGatewayConfig_skipsRefundWithoutApiCall":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PhoneNumberFormatTest::testFormatNumberMethod with data set #0":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PhoneNumberFormatTest::testFormatNumberMethod with data set #1":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PhoneNumberFormatTest::testFormatNumberMethod with data set #2":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PhoneNumberFormatTest::testFormatNumberMethod with data set #3":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PhoneNumberFormatTest::testFormatNumberMethod with data set #4":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PhoneNumberFormatTest::testFormatNumberMethod with data set #5":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PhoneNumberFormatTest::testFormatNumberMethod with data set #6":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PhoneNumberFormatTest::testFormatNumberMethod with data set #7":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PhoneNumberFormatTest::testFormatNumberMethod with data set #8":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PhoneNumberFormatTest::testFormatNumberMethod with data set #9":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PhoneNumberFormatTest::testFormatNumberMethod with data set #10":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PhoneNumberFormatTest::testFormatNumberMethod with data set #11":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PhoneNumberFormatTest::testFormatNumberMethod with data set #12":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PhoneNumberFormatTest::testFormatNumberMethod with data set #13":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PhoneNumberFormatTest::testFormatNumberMethod with data set #14":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PhoneNumberFormatTest::testFormatNumberMethod with data set #15":0.001,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PhoneNumberFormatTest::testFormatNumberMethod with data set #16":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PhoneNumberFormatTest::testFormatNumberMethod with data set #17":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PhoneNumberFormatTest::testFormatNumberMethod with data set #18":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PhoneNumberFormatTest::testFormatNumberMethod with data set #19":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PhoneNumberFormatTest::testFormatNumberMethod with data set #20":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PhoneNumberFormatTest::testFormatNumberMethod with data set #21":0,"Tests\\PayPlug\\SyliusPayPlugPlugin\\PHPUnit\\PhoneNumberFormatTest::testFormatNumberMethod with data set #22":0}} \ No newline at end of file diff --git a/src/PaymentProcessing/AbortPaymentProcessor.php b/src/PaymentProcessing/AbortPaymentProcessor.php index 2b805aac..cd72d57f 100644 --- a/src/PaymentProcessing/AbortPaymentProcessor.php +++ b/src/PaymentProcessing/AbortPaymentProcessor.php @@ -5,7 +5,7 @@ namespace PayPlug\SyliusPayPlugPlugin\PaymentProcessing; use Payplug\Exception\HttpException; -use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactory; +use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface; use Sylius\Component\Core\Model\PaymentInterface; use Sylius\Component\Payment\PaymentTransitions; use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; @@ -16,7 +16,7 @@ class AbortPaymentProcessor { public function __construct( - private PayPlugApiClientFactory $payplugApiClientFactory, + private PayPlugApiClientFactoryInterface $payplugApiClientFactory, ) { } diff --git a/tests/PHPUnit/Action/ConvertPaymentActionTest.php b/tests/PHPUnit/Action/ConvertPaymentActionTest.php new file mode 100644 index 00000000..aebea7a8 --- /dev/null +++ b/tests/PHPUnit/Action/ConvertPaymentActionTest.php @@ -0,0 +1,146 @@ +paymentDataCreator = $this->createMock(PayPlugPaymentDataCreator::class); + $this->apiClient = $this->createMock(PayPlugApiClientInterface::class); + + $this->action = new ConvertPaymentAction($this->paymentDataCreator); + $this->action->setApi($this->apiClient); + } + + // ------------------------------------------------------------------------- + // supports() + // ------------------------------------------------------------------------- + + /** + * Passes a Convert request with a PaymentInterface source and 'array' as the target format. + * Verifies supports() returns true. + */ + public function testSupports_withConvertRequestAndPaymentSource_returnsTrue(): void + { + $request = $this->createMock(Convert::class); + $request->method('getSource')->willReturn($this->createMock(PaymentInterface::class)); + $request->method('getTo')->willReturn('array'); + + self::assertTrue($this->action->supports($request)); + } + + /** + * Source is a plain stdClass instead of a PaymentInterface. + * Verifies supports() returns false. + */ + public function testSupports_withConvertRequestButNonPaymentSource_returnsFalse(): void + { + $request = $this->createMock(Convert::class); + $request->method('getSource')->willReturn(new \stdClass()); + $request->method('getTo')->willReturn('array'); + + self::assertFalse($this->action->supports($request)); + } + + /** + * Source is a valid PaymentInterface but the target format is 'json' instead of 'array'. + * Verifies supports() returns false. + */ + public function testSupports_withConvertRequestButWrongTo_returnsFalse(): void + { + $request = $this->createMock(Convert::class); + $request->method('getSource')->willReturn($this->createMock(PaymentInterface::class)); + $request->method('getTo')->willReturn('json'); + + self::assertFalse($this->action->supports($request)); + } + + // ------------------------------------------------------------------------- + // execute() — delegates to creator and sets result + // ------------------------------------------------------------------------- + + /** + * Calls execute() and verifies the action delegates to PayPlugPaymentDataCreator::create() exactly once. + * The ArrayObject returned by the creator is converted to an array and passed to setResult(). + */ + public function testExecute_delegatesToCreatorAndSetsResult(): void + { + $payment = $this->createMock(PaymentInterface::class); + + $createdDetails = new ArrayObject([ + 'amount' => 1500, + 'currency' => 'EUR', + ]); + + $this->paymentDataCreator + ->expects(self::once()) + ->method('create') + ->with($payment) + ->willReturn($createdDetails) + ; + + $request = $this->createMock(Convert::class); + $request->method('getSource')->willReturn($payment); + $request->method('getTo')->willReturn('array'); + $request->expects(self::once()) + ->method('setResult') + ->with(['amount' => 1500, 'currency' => 'EUR']) + ; + + $this->action->execute($request); + } + + // ------------------------------------------------------------------------- + // execute() — result contains all fields from creator + // ------------------------------------------------------------------------- + + /** + * The creator returns an ArrayObject with multiple nested fields (amount, payment_method, metadata). + * Verifies all fields are present and correctly typed in the array passed to setResult(). + */ + public function testExecute_passesAllCreatorFieldsToResult(): void + { + $payment = $this->createMock(PaymentInterface::class); + + $createdDetails = new ArrayObject([ + 'amount' => 2000, + 'currency' => 'EUR', + 'payment_method' => 'bancontact', + 'metadata' => ['customer_id' => 42, 'order_number' => 'ORD-99'], + ]); + + $this->paymentDataCreator->method('create')->willReturn($createdDetails); + + $capturedResult = null; + $request = $this->createMock(Convert::class); + $request->method('getSource')->willReturn($payment); + $request->method('getTo')->willReturn('array'); + $request->method('setResult')->willReturnCallback(function (array $result) use (&$capturedResult) { + $capturedResult = $result; + }); + + $this->action->execute($request); + + self::assertSame(2000, $capturedResult['amount']); + self::assertSame('bancontact', $capturedResult['payment_method']); + self::assertSame(42, $capturedResult['metadata']['customer_id']); + } +} diff --git a/tests/PHPUnit/Action/StatusActionTest.php b/tests/PHPUnit/Action/StatusActionTest.php new file mode 100644 index 00000000..ce9b9962 --- /dev/null +++ b/tests/PHPUnit/Action/StatusActionTest.php @@ -0,0 +1,331 @@ +stateMachine = $this->createMock(StateMachineInterface::class); + $this->refundPaymentHandler = $this->createMock(RefundPaymentHandlerInterface::class); + $this->paymentNotificationHandler = $this->createMock(PaymentNotificationHandler::class); + $this->requestStack = $this->createMock(RequestStack::class); + $this->apiClient = $this->createMock(PayPlugApiClientInterface::class); + $this->gateway = $this->createMock(GatewayInterface::class); + + $this->action = new StatusAction( + $this->stateMachine, + $this->refundPaymentHandler, + $this->paymentNotificationHandler, + $this->requestStack, + ); + $this->action->setApi($this->apiClient); + $this->action->setGateway($this->gateway); + } + + // ------------------------------------------------------------------------- + // supports() + // ------------------------------------------------------------------------- + + /** + * Passes a GetStatusInterface request whose model is a PaymentInterface. + * Verifies supports() returns true (the action handles this combination). + */ + public function testSupports_withGetStatusAndPaymentModel_returnsTrue(): void + { + $request = $this->createMock(GetStatusInterface::class); + $request->method('getModel')->willReturn($this->createMock(PaymentInterface::class)); + + self::assertTrue($this->action->supports($request)); + } + + /** + * Passes a GetStatusInterface request whose model is a plain stdClass (not a payment). + * Verifies supports() returns false. + */ + public function testSupports_withNonPaymentModel_returnsFalse(): void + { + $request = $this->createMock(GetStatusInterface::class); + $request->method('getModel')->willReturn(new \stdClass()); + + self::assertFalse($this->action->supports($request)); + } + + // ------------------------------------------------------------------------- + // execute() — missing status or payment_id → markNew + // ------------------------------------------------------------------------- + + /** + * Payment details are empty (no status, no payment_id) — payment was never initiated. + * Verifies execute() calls markNew() on the request. + */ + public function testExecute_missingStatusAndPaymentId_marksNew(): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getDetails')->willReturn([]); + + $request = $this->buildStatusRequest($payment); + $request->expects(self::once())->method('markNew'); + + $this->action->execute($request); + } + + /** + * Payment details have a status but no payment_id (redirect happened, ID not yet stored). + * Verifies execute() calls markNew() (cannot poll the API without an ID). + */ + public function testExecute_missingPaymentId_marksNew(): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getDetails')->willReturn(['status' => PayPlugApiClientInterface::STATUS_CREATED]); + + $request = $this->buildStatusRequest($payment); + $request->expects(self::once())->method('markNew'); + + $this->action->execute($request); + } + + // ------------------------------------------------------------------------- + // execute() — STATUS_CANCELED → markCanceled + // ------------------------------------------------------------------------- + + /** + * Payment details contain STATUS_CANCELED and a valid payment_id. + * Verifies execute() calls markCanceled() on the request. + */ + public function testExecute_canceledStatus_marksCanceled(): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getDetails')->willReturn([ + 'status' => PayPlugApiClientInterface::STATUS_CANCELED, + 'payment_id' => 'pay_001', + ]); + $payment->method('setDetails'); + + $request = $this->buildStatusRequest($payment); + $request->expects(self::once())->method('markCanceled'); + + $this->gateway->method('execute'); // GetHttpRequest + + $this->action->execute($request); + } + + // ------------------------------------------------------------------------- + // execute() — STATUS_CAPTURED → markCaptured + // ------------------------------------------------------------------------- + + /** + * Payment details contain STATUS_CAPTURED and a valid payment_id. + * Verifies execute() calls markCaptured() on the request. + */ + public function testExecute_capturedStatus_marksCaptured(): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getDetails')->willReturn([ + 'status' => PayPlugApiClientInterface::STATUS_CAPTURED, + 'payment_id' => 'pay_002', + ]); + $payment->method('setDetails'); + + $request = $this->buildStatusRequest($payment); + $request->expects(self::once())->method('markCaptured'); + + $this->gateway->method('execute'); + + $this->action->execute($request); + } + + // ------------------------------------------------------------------------- + // execute() — FAILED → markFailed + // ------------------------------------------------------------------------- + + /** + * Payment details contain FAILED status and a valid payment_id. + * Verifies execute() calls markFailed() on the request. + */ + public function testExecute_failedStatus_marksFailed(): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getDetails')->willReturn([ + 'status' => PayPlugApiClientInterface::FAILED, + 'payment_id' => 'pay_003', + ]); + $payment->method('setDetails'); + + $request = $this->buildStatusRequest($payment); + $request->expects(self::once())->method('markFailed'); + + $this->gateway->method('execute'); + + $this->action->execute($request); + } + + // ------------------------------------------------------------------------- + // execute() — STATUS_AUTHORIZED → markAuthorized + // ------------------------------------------------------------------------- + + /** + * Payment details contain STATUS_AUTHORIZED (deferred capture pending). + * Verifies execute() calls markAuthorized() on the request. + */ + public function testExecute_authorizedStatus_marksAuthorized(): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getDetails')->willReturn([ + 'status' => PayPlugApiClientInterface::STATUS_AUTHORIZED, + 'payment_id' => 'pay_004', + ]); + $payment->method('setDetails'); + + $request = $this->buildStatusRequest($payment); + $request->expects(self::once())->method('markAuthorized'); + + $this->gateway->method('execute'); + + $this->action->execute($request); + } + + // ------------------------------------------------------------------------- + // execute() — STATUS_CANCELED_BY_ONEY → markCanceled + state machine transition + // ------------------------------------------------------------------------- + + /** + * Payment details contain STATUS_CANCELED_BY_ONEY (Oney refused the financing request). + * Verifies markCanceled() is called and the state machine applies the oney_request_payment transition. + */ + public function testExecute_canceledByOney_marksCanceledAndAppliesOneyTransition(): void + { + $order = $this->createMock(OrderInterface::class); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getDetails')->willReturn([ + 'status' => PayPlugApiClientInterface::STATUS_CANCELED_BY_ONEY, + 'payment_id' => 'pay_005', + ]); + $payment->method('setDetails'); + $payment->method('getOrder')->willReturn($order); + + $request = $this->buildStatusRequest($payment); + $request->expects(self::once())->method('markCanceled'); + + $this->gateway->method('execute'); + + $this->stateMachine->method('can')->willReturn(true); + $this->stateMachine->expects(self::once()) + ->method('apply') + ->with($order, OrderPaymentTransitions::GRAPH, OrderPaymentTransitions::TRANSITION_ONEY_REQUEST_PAYMENT) + ; + + $this->action->execute($request); + } + + // ------------------------------------------------------------------------- + // execute() — STATUS_CREATED + payum_token in query → polls API + // ------------------------------------------------------------------------- + + /** + * Status is STATUS_CREATED and the HTTP request carries a payum_token (customer returning from PayPlug). + * Verifies the API client's retrieve() is called once and the notification handler processes the result. + */ + public function testExecute_createdStatusWithPayumToken_retrievesFromApi(): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getDetails')->willReturn([ + 'status' => PayPlugApiClientInterface::STATUS_CREATED, + 'payment_id' => 'pay_006', + ]); + $payment->method('setDetails'); + $payment->method('getOrder')->willReturn($this->createMock(OrderInterface::class)); + + $request = $this->buildStatusRequest($payment); + + // Simulate GetHttpRequest populating query['payum_token'] + $this->gateway->method('execute')->willReturnCallback(function ($req) { + if ($req instanceof \Payum\Core\Request\GetHttpRequest) { + $req->query['payum_token'] = 'tok_xyz'; + } + }); + + $apiPayment = $this->createMock(PayplugPayment::class); + $this->apiClient->expects(self::once())->method('retrieve')->with('pay_006')->willReturn($apiPayment); + $this->paymentNotificationHandler->expects(self::once())->method('treat'); + + $this->action->execute($request); + } + + // ------------------------------------------------------------------------- + // execute() — unknown status → markUnknown + // ------------------------------------------------------------------------- + + /** + * Payment details contain an unrecognised status string not covered by any case in markRequestAs(). + * Verifies execute() calls markUnknown() on the request. + */ + public function testExecute_unknownStatus_marksUnknown(): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getDetails')->willReturn([ + 'status' => 'some_unknown_status', + 'payment_id' => 'pay_007', + ]); + $payment->method('setDetails'); + + $request = $this->buildStatusRequest($payment); + $request->expects(self::once())->method('markUnknown'); + + $this->gateway->method('execute'); + + $this->action->execute($request); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Use GetHumanStatus (concrete class) so all interface methods + getFirstModel() are available. + */ + private function buildStatusRequest(PaymentInterface $payment): GetHumanStatus&MockObject + { + $request = $this->getMockBuilder(GetHumanStatus::class) + ->disableOriginalConstructor() + ->getMock() + ; + $request->method('getModel')->willReturn($payment); + $request->method('getFirstModel')->willReturn($payment); + + return $request; + } +} diff --git a/tests/PHPUnit/Creator/PayPlugPaymentDataCreatorTest.php b/tests/PHPUnit/Creator/PayPlugPaymentDataCreatorTest.php new file mode 100644 index 00000000..e8bf2b12 --- /dev/null +++ b/tests/PHPUnit/Creator/PayPlugPaymentDataCreatorTest.php @@ -0,0 +1,598 @@ +canSaveCardChecker = $this->createMock(CanSaveCardCheckerInterface::class); + $this->payplugCardRepository = $this->createMock(RepositoryInterface::class); + $this->requestStack = $this->createMock(RequestStack::class); + $this->payplugFeatureChecker = $this->createMock(PayplugFeatureChecker::class); + + $this->creator = new PayPlugPaymentDataCreator( + $this->canSaveCardChecker, + $this->payplugCardRepository, + $this->requestStack, + $this->payplugFeatureChecker, + ); + } + + // ------------------------------------------------------------------------- + // formatNumber() tests + // ------------------------------------------------------------------------- + + /** + * Parses a valid French mobile number (06…) via libphonenumber. + * Expects E.164 format and is_mobile=true. + */ + public function testFormatNumber_validMobileNumberFR_returnsMobileFlag(): void + { + $result = $this->creator->formatNumber('0615151515', 'FR'); + + self::assertSame('+33615151515', $result['phone']); + self::assertTrue($result['is_mobile']); + } + + /** + * Parses a valid French landline number (01…) via libphonenumber. + * Expects E.164 format and is_mobile=false. + */ + public function testFormatNumber_validLandlineNumberFR_returnsNonMobileFlag(): void + { + $result = $this->creator->formatNumber('0123456789', 'FR'); + + self::assertStringStartsWith('+33', $result['phone']); + self::assertFalse($result['is_mobile']); + } + + /** + * Passes a too-short number that fails libphonenumber validation (not parseable as valid). + * Expects both phone and is_mobile to be null. + */ + public function testFormatNumber_invalidNumber_returnsNullValues(): void + { + $result = $this->creator->formatNumber('123', 'FR'); + + self::assertNull($result['phone']); + self::assertNull($result['is_mobile']); + } + + /** + * Passes a non-numeric string that libphonenumber cannot parse at all. + * Expects both phone and is_mobile to be null (exception caught internally). + */ + public function testFormatNumber_unparseable_returnsNullValues(): void + { + // A string that libphonenumber cannot parse at all + $result = $this->creator->formatNumber('not-a-phone', 'FR'); + + self::assertNull($result['phone']); + self::assertNull($result['is_mobile']); + } + + /** + * Parses a valid Belgian landline number with the BE region hint. + * Verifies the country-code prefix (+32) is correctly applied in E.164 format. + */ + public function testFormatNumber_validBelgianNumber_returnsFormattedE164(): void + { + $result = $this->creator->formatNumber('023456789', 'BE'); + + self::assertSame('+3223456789', $result['phone']); + self::assertFalse($result['is_mobile']); + } + + // ------------------------------------------------------------------------- + // create() — basic fields + // ------------------------------------------------------------------------- + + /** + * Calls create() with a minimal payment (no gateway, no phone). + * Verifies amount, currency, customer_id and order_number are all set in the output. + */ + public function testCreate_basicPayment_populatesAmountCurrencyMetadata(): void + { + $payment = $this->buildMinimalPayment(1500, 'EUR', 42, 'ORDER-001'); + + $details = $this->creator->create($payment); + + self::assertSame(1500, $details['amount']); + self::assertSame('EUR', $details['currency']); + self::assertSame(42, $details['metadata']['customer_id']); + self::assertSame('ORDER-001', $details['metadata']['order_number']); + } + + /** + * Calls create() with an explicit $context array passed as second argument. + * Verifies the array is forwarded verbatim as payment_context in the output. + */ + public function testCreate_withContext_addsPaymentContextField(): void + { + $payment = $this->buildMinimalPayment(1000, 'EUR', 1, 'ORD-1'); + $context = ['some' => 'context']; + + $details = $this->creator->create($payment, $context); + + self::assertSame($context, $details['payment_context']); + } + + /** + * Calls create() without passing a $context argument (default null). + * Verifies payment_context is absent from the output array. + */ + public function testCreate_withoutContext_doesNotAddPaymentContextField(): void + { + $payment = $this->buildMinimalPayment(1000, 'EUR', 1, 'ORD-1'); + + $details = $this->creator->create($payment); + + self::assertArrayNotHasKey('payment_context', $details->getArrayCopy()); + } + + // ------------------------------------------------------------------------- + // create() — billing address title (gender mapping) + // ------------------------------------------------------------------------- + + /** + * Builds a payment with a customer whose gender is 'm'. + * Verifies formatTitle() maps it to the PayPlug salutation 'mr'. + */ + public function testCreate_maleCustomer_billingTitleIsMr(): void + { + $payment = $this->buildMinimalPayment(1000, 'EUR', 1, 'ORD-1', 'm'); + + $details = $this->creator->create($payment); + + self::assertSame('mr', $details['billing']['title']); + } + + /** + * Builds a payment with a customer whose gender is 'f'. + * Verifies formatTitle() maps it to the PayPlug salutation 'mrs'. + */ + public function testCreate_femaleCustomer_billingTitleIsMrs(): void + { + $payment = $this->buildMinimalPayment(1000, 'EUR', 1, 'ORD-1', 'f'); + + $details = $this->creator->create($payment); + + self::assertSame('mrs', $details['billing']['title']); + } + + /** + * Builds a payment with a customer whose gender is an unrecognised value ('u'). + * Verifies formatTitle() returns null (no match in the gender→salutation map). + */ + public function testCreate_unknownGenderCustomer_billingTitleIsNull(): void + { + $payment = $this->buildMinimalPayment(1000, 'EUR', 1, 'ORD-1', 'u'); + + $details = $this->creator->create($payment); + + self::assertNull($details['billing']['title']); + } + + // ------------------------------------------------------------------------- + // create() — locale / language code + // ------------------------------------------------------------------------- + + /** + * Sets the order locale to 'fr_FR' (full Sylius locale with region). + * Verifies formatLanguageCode() truncates it to just 'fr' in billing.language. + */ + public function testCreate_frFrLocale_billingLanguageIsFr(): void + { + $payment = $this->buildMinimalPayment(1000, 'EUR', 1, 'ORD-1', 'm', 'fr_FR'); + + $details = $this->creator->create($payment); + + self::assertSame('fr', $details['billing']['language']); + } + + /** + * Sets the order locale to 'en_US'. + * Verifies formatLanguageCode() truncates it to 'en'. + */ + public function testCreate_enUsLocale_billingLanguageIsEn(): void + { + $payment = $this->buildMinimalPayment(1000, 'EUR', 1, 'ORD-1', 'm', 'en_US'); + + $details = $this->creator->create($payment); + + self::assertSame('en', $details['billing']['language']); + } + + // ------------------------------------------------------------------------- + // create() — delivery type (billing vs new) + // ------------------------------------------------------------------------- + + /** + * Uses identical address IDs for billing and shipping. + * Verifies the delivery_type field is set to 'BILLING' (ship-to-billing shortcut). + */ + public function testCreate_sameShippingAndBillingId_deliveryTypeIsBilling(): void + { + $payment = $this->buildMinimalPayment(1000, 'EUR', 1, 'ORD-1', 'm', 'fr_FR', sameAddress: true); + + $details = $this->creator->create($payment); + + self::assertSame('BILLING', $details['shipping']['delivery_type']); + } + + /** + * Uses distinct address IDs for billing and shipping. + * Verifies the delivery_type field is set to 'NEW'. + */ + public function testCreate_differentShippingAndBillingId_deliveryTypeIsNew(): void + { + $payment = $this->buildMinimalPayment(1000, 'EUR', 1, 'ORD-1', 'm', 'fr_FR', sameAddress: false); + + $details = $this->creator->create($payment); + + self::assertSame('NEW', $details['shipping']['delivery_type']); + } + + // ------------------------------------------------------------------------- + // create() — PayPlug gateway one-click / deferred capture + // ------------------------------------------------------------------------- + + /** + * Uses the PayPlug gateway with CanSaveCardChecker returning false and deferred capture disabled. + * Verifies allow_save_card is false (one-click feature off). + */ + public function testCreate_payplugGateway_setsAllowSaveCardFalseByDefault(): void + { + $payment = $this->buildMinimalPaymentWithGateway(PayPlugGatewayFactory::FACTORY_NAME); + $this->canSaveCardChecker->method('isAllowed')->willReturn(false); + $this->payplugFeatureChecker->method('isDeferredCaptureEnabled')->willReturn(false); + + $details = $this->creator->create($payment); + + self::assertFalse($details['allow_save_card']); + } + + /** + * One-click is allowed but the session holds no 'payplug_payment_method' key (new customer). + * Verifies allow_save_card is true so the card-saving checkbox appears at checkout. + */ + public function testCreate_payplugGatewayWithOneClickAllowed_sessionNull_setsAllowSaveCardTrue(): void + { + $payment = $this->buildMinimalPaymentWithGateway(PayPlugGatewayFactory::FACTORY_NAME); + $this->canSaveCardChecker->method('isAllowed')->willReturn(true); + $this->payplugFeatureChecker->method('isDeferredCaptureEnabled')->willReturn(false); + + $session = $this->createMock(SessionInterface::class); + $session->method('get')->with('payplug_payment_method')->willReturn(null); + $this->requestStack->method('getSession')->willReturn($session); + + $details = $this->creator->create($payment); + + self::assertTrue($details['allow_save_card']); + } + + /** + * One-click is allowed and the session holds 'other' (not a numeric card ID). + * Verifies allow_save_card is still true and no payment_method/initiator fields are injected. + */ + public function testCreate_payplugGatewayWithOneClickAllowed_sessionOther_setsAllowSaveCardTrue(): void + { + $payment = $this->buildMinimalPaymentWithGateway(PayPlugGatewayFactory::FACTORY_NAME); + $this->canSaveCardChecker->method('isAllowed')->willReturn(true); + $this->payplugFeatureChecker->method('isDeferredCaptureEnabled')->willReturn(false); + + $session = $this->createMock(SessionInterface::class); + $session->method('get')->with('payplug_payment_method')->willReturn('other'); + $this->requestStack->method('getSession')->willReturn($session); + + $details = $this->creator->create($payment); + + self::assertTrue($details['allow_save_card']); + } + + /** + * Session holds a numeric card ID that resolves to a saved Card entity in the repository. + * Verifies the card's external ID is set as payment_method and initiator is set to 'PAYER'. + */ + public function testCreate_payplugGatewayWithValidCardId_setsPaymentMethodFromCard(): void + { + $payment = $this->buildMinimalPaymentWithGateway(PayPlugGatewayFactory::FACTORY_NAME); + $this->canSaveCardChecker->method('isAllowed')->willReturn(true); + $this->payplugFeatureChecker->method('isDeferredCaptureEnabled')->willReturn(false); + + $session = $this->createMock(SessionInterface::class); + $session->method('get')->with('payplug_payment_method')->willReturn('42'); + $this->requestStack->method('getSession')->willReturn($session); + + $card = $this->createMock(Card::class); + $card->method('getExternalId')->willReturn('card_ext_id_xyz'); + $this->payplugCardRepository->method('find')->with('42')->willReturn($card); + + $details = $this->creator->create($payment); + + self::assertSame('card_ext_id_xyz', $details['payment_method']); + self::assertSame('PAYER', $details['initiator']); + } + + /** + * Session holds a numeric card ID that returns null from the repository (card not found). + * Verifies payment_method and initiator are absent from the output (one-click skipped). + */ + public function testCreate_payplugGatewayWithInvalidCardId_skipsPaymentMethod(): void + { + $payment = $this->buildMinimalPaymentWithGateway(PayPlugGatewayFactory::FACTORY_NAME); + $this->canSaveCardChecker->method('isAllowed')->willReturn(true); + $this->payplugFeatureChecker->method('isDeferredCaptureEnabled')->willReturn(false); + + $session = $this->createMock(SessionInterface::class); + $session->method('get')->with('payplug_payment_method')->willReturn('99'); + $this->requestStack->method('getSession')->willReturn($session); + + $this->payplugCardRepository->method('find')->with('99')->willReturn(null); + + $details = $this->creator->create($payment); + + self::assertArrayNotHasKey('payment_method', $details->getArrayCopy()); + self::assertArrayNotHasKey('initiator', $details->getArrayCopy()); + } + + /** + * Enables deferred capture via PayplugFeatureChecker. + * Verifies amount is replaced by authorized_amount and the amount key is absent from the payload. + */ + public function testCreate_payplugGatewayWithDeferredCapture_convertsAmountToAuthorizedAmount(): void + { + $payment = $this->buildMinimalPaymentWithGateway(PayPlugGatewayFactory::FACTORY_NAME); + $this->canSaveCardChecker->method('isAllowed')->willReturn(false); + $this->payplugFeatureChecker->method('isDeferredCaptureEnabled')->willReturn(true); + + $details = $this->creator->create($payment); + + self::assertArrayNotHasKey('amount', $details->getArrayCopy()); + self::assertSame(1000, $details['authorized_amount']); + } + + // ------------------------------------------------------------------------- + // create() — Oney gateway + // ------------------------------------------------------------------------- + + /** + * Uses the Oney gateway factory with a session value selecting 'oney_x3_with_fees'. + * Verifies payment_method, auto_capture, authorized_amount and payment_context are all set. + */ + public function testCreate_oneyGateway_setsOneySpecificFields(): void + { + $payment = $this->buildMinimalPaymentWithGateway(OneyGatewayFactory::FACTORY_NAME); + + $session = $this->createMock(SessionInterface::class); + $session->method('get') + ->with('oney_payment_method', 'oney_x3_with_fees') + ->willReturn('oney_x3_with_fees'); + $this->requestStack->method('getSession')->willReturn($session); + + $details = $this->creator->create($payment); + + self::assertSame('oney_x3_with_fees', $details['payment_method']); + self::assertTrue($details['auto_capture']); + self::assertArrayNotHasKey('amount', $details->getArrayCopy()); + self::assertSame(1000, $details['authorized_amount']); + self::assertArrayHasKey('payment_context', $details->getArrayCopy()); + } + + // ------------------------------------------------------------------------- + // create() — Bancontact gateway (PPRO payment_method field) + // ------------------------------------------------------------------------- + + /** + * Uses the Bancontact gateway factory (a PPRO method routed through PayPlug). + * Verifies the payment_method field is set to the literal string 'bancontact'. + */ + public function testCreate_bancontactGateway_setsBancontactPaymentMethod(): void + { + $payment = $this->buildMinimalPaymentWithGateway(BancontactGatewayFactory::FACTORY_NAME); + + $details = $this->creator->create($payment); + + self::assertSame('bancontact', $details['payment_method']); + } + + // ------------------------------------------------------------------------- + // create() — phone number in billing / shipping address + // ------------------------------------------------------------------------- + + /** + * Sets a French mobile number on the billing address. + * Verifies mobile_phone_number is populated and landline_phone_number is null. + */ + public function testCreate_withMobilePhoneInBillingAddress_populatesMobilePhone(): void + { + $payment = $this->buildMinimalPayment(1000, 'EUR', 1, 'ORD-1', 'm', 'fr_FR', false, '0615151515'); + + $details = $this->creator->create($payment); + + self::assertSame('+33615151515', $details['billing']['mobile_phone_number']); + self::assertNull($details['billing']['landline_phone_number']); + } + + /** + * Sets a French landline number on the billing address. + * Verifies landline_phone_number is populated and mobile_phone_number is null. + */ + public function testCreate_withLandlinePhoneInBillingAddress_populatesLandlinePhone(): void + { + $payment = $this->buildMinimalPayment(1000, 'EUR', 1, 'ORD-1', 'm', 'fr_FR', false, '0123456789'); + + $details = $this->creator->create($payment); + + self::assertNull($details['billing']['mobile_phone_number']); + self::assertStringStartsWith('+33', $details['billing']['landline_phone_number']); + } + + /** + * Sets getPhoneNumber() to null on the billing address. + * Verifies both mobile_phone_number and landline_phone_number are null in the output. + */ + public function testCreate_withNullPhone_doesNotPopulatePhoneFields(): void + { + $payment = $this->buildMinimalPayment(1000, 'EUR', 1, 'ORD-1', 'm', 'fr_FR', false, null); + + $details = $this->creator->create($payment); + + self::assertNull($details['billing']['mobile_phone_number']); + self::assertNull($details['billing']['landline_phone_number']); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function buildMinimalPayment( + int $amount, + string $currency, + int $customerId, + string $orderNumber, + string $gender = 'm', + string $locale = 'fr_FR', + bool $sameAddress = false, + ?string $phone = null, + ): PaymentInterface { + $customer = $this->createMock(CustomerInterface::class); + $customer->method('getId')->willReturn($customerId); + $customer->method('getGender')->willReturn($gender); + $customer->method('getEmail')->willReturn('test@example.com'); + + $billingAddress = $this->buildAddress(1, 'FR', $phone); + $shippingAddress = $this->buildAddress($sameAddress ? 1 : 2, 'FR', $phone); + + $order = $this->createMock(OrderInterface::class); + $order->method('getCustomer')->willReturn($customer); + $order->method('getNumber')->willReturn($orderNumber); + $order->method('getLocaleCode')->willReturn($locale); + $order->method('getBillingAddress')->willReturn($billingAddress); + $order->method('getShippingAddress')->willReturn($shippingAddress); + $order->method('getItems')->willReturn(new ArrayCollection([])); + $order->method('getShipments')->willReturn(new ArrayCollection([])); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getAmount')->willReturn($amount); + $payment->method('getCurrencyCode')->willReturn($currency); + $payment->method('getOrder')->willReturn($order); + $payment->method('getMethod')->willReturn(null); + + return $payment; + } + + private function buildMinimalPaymentWithGateway(string $factoryName): PaymentInterface + { + $customer = $this->createMock(CustomerInterface::class); + $customer->method('getId')->willReturn(1); + $customer->method('getGender')->willReturn('m'); + $customer->method('getEmail')->willReturn('test@example.com'); + + $billingAddress = $this->buildAddress(1, 'FR', null); + $shippingAddress = $this->buildAddress(2, 'FR', null); + + $variant = $this->createMock(ProductVariantInterface::class); + $variant->method('getCode')->willReturn('PROD-001'); + + $unit = $this->createMock(OrderItemUnitInterface::class); + $unit->method('getId')->willReturn(10); + + $orderItem = $this->createMock(OrderItemInterface::class); + $orderItem->method('getUnits')->willReturn(new ArrayCollection([$unit])); + $orderItem->method('getVariant')->willReturn($variant); + $orderItem->method('getProductName')->willReturn('Product A'); + $orderItem->method('getVariantName')->willReturn('Size M'); + $orderItem->method('getTotal')->willReturn(1000); + $orderItem->method('getUnitPrice')->willReturn(1000); + $orderItem->method('getQuantity')->willReturn(1); + + $shippingMethod = $this->createMock(ShippingMethodInterface::class); + $shippingMethod->method('getName')->willReturn('DHL'); + + $shipment = $this->createMock(ShipmentInterface::class); + $shipment->method('getMethod')->willReturn($shippingMethod); + + $itemUnitsCollection = $this->createMock(\Doctrine\Common\Collections\Collection::class); + $itemUnitsCollection->method('count')->willReturn(1); + + $order = $this->createMock(OrderInterface::class); + $order->method('getCustomer')->willReturn($customer); + $order->method('getNumber')->willReturn('ORD-001'); + $order->method('getLocaleCode')->willReturn('fr_FR'); + $order->method('getBillingAddress')->willReturn($billingAddress); + $order->method('getShippingAddress')->willReturn($shippingAddress); + $order->method('getItems')->willReturn(new ArrayCollection([$orderItem])); + $order->method('getShipments')->willReturn(new ArrayCollection([$shipment])); + $order->method('getItemUnits')->willReturn($itemUnitsCollection); + + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getFactoryName')->willReturn($factoryName); + + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getAmount')->willReturn(1000); + $payment->method('getCurrencyCode')->willReturn('EUR'); + $payment->method('getOrder')->willReturn($order); + $payment->method('getMethod')->willReturn($paymentMethod); + + return $payment; + } + + private function buildAddress(int $id, string $countryCode, ?string $phone): AddressInterface + { + $address = $this->createMock(AddressInterface::class); + $address->method('getId')->willReturn($id); + $address->method('getCountryCode')->willReturn($countryCode); + $address->method('getPhoneNumber')->willReturn($phone); + $address->method('getFirstName')->willReturn('John'); + $address->method('getLastName')->willReturn('Doe'); + $address->method('getCompany')->willReturn(null); + $address->method('getStreet')->willReturn('1 Rue de la Paix'); + $address->method('getPostcode')->willReturn('75001'); + $address->method('getCity')->willReturn('Paris'); + $address->method('getProvinceName')->willReturn(null); + + return $address; + } +} diff --git a/tests/PHPUnit/Handler/PaymentNotificationHandlerTest.php b/tests/PHPUnit/Handler/PaymentNotificationHandlerTest.php new file mode 100644 index 00000000..f4eec8f1 --- /dev/null +++ b/tests/PHPUnit/Handler/PaymentNotificationHandlerTest.php @@ -0,0 +1,391 @@ +logger = $this->createMock(LoggerInterface::class); + $this->payplugCardRepository = $this->createMock(RepositoryInterface::class); + $this->payplugCardFactory = $this->createMock(FactoryInterface::class); + $this->customerRepository = $this->createMock(CustomerRepositoryInterface::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->lockFactory = $this->createMock(LockFactory::class); + $this->requestStack = $this->createMock(RequestStack::class); + + $this->handler = new PaymentNotificationHandler( + $this->logger, + $this->payplugCardRepository, + $this->payplugCardFactory, + $this->customerRepository, + $this->entityManager, + $this->lockFactory, + $this->requestStack, + ); + } + + // ------------------------------------------------------------------------- + // treat() — guard: non-Payment resource is ignored + // ------------------------------------------------------------------------- + + /** + * Passes a non-Payment Payplug resource (e.g. a Refund) to treat(). + * Verifies the handler returns immediately without acquiring a lock or touching state. + */ + public function testTreat_withNonPaymentResource_doesNothing(): void + { + $payment = $this->createMock(PaymentInterface::class); + $resource = $this->createMock(\Payplug\Resource\IVerifiableAPIResource::class); + $details = new \ArrayObject(); + + // lock should never be created + $this->lockFactory->expects(self::never())->method('createLock'); + + $this->handler->treat($payment, $resource, $details); + } + + // ------------------------------------------------------------------------- + // treat() — status already ABORTED → release lock, return early + // ------------------------------------------------------------------------- + + /** + * Pre-fills details with STATUS_ABORTED before calling treat(). + * Verifies the lock is released and the status remains ABORTED (no further processing). + */ + public function testTreat_withAbortedStatus_releasesLockAndReturns(): void + { + $lock = $this->buildLock(); + $this->lockFactory->method('createLock')->willReturn($lock); + $lock->expects(self::once())->method('release'); + + $payment = $this->createMock(PaymentInterface::class); + $this->entityManager->expects(self::once())->method('refresh'); + + $paymentResource = $this->buildPayment(['id' => 'pay_abc', 'is_paid' => false, 'is_live' => false]); + $details = new \ArrayObject(['status' => PayPlugApiClientInterface::STATUS_ABORTED]); + + $this->handler->treat($payment, $paymentResource, $details); + + // Status must remain ABORTED + self::assertSame(PayPlugApiClientInterface::STATUS_ABORTED, $details['status']); + } + + // ------------------------------------------------------------------------- + // treat() — is_paid = true → STATUS_CAPTURED + // ------------------------------------------------------------------------- + + /** + * Builds a Payplug Payment resource with is_paid=true and no card metadata. + * Verifies treat() sets the details status to STATUS_CAPTURED. + */ + public function testTreat_withIsPaidTrue_setsStatusCaptured(): void + { + $lock = $this->buildLock(); + $this->lockFactory->method('createLock')->willReturn($lock); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn($this->createMock(PaymentMethodInterface::class)); + $this->entityManager->method('refresh'); + + // is_paid=true, no metadata → saveCard is a no-op + $paymentResource = $this->buildPayment([ + 'id' => 'pay_001', + 'is_paid' => true, + 'is_live' => false, + 'created_at' => time(), + ]); + $details = new \ArrayObject(['status' => PayPlugApiClientInterface::STATUS_CREATED]); + + $this->handler->treat($payment, $paymentResource, $details); + + self::assertSame(PayPlugApiClientInterface::STATUS_CAPTURED, $details['status']); + } + + // ------------------------------------------------------------------------- + // treat() — Oney pending file → STATUS_AUTHORIZED + // ------------------------------------------------------------------------- + + /** + * Builds an Oney payment with payment_method.is_pending=true (under legal-document review). + * Verifies treat() sets the status to STATUS_AUTHORIZED (awaiting Oney approval). + */ + public function testTreat_withOneyPendingReview_setsStatusAuthorized(): void + { + $lock = $this->buildLock(); + $this->lockFactory->method('createLock')->willReturn($lock); + + $payment = $this->createMock(PaymentInterface::class); + $this->entityManager->method('refresh'); + + // is_paid=false, payment_method with is_pending=true + $paymentResource = $this->buildPayment([ + 'id' => 'pay_002', + 'is_paid' => false, + 'is_live' => false, + 'payment_method' => ['is_pending' => true, 'type' => 'oney_x3_with_fees'], + ]); + $details = new \ArrayObject(['status' => PayPlugApiClientInterface::STATUS_CREATED]); + + $this->handler->treat($payment, $paymentResource, $details); + + self::assertSame(PayPlugApiClientInterface::STATUS_AUTHORIZED, $details['status']); + } + + // ------------------------------------------------------------------------- + // treat() — Oney refused → STATUS_CANCELED_BY_ONEY + // ------------------------------------------------------------------------- + + /** + * Builds an Oney payment with is_pending=false and a failure code (Oney refusal). + * Verifies status is set to STATUS_CANCELED_BY_ONEY and failure details are copied into details. + */ + public function testTreat_withOneyRefused_setsStatusCanceledByOney(): void + { + $lock = $this->buildLock(); + $this->lockFactory->method('createLock')->willReturn($lock); + + $payment = $this->createMock(PaymentInterface::class); + $this->entityManager->method('refresh'); + + $oneyType = OneyGatewayFactory::PAYMENT_CHOICES[0]; // 'oney_x3_with_fees' + + $paymentResource = $this->buildPayment([ + 'id' => 'pay_003', + 'is_paid' => false, + 'is_live' => false, + 'payment_method' => ['is_pending' => false, 'type' => $oneyType], + 'failure' => ['code' => 'card_declined', 'message' => 'Card declined'], + ]); + $details = new \ArrayObject(['status' => PayPlugApiClientInterface::STATUS_CREATED]); + + $this->handler->treat($payment, $paymentResource, $details); + + self::assertSame(PayPlugApiClientInterface::STATUS_CANCELED_BY_ONEY, $details['status']); + self::assertSame('card_declined', $details['failure']['code']); + } + + // ------------------------------------------------------------------------- + // treat() — generic failure → FAILED + // ------------------------------------------------------------------------- + + /** + * Builds a non-Oney payment with a failure code and is_paid=false. + * Verifies status is set to FAILED and the failure details (code, message) are persisted. + */ + public function testTreat_withGenericFailure_setsStatusFailed(): void + { + $lock = $this->buildLock(); + $this->lockFactory->method('createLock')->willReturn($lock); + + $payment = $this->createMock(PaymentInterface::class); + $this->entityManager->method('refresh'); + $this->logger->expects(self::once())->method('info'); + + $paymentResource = $this->buildPayment([ + 'id' => 'pay_004', + 'is_paid' => false, + 'is_live' => false, + 'failure' => ['code' => 'do_not_honor', 'message' => 'Do not honor'], + ]); + $details = new \ArrayObject(['status' => PayPlugApiClientInterface::STATUS_CREATED]); + + $this->handler->treat($payment, $paymentResource, $details); + + self::assertSame(PayPlugApiClientInterface::FAILED, $details['status']); + self::assertSame('do_not_honor', $details['failure']['code']); + } + + // ------------------------------------------------------------------------- + // treat() — card saving: card saved when metadata has valid customer_id + // ------------------------------------------------------------------------- + + /** + * Builds a paid payment with metadata.customer_id and full card data in the response. + * Verifies a new Card entity is created via the factory and persisted via the repository. + */ + public function testTreat_withIsPaidAndValidCardMetadata_savesCard(): void + { + $lock = $this->buildLock(); + $this->lockFactory->method('createLock')->willReturn($lock); + + $customer = $this->createMock(CustomerInterface::class); + $this->customerRepository->method('find')->with(5)->willReturn($customer); + + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn($paymentMethod); + $this->entityManager->method('refresh'); + + $paymentResource = $this->buildPayment([ + 'id' => 'pay_005', + 'is_paid' => true, + 'is_live' => true, + 'created_at' => time(), + 'metadata' => ['customer_id' => 5], + 'card' => [ + 'id' => 'card_external_123', + 'brand' => 'Visa', + 'country' => 'FR', + 'last4' => '4242', + 'exp_month' => 12, + 'exp_year' => 2030, + ], + ]); + + // No existing card in repo + $this->payplugCardRepository->method('findOneBy')->willReturn(null); + + $card = $this->createMock(Card::class); + $card->method('setCustomer')->willReturnSelf(); + $card->method('setPaymentMethod')->willReturnSelf(); + $card->method('setExternalId')->willReturnSelf(); + $card->method('setBrand')->willReturnSelf(); + $card->method('setCountryCode')->willReturnSelf(); + $card->method('setLast4')->willReturnSelf(); + $card->method('setExpirationMonth')->willReturnSelf(); + $card->method('setExpirationYear')->willReturnSelf(); + $card->method('setIsLive')->willReturnSelf(); + + $this->payplugCardFactory->method('createNew')->willReturn($card); + $this->payplugCardRepository->expects(self::once())->method('add')->with($card); + + $details = new \ArrayObject(['status' => PayPlugApiClientInterface::STATUS_CREATED]); + + $this->handler->treat($payment, $paymentResource, $details); + + self::assertSame(PayPlugApiClientInterface::STATUS_CAPTURED, $details['status']); + } + + // ------------------------------------------------------------------------- + // treat() — card NOT saved when no customer_id in metadata + // ------------------------------------------------------------------------- + + /** + * Builds a paid payment with no metadata key at all (guest checkout or feature disabled). + * Verifies the card factory and repository are never called (no card saved). + */ + public function testTreat_withIsPaidAndMissingCustomerId_doesNotSaveCard(): void + { + $lock = $this->buildLock(); + $this->lockFactory->method('createLock')->willReturn($lock); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn($this->createMock(PaymentMethodInterface::class)); + $this->entityManager->method('refresh'); + + // is_paid=true, no metadata key at all + $paymentResource = $this->buildPayment([ + 'id' => 'pay_006', + 'is_paid' => true, + 'is_live' => false, + 'created_at' => time(), + ]); + + $this->payplugCardRepository->expects(self::never())->method('add'); + $this->payplugCardFactory->expects(self::never())->method('createNew'); + + $details = new \ArrayObject(['status' => PayPlugApiClientInterface::STATUS_CREATED]); + $this->handler->treat($payment, $paymentResource, $details); + + self::assertSame(PayPlugApiClientInterface::STATUS_CAPTURED, $details['status']); + } + + // ------------------------------------------------------------------------- + // treat() — card NOT saved when card already exists in repo + // ------------------------------------------------------------------------- + + /** + * Builds a paid payment with card data, but the repository already returns an existing Card. + * Verifies the card factory and repository add() are never called (no duplicate). + */ + public function testTreat_withIsPaidAndCardAlreadyExists_doesNotSaveCardAgain(): void + { + $lock = $this->buildLock(); + $this->lockFactory->method('createLock')->willReturn($lock); + + $customer = $this->createMock(CustomerInterface::class); + $this->customerRepository->method('find')->with(7)->willReturn($customer); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn($this->createMock(PaymentMethodInterface::class)); + $this->entityManager->method('refresh'); + + $paymentResource = $this->buildPayment([ + 'id' => 'pay_007', + 'is_paid' => true, + 'is_live' => false, + 'created_at' => time(), + 'metadata' => ['customer_id' => 7], + 'card' => ['id' => 'card_ext_already', 'brand' => 'Visa', 'country' => 'FR', 'last4' => '1111', 'exp_month' => 1, 'exp_year' => 2029], + ]); + + // Card already in repo + $existingCard = $this->createMock(Card::class); + $this->payplugCardRepository->method('findOneBy')->willReturn($existingCard); + + $this->payplugCardFactory->expects(self::never())->method('createNew'); + $this->payplugCardRepository->expects(self::never())->method('add'); + + $details = new \ArrayObject(['status' => PayPlugApiClientInterface::STATUS_CREATED]); + $this->handler->treat($payment, $paymentResource, $details); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function buildLock(): LockInterface&MockObject + { + $lock = $this->createMock(LockInterface::class); + $lock->method('acquire')->willReturn(true); + + return $lock; + } + + /** + * Creates a real Payplug Payment resource from an attributes array. + * This uses the SDK's own factory and avoids the complexity of mocking magic methods. + */ + private function buildPayment(array $attributes): PayplugPayment + { + return PayplugPayment::fromAttributes($attributes); + } +} diff --git a/tests/PHPUnit/PaymentProcessing/AbortPaymentProcessorTest.php b/tests/PHPUnit/PaymentProcessing/AbortPaymentProcessorTest.php new file mode 100644 index 00000000..0a218a91 --- /dev/null +++ b/tests/PHPUnit/PaymentProcessing/AbortPaymentProcessorTest.php @@ -0,0 +1,132 @@ +apiClientFactory = $this->createMock(PayPlugApiClientFactoryInterface::class); + $this->apiClient = $this->createMock(PayPlugApiClientInterface::class); + + $this->apiClientFactory->method('createForPaymentMethod')->willReturn($this->apiClient); + + $this->processor = new AbortPaymentProcessor($this->apiClientFactory); + } + + // ------------------------------------------------------------------------- + // process() — no payment_id in details → no API call + // ------------------------------------------------------------------------- + + /** + * The payment details array contains no payment_id key (payment was never created on PayPlug). + * Verifies the API factory and abortPayment() are never called. + */ + public function testProcess_withNoPaymentId_doesNotCallApi(): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getDetails')->willReturn([]); + $payment->method('getMethod')->willReturn($this->createMock(PaymentMethodInterface::class)); + + $this->apiClientFactory->expects(self::never())->method('createForPaymentMethod'); + $this->apiClient->expects(self::never())->method('abortPayment'); + + $this->processor->process($payment); + } + + // ------------------------------------------------------------------------- + // process() — null method → no API call + // ------------------------------------------------------------------------- + + /** + * A payment_id exists but getMethod() returns null (no payment method attached). + * Verifies abortPayment() is never called (guard clause exits early). + */ + public function testProcess_withNullMethod_doesNotCallApi(): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getDetails')->willReturn(['payment_id' => 'pay_abc']); + $payment->method('getMethod')->willReturn(null); + + $this->apiClient->expects(self::never())->method('abortPayment'); + + $this->processor->process($payment); + } + + // ------------------------------------------------------------------------- + // process() — valid payment_id → abortPayment called + // ------------------------------------------------------------------------- + + /** + * A valid payment_id and payment method are present; the API client is wired correctly. + * Verifies createForPaymentMethod() and abortPayment() are each called exactly once. + */ + public function testProcess_withPaymentId_callsAbortPayment(): void + { + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getDetails')->willReturn(['payment_id' => 'pay_xyz']); + $payment->method('getMethod')->willReturn($paymentMethod); + + $this->apiClientFactory->expects(self::once()) + ->method('createForPaymentMethod') + ->with($paymentMethod) + ->willReturn($this->apiClient) + ; + $this->apiClient->expects(self::once())->method('abortPayment')->with('pay_xyz'); + + $this->processor->process($payment); + } + + // ------------------------------------------------------------------------- + // process() — HttpException is silently swallowed + // ------------------------------------------------------------------------- + + /** + * The API client throws an HttpException (e.g. payment already aborted on PayPlug's side). + * Verifies process() completes without re-throwing (exception is intentionally swallowed). + */ + public function testProcess_httpException_isSilentlySwallowed(): void + { + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getDetails')->willReturn(['payment_id' => 'pay_already_failed']); + $payment->method('getMethod')->willReturn($paymentMethod); + + $this->apiClient->method('abortPayment') + ->willThrowException($this->buildHttpException()) + ; + + // Should not throw — HttpException is caught and ignored + $this->processor->process($payment); + self::assertTrue(true); // Reached without exception + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function buildHttpException(): HttpException + { + return new HttpException('Conflict', '{"object": "error"}', 409); + } +} diff --git a/tests/PHPUnit/PaymentProcessing/RefundPaymentHandlerTest.php b/tests/PHPUnit/PaymentProcessing/RefundPaymentHandlerTest.php new file mode 100644 index 00000000..3e474cd9 --- /dev/null +++ b/tests/PHPUnit/PaymentProcessing/RefundPaymentHandlerTest.php @@ -0,0 +1,256 @@ +unitRefundTotalCalculator = $this->createMock(UnitRefundTotalCalculatorInterface::class); + $this->remainingTotalProvider = $this->createMock(RemainingTotalProviderInterface::class); + $this->refundPaymentRepository = $this->createMock(ObjectRepository::class); + $this->refundPaymentCompletedStateApplier = $this->createMock(RefundPaymentCompletedStateApplierInterface::class); + + $this->handler = new RefundPaymentHandler( + $this->unitRefundTotalCalculator, + $this->remainingTotalProvider, + $this->refundPaymentRepository, + $this->refundPaymentCompletedStateApplier, + ); + } + + // ------------------------------------------------------------------------- + // fromRequest() — exact single-item match + // ------------------------------------------------------------------------- + + /** + * Refund amount matches exactly one order item unit's remaining total. + * Verifies fromRequest() returns a RefundUnits command targeting that single unit. + */ + public function testFromRequest_exactSingleItemMatch_returnsRefundUnitsForThatItem(): void + { + $refund = $this->buildRefund(1000); + $payment = $this->buildPayment('ORD-1', [ + 11 => 1000, // unit ID 11 has remaining 1000 — exact match + ], []); + + // The calculator is called for unit 11 with null amount (full refund) + $this->unitRefundTotalCalculator + ->method('calculateForUnitWithIdAndType') + ->willReturn(1000) + ; + + $result = $this->handler->fromRequest($refund, $payment); + + self::assertInstanceOf(RefundUnits::class, $result); + } + + // ------------------------------------------------------------------------- + // fromRequest() — exact shipment match + // ------------------------------------------------------------------------- + + /** + * Refund amount matches exactly one shipping adjustment (no item match). + * Verifies fromRequest() returns a RefundUnits command targeting that shipment adjustment. + */ + public function testFromRequest_exactShipmentMatch_returnsRefundUnitsForShipment(): void + { + $refund = $this->buildRefund(500); + // No item matches, but shipment 20 matches exactly + $payment = $this->buildPayment('ORD-2', [ + 11 => 300, + ], [ + 20 => 500, + ]); + + $this->unitRefundTotalCalculator + ->method('calculateForUnitWithIdAndType') + ->willReturn(500) + ; + + $result = $this->handler->fromRequest($refund, $payment); + + self::assertInstanceOf(RefundUnits::class, $result); + } + + // ------------------------------------------------------------------------- + // fromRequest() — partial allocation across multiple items + // ------------------------------------------------------------------------- + + /** + * Refund amount (700) has no exact single-item match but fits across two items (400+300). + * Verifies fromRequest() falls back to partial allocation and returns a valid RefundUnits command. + */ + public function testFromRequest_partialAllocation_spreadsAcrossItems(): void + { + // Refund 700, items have 400 + 300 — no single exact match, so partial + $refund = $this->buildRefund(700); + $payment = $this->buildPayment('ORD-3', [ + 11 => 400, + 12 => 300, + ], []); + + $this->unitRefundTotalCalculator + ->method('calculateForUnitWithIdAndType') + ->willReturnCallback(fn ($id, $type, $amount) => (int) ($amount * 100)) + ; + + $result = $this->handler->fromRequest($refund, $payment); + + self::assertInstanceOf(RefundUnits::class, $result); + } + + // ------------------------------------------------------------------------- + // fromRequest() — amount exceeds all available → throws InvalidRefundAmount + // ------------------------------------------------------------------------- + + /** + * Refund amount (9999) exceeds the total available across all items and shipments (none). + * Verifies fromRequest() throws InvalidRefundAmount when allocation is impossible. + */ + public function testFromRequest_amountExceedsAvailable_throwsInvalidRefundAmount(): void + { + $this->expectException(InvalidRefundAmount::class); + + // Total available is 0 (no items, no shipments) + $refund = $this->buildRefund(9999); + $payment = $this->buildPayment('ORD-4', [], []); + + $this->handler->fromRequest($refund, $payment); + } + + // ------------------------------------------------------------------------- + // fromRequest() — zero-amount item is skipped + // ------------------------------------------------------------------------- + + /** + * One item has zero remaining refundable amount; the other has the exact target amount. + * Verifies the zero-remaining item is skipped and allocation succeeds on the valid item. + */ + public function testFromRequest_itemWithZeroRemaining_isSkipped(): void + { + // Only item with zero remaining, one with positive + $refund = $this->buildRefund(300); + $payment = $this->buildPayment('ORD-5', [ + 11 => 0, + 12 => 300, + ], []); + + $this->unitRefundTotalCalculator + ->method('calculateForUnitWithIdAndType') + ->willReturn(300) + ; + + $result = $this->handler->fromRequest($refund, $payment); + + self::assertInstanceOf(RefundUnits::class, $result); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function buildRefund(int $amount): Refund + { + return Refund::fromAttributes(['amount' => $amount]); + } + + /** + * @param array $itemRemainingPrices map of unitId → remainingAmount + * @param array $shipmentRemainingPrices map of adjustmentId → remainingAmount + */ + private function buildPayment( + string $orderNumber, + array $itemRemainingPrices, + array $shipmentRemainingPrices, + ): PaymentInterface { + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $paymentMethod->method('getId')->willReturn(1); + + // Build order items / units + $orderItems = []; + $unitIdToRemaining = []; + + foreach ($itemRemainingPrices as $unitId => $remaining) { + $unit = $this->createMock(OrderItemUnitInterface::class); + $unit->method('getId')->willReturn($unitId); + + $orderItem = $this->createMock(OrderItemInterface::class); + $orderItem->method('getUnits')->willReturn(new ArrayCollection([$unit])); + + $orderItems[] = $orderItem; + $unitIdToRemaining[$unitId] = $remaining; + } + + // Build shipping adjustments + $adjustments = []; + $shipmentIdToRemaining = []; + + foreach ($shipmentRemainingPrices as $adjId => $remaining) { + $adj = $this->createMock(AdjustmentInterface::class); + $adj->method('getId')->willReturn($adjId); + $adjustments[] = $adj; + $shipmentIdToRemaining[$adjId] = $remaining; + } + + $order = $this->createMock(OrderInterface::class); + $order->method('getNumber')->willReturn($orderNumber); + $order->method('getItems')->willReturn(new ArrayCollection($orderItems)); + $order->method('getAdjustments') + ->with(AdjustmentInterface::SHIPPING_ADJUSTMENT) + ->willReturn(new ArrayCollection($adjustments)) + ; + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn($paymentMethod); + $payment->method('getOrder')->willReturn($order); + + // Wire up remainingTotalProvider + $this->remainingTotalProvider + ->method('getTotalLeftToRefund') + ->willReturnCallback(function (int $id, RefundType $type) use ($unitIdToRemaining, $shipmentIdToRemaining) { + if (isset($unitIdToRemaining[$id])) { + return $unitIdToRemaining[$id]; + } + if (isset($shipmentIdToRemaining[$id])) { + return $shipmentIdToRemaining[$id]; + } + + return 0; + }) + ; + + return $payment; + } +} diff --git a/tests/PHPUnit/PaymentProcessing/RefundPaymentProcessorTest.php b/tests/PHPUnit/PaymentProcessing/RefundPaymentProcessorTest.php new file mode 100644 index 00000000..456a0e19 --- /dev/null +++ b/tests/PHPUnit/PaymentProcessing/RefundPaymentProcessorTest.php @@ -0,0 +1,222 @@ +requestStack = $this->createMock(RequestStack::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->translator = $this->createMock(TranslatorInterface::class); + $this->refundPaymentRepository = $this->createMock(RepositoryInterface::class); + $this->payplugRefundHistoryRepository = $this->createMock(RefundHistoryRepositoryInterface::class); + $this->apiClientFactory = $this->createMock(PayPlugApiClientFactoryInterface::class); + $this->apiClient = $this->createMock(PayPlugApiClientInterface::class); + + $this->apiClientFactory->method('createForPaymentMethod')->willReturn($this->apiClient); + + $this->processor = new RefundPaymentProcessor( + $this->requestStack, + $this->logger, + $this->translator, + $this->refundPaymentRepository, + $this->payplugRefundHistoryRepository, + $this->apiClientFactory, + ); + } + + // ------------------------------------------------------------------------- + // process() — full refund success + // ------------------------------------------------------------------------- + + /** + * Calls process() with a valid payment containing a payment_id and the PayPlug factory. + * Verifies the API client's refundPayment() is called once with the correct payment ID. + */ + public function testProcess_fullRefundSuccess_callsApiRefund(): void + { + $payment = $this->buildPayment(PayPlugGatewayFactory::FACTORY_NAME, ['payment_id' => 'pay_abc']); + + $this->apiClient->expects(self::once())->method('refundPayment')->with('pay_abc'); + + $this->processor->process($payment); + } + + // ------------------------------------------------------------------------- + // process() — API exception → UpdateHandlingException + // ------------------------------------------------------------------------- + + /** + * The API client throws a generic Exception during refundPayment(). + * Verifies the processor catches it, logs an error, and re-throws UpdateHandlingException. + */ + public function testProcess_apiThrowsException_throwsUpdateHandlingException(): void + { + $this->expectException(UpdateHandlingException::class); + + $payment = $this->buildPayment(PayPlugGatewayFactory::FACTORY_NAME, ['payment_id' => 'pay_fail']); + + $this->apiClient->method('refundPayment')->willThrowException(new Exception('API error')); + $this->logger->expects(self::once())->method('error'); + + $this->processor->process($payment); + } + + // ------------------------------------------------------------------------- + // prepare() — gateway config is null → prepare() returns early, no API client created + // ------------------------------------------------------------------------- + + /** + * The payment method returns null for getGatewayConfig(), so prepare() exits early. + * Verifies the API factory is never called and a Throwable is thrown (uninitialized client). + */ + public function testProcess_nullGatewayConfig_skipsRefundWithoutApiCall(): void + { + // Build a payment where getGatewayConfig() returns null + $paymentMethod = $this->createMock(\Sylius\Component\Core\Model\PaymentMethodInterface::class); + $paymentMethod->method('getGatewayConfig')->willReturn(null); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn($paymentMethod); + $payment->method('getDetails')->willReturn(['payment_id' => 'pay_xyz']); + + // Null gateway config → prepare() returns early without calling API factory + $this->apiClientFactory->expects(self::never())->method('createForPaymentMethod'); + + // process() after prepare() returns early → Assert::string('pay_xyz') passes + // → $this->payPlugApiClient uninitialized → TypeError + // This reflects the production code design limitation. + $this->expectException(\Throwable::class); + + $this->processor->process($payment); + } + + // ------------------------------------------------------------------------- + // onRefundCompleteTransitionEvent() — non-PaymentInterface subject → returns early + // ------------------------------------------------------------------------- + + /** + * Fires onRefundCompleteTransitionEvent() with a plain stdClass as the workflow subject. + * Verifies the handler returns early without ever calling the API. + */ + public function testOnRefundCompleteTransitionEvent_withNonPaymentSubject_doesNothing(): void + { + // CompletedEvent is final — build a real one with a non-Payment subject. + $marking = new \Symfony\Component\Workflow\Marking(); + $event = new \Symfony\Component\Workflow\Event\CompletedEvent(new \stdClass(), $marking); + + $this->apiClient->expects(self::never())->method('refundPayment'); + + $this->processor->onRefundCompleteTransitionEvent($event); + } + + // ------------------------------------------------------------------------- + // processWithAmount() — partial refund success, creates RefundHistory + // ------------------------------------------------------------------------- + + /** + * Calls processWithAmount() with amount=500 and refundPaymentId=77; the API returns a Refund object. + * Verifies setDetails() is called on the payment and the RefundHistory entry is persisted via add(). + */ + public function testProcessWithAmount_success_createsRefundHistoryEntry(): void + { + $payment = $this->buildPayment(PayPlugGatewayFactory::FACTORY_NAME, ['payment_id' => 'pay_partial']); + $payment->expects(self::once())->method('setDetails'); + + $refundApiObject = $this->createMock(Refund::class); + $refundApiObject->id = 'ref_ext_001'; + $refundApiObject->amount = 500; + $refundApiObject->metadata = []; + + $this->apiClient + ->method('refundPaymentWithAmount') + ->with('pay_partial', 500, 77) + ->willReturn($refundApiObject) + ; + + $refundPayment = $this->createMock(RefundPayment::class); + $this->refundPaymentRepository->method('findOneBy')->with(['id' => 77])->willReturn($refundPayment); + + $this->payplugRefundHistoryRepository->expects(self::once())->method('add'); + + $this->processor->processWithAmount($payment, 500, 77); + } + + // ------------------------------------------------------------------------- + // processWithAmount() — API exception → UpdateHandlingException + // ------------------------------------------------------------------------- + + /** + * The API client throws during refundPaymentWithAmount() for a partial refund. + * Verifies the processor logs the error and re-throws UpdateHandlingException. + */ + public function testProcessWithAmount_apiThrowsException_throwsUpdateHandlingException(): void + { + $this->expectException(UpdateHandlingException::class); + + $payment = $this->buildPayment(PayPlugGatewayFactory::FACTORY_NAME, ['payment_id' => 'pay_partial_fail']); + + $this->apiClient->method('refundPaymentWithAmount')->willThrowException(new Exception('fail')); + $this->logger->expects(self::once())->method('error'); + + $this->processor->processWithAmount($payment, 300, 42); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function buildPayment(string $factoryName, array $details): PaymentInterface&MockObject + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getFactoryName')->willReturn($factoryName); + + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn($paymentMethod); + $payment->method('getDetails')->willReturn($details); + + return $payment; + } +} diff --git a/tests/PHPUnit/PhoneNumberFormatTest.php b/tests/PHPUnit/PhoneNumberFormatTest.php index 8fcbcd01..2cea08b7 100644 --- a/tests/PHPUnit/PhoneNumberFormatTest.php +++ b/tests/PHPUnit/PhoneNumberFormatTest.php @@ -8,7 +8,7 @@ use libphonenumber\PhoneNumberUtil; use PHPUnit\Framework\TestCase; -final class phoneNumberFormatTest extends TestCase +final class PhoneNumberFormatTest extends TestCase { /** * @dataProvider landlinePhoneNumbersDataProvider diff --git a/tests/PHPUnit/Provider/SupportedMethodsProviderTest.php b/tests/PHPUnit/Provider/SupportedMethodsProviderTest.php new file mode 100644 index 00000000..69f5f312 --- /dev/null +++ b/tests/PHPUnit/Provider/SupportedMethodsProviderTest.php @@ -0,0 +1,213 @@ +currencyContext = $this->createMock(CurrencyContextInterface::class); + $this->provider = new SupportedMethodsProvider($this->currencyContext); + } + + // ------------------------------------------------------------------------- + // provide() — factory name filter + // ------------------------------------------------------------------------- + + /** + * The payment method's factory name (PayPlug) differs from the queried factory (Bancontact). + * Verifies the method is passed through unchanged — currency/amount checks only apply to matching factories. + */ + public function testProvide_withDifferentFactory_doesNotFilter(): void + { + $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); + + // PaymentMethod is PayPlug, but we're querying for Bancontact — so it's passed through as-is + $method = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME); + $authorizedCurrencies = ['EUR' => ['min_amount' => 99, 'max_amount' => 2000000]]; + + $result = $this->provider->provide([$method], BancontactGatewayFactory::FACTORY_NAME, $authorizedCurrencies, 1000); + + // Method stays in list (it doesn't match the target factory, so the currency/amount check never runs) + self::assertCount(1, $result); + } + + // ------------------------------------------------------------------------- + // provide() — currency not authorized → method removed + // ------------------------------------------------------------------------- + + /** + * The current currency is USD but the method only authorises EUR. + * Verifies the method is removed from the result list. + */ + public function testProvide_withUnauthorizedCurrency_removesMethod(): void + { + $this->currencyContext->method('getCurrencyCode')->willReturn('USD'); + + $method = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME); + $authorizedCurrencies = ['EUR' => ['min_amount' => 99, 'max_amount' => 2000000]]; + + $result = $this->provider->provide([$method], PayPlugGatewayFactory::FACTORY_NAME, $authorizedCurrencies, 1000); + + self::assertEmpty($result); + } + + // ------------------------------------------------------------------------- + // provide() — amount below min_amount → method removed + // ------------------------------------------------------------------------- + + /** + * The order amount (50) is below the method's configured min_amount (99). + * Verifies the method is removed from the result list. + */ + public function testProvide_withAmountBelowMin_removesMethod(): void + { + $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); + + $method = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME); + $authorizedCurrencies = ['EUR' => ['min_amount' => 99, 'max_amount' => 2000000]]; + + $result = $this->provider->provide([$method], PayPlugGatewayFactory::FACTORY_NAME, $authorizedCurrencies, 50); + + self::assertEmpty($result); + } + + // ------------------------------------------------------------------------- + // provide() — amount above max_amount → method removed + // ------------------------------------------------------------------------- + + /** + * The order amount (2 000 001) exceeds the method's configured max_amount (2 000 000). + * Verifies the method is removed from the result list. + */ + public function testProvide_withAmountAboveMax_removesMethod(): void + { + $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); + + $method = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME); + $authorizedCurrencies = ['EUR' => ['min_amount' => 99, 'max_amount' => 2000000]]; + + $result = $this->provider->provide([$method], PayPlugGatewayFactory::FACTORY_NAME, $authorizedCurrencies, 2000001); + + self::assertEmpty($result); + } + + // ------------------------------------------------------------------------- + // provide() — valid currency and amount → method kept + // ------------------------------------------------------------------------- + + /** + * Currency is EUR and amount (1000) is within [99, 2 000 000]. + * Verifies the method is kept and returned as the sole element. + */ + public function testProvide_withValidCurrencyAndAmount_keepsMethod(): void + { + $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); + + $method = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME); + $authorizedCurrencies = ['EUR' => ['min_amount' => 99, 'max_amount' => 2000000]]; + + $result = $this->provider->provide([$method], PayPlugGatewayFactory::FACTORY_NAME, $authorizedCurrencies, 1000); + + self::assertCount(1, $result); + self::assertSame($method, reset($result)); + } + + // ------------------------------------------------------------------------- + // provide() — amount at exact min boundary → kept + // ------------------------------------------------------------------------- + + /** + * Amount equals exactly the min_amount boundary (99 == 99). + * Verifies the method is kept (inclusive lower bound). + */ + public function testProvide_withAmountAtMinBoundary_keepsMethod(): void + { + $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); + + $method = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME); + $authorizedCurrencies = ['EUR' => ['min_amount' => 99, 'max_amount' => 2000000]]; + + $result = $this->provider->provide([$method], PayPlugGatewayFactory::FACTORY_NAME, $authorizedCurrencies, 99); + + self::assertCount(1, $result); + } + + // ------------------------------------------------------------------------- + // provide() — amount at exact max boundary → kept + // ------------------------------------------------------------------------- + + /** + * Amount equals exactly the max_amount boundary (2 000 000 == 2 000 000). + * Verifies the method is kept (inclusive upper bound). + */ + public function testProvide_withAmountAtMaxBoundary_keepsMethod(): void + { + $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); + + $method = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME); + $authorizedCurrencies = ['EUR' => ['min_amount' => 99, 'max_amount' => 2000000]]; + + $result = $this->provider->provide([$method], PayPlugGatewayFactory::FACTORY_NAME, $authorizedCurrencies, 2000000); + + self::assertCount(1, $result); + } + + // ------------------------------------------------------------------------- + // provide() — mixed list: only matching factory + valid amount/currency kept + // ------------------------------------------------------------------------- + + /** + * List contains a PayPlug method (matching factory) and a Bancontact method (different factory). + * Verifies the PayPlug method is kept and the Bancontact method is also kept (no filter for its factory). + */ + public function testProvide_withMixedList_filtersCorrectly(): void + { + $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); + + $payplugMethod = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME); + $bancontactMethod = $this->buildPaymentMethod(BancontactGatewayFactory::FACTORY_NAME); + $authorizedCurrencies = ['EUR' => ['min_amount' => 99, 'max_amount' => 2000000]]; + + // We query for PayPlug factory only; amount is valid + $result = $this->provider->provide( + [$payplugMethod, $bancontactMethod], + PayPlugGatewayFactory::FACTORY_NAME, + $authorizedCurrencies, + 1000, + ); + + // Bancontact is skipped (different factory), PayPlug stays + self::assertCount(2, $result); // Bancontact not removed (no filter applied for different factory) + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function buildPaymentMethod(string $factoryName): PaymentMethodInterface + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getFactoryName')->willReturn($factoryName); + + $method = $this->createMock(PaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn($gatewayConfig); + + return $method; + } +} diff --git a/tests/PHPUnit/Resolver/OneyPaymentMethodsResolverDecoratorTest.php b/tests/PHPUnit/Resolver/OneyPaymentMethodsResolverDecoratorTest.php new file mode 100644 index 00000000..187bab8c --- /dev/null +++ b/tests/PHPUnit/Resolver/OneyPaymentMethodsResolverDecoratorTest.php @@ -0,0 +1,271 @@ +decorated = $this->createMock(PaymentMethodsResolverInterface::class); + $this->currencyContext = $this->createMock(CurrencyContextInterface::class); + $this->oneyChecker = $this->createMock(OneyCheckerInterface::class); + + $this->decorator = new OneyPaymentMethodsResolverDecorator( + $this->decorated, + $this->currencyContext, + $this->oneyChecker, + ); + } + + // ------------------------------------------------------------------------- + // Non-Oney payment methods are passed through unchanged + // ------------------------------------------------------------------------- + + /** + * The only method in the list uses the standard PayPlug factory (not Oney). + * Verifies it is returned unchanged — Oney-specific filters are skipped for non-Oney methods. + */ + public function testGetSupportedMethods_nonOneyMethod_isNotFiltered(): void + { + $method = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME); + $payment = $this->buildPayment([$method], 1000, 'FR', 'FR', 1); + + $this->decorated->method('getSupportedMethods')->willReturn([$method]); + + $result = $this->decorator->getSupportedMethods($payment); + + self::assertCount(1, $result); + } + + // ------------------------------------------------------------------------- + // Oney disabled at account level → removed + // ------------------------------------------------------------------------- + + /** + * OneyChecker::isEnabled() returns false (merchant account lacks Oney permission). + * Verifies the Oney method is removed from the list. + */ + public function testGetSupportedMethods_oneyDisabled_removesOneyMethod(): void + { + $oneyMethod = $this->buildPaymentMethod(OneyGatewayFactory::FACTORY_NAME); + $payment = $this->buildPayment([$oneyMethod], 1000, 'FR', 'FR', 1); + + $this->decorated->method('getSupportedMethods')->willReturn([$oneyMethod]); + $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); + + $this->oneyChecker->method('isEnabled')->willReturn(false); + + $result = $this->decorator->getSupportedMethods($payment); + + self::assertEmpty($result); + } + + // ------------------------------------------------------------------------- + // Price ineligible → removed + // ------------------------------------------------------------------------- + + /** + * Oney is enabled but the order amount (50) is outside Oney's eligible price range. + * Verifies the Oney method is removed from the list. + */ + public function testGetSupportedMethods_priceIneligible_removesOneyMethod(): void + { + $oneyMethod = $this->buildPaymentMethod(OneyGatewayFactory::FACTORY_NAME); + $payment = $this->buildPayment([$oneyMethod], 50, 'FR', 'FR', 1); + + $this->decorated->method('getSupportedMethods')->willReturn([$oneyMethod]); + $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); + + $this->oneyChecker->method('isEnabled')->willReturn(true); + $this->oneyChecker->method('isPriceEligible')->willReturn(false); + + $result = $this->decorator->getSupportedMethods($payment); + + self::assertEmpty($result); + } + + // ------------------------------------------------------------------------- + // Too many items (> 999) → removed + // ------------------------------------------------------------------------- + + /** + * Item count is 1000, which exceeds Oney's 999-item limit. + * Verifies the Oney method is removed from the list. + */ + public function testGetSupportedMethods_tooManyItems_removesOneyMethod(): void + { + $oneyMethod = $this->buildPaymentMethod(OneyGatewayFactory::FACTORY_NAME); + $payment = $this->buildPayment([$oneyMethod], 1000, 'FR', 'FR', 1000); + + $this->decorated->method('getSupportedMethods')->willReturn([$oneyMethod]); + $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); + + $this->oneyChecker->method('isEnabled')->willReturn(true); + $this->oneyChecker->method('isPriceEligible')->willReturn(true); + $this->oneyChecker->method('isNumberOfProductEligible')->willReturn(false); + + $result = $this->decorator->getSupportedMethods($payment); + + self::assertEmpty($result); + } + + // ------------------------------------------------------------------------- + // Country ineligible (shipping ≠ billing or not FR) → removed + // ------------------------------------------------------------------------- + + /** + * Shipping country is DE while billing is FR — OneyChecker reports the country as ineligible. + * Verifies the Oney method is removed from the list. + */ + public function testGetSupportedMethods_countryIneligible_removesOneyMethod(): void + { + $oneyMethod = $this->buildPaymentMethod(OneyGatewayFactory::FACTORY_NAME); + $payment = $this->buildPayment([$oneyMethod], 1000, 'DE', 'FR', 1); + + $this->decorated->method('getSupportedMethods')->willReturn([$oneyMethod]); + $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); + + $this->oneyChecker->method('isEnabled')->willReturn(true); + $this->oneyChecker->method('isPriceEligible')->willReturn(true); + $this->oneyChecker->method('isNumberOfProductEligible')->willReturn(true); + $this->oneyChecker->method('isCountryEligible')->willReturn(false); + + $result = $this->decorator->getSupportedMethods($payment); + + self::assertEmpty($result); + } + + // ------------------------------------------------------------------------- + // All conditions met → Oney method kept + // ------------------------------------------------------------------------- + + /** + * Oney is enabled, price is eligible, item count is 2, and country is eligible. + * Verifies the Oney method is kept in the result list. + */ + public function testGetSupportedMethods_allConditionsMet_keepsOneyMethod(): void + { + $oneyMethod = $this->buildPaymentMethod(OneyGatewayFactory::FACTORY_NAME); + $payment = $this->buildPayment([$oneyMethod], 1000, 'FR', 'FR', 2); + + $this->decorated->method('getSupportedMethods')->willReturn([$oneyMethod]); + $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); + + $this->oneyChecker->method('isEnabled')->willReturn(true); + $this->oneyChecker->method('isPriceEligible')->willReturn(true); + $this->oneyChecker->method('isNumberOfProductEligible')->willReturn(true); + $this->oneyChecker->method('isCountryEligible')->willReturn(true); + + $result = $this->decorator->getSupportedMethods($payment); + + self::assertCount(1, $result); + } + + // ------------------------------------------------------------------------- + // Mixed list: non-Oney kept, Oney removed when disabled + // ------------------------------------------------------------------------- + + /** + * The list contains both an Oney method (disabled) and a standard PayPlug method. + * Verifies only the Oney method is removed; the PayPlug method is returned intact. + */ + public function testGetSupportedMethods_mixedList_onlyOneyRemoved(): void + { + $oneyMethod = $this->buildPaymentMethod(OneyGatewayFactory::FACTORY_NAME); + $payplugMethod = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME); + $payment = $this->buildPayment([$oneyMethod, $payplugMethod], 1000, 'FR', 'FR', 1); + + $this->decorated->method('getSupportedMethods')->willReturn([$oneyMethod, $payplugMethod]); + $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); + + $this->oneyChecker->method('isEnabled')->willReturn(false); + + $result = $this->decorator->getSupportedMethods($payment); + + self::assertCount(1, $result); + self::assertSame($payplugMethod, reset($result)); + } + + // ------------------------------------------------------------------------- + // supports() delegates to decorated + // ------------------------------------------------------------------------- + + /** + * Calls supports() on the decorator with a Payment object. + * Verifies the call is forwarded verbatim to the decorated resolver and its result is returned. + */ + public function testSupports_delegatesToDecorated(): void + { + $payment = $this->createMock(Payment::class); + $this->decorated->method('supports')->with($payment)->willReturn(true); + + self::assertTrue($this->decorator->supports($payment)); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function buildPaymentMethod(string $factoryName): PaymentMethodInterface + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getFactoryName')->willReturn($factoryName); + + $method = $this->createMock(PaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn($gatewayConfig); + + return $method; + } + + private function buildPayment( + array $methods, + int $amount, + string $shippingCountry, + string $billingCountry, + int $itemUnitCount, + ): Payment { + $shippingAddress = $this->createMock(AddressInterface::class); + $shippingAddress->method('getCountryCode')->willReturn($shippingCountry); + + $billingAddress = $this->createMock(AddressInterface::class); + $billingAddress->method('getCountryCode')->willReturn($billingCountry); + + $itemUnits = new ArrayCollection(array_fill(0, $itemUnitCount, new \stdClass())); + + $order = $this->createMock(OrderInterface::class); + $order->method('getShippingAddress')->willReturn($shippingAddress); + $order->method('getBillingAddress')->willReturn($billingAddress); + $order->method('getItemUnits')->willReturn($itemUnits); + + $payment = $this->createMock(Payment::class); + $payment->method('getAmount')->willReturn($amount); + $payment->method('getOrder')->willReturn($order); + + return $payment; + } +} diff --git a/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php b/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php new file mode 100644 index 00000000..f8fb7b70 --- /dev/null +++ b/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php @@ -0,0 +1,226 @@ +requestStack = $this->createMock(RequestStack::class); + $this->validator = $this->createMock(ValidatorInterface::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + + $this->paymentMethodValidator = new PaymentMethodValidator( + $this->requestStack, + $this->validator, + $this->entityManager, + ); + } + + // ------------------------------------------------------------------------- + // process() — null gateway config → early return, no flush + // ------------------------------------------------------------------------- + + /** + * getGatewayConfig() returns null (payment method not yet fully configured). + * Verifies process() exits immediately: validator and EntityManager are never called. + */ + public function testProcess_nullGatewayConfig_returnsEarlyWithoutFlush(): void + { + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $paymentMethod->method('getGatewayConfig')->willReturn(null); + + $this->entityManager->expects(self::never())->method('flush'); + $this->validator->expects(self::never())->method('validate'); + + $this->paymentMethodValidator->process($paymentMethod); + } + + // ------------------------------------------------------------------------- + // process() — unsupported factory name → InvalidArgumentException + // ------------------------------------------------------------------------- + + /** + * The gateway config declares a factory name not handled by the validator's match statement. + * Verifies process() throws an InvalidArgumentException. + */ + public function testProcess_unsupportedFactory_throwsInvalidArgumentException(): void + { + $this->expectException(\InvalidArgumentException::class); + + $paymentMethod = $this->buildPaymentMethod('unsupported_factory', []); + + $this->paymentMethodValidator->process($paymentMethod); + } + + // ------------------------------------------------------------------------- + // process() — 0 violations → no flash, method stays enabled, flush called + // ------------------------------------------------------------------------- + + /** + * Validation returns an empty ConstraintViolationList (all API permissions are satisfied). + * Verifies the method is not disabled, no flash is added, and flush() is called once. + */ + public function testProcess_noViolations_doesNotDisableOrFlashError(): void + { + $paymentMethod = $this->buildPaymentMethod(BancontactGatewayFactory::FACTORY_NAME, []); + + $this->validator->method('validate')->willReturn(new ConstraintViolationList()); + + $paymentMethod->expects(self::never())->method('disable'); + + $flashBag = $this->createMock(FlashBagInterface::class); + $flashBag->expects(self::never())->method('add'); + $session = $this->createMock(Session::class); + $session->method('getFlashBag')->willReturn($flashBag); + $this->requestStack->method('getSession')->willReturn($session); + + $this->entityManager->expects(self::once())->method('flush'); + + $this->paymentMethodValidator->process($paymentMethod); + } + + // ------------------------------------------------------------------------- + // process() — violations → flash each + disabled message, method disabled, flush called + // ------------------------------------------------------------------------- + + /** + * Validation returns one violation (e.g. Oney feature not enabled on the account). + * Verifies disable() is called, two flash messages are added (violation + disabled notice), and flush() runs. + */ + public function testProcess_withViolations_disablesMethodAndFlashesErrors(): void + { + $paymentMethod = $this->buildPaymentMethod(OneyGatewayFactory::FACTORY_NAME, []); + + $violation = $this->createMock(\Symfony\Component\Validator\ConstraintViolationInterface::class); + $violation->method('getMessage')->willReturn('Oney not enabled'); + + $violationList = new ConstraintViolationList([$violation]); + $this->validator->method('validate')->willReturn($violationList); + + $flashBag = $this->createMock(FlashBagInterface::class); + // Should be called twice: once for the violation message, once for "payment_method_disabled" + $flashBag->expects(self::exactly(2))->method('add')->with('payplug_error', self::anything()); + $session = $this->createMock(Session::class); + $session->method('getFlashBag')->willReturn($flashBag); + $this->requestStack->method('getSession')->willReturn($session); + + $paymentMethod->expects(self::once())->method('disable'); + $this->entityManager->expects(self::once())->method('flush'); + + $this->paymentMethodValidator->process($paymentMethod); + } + + // ------------------------------------------------------------------------- + // process() — PayPlug factory, no special flags → only IsCanSavePaymentMethod constraint + // ------------------------------------------------------------------------- + + /** + * PayPlug gateway with ONE_CLICK, DEFERRED_CAPTURE and INTEGRATED_PAYMENT all false. + * Verifies only the base IsCanSavePaymentMethod constraint (1 total) is passed to the validator. + */ + public function testProcess_payplugFactory_noFlags_validatesWithBaseConstraintOnly(): void + { + $config = [ + PayPlugGatewayFactory::ONE_CLICK => false, + PayPlugGatewayFactory::DEFERRED_CAPTURE => false, + PayPlugGatewayFactory::INTEGRATED_PAYMENT => false, + ]; + $paymentMethod = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME, $config); + + $this->validator + ->expects(self::once()) + ->method('validate') + ->willReturnCallback(function ($subject, array $constraints) { + // Only the base IsCanSavePaymentMethod constraint (no permission constraints) + self::assertCount(1, $constraints); + + return new ConstraintViolationList(); + }) + ; + + $flashBag = $this->createMock(FlashBagInterface::class); + $session = $this->createMock(Session::class); + $session->method('getFlashBag')->willReturn($flashBag); + $this->requestStack->method('getSession')->willReturn($session); + + $this->paymentMethodValidator->process($paymentMethod); + } + + // ------------------------------------------------------------------------- + // process() — PayPlug factory, all flags enabled → 4 constraints (base + 3 permissions) + // ------------------------------------------------------------------------- + + /** + * PayPlug gateway with ONE_CLICK, DEFERRED_CAPTURE and INTEGRATED_PAYMENT all true. + * Verifies 4 constraints are passed to the validator (base + one per enabled feature flag). + */ + public function testProcess_payplugFactory_allFlagsEnabled_validatesWithAllConstraints(): void + { + $config = [ + PayPlugGatewayFactory::ONE_CLICK => true, + PayPlugGatewayFactory::DEFERRED_CAPTURE => true, + PayPlugGatewayFactory::INTEGRATED_PAYMENT => true, + ]; + $paymentMethod = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME, $config); + + $this->validator + ->expects(self::once()) + ->method('validate') + ->willReturnCallback(function ($subject, array $constraints) { + // Base + CAN_SAVE_CARD + CAN_CREATE_DEFERRED_PAYMENT + CAN_USE_INTEGRATED_PAYMENTS + self::assertCount(4, $constraints); + + return new ConstraintViolationList(); + }) + ; + + $flashBag = $this->createMock(FlashBagInterface::class); + $session = $this->createMock(Session::class); + $session->method('getFlashBag')->willReturn($flashBag); + $this->requestStack->method('getSession')->willReturn($session); + + $this->paymentMethodValidator->process($paymentMethod); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function buildPaymentMethod(string $factoryName, array $config): PaymentMethodInterface&MockObject + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getFactoryName')->willReturn($factoryName); + $gatewayConfig->method('getConfig')->willReturn($config); + + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig); + + return $paymentMethod; + } +}