diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 00000000..6fed9975
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,34 @@
+## Description
+
+
+
+**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/.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/auto-tag-rc.yml b/.github/workflows/auto-tag-rc.yml
new file mode 100644
index 00000000..ef9f1ce0
--- /dev/null
+++ b/.github/workflows/auto-tag-rc.yml
@@ -0,0 +1,10 @@
+name: Auto-tag RC0
+
+'on':
+ create:
+
+jobs:
+ create-rc0-tag:
+ if: startsWith(github.ref, 'refs/heads/release/')
+ uses: payplug/template-ci/.github/workflows/auto_tag_rc.yml@main
+ secrets: inherit
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 00000000..a9caf7c4
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,29 @@
+name: CI
+
+'on':
+ pull_request:
+ branches:
+ - test-develop-ci
+ - test-master-ci
+ paths-ignore:
+ - README.md
+
+jobs:
+ quality:
+ uses: payplug/template-ci/.github/workflows/sylius_quality.yml@main
+
+ sylius-matrix:
+ needs: [quality]
+ if: github.base_ref == 'test-develop-ci'
+ uses: payplug/template-ci/.github/workflows/sylius_phpunit.yml@main
+
+ 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
deleted file mode 100644
index c90cc2c8..00000000
--- a/.github/workflows/pull_request_template.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# ⚠️ Requirements
-Reviewer, please take a look at those requirements:
-
-- [ ] Check that plugin version has been upgrated and are identical in both `composer.json` and `src/PayPlugSyliusPayPlugPlugin.php` files
-
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 00000000..5030bcc7
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,19 @@
+name: Release — RC
+
+'on':
+ push:
+ tags:
+ - '*-rc*'
+
+jobs:
+ quality:
+ uses: payplug/template-ci/.github/workflows/sylius_quality.yml@main
+
+ phpunit:
+ needs: [quality]
+ uses: payplug/template-ci/.github/workflows/sylius_phpunit.yml@main
+
+ github-release:
+ needs: phpunit
+ uses: payplug/template-ci/.github/workflows/github_release_rc.yml@main
+ secrets: inherit
diff --git a/.github/workflows/sylius.yaml b/.github/workflows/sylius.yaml
deleted file mode 100644
index 33dc6c31..00000000
--- a/.github/workflows/sylius.yaml
+++ /dev/null
@@ -1,105 +0,0 @@
-name: Sylius
-'on':
- push:
- branches:
- - develop
- - qa
- - master
- paths-ignore:
- - README.md
- pull_request:
- paths-ignore:
- - README.md
-jobs:
- sylius:
- name: 'PHPUnit-Behat (PHP ${{ matrix.php }} Sylius ${{ matrix.sylius }} Symfony ${{ matrix.symfony }})'
- runs-on: ubuntu-latest
- strategy:
- fail-fast: false
- matrix:
- php:
- - 8.2
- - 8.4
- sylius:
- - 2.1.0
- - 2.0.0
- symfony:
- - 6.4
- - 7.3
- node:
- - 20.x
- env:
- APP_ENV: test
- package-name: payplug/sylius-payplug-plugin
- steps:
- -
- name: 'Setup PHP'
- uses: shivammathur/setup-php@v2
- with:
- php-version: '${{ matrix.php }}'
- ini-values: date.timezone=UTC
- extensions: intl
- tools: symfony
- coverage: none
- -
- name: 'Setup Node'
- uses: actions/setup-node@v3
- with:
- node-version: '${{ matrix.node }}'
- -
- uses: actions/checkout@v3
- -
- 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
- id: cache-composer
- with:
- path: '${{ steps.composer-cache.outputs.dir }}'
- key: 'php-${{ matrix.php }}-sylius-${{ matrix.sylius }}-symfony-${{ matrix.symfony }}-composer-${{ hashFiles(''**/composer.json'') }}'
- restore-keys: 'php-${{ matrix.php }}-sylius-${{ matrix.sylius }}-symfony-${{ matrix.symfony }}-composer-'
- -
- name: 'Composer - Create cache directory'
- run: 'mkdir -p /home/runner/.composer/cache'
- if: 'steps.cache-composer.outputs.cache-hit != ''true'''
- -
- name: 'Composer - Github Auth'
- run: 'composer config -g github-oauth.github.com ${{ github.token }}'
- -
- name: 'Yarn - Get cache directory'
- id: yarn-cache
- run: 'echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT'
- -
- name: 'Yarn - Set Cache'
- uses: actions/cache@v4
- with:
- path: '${{ steps.yarn-cache.outputs.dir }}'
- key: 'node-${{ matrix.node }}-yarn-${{ hashFiles(''**/package.json **/yarn.lock'') }}'
- restore-keys: "node-${{ matrix.node }}-yarn-\n"
- -
- name: 'Install Sylius-Standard and Plugin'
- run: 'make install -e SYLIUS_VERSION=${{ matrix.sylius }} SYMFONY_VERSION=${{ matrix.symfony }}'
- id: end-of-setup-sylius
- -
- name: 'Doctrine Schema Validate - Run'
- run: 'vendor/bin/console doctrine:schema:validate --skip-sync'
- -
- name: 'Run PHPUnit'
- run: 'make phpunit'
- if: 'always() && steps.end-of-setup-sylius.outcome == ''success'''
- -
- uses: actions/upload-artifact@v4
- if: failure()
- with:
- name: logs
- path: ./tests/Application/etc/build
- services:
- mariadb:
- image: 'mariadb:10.4.11'
- ports:
- - '3306:3306'
- env:
- MYSQL_ALLOW_EMPTY_PASSWORD: true
- options: '--health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3'
diff --git a/.gitignore b/.gitignore
index cde5dab0..f47650bd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,3 +24,4 @@ CLAUDE.md
.DS_Store
.claude
.review
+.phpunit.result.cache
diff --git a/README.md b/README.md
index 610a38d8..40b8025e 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,7 @@
[](https://github.com/payplug/SyliusPayPlugPlugin/blob/master/LICENSE)
-[](https://github.com/payplug/SyliusPayPlugPlugin/actions/workflows/analysis.yaml)
-[](https://github.com/payplug/SyliusPayPlugPlugin/actions/workflows/sylius.yaml)
+[](https://sonarcloud.io/summary/new_code?id=github-payplug-payplug-syliuspayplugplugin)
+[](https://sonarcloud.io/summary/new_code?id=github-payplug-payplug-syliuspayplugplugin)
+[](https://sonarcloud.io/summary/new_code?id=github-payplug-payplug-syliuspayplugplugin)
[](https://packagist.org/packages/payplug/sylius-payplug-plugin)
[](https://packagist.org/packages/payplug/sylius-payplug-plugin)
diff --git a/composer.json b/composer.json
index f7d338e1..c692cafd 100755
--- a/composer.json
+++ b/composer.json
@@ -47,7 +47,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 +80,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/AmericanExpressGatewayFactory.php b/src/Gateway/AmericanExpressGatewayFactory.php
index 4e20cfe8..21458851 100644
--- a/src/Gateway/AmericanExpressGatewayFactory.php
+++ b/src/Gateway/AmericanExpressGatewayFactory.php
@@ -11,11 +11,4 @@ final class AmericanExpressGatewayFactory extends AbstractGatewayFactory
public const FACTORY_TITLE = 'American Express by PayPlug';
public const PAYMENT_METHOD_AMERICAN_EXPRESS = 'american_express';
-
- public const AUTHORIZED_CURRENCIES = [
- 'EUR' => [
- 'min_amount' => 100,
- 'max_amount' => 2000000,
- ],
- ];
}
diff --git a/src/Gateway/ApplePayGatewayFactory.php b/src/Gateway/ApplePayGatewayFactory.php
index 8ee80585..a7971241 100644
--- a/src/Gateway/ApplePayGatewayFactory.php
+++ b/src/Gateway/ApplePayGatewayFactory.php
@@ -11,11 +11,4 @@ final class ApplePayGatewayFactory extends AbstractGatewayFactory
public const FACTORY_TITLE = 'Apple Pay by PayPlug';
public const PAYMENT_METHOD_APPLE_PAY = 'apple_pay';
-
- public const AUTHORIZED_CURRENCIES = [
- 'EUR' => [
- 'min_amount' => 100,
- 'max_amount' => 2000000,
- ],
- ];
}
diff --git a/src/Gateway/BancontactGatewayFactory.php b/src/Gateway/BancontactGatewayFactory.php
index 287673c2..b53c5299 100644
--- a/src/Gateway/BancontactGatewayFactory.php
+++ b/src/Gateway/BancontactGatewayFactory.php
@@ -11,11 +11,4 @@ final class BancontactGatewayFactory extends AbstractGatewayFactory
public const FACTORY_TITLE = 'Bancontact by PayPlug';
public const PAYMENT_METHOD_BANCONTACT = 'bancontact';
-
- public const AUTHORIZED_CURRENCIES = [
- 'EUR' => [
- 'min_amount' => 100,
- 'max_amount' => 2000000,
- ],
- ];
}
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/PayPlugGatewayFactory.php b/src/Gateway/PayPlugGatewayFactory.php
index a6aa1f7a..99cb0e41 100644
--- a/src/Gateway/PayPlugGatewayFactory.php
+++ b/src/Gateway/PayPlugGatewayFactory.php
@@ -16,11 +16,4 @@ final class PayPlugGatewayFactory extends AbstractGatewayFactory
public const INTEGRATED_PAYMENT = 'integratedPayment';
public const DEFERRED_CAPTURE = 'deferredCapture';
-
- public const AUTHORIZED_CURRENCIES = [
- 'EUR' => [
- 'min_amount' => 99,
- 'max_amount' => 2000000,
- ],
- ];
}
diff --git a/src/Gateway/ScalapayGatewayFactory.php b/src/Gateway/ScalapayGatewayFactory.php
new file mode 100644
index 00000000..5e4b063a
--- /dev/null
+++ b/src/Gateway/ScalapayGatewayFactory.php
@@ -0,0 +1,14 @@
+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..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,
) {
}
@@ -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..b6d74a0f 100644
--- a/src/PaymentProcessing/RefundPaymentProcessor.php
+++ b/src/PaymentProcessing/RefundPaymentProcessor.php
@@ -13,12 +13,12 @@
use PayPlug\SyliusPayPlugPlugin\Gateway\BancontactGatewayFactory;
use PayPlug\SyliusPayPlugPlugin\Gateway\OneyGatewayFactory;
use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory;
+use PayPlug\SyliusPayPlugPlugin\Gateway\ScalapayGatewayFactory;
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 +28,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
@@ -124,6 +125,7 @@ private function prepare(PaymentInterface $payment): void
BancontactGatewayFactory::FACTORY_NAME,
ApplePayGatewayFactory::FACTORY_NAME,
AmericanExpressGatewayFactory::FACTORY_NAME,
+ ScalapayGatewayFactory::FACTORY_NAME,
], true)
) {
return;
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 @@
+currencyContext->getCurrencyCode();
+ $authorizedCurrencies = null;
+ $allowedCountries = null;
foreach ($supportedMethods as $key => $paymentMethod) {
Assert::isInstanceOf($paymentMethod, PaymentMethodInterface::class);
@@ -33,6 +38,15 @@ public function provide(
continue;
}
+ $authorizedCurrencies ??= $this->resolveAuthorizedCurrencies($factoryName);
+ $allowedCountries ??= $this->resolveAllowedCountries($factoryName);
+
+ if ($billingCountryCode !== null && $allowedCountries !== [] && !\in_array($billingCountryCode, $allowedCountries, true)) {
+ unset($supportedMethods[$key]);
+
+ continue;
+ }
+
if (!\array_key_exists($activeCurrencyCode, $authorizedCurrencies)) {
unset($supportedMethods[$key]);
@@ -51,4 +65,65 @@ public function provide(
return $supportedMethods;
}
+
+ private function resolveAuthorizedCurrencies(string $factoryName): array
+ {
+ $account = $this->clientFactory->create($factoryName)->getAccount();
+
+ $configuration = $account['configuration'] ?? [];
+ Assert::isArray($configuration);
+ $defaultMin = $configuration['min_amounts'] ?? [];
+ Assert::isArray($defaultMin);
+ $defaultMax = $configuration['max_amounts'] ?? [];
+ Assert::isArray($defaultMax);
+
+ $underscorePos = strpos($factoryName, '_');
+ if ($underscorePos !== false) {
+ $pmKey = substr($factoryName, $underscorePos + 1);
+ $paymentMethods = $account['payment_methods'] ?? [];
+ Assert::isArray($paymentMethods);
+ $pmData = $paymentMethods[$pmKey] ?? [];
+ Assert::isArray($pmData);
+ $minAmounts = isset($pmData['min_amounts']) && \is_array($pmData['min_amounts']) ? $pmData['min_amounts'] : $defaultMin;
+ $maxAmounts = isset($pmData['max_amounts']) && \is_array($pmData['max_amounts']) ? $pmData['max_amounts'] : $defaultMax;
+ } else {
+ $minAmounts = $defaultMin;
+ $maxAmounts = $defaultMax;
+ }
+
+ $currencies = [];
+ foreach ($minAmounts as $currency => $min) {
+ Assert::string($currency);
+ Assert::integer($min);
+ if (isset($maxAmounts[$currency]) && \is_int($maxAmounts[$currency])) {
+ $currencies[$currency] = ['min_amount' => $min, 'max_amount' => $maxAmounts[$currency]];
+ }
+ }
+
+ return $currencies;
+ }
+
+ private function resolveAllowedCountries(string $factoryName): array
+ {
+ $underscorePos = strpos($factoryName, '_');
+ if ($underscorePos === false) {
+ return [];
+ }
+
+ $account = $this->clientFactory->create($factoryName)->getAccount();
+ $pmKey = substr($factoryName, $underscorePos + 1);
+ $paymentMethods = $account['payment_methods'] ?? [];
+ Assert::isArray($paymentMethods);
+ $pmData = $paymentMethods[$pmKey] ?? [];
+ Assert::isArray($pmData);
+
+ $allowedCountries = $pmData['allowed_countries'] ?? [];
+ Assert::isArray($allowedCountries);
+
+ if (\in_array('ALL', $allowedCountries, true)) {
+ return [];
+ }
+
+ return $allowedCountries;
+ }
}
diff --git a/src/Provider/SupportedRefundPaymentMethodsProviderDecorator.php b/src/Provider/SupportedRefundPaymentMethodsProviderDecorator.php
index 40095917..c590fc26 100644
--- a/src/Provider/SupportedRefundPaymentMethodsProviderDecorator.php
+++ b/src/Provider/SupportedRefundPaymentMethodsProviderDecorator.php
@@ -5,11 +5,11 @@
namespace PayPlug\SyliusPayPlugPlugin\Provider;
use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory;
-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\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
diff --git a/src/Repository/PaymentRepository.php b/src/Repository/PaymentRepository.php
index d3df9471..d500fbc1 100644
--- a/src/Repository/PaymentRepository.php
+++ b/src/Repository/PaymentRepository.php
@@ -27,14 +27,16 @@ public function findAllActiveByGatewayFactoryName(string $gatewayFactoryName): a
public function findOneByPayPlugPaymentId(string $payplugPaymentId): ?PaymentInterface
{
- /** @var PaymentInterface|null */
- return $this->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/AmericanExpressPaymentMethodsResolverDecorator.php b/src/Resolver/AmericanExpressPaymentMethodsResolverDecorator.php
index 923a3eca..d525bf61 100644
--- a/src/Resolver/AmericanExpressPaymentMethodsResolverDecorator.php
+++ b/src/Resolver/AmericanExpressPaymentMethodsResolverDecorator.php
@@ -6,6 +6,7 @@
use PayPlug\SyliusPayPlugPlugin\Gateway\AmericanExpressGatewayFactory;
use PayPlug\SyliusPayPlugPlugin\Provider\SupportedMethodsProvider;
+use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\Payment;
use Sylius\Component\Payment\Model\PaymentInterface as BasePaymentInterface;
use Sylius\Component\Payment\Resolver\PaymentMethodsResolverInterface;
@@ -28,11 +29,15 @@ public function getSupportedMethods(BasePaymentInterface $subject): array
Assert::isInstanceOf($subject, Payment::class);
$supportedMethods = $this->decorated->getSupportedMethods($subject);
+ /** @var OrderInterface $order */
+ $order = $subject->getOrder();
+ $billingCountryCode = $order->getBillingAddress()?->getCountryCode();
+
return $this->supportedMethodsProvider->provide(
$supportedMethods,
AmericanExpressGatewayFactory::FACTORY_NAME,
- AmericanExpressGatewayFactory::AUTHORIZED_CURRENCIES,
$subject->getAmount() ?? 0,
+ $billingCountryCode,
);
}
diff --git a/src/Resolver/ApplePayPaymentMethodsResolverDecorator.php b/src/Resolver/ApplePayPaymentMethodsResolverDecorator.php
index 1374a5ad..5205eaf7 100644
--- a/src/Resolver/ApplePayPaymentMethodsResolverDecorator.php
+++ b/src/Resolver/ApplePayPaymentMethodsResolverDecorator.php
@@ -6,6 +6,7 @@
use PayPlug\SyliusPayPlugPlugin\Gateway\ApplePayGatewayFactory;
use PayPlug\SyliusPayPlugPlugin\Provider\SupportedMethodsProvider;
+use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\Payment;
use Sylius\Component\Payment\Model\PaymentInterface as BasePaymentInterface;
use Sylius\Component\Payment\Resolver\PaymentMethodsResolverInterface;
@@ -28,11 +29,15 @@ public function getSupportedMethods(BasePaymentInterface $subject): array
Assert::isInstanceOf($subject, Payment::class);
$supportedMethods = $this->decorated->getSupportedMethods($subject);
+ /** @var OrderInterface $order */
+ $order = $subject->getOrder();
+ $billingCountryCode = $order->getBillingAddress()?->getCountryCode();
+
return $this->supportedMethodsProvider->provide(
$supportedMethods,
ApplePayGatewayFactory::FACTORY_NAME,
- ApplePayGatewayFactory::AUTHORIZED_CURRENCIES,
$subject->getAmount() ?? 0,
+ $billingCountryCode,
);
}
diff --git a/src/Resolver/BancontactPaymentMethodsResolverDecorator.php b/src/Resolver/BancontactPaymentMethodsResolverDecorator.php
index 22dc0c51..c003b444 100644
--- a/src/Resolver/BancontactPaymentMethodsResolverDecorator.php
+++ b/src/Resolver/BancontactPaymentMethodsResolverDecorator.php
@@ -6,6 +6,7 @@
use PayPlug\SyliusPayPlugPlugin\Gateway\BancontactGatewayFactory;
use PayPlug\SyliusPayPlugPlugin\Provider\SupportedMethodsProvider;
+use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\Payment;
use Sylius\Component\Payment\Model\PaymentInterface as BasePaymentInterface;
use Sylius\Component\Payment\Resolver\PaymentMethodsResolverInterface;
@@ -28,11 +29,15 @@ public function getSupportedMethods(BasePaymentInterface $subject): array
Assert::isInstanceOf($subject, Payment::class);
$supportedMethods = $this->decorated->getSupportedMethods($subject);
+ /** @var OrderInterface $order */
+ $order = $subject->getOrder();
+ $billingCountryCode = $order->getBillingAddress()?->getCountryCode();
+
return $this->supportedMethodsProvider->provide(
$supportedMethods,
BancontactGatewayFactory::FACTORY_NAME,
- BancontactGatewayFactory::AUTHORIZED_CURRENCIES,
$subject->getAmount() ?? 0,
+ $billingCountryCode,
);
}
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/PayPlugPaymentMethodsResolverDecorator.php b/src/Resolver/PayPlugPaymentMethodsResolverDecorator.php
index 38818645..39412fa1 100644
--- a/src/Resolver/PayPlugPaymentMethodsResolverDecorator.php
+++ b/src/Resolver/PayPlugPaymentMethodsResolverDecorator.php
@@ -6,6 +6,7 @@
use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory;
use PayPlug\SyliusPayPlugPlugin\Provider\SupportedMethodsProvider;
+use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\Payment;
use Sylius\Component\Payment\Model\PaymentInterface as BasePaymentInterface;
use Sylius\Component\Payment\Resolver\PaymentMethodsResolverInterface;
@@ -28,11 +29,15 @@ public function getSupportedMethods(BasePaymentInterface $subject): array
Assert::isInstanceOf($subject, Payment::class);
$supportedMethods = $this->decorated->getSupportedMethods($subject);
+ /** @var OrderInterface $order */
+ $order = $subject->getOrder();
+ $billingCountryCode = $order->getBillingAddress()?->getCountryCode();
+
return $this->supportedMethodsProvider->provide(
$supportedMethods,
PayPlugGatewayFactory::FACTORY_NAME,
- PayPlugGatewayFactory::AUTHORIZED_CURRENCIES,
$subject->getAmount() ?? 0,
+ $billingCountryCode,
);
}
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..05664c39
--- /dev/null
+++ b/src/Resolver/ScalapayPaymentMethodsResolverDecorator.php
@@ -0,0 +1,48 @@
+decorated->getSupportedMethods($subject);
+
+ /** @var OrderInterface $order */
+ $order = $subject->getOrder();
+ $billingCountryCode = $order->getBillingAddress()?->getCountryCode();
+
+ return $this->supportedMethodsProvider->provide(
+ $supportedMethods,
+ ScalapayGatewayFactory::FACTORY_NAME,
+ $subject->getAmount() ?? 0,
+ $billingCountryCode,
+ );
+ }
+
+ 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/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..144af170
--- /dev/null
+++ b/tests/PHPUnit/Creator/PayPlugPaymentDataCreatorTest.php
@@ -0,0 +1,603 @@
+canSaveCardChecker = $this->createMock(CanSaveCardCheckerInterface::class);
+ $this->payplugCardRepository = $this->createMock(RepositoryInterface::class);
+ $this->requestStack = $this->createMock(RequestStack::class);
+ $this->payplugFeatureChecker = $this->createMock(PayplugFeatureChecker::class);
+ $this->urlGenerator = $this->createMock(UrlGeneratorInterface::class);
+
+ $this->creator = new PayPlugPaymentDataCreator(
+ $this->canSaveCardChecker,
+ $this->payplugCardRepository,
+ $this->requestStack,
+ $this->payplugFeatureChecker,
+ $this->urlGenerator,
+ );
+ }
+
+ // -------------------------------------------------------------------------
+ // 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..62d92df0
--- /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(): SharedLockInterface&MockObject
+ {
+ $lock = $this->createMock(SharedLockInterface::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..d4ba9812
--- /dev/null
+++ b/tests/PHPUnit/PaymentProcessing/RefundPaymentProcessorTest.php
@@ -0,0 +1,241 @@
+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() — Scalapay gateway → refund is processed
+ // -------------------------------------------------------------------------
+
+ /**
+ * Calls process() with a Scalapay payment; verifies the API client factory is invoked
+ * and refundPayment() is called, confirming Scalapay is included in the supported gateway list.
+ */
+ public function testProcess_scalapayGateway_callsApiRefund(): void
+ {
+ $payment = $this->buildPayment(ScalapayGatewayFactory::FACTORY_NAME, ['payment_id' => 'pay_scalapay']);
+
+ $this->apiClientFactory->expects(self::once())->method('createForPaymentMethod');
+ $this->apiClient->expects(self::once())->method('refundPayment')->with('pay_scalapay');
+
+ $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..6e015ea5
--- /dev/null
+++ b/tests/PHPUnit/Provider/SupportedMethodsProviderTest.php
@@ -0,0 +1,387 @@
+currencyContext = $this->createMock(CurrencyContextInterface::class);
+ $this->clientFactory = $this->createMock(PayPlugApiClientFactoryInterface::class);
+ $this->apiClient = $this->createMock(PayPlugApiClientInterface::class);
+
+ $this->clientFactory->method('create')->willReturn($this->apiClient);
+
+ $this->provider = new SupportedMethodsProvider($this->currencyContext, $this->clientFactory);
+ }
+
+ // -------------------------------------------------------------------------
+ // 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');
+ // getAccount() must never be called: the PayPlug method never matches the Bancontact factory,
+ // so the loop always hits `continue` before reaching the lazy-loading lines.
+ $this->apiClient->expects(self::never())->method('getAccount');
+
+ // PaymentMethod is PayPlug, but we're querying for Bancontact — so it's passed through as-is
+ $method = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME);
+
+ $result = $this->provider->provide([$method], BancontactGatewayFactory::FACTORY_NAME, 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 authorizes EUR.
+ * Verifies the method is removed from the result list.
+ */
+ public function testProvide_withUnauthorizedCurrency_removesMethod(): void
+ {
+ $this->currencyContext->method('getCurrencyCode')->willReturn('USD');
+ $this->apiClient->method('getAccount')->willReturn($this->buildAccount(99, 2000000));
+
+ $method = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME);
+
+ $result = $this->provider->provide([$method], PayPlugGatewayFactory::FACTORY_NAME, 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');
+ $this->apiClient->method('getAccount')->willReturn($this->buildAccount(99, 2000000));
+
+ $method = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME);
+
+ $result = $this->provider->provide([$method], PayPlugGatewayFactory::FACTORY_NAME, 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');
+ $this->apiClient->method('getAccount')->willReturn($this->buildAccount(99, 2000000));
+
+ $method = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME);
+
+ $result = $this->provider->provide([$method], PayPlugGatewayFactory::FACTORY_NAME, 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');
+ $this->apiClient->method('getAccount')->willReturn($this->buildAccount(99, 2000000));
+
+ $method = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME);
+
+ $result = $this->provider->provide([$method], PayPlugGatewayFactory::FACTORY_NAME, 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');
+ $this->apiClient->method('getAccount')->willReturn($this->buildAccount(99, 2000000));
+
+ $method = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME);
+
+ $result = $this->provider->provide([$method], PayPlugGatewayFactory::FACTORY_NAME, 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');
+ $this->apiClient->method('getAccount')->willReturn($this->buildAccount(99, 2000000));
+
+ $method = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME);
+
+ $result = $this->provider->provide([$method], PayPlugGatewayFactory::FACTORY_NAME, 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');
+ $this->apiClient->method('getAccount')->willReturn($this->buildAccount(99, 2000000));
+
+ $payplugMethod = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME);
+ $bancontactMethod = $this->buildPaymentMethod(BancontactGatewayFactory::FACTORY_NAME);
+
+ // We query for PayPlug factory only; amount is valid
+ $result = $this->provider->provide(
+ [$payplugMethod, $bancontactMethod],
+ PayPlugGatewayFactory::FACTORY_NAME,
+ 1000,
+ );
+
+ // Bancontact is skipped (different factory), PayPlug stays
+ self::assertCount(2, $result); // Bancontact not removed (no filter applied for different factory)
+ }
+
+ // -------------------------------------------------------------------------
+ // provide() — allowed_countries filter
+ // -------------------------------------------------------------------------
+
+ /**
+ * Billing country is in the allowed_countries list → method kept.
+ */
+ public function testProvide_withAllowedCountry_keepsMethod(): void
+ {
+ $this->currencyContext->method('getCurrencyCode')->willReturn('EUR');
+ $account = [
+ 'configuration' => ['min_amounts' => ['EUR' => 30], 'max_amounts' => ['EUR' => 2000000]],
+ 'payment_methods' => ['scalapay' => ['min_amounts' => ['EUR' => 500], 'max_amounts' => ['EUR' => 200000], 'allowed_countries' => ['FR', 'DE']]],
+ ];
+ $this->apiClient->method('getAccount')->willReturn($account);
+
+ $method = $this->buildPaymentMethod('payplug_scalapay');
+ $result = $this->provider->provide([$method], 'payplug_scalapay', 1000, 'FR');
+
+ self::assertCount(1, $result);
+ }
+
+ /**
+ * Billing country is NOT in the allowed_countries list → method removed.
+ */
+ public function testProvide_withDisallowedCountry_removesMethod(): void
+ {
+ $this->currencyContext->method('getCurrencyCode')->willReturn('EUR');
+ $account = [
+ 'configuration' => ['min_amounts' => ['EUR' => 30], 'max_amounts' => ['EUR' => 2000000]],
+ 'payment_methods' => ['scalapay' => ['min_amounts' => ['EUR' => 500], 'max_amounts' => ['EUR' => 200000], 'allowed_countries' => ['FR', 'DE']]],
+ ];
+ $this->apiClient->method('getAccount')->willReturn($account);
+
+ $method = $this->buildPaymentMethod('payplug_scalapay');
+ $result = $this->provider->provide([$method], 'payplug_scalapay', 1000, 'US');
+
+ self::assertEmpty($result);
+ }
+
+ /**
+ * allowed_countries = ["ALL"] → country check skipped, method kept.
+ */
+ public function testProvide_withAllowedCountriesAll_keepsMethod(): void
+ {
+ $this->currencyContext->method('getCurrencyCode')->willReturn('EUR');
+ $account = [
+ 'configuration' => ['min_amounts' => ['EUR' => 30], 'max_amounts' => ['EUR' => 2000000]],
+ 'payment_methods' => ['bancontact' => ['min_amounts' => ['EUR' => 30], 'max_amounts' => ['EUR' => 2000000], 'allowed_countries' => ['ALL']]],
+ ];
+ $this->apiClient->method('getAccount')->willReturn($account);
+
+ $method = $this->buildPaymentMethod('payplug_bancontact');
+ $result = $this->provider->provide([$method], 'payplug_bancontact', 1000, 'US');
+
+ self::assertCount(1, $result);
+ }
+
+ /**
+ * Billing country is null (no billing address yet) → country check skipped, method kept.
+ */
+ public function testProvide_withNullBillingCountry_keepsMethod(): void
+ {
+ $this->currencyContext->method('getCurrencyCode')->willReturn('EUR');
+ $account = [
+ 'configuration' => ['min_amounts' => ['EUR' => 30], 'max_amounts' => ['EUR' => 2000000]],
+ 'payment_methods' => ['scalapay' => ['min_amounts' => ['EUR' => 500], 'max_amounts' => ['EUR' => 200000], 'allowed_countries' => ['FR', 'DE']]],
+ ];
+ $this->apiClient->method('getAccount')->willReturn($account);
+
+ $method = $this->buildPaymentMethod('payplug_scalapay');
+ $result = $this->provider->provide([$method], 'payplug_scalapay', 1000, null);
+
+ self::assertCount(1, $result);
+ }
+
+ // -------------------------------------------------------------------------
+ // provide() — payment method specific amounts used over configuration defaults
+ // -------------------------------------------------------------------------
+
+ /**
+ * When the account JSON has specific min/max for the payment method,
+ * those values take precedence over the configuration defaults.
+ */
+ public function testProvide_usesPaymentMethodSpecificAmounts(): void
+ {
+ $this->currencyContext->method('getCurrencyCode')->willReturn('EUR');
+
+ // Scalapay has tighter limits (500–200000) vs configuration defaults (30–2000000)
+ $account = [
+ 'configuration' => [
+ 'min_amounts' => ['EUR' => 30],
+ 'max_amounts' => ['EUR' => 2000000],
+ ],
+ 'payment_methods' => [
+ 'scalapay' => [
+ 'min_amounts' => ['EUR' => 500],
+ 'max_amounts' => ['EUR' => 200000],
+ ],
+ ],
+ ];
+ $this->apiClient->method('getAccount')->willReturn($account);
+
+ $method = $this->buildPaymentMethod('payplug_scalapay');
+
+ // Amount 400 is above the configuration default min (30) but below Scalapay's min (500)
+ $result = $this->provider->provide([$method], 'payplug_scalapay', 400);
+ self::assertEmpty($result);
+
+ // Amount 500 is exactly at Scalapay's min — should be kept
+ $result2 = $this->provider->provide([$method], 'payplug_scalapay', 500);
+ self::assertCount(1, $result2);
+ }
+
+ // -------------------------------------------------------------------------
+ // provide() — fallback to configuration when payment method has no amounts
+ // -------------------------------------------------------------------------
+
+ /**
+ * When the payment method entry in the API has no min/max amounts (e.g. Apple Pay),
+ * the configuration defaults are used.
+ */
+ public function testProvide_fallsBackToConfigurationAmounts(): void
+ {
+ $this->currencyContext->method('getCurrencyCode')->willReturn('EUR');
+
+ $account = [
+ 'configuration' => [
+ 'min_amounts' => ['EUR' => 30],
+ 'max_amounts' => ['EUR' => 2000000],
+ ],
+ 'payment_methods' => [
+ 'apple_pay' => [
+ 'enabled' => true,
+ // no min_amounts / max_amounts
+ ],
+ ],
+ ];
+ $this->apiClient->method('getAccount')->willReturn($account);
+
+ $method = $this->buildPaymentMethod('payplug_apple_pay');
+
+ // 30 is at configuration min — should be kept
+ $result = $this->provider->provide([$method], 'payplug_apple_pay', 30);
+ self::assertCount(1, $result);
+
+ // 29 is below configuration min — should be removed
+ $result2 = $this->provider->provide([$method], 'payplug_apple_pay', 29);
+ self::assertEmpty($result2);
+ }
+
+ // -------------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------------
+
+ private function buildAccount(int $minAmount, int $maxAmount): array
+ {
+ return [
+ 'configuration' => [
+ 'min_amounts' => ['EUR' => $minAmount],
+ 'max_amounts' => ['EUR' => $maxAmount],
+ ],
+ 'payment_methods' => [],
+ ];
+ }
+
+ 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;
+ }
+}
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.