From 5a70fdb75a2164f1ea141aa332939f91b10af6bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Im=C3=A8ne?= Date: Thu, 24 Sep 2026 19:32:11 +0200 Subject: [PATCH 1/3] PRE-3673: Capture/deferred/void for UHF --- CHANGELOG.md | 3 + composer.json | 2 +- composer.lock | 630 +++++++++--------- config/routing/admin.yaml | 18 +- config/services.yaml | 7 + config/twig_hooks/admin.yaml | 9 + doc/authorized_payment.md | 80 +++ .../Admin/AuthorizedPaymentController.php | 181 +++++ .../CaptureAliasPaymentRequestHandler.php | 7 +- .../CaptureHostedPaymentRequestHandler.php | 8 +- .../NotifyHostedPaymentRequestHandler.php | 6 +- .../AuthorizationOperationException.php | 38 ++ src/Gateway/PayPlugGatewayFactory.php | 12 + ...HostedFieldsWebhookNotificationHandler.php | 95 ++- .../AuthorizedPaymentOperationProcessor.php | 396 +++++++++++ .../RefundPaymentProcessor.php | 7 +- src/Twig/AuthorizationExtension.php | 71 ++ src/Upc/AuthorizationDetails.php | 296 ++++++++ src/Upc/AuthorizationOperatorInterface.php | 49 ++ src/Upc/PaymentCaptureContextBuilder.php | 6 + src/Upc/PaymentCaptureOutcomeApplier.php | 5 +- src/Upc/UnifiedApiAuthorizationOperator.php | 76 +++ .../admin/order/show/authorization.html.twig | 110 +++ .../order/show/payment_complete.html.twig | 7 + .../Admin/AuthorizedPaymentControllerTest.php | 258 +++++++ ...edFieldsWebhookNotificationHandlerTest.php | 58 ++ ...uthorizedPaymentOperationProcessorTest.php | 364 ++++++++++ .../RefundPaymentProcessorTest.php | 25 + .../PHPUnit/Upc/AuthorizationDetailsTest.php | 120 ++++ .../Upc/PaymentCaptureContextBuilderTest.php | 37 + .../Upc/PaymentCaptureOutcomeApplierTest.php | 13 + .../UnifiedApiAuthorizationOperatorTest.php | 155 +++++ translations/flashes.en.yml | 22 + translations/flashes.fr.yml | 22 + translations/flashes.it.yml | 22 + translations/messages.en.yml | 19 + translations/messages.fr.yml | 19 + translations/messages.it.yml | 19 + 38 files changed, 2948 insertions(+), 324 deletions(-) create mode 100644 src/Action/Admin/AuthorizedPaymentController.php create mode 100644 src/Exception/Payment/AuthorizationOperationException.php create mode 100644 src/PaymentProcessing/AuthorizedPaymentOperationProcessor.php create mode 100644 src/Twig/AuthorizationExtension.php create mode 100644 src/Upc/AuthorizationDetails.php create mode 100644 src/Upc/AuthorizationOperatorInterface.php create mode 100644 src/Upc/UnifiedApiAuthorizationOperator.php create mode 100644 templates/admin/order/show/authorization.html.twig create mode 100644 templates/admin/order/show/payment_complete.html.twig create mode 100644 tests/PHPUnit/Action/Admin/AuthorizedPaymentControllerTest.php create mode 100644 tests/PHPUnit/PaymentProcessing/AuthorizedPaymentOperationProcessorTest.php create mode 100644 tests/PHPUnit/Upc/AuthorizationDetailsTest.php create mode 100644 tests/PHPUnit/Upc/UnifiedApiAuthorizationOperatorTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 661ca66c..32548821 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The connected PayPlug account is displayed on each gateway's update screen - "Disconnect this account" per gateway, clearing that gateway's credentials without touching others - Channels already claimed by another enabled gateway of the same type are rendered unselectable +- **Hosted Fields deferred capture**: with deferred capture enabled, Hosted Fields payments are + created as authorizations and captured (in full, partially, several times) or cancelled (in full + or partially) from the admin order screen, with the remaining amount and capture deadline shown > [!IMPORTANT] > Merchants will need to contact support to switch to the new authentication method. diff --git a/composer.json b/composer.json index 1d0ec8a0..a9069864 100755 --- a/composer.json +++ b/composer.json @@ -14,7 +14,7 @@ "ext-json": "*", "giggsey/libphonenumber-for-php": "^8.12", "payplug/payplug-php": "^4.0", - "payplug/unified-plugin-core": "^1.1.2", + "payplug/unified-plugin-core": "^1.1.3", "php-http/message-factory": "^1.1", "sylius/refund-plugin": "^2.0", "sylius/sylius": "^2.0", diff --git a/composer.lock b/composer.lock index 1c15664d..1d20f3e4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7d9d744cdee9e08809f29130004ba4ed", + "content-hash": "dcc0d25a7d712c2284f331538785b388", "packages": [ { "name": "alcohol/iso4217", @@ -70,16 +70,16 @@ }, { "name": "api-platform/doctrine-common", - "version": "v4.3.18", + "version": "v4.3.20", "source": { "type": "git", "url": "https://github.com/api-platform/doctrine-common.git", - "reference": "45057f5226c3bdfbb0803220a9cfa667b5da83ad" + "reference": "202b4e6626c01a593e61b89028f89348e7190dd9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/doctrine-common/zipball/45057f5226c3bdfbb0803220a9cfa667b5da83ad", - "reference": "45057f5226c3bdfbb0803220a9cfa667b5da83ad", + "url": "https://api.github.com/repos/api-platform/doctrine-common/zipball/202b4e6626c01a593e61b89028f89348e7190dd9", + "reference": "202b4e6626c01a593e61b89028f89348e7190dd9", "shasum": "" }, "require": { @@ -154,22 +154,22 @@ "rest" ], "support": { - "source": "https://github.com/api-platform/doctrine-common/tree/v4.3.18" + "source": "https://github.com/api-platform/doctrine-common/tree/v4.3.20" }, - "time": "2026-08-16T07:56:59+00:00" + "time": "2026-09-12T13:19:57+00:00" }, { "name": "api-platform/doctrine-orm", - "version": "v4.3.18", + "version": "v4.3.20", "source": { "type": "git", "url": "https://github.com/api-platform/doctrine-orm.git", - "reference": "a8831db3ffed64cfc55ceac4f43e600eb6bf3bb5" + "reference": "997fa78bf13ddc5cb3951eea61bd23f425e91486" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/doctrine-orm/zipball/a8831db3ffed64cfc55ceac4f43e600eb6bf3bb5", - "reference": "a8831db3ffed64cfc55ceac4f43e600eb6bf3bb5", + "url": "https://api.github.com/repos/api-platform/doctrine-orm/zipball/997fa78bf13ddc5cb3951eea61bd23f425e91486", + "reference": "997fa78bf13ddc5cb3951eea61bd23f425e91486", "shasum": "" }, "require": { @@ -243,13 +243,13 @@ "rest" ], "support": { - "source": "https://github.com/api-platform/doctrine-orm/tree/v4.3.18" + "source": "https://github.com/api-platform/doctrine-orm/tree/v4.3.20" }, - "time": "2026-07-22T15:09:18+00:00" + "time": "2026-09-09T07:14:18+00:00" }, { "name": "api-platform/documentation", - "version": "v4.3.18", + "version": "v4.3.20", "source": { "type": "git", "url": "https://github.com/api-platform/documentation.git", @@ -312,7 +312,7 @@ }, { "name": "api-platform/http-cache", - "version": "v4.3.18", + "version": "v4.3.20", "source": { "type": "git", "url": "https://github.com/api-platform/http-cache.git", @@ -392,7 +392,7 @@ }, { "name": "api-platform/hydra", - "version": "v4.3.18", + "version": "v4.3.20", "source": { "type": "git", "url": "https://github.com/api-platform/hydra.git", @@ -473,13 +473,13 @@ "rest" ], "support": { - "source": "https://github.com/api-platform/hydra/tree/v4.3.18" + "source": "https://github.com/api-platform/hydra/tree/v4.3.20" }, "time": "2026-08-16T15:12:48+00:00" }, { "name": "api-platform/json-schema", - "version": "v4.3.18", + "version": "v4.3.20", "source": { "type": "git", "url": "https://github.com/api-platform/json-schema.git", @@ -554,13 +554,13 @@ "swagger" ], "support": { - "source": "https://github.com/api-platform/json-schema/tree/v4.3.18" + "source": "https://github.com/api-platform/json-schema/tree/v4.3.20" }, "time": "2026-09-02T08:21:34+00:00" }, { "name": "api-platform/jsonld", - "version": "v4.3.18", + "version": "v4.3.20", "source": { "type": "git", "url": "https://github.com/api-platform/jsonld.git", @@ -634,22 +634,22 @@ "rest" ], "support": { - "source": "https://github.com/api-platform/jsonld/tree/v4.3.18" + "source": "https://github.com/api-platform/jsonld/tree/v4.3.20" }, "time": "2026-06-13T05:11:46+00:00" }, { "name": "api-platform/metadata", - "version": "v4.3.18", + "version": "v4.3.20", "source": { "type": "git", "url": "https://github.com/api-platform/metadata.git", - "reference": "cff651763e34d72195f516e4c36790ace623dfe4" + "reference": "4cbdad3301afbade9f9dc46f75246fad25fe4aeb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/metadata/zipball/cff651763e34d72195f516e4c36790ace623dfe4", - "reference": "cff651763e34d72195f516e4c36790ace623dfe4", + "url": "https://api.github.com/repos/api-platform/metadata/zipball/4cbdad3301afbade9f9dc46f75246fad25fe4aeb", + "reference": "4cbdad3301afbade9f9dc46f75246fad25fe4aeb", "shasum": "" }, "require": { @@ -732,13 +732,13 @@ "swagger" ], "support": { - "source": "https://github.com/api-platform/metadata/tree/v4.3.18" + "source": "https://github.com/api-platform/metadata/tree/v4.3.20" }, - "time": "2026-09-04T08:39:01+00:00" + "time": "2026-09-11T12:56:16+00:00" }, { "name": "api-platform/openapi", - "version": "v4.3.18", + "version": "v4.3.20", "source": { "type": "git", "url": "https://github.com/api-platform/openapi.git", @@ -823,22 +823,22 @@ "swagger" ], "support": { - "source": "https://github.com/api-platform/openapi/tree/v4.3.18" + "source": "https://github.com/api-platform/openapi/tree/v4.3.20" }, "time": "2026-06-16T10:01:53+00:00" }, { "name": "api-platform/serializer", - "version": "v4.3.18", + "version": "v4.3.20", "source": { "type": "git", "url": "https://github.com/api-platform/serializer.git", - "reference": "7b1237b6fe3b5a84eba9f44a97f23b99ea267e0c" + "reference": "d6984c417ca6c0ea22b31269c274564577ad71c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/serializer/zipball/7b1237b6fe3b5a84eba9f44a97f23b99ea267e0c", - "reference": "7b1237b6fe3b5a84eba9f44a97f23b99ea267e0c", + "url": "https://api.github.com/repos/api-platform/serializer/zipball/d6984c417ca6c0ea22b31269c274564577ad71c6", + "reference": "d6984c417ca6c0ea22b31269c274564577ad71c6", "shasum": "" }, "require": { @@ -917,22 +917,22 @@ "serializer" ], "support": { - "source": "https://github.com/api-platform/serializer/tree/v4.3.18" + "source": "https://github.com/api-platform/serializer/tree/v4.3.20" }, - "time": "2026-08-16T16:40:51+00:00" + "time": "2026-09-09T07:14:58+00:00" }, { "name": "api-platform/state", - "version": "v4.3.18", + "version": "v4.3.20", "source": { "type": "git", "url": "https://github.com/api-platform/state.git", - "reference": "7a50bbef781fc0e98df30d0d99b630d1d27c5ab6" + "reference": "e5b1b7fa70fa8a8de2065ab4fc960db035723c0b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/state/zipball/7a50bbef781fc0e98df30d0d99b630d1d27c5ab6", - "reference": "7a50bbef781fc0e98df30d0d99b630d1d27c5ab6", + "url": "https://api.github.com/repos/api-platform/state/zipball/e5b1b7fa70fa8a8de2065ab4fc960db035723c0b", + "reference": "e5b1b7fa70fa8a8de2065ab4fc960db035723c0b", "shasum": "" }, "require": { @@ -1014,22 +1014,22 @@ "swagger" ], "support": { - "source": "https://github.com/api-platform/state/tree/v4.3.18" + "source": "https://github.com/api-platform/state/tree/v4.3.20" }, - "time": "2026-08-16T07:58:36+00:00" + "time": "2026-09-12T13:42:25+00:00" }, { "name": "api-platform/symfony", - "version": "v4.3.18", + "version": "v4.3.20", "source": { "type": "git", "url": "https://github.com/api-platform/symfony.git", - "reference": "42e4ff04ef9183f45ebaffcd17681a3d6710adb9" + "reference": "c158ab345142aeee5335050034818da234fd16e9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/symfony/zipball/42e4ff04ef9183f45ebaffcd17681a3d6710adb9", - "reference": "42e4ff04ef9183f45ebaffcd17681a3d6710adb9", + "url": "https://api.github.com/repos/api-platform/symfony/zipball/c158ab345142aeee5335050034818da234fd16e9", + "reference": "c158ab345142aeee5335050034818da234fd16e9", "shasum": "" }, "require": { @@ -1143,22 +1143,22 @@ "symfony" ], "support": { - "source": "https://github.com/api-platform/symfony/tree/v4.3.18" + "source": "https://github.com/api-platform/symfony/tree/v4.3.20" }, - "time": "2026-09-02T20:01:30+00:00" + "time": "2026-09-24T21:51:53+00:00" }, { "name": "api-platform/validator", - "version": "v4.3.18", + "version": "v4.3.20", "source": { "type": "git", "url": "https://github.com/api-platform/validator.git", - "reference": "6df6804799f8831469d2602d0845a0316e81fbab" + "reference": "b7a35b2a15216229ef93271b1fdd4a4d691d2ba7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/validator/zipball/6df6804799f8831469d2602d0845a0316e81fbab", - "reference": "6df6804799f8831469d2602d0845a0316e81fbab", + "url": "https://api.github.com/repos/api-platform/validator/zipball/b7a35b2a15216229ef93271b1fdd4a4d691d2ba7", + "reference": "b7a35b2a15216229ef93271b1fdd4a4d691d2ba7", "shasum": "" }, "require": { @@ -1219,9 +1219,9 @@ "validator" ], "support": { - "source": "https://github.com/api-platform/validator/tree/v4.3.18" + "source": "https://github.com/api-platform/validator/tree/v4.3.20" }, - "time": "2026-05-07T11:45:31+00:00" + "time": "2026-09-05T06:05:04+00:00" }, { "name": "babdev/pagerfanta-bundle", @@ -1361,23 +1361,24 @@ }, { "name": "brick/math", - "version": "0.18.0", + "version": "1.0.0", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad" + "reference": "2effe05d2177c451b86c6a073196a4034c02f211" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/82944324d1c1bdb2c2618e89978d4e2ad78d69ad", - "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad", + "url": "https://api.github.com/repos/brick/math/zipball/2effe05d2177c451b86c6a073196a4034c02f211", + "reference": "2effe05d2177c451b86c6a073196a4034c02f211", "shasum": "" }, "require": { "php": "^8.2" }, "require-dev": { - "phpstan/phpstan": "2.1.22", + "phpstan/phpstan": "2.2.13", + "phpstan/phpstan-phpunit": "2.0.18", "phpunit/phpunit": "^11.5" }, "type": "library", @@ -1408,7 +1409,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.18.0" + "source": "https://github.com/brick/math/tree/1.0.0" }, "funding": [ { @@ -1416,7 +1417,7 @@ "type": "github" } ], - "time": "2026-06-14T18:21:03+00:00" + "time": "2026-09-12T10:28:18+00:00" }, { "name": "clue/stream-filter", @@ -1486,16 +1487,16 @@ }, { "name": "composer/semver", - "version": "3.4.4", + "version": "3.5.0", "source": { "type": "git", "url": "https://github.com/composer/semver.git", - "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + "reference": "f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", - "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "url": "https://api.github.com/repos/composer/semver/zipball/f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5", + "reference": "f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5", "shasum": "" }, "require": { @@ -1537,7 +1538,7 @@ "homepage": "http://robbast.nl" } ], - "description": "Semver library that offers utilities, version constraint parsing and validation.", + "description": "Version comparison library that offers utilities, version constraint parsing and validation.", "keywords": [ "semantic", "semver", @@ -1547,7 +1548,7 @@ "support": { "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.4.4" + "source": "https://github.com/composer/semver/tree/3.5.0" }, "funding": [ { @@ -1559,7 +1560,7 @@ "type": "github" } ], - "time": "2025-08-20T19:15:30+00:00" + "time": "2026-09-24T14:38:51+00:00" }, { "name": "doctrine/collections", @@ -1987,16 +1988,16 @@ }, { "name": "doctrine/doctrine-bundle", - "version": "2.19.0", + "version": "2.19.1", "source": { "type": "git", "url": "https://github.com/doctrine/DoctrineBundle.git", - "reference": "07b90f707b82981097731c419f546e7ba97fba3c" + "reference": "72b53688c30451a7c65addd50b2167d31459724a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/07b90f707b82981097731c419f546e7ba97fba3c", - "reference": "07b90f707b82981097731c419f546e7ba97fba3c", + "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/72b53688c30451a7c65addd50b2167d31459724a", + "reference": "72b53688c30451a7c65addd50b2167d31459724a", "shasum": "" }, "require": { @@ -2091,7 +2092,7 @@ ], "support": { "issues": "https://github.com/doctrine/DoctrineBundle/issues", - "source": "https://github.com/doctrine/DoctrineBundle/tree/2.19.0" + "source": "https://github.com/doctrine/DoctrineBundle/tree/2.19.1" }, "funding": [ { @@ -2107,7 +2108,7 @@ "type": "tidelift" } ], - "time": "2026-07-23T14:52:05+00:00" + "time": "2026-09-08T11:48:41+00:00" }, { "name": "doctrine/doctrine-migrations-bundle", @@ -2455,27 +2456,25 @@ }, { "name": "doctrine/lexer", - "version": "3.0.1", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/doctrine/lexer.git", - "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + "reference": "e96fe45e92a54233726014a7cc7340abf29bb14c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", - "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/e96fe45e92a54233726014a7cc7340abf29bb14c", + "reference": "e96fe45e92a54233726014a7cc7340abf29bb14c", "shasum": "" }, "require": { "php": "^8.1" }, "require-dev": { - "doctrine/coding-standard": "^12", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^10.5", - "psalm/plugin-phpunit": "^0.18.3", - "vimeo/psalm": "^5.21" + "doctrine/coding-standard": "^14", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10.5.58 || ^12.5.4" }, "type": "library", "autoload": { @@ -2512,7 +2511,7 @@ ], "support": { "issues": "https://github.com/doctrine/lexer/issues", - "source": "https://github.com/doctrine/lexer/tree/3.0.1" + "source": "https://github.com/doctrine/lexer/tree/3.0.2" }, "funding": [ { @@ -2528,7 +2527,7 @@ "type": "tidelift" } ], - "time": "2024-02-05T11:56:58+00:00" + "time": "2026-06-14T20:44:06+00:00" }, { "name": "doctrine/migrations", @@ -5136,16 +5135,16 @@ }, { "name": "monolog/monolog", - "version": "3.11.0", + "version": "3.12.0", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "147f303310f06334f03f409e49d7ad1e275ff05a" + "reference": "72c534fc0ab181ef52d92a68382318631e301608" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/147f303310f06334f03f409e49d7ad1e275ff05a", - "reference": "147f303310f06334f03f409e49d7ad1e275ff05a", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/72c534fc0ab181ef52d92a68382318631e301608", + "reference": "72c534fc0ab181ef52d92a68382318631e301608", "shasum": "" }, "require": { @@ -5171,6 +5170,7 @@ "phpstan/phpstan-strict-rules": "^2", "phpunit/phpunit": "^10.5.17 || ^11.0.7", "predis/predis": "^1.1 || ^2", + "psr/clock": "^1.0", "rollbar/rollbar": "^4.0", "ruflin/elastica": "^7 || ^8", "symfony/mailer": "^5.4 || ^6", @@ -5189,6 +5189,7 @@ "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "psr/clock": "Required to pass a clock to the Logger and control the timestamp of log records", "rollbar/rollbar": "Allow sending log messages to Rollbar", "ruflin/elastica": "Allow sending log messages to an Elastic Search server" }, @@ -5223,7 +5224,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/3.11.0" + "source": "https://github.com/Seldaek/monolog/tree/3.12.0" }, "funding": [ { @@ -5235,7 +5236,7 @@ "type": "tidelift" } ], - "time": "2026-09-02T12:39:56+00:00" + "time": "2026-09-09T08:34:20+00:00" }, { "name": "myclabs/php-enum", @@ -5302,16 +5303,16 @@ }, { "name": "pagerfanta/pagerfanta", - "version": "v4.8.0", + "version": "v4.9.0", "source": { "type": "git", "url": "https://github.com/BabDev/Pagerfanta.git", - "reference": "72881e6839330b2961c574b3b4b20d409d6a0955" + "reference": "aa8aeff8d2f0e115901aa08368453d049605027e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/BabDev/Pagerfanta/zipball/72881e6839330b2961c574b3b4b20d409d6a0955", - "reference": "72881e6839330b2961c574b3b4b20d409d6a0955", + "url": "https://api.github.com/repos/BabDev/Pagerfanta/zipball/aa8aeff8d2f0e115901aa08368453d049605027e", + "reference": "aa8aeff8d2f0e115901aa08368453d049605027e", "shasum": "" }, "require": { @@ -5349,13 +5350,13 @@ "doctrine/phpcr-odm": "^1.7 || ^2.0", "jackalope/jackalope-doctrine-dbal": "^1.9 || ^2.0", "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "2.1.36", - "phpstan/phpstan-phpunit": "2.0.11", - "phpunit/phpunit": "10.5.60", - "rector/rector": "2.3.4", + "phpstan/phpstan": "2.2.13", + "phpstan/phpstan-phpunit": "2.0.18", + "phpunit/phpunit": "10.5.64", + "rector/rector": "2.6.6", "ruflin/elastica": "^7.3 || ^8.0", "solarium/solarium": "^6.2", - "symfony/cache": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/cache": "^5.4 || ^6.4 || ^7.4 || ^8.1", "twig/twig": "^2.13 || ^3.0" }, "suggest": { @@ -5391,7 +5392,7 @@ ], "support": { "issues": "https://github.com/BabDev/Pagerfanta/issues", - "source": "https://github.com/BabDev/Pagerfanta/tree/v4.8.0" + "source": "https://github.com/BabDev/Pagerfanta/tree/v4.9.0" }, "funding": [ { @@ -5399,7 +5400,7 @@ "type": "github" } ], - "time": "2026-01-22T13:58:52+00:00" + "time": "2026-09-08T18:12:20+00:00" }, { "name": "paragonie/constant_time_encoding", @@ -5747,16 +5748,16 @@ }, { "name": "payplug/unified-plugin-core", - "version": "1.1.2", + "version": "1.3.0", "source": { "type": "git", "url": "https://github.com/payplug/unified-plugin-core.git", - "reference": "10ead914e201c06243ddfd61b5ac23f9799a39a1" + "reference": "050f14e31c88e3ac3e50a2ec4707776110e1dc7f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/payplug/unified-plugin-core/zipball/10ead914e201c06243ddfd61b5ac23f9799a39a1", - "reference": "10ead914e201c06243ddfd61b5ac23f9799a39a1", + "url": "https://api.github.com/repos/payplug/unified-plugin-core/zipball/050f14e31c88e3ac3e50a2ec4707776110e1dc7f", + "reference": "050f14e31c88e3ac3e50a2ec4707776110e1dc7f", "shasum": "" }, "require": { @@ -5793,9 +5794,9 @@ "description": "Core foundations shared library for Payplug e-commerce plugins.", "support": { "issues": "https://github.com/payplug/unified-plugin-core/issues", - "source": "https://github.com/payplug/unified-plugin-core/tree/1.1.2" + "source": "https://github.com/payplug/unified-plugin-core/tree/1.3.0" }, - "time": "2026-09-15T14:24:30+00:00" + "time": "2026-09-29T08:57:15+00:00" }, { "name": "payum/core", @@ -7113,20 +7114,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.3", + "version": "4.9.4", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", - "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/75d73f48d02797c2c285a7e9f348fadc0102ffe2", + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2", "shasum": "" }, "require": { - "brick/math": ">=0.8.16 <=0.18", + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -7185,9 +7186,9 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.3" + "source": "https://github.com/ramsey/uuid/tree/4.9.4" }, - "time": "2026-06-18T03:57:49+00:00" + "time": "2026-09-16T11:39:30+00:00" }, { "name": "stof/doctrine-extensions-bundle", @@ -7592,16 +7593,16 @@ }, { "name": "sylius/mailer-bundle", - "version": "v2.2.0", + "version": "v2.2.1", "source": { "type": "git", "url": "https://github.com/Sylius/SyliusMailerBundle.git", - "reference": "56e90d6f5225c1d9c5444722c62f5a9bc18bd786" + "reference": "fd25981bd6653590ab380bde88a2a69b88803d61" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Sylius/SyliusMailerBundle/zipball/56e90d6f5225c1d9c5444722c62f5a9bc18bd786", - "reference": "56e90d6f5225c1d9c5444722c62f5a9bc18bd786", + "url": "https://api.github.com/repos/Sylius/SyliusMailerBundle/zipball/fd25981bd6653590ab380bde88a2a69b88803d61", + "reference": "fd25981bd6653590ab380bde88a2a69b88803d61", "shasum": "" }, "require": { @@ -7672,7 +7673,7 @@ ], "support": { "issues": "https://github.com/Sylius/SyliusMailerBundle/issues", - "source": "https://github.com/Sylius/SyliusMailerBundle/tree/v2.2.0" + "source": "https://github.com/Sylius/SyliusMailerBundle/tree/v2.2.1" }, "funding": [ { @@ -7680,7 +7681,7 @@ "type": "github" } ], - "time": "2026-02-06T10:52:59+00:00" + "time": "2026-09-18T13:34:23+00:00" }, { "name": "sylius/pdf-generation-bundle", @@ -8071,16 +8072,16 @@ }, { "name": "sylius/sylius", - "version": "v2.2.9", + "version": "v2.2.10", "source": { "type": "git", "url": "https://github.com/Sylius/Sylius.git", - "reference": "253169df80a8c8babf479c6de5ce3d422e37dc6a" + "reference": "1a5d6da030245dbb23be1feff055f2c125bd9b12" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Sylius/Sylius/zipball/253169df80a8c8babf479c6de5ce3d422e37dc6a", - "reference": "253169df80a8c8babf479c6de5ce3d422e37dc6a", + "url": "https://api.github.com/repos/Sylius/Sylius/zipball/1a5d6da030245dbb23be1feff055f2c125bd9b12", + "reference": "1a5d6da030245dbb23be1feff055f2c125bd9b12", "shasum": "" }, "require": { @@ -8213,7 +8214,7 @@ "conflict": { "api-platform/serializer": "4.2.17", "api-platform/symfony": "4.3.16", - "doctrine/orm": "2.20.7 || 3.5.3 || 3.6.8", + "doctrine/orm": "2.20.7 || 3.5.3 || 3.6.8 || >=3.7", "symfony/ux-live-component": "2.28.0 || 2.28.1" }, "replace": { @@ -8354,7 +8355,7 @@ "homepage": "https://sylius.com", "support": { "issues": "https://github.com/Sylius/Sylius/issues", - "source": "https://github.com/Sylius/Sylius/tree/v2.2.9" + "source": "https://github.com/Sylius/Sylius/tree/v2.2.10" }, "funding": [ { @@ -8362,7 +8363,7 @@ "type": "github" } ], - "time": "2026-09-02T13:54:32+00:00" + "time": "2026-09-28T12:53:01+00:00" }, { "name": "sylius/telemetry", @@ -8747,16 +8748,16 @@ }, { "name": "symfony/cache", - "version": "v6.4.45", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "ea9758447aa48581e2e2c0d318782d89e2182136" + "reference": "b001ef092224de1358462ceabfbdf4d4c2d4c9d2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/ea9758447aa48581e2e2c0d318782d89e2182136", - "reference": "ea9758447aa48581e2e2c0d318782d89e2182136", + "url": "https://api.github.com/repos/symfony/cache/zipball/b001ef092224de1358462ceabfbdf4d4c2d4c9d2", + "reference": "b001ef092224de1358462ceabfbdf4d4c2d4c9d2", "shasum": "" }, "require": { @@ -8823,7 +8824,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v6.4.45" + "source": "https://github.com/symfony/cache/tree/v6.4.46" }, "funding": [ { @@ -8843,7 +8844,7 @@ "type": "tidelift" } ], - "time": "2026-08-30T20:10:39+00:00" + "time": "2026-09-08T06:25:25+00:00" }, { "name": "symfony/cache-contracts", @@ -9084,16 +9085,16 @@ }, { "name": "symfony/console", - "version": "v6.4.45", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "3b8473e0d14157f2d22b0a0d7259ad23483d1e6d" + "reference": "9e118dd9e3b94544ff3cce860457c2951a0a2bd6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/3b8473e0d14157f2d22b0a0d7259ad23483d1e6d", - "reference": "3b8473e0d14157f2d22b0a0d7259ad23483d1e6d", + "url": "https://api.github.com/repos/symfony/console/zipball/9e118dd9e3b94544ff3cce860457c2951a0a2bd6", + "reference": "9e118dd9e3b94544ff3cce860457c2951a0a2bd6", "shasum": "" }, "require": { @@ -9158,7 +9159,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.45" + "source": "https://github.com/symfony/console/tree/v6.4.46" }, "funding": [ { @@ -9178,7 +9179,7 @@ "type": "tidelift" } ], - "time": "2026-08-25T13:08:31+00:00" + "time": "2026-09-10T10:42:14+00:00" }, { "name": "symfony/dependency-injection", @@ -9338,16 +9339,16 @@ }, { "name": "symfony/doctrine-bridge", - "version": "v6.4.45", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-bridge.git", - "reference": "57ddd0c22de16bf22e10d810d323027bf4e532ac" + "reference": "6194d8f369d8cbe5a02f2b899776ca30ada7d411" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/57ddd0c22de16bf22e10d810d323027bf4e532ac", - "reference": "57ddd0c22de16bf22e10d810d323027bf4e532ac", + "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/6194d8f369d8cbe5a02f2b899776ca30ada7d411", + "reference": "6194d8f369d8cbe5a02f2b899776ca30ada7d411", "shasum": "" }, "require": { @@ -9426,7 +9427,7 @@ "description": "Provides integration for Doctrine with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-bridge/tree/v6.4.45" + "source": "https://github.com/symfony/doctrine-bridge/tree/v6.4.46" }, "funding": [ { @@ -9446,7 +9447,7 @@ "type": "tidelift" } ], - "time": "2026-08-30T18:34:07+00:00" + "time": "2026-09-11T10:34:36+00:00" }, { "name": "symfony/doctrine-messenger", @@ -9907,16 +9908,16 @@ }, { "name": "symfony/finder", - "version": "v6.4.44", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "211b28d13d044dacdc00c1629a3bbcff27dc793a" + "reference": "04db08efd85558162ef43fba10c299871b60d60b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/211b28d13d044dacdc00c1629a3bbcff27dc793a", - "reference": "211b28d13d044dacdc00c1629a3bbcff27dc793a", + "url": "https://api.github.com/repos/symfony/finder/zipball/04db08efd85558162ef43fba10c299871b60d60b", + "reference": "04db08efd85558162ef43fba10c299871b60d60b", "shasum": "" }, "require": { @@ -9951,7 +9952,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v6.4.44" + "source": "https://github.com/symfony/finder/tree/v6.4.46" }, "funding": [ { @@ -9971,20 +9972,20 @@ "type": "tidelift" } ], - "time": "2026-08-21T10:00:03+00:00" + "time": "2026-09-10T14:17:24+00:00" }, { "name": "symfony/form", - "version": "v6.4.45", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/form.git", - "reference": "03810456895dfc7e7fca450806e4eb21669927c8" + "reference": "b0f1e86b3800e3cb9b90c0b9ce83cd9a822ef63d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/form/zipball/03810456895dfc7e7fca450806e4eb21669927c8", - "reference": "03810456895dfc7e7fca450806e4eb21669927c8", + "url": "https://api.github.com/repos/symfony/form/zipball/b0f1e86b3800e3cb9b90c0b9ce83cd9a822ef63d", + "reference": "b0f1e86b3800e3cb9b90c0b9ce83cd9a822ef63d", "shasum": "" }, "require": { @@ -10052,7 +10053,7 @@ "description": "Allows to easily create, process and reuse HTML forms", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/form/tree/v6.4.45" + "source": "https://github.com/symfony/form/tree/v6.4.46" }, "funding": [ { @@ -10072,20 +10073,20 @@ "type": "tidelift" } ], - "time": "2026-08-29T12:19:25+00:00" + "time": "2026-09-14T16:44:10+00:00" }, { "name": "symfony/framework-bundle", - "version": "v6.4.45", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "a19f20ad64c9b727ce21421638e7c92fa381ae9d" + "reference": "407e77216969a366fd2bbf6d96a84c6b4a6500a2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/a19f20ad64c9b727ce21421638e7c92fa381ae9d", - "reference": "a19f20ad64c9b727ce21421638e7c92fa381ae9d", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/407e77216969a366fd2bbf6d96a84c6b4a6500a2", + "reference": "407e77216969a366fd2bbf6d96a84c6b4a6500a2", "shasum": "" }, "require": { @@ -10205,7 +10206,7 @@ "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v6.4.45" + "source": "https://github.com/symfony/framework-bundle/tree/v6.4.46" }, "funding": [ { @@ -10225,20 +10226,20 @@ "type": "tidelift" } ], - "time": "2026-08-30T20:10:39+00:00" + "time": "2026-09-13T09:15:59+00:00" }, { "name": "symfony/http-client", - "version": "v6.4.45", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "149b874c2f65b68032a0409288ca48667864e43d" + "reference": "2027434cec6519eee0bcff54efa42f7aa7e6e182" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/149b874c2f65b68032a0409288ca48667864e43d", - "reference": "149b874c2f65b68032a0409288ca48667864e43d", + "url": "https://api.github.com/repos/symfony/http-client/zipball/2027434cec6519eee0bcff54efa42f7aa7e6e182", + "reference": "2027434cec6519eee0bcff54efa42f7aa7e6e182", "shasum": "" }, "require": { @@ -10303,7 +10304,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v6.4.45" + "source": "https://github.com/symfony/http-client/tree/v6.4.46" }, "funding": [ { @@ -10323,7 +10324,7 @@ "type": "tidelift" } ], - "time": "2026-08-30T13:44:49+00:00" + "time": "2026-09-08T13:27:13+00:00" }, { "name": "symfony/http-client-contracts", @@ -10409,16 +10410,16 @@ }, { "name": "symfony/http-foundation", - "version": "v6.4.45", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "945bfd2bcccca941f75ce45d5530b36d4cc6dc0b" + "reference": "08bbdd5cf09248785442c41929aabe42f8ef5cfd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/945bfd2bcccca941f75ce45d5530b36d4cc6dc0b", - "reference": "945bfd2bcccca941f75ce45d5530b36d4cc6dc0b", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/08bbdd5cf09248785442c41929aabe42f8ef5cfd", + "reference": "08bbdd5cf09248785442c41929aabe42f8ef5cfd", "shasum": "" }, "require": { @@ -10466,7 +10467,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v6.4.45" + "source": "https://github.com/symfony/http-foundation/tree/v6.4.46" }, "funding": [ { @@ -10486,20 +10487,20 @@ "type": "tidelift" } ], - "time": "2026-08-30T20:10:39+00:00" + "time": "2026-09-14T17:40:49+00:00" }, { "name": "symfony/http-kernel", - "version": "v6.4.45", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "e5e8372b0e16ee6d6afa8fba37f23c25822dbfe2" + "reference": "c662aa16f2a6b078800ef5302444043c38932213" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/e5e8372b0e16ee6d6afa8fba37f23c25822dbfe2", - "reference": "e5e8372b0e16ee6d6afa8fba37f23c25822dbfe2", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/c662aa16f2a6b078800ef5302444043c38932213", + "reference": "c662aa16f2a6b078800ef5302444043c38932213", "shasum": "" }, "require": { @@ -10584,7 +10585,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v6.4.45" + "source": "https://github.com/symfony/http-kernel/tree/v6.4.46" }, "funding": [ { @@ -10604,7 +10605,7 @@ "type": "tidelift" } ], - "time": "2026-08-30T20:52:35+00:00" + "time": "2026-09-15T07:06:31+00:00" }, { "name": "symfony/intl", @@ -10778,16 +10779,16 @@ }, { "name": "symfony/mailer", - "version": "v6.4.44", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "cea6ee2fb64d486f709d80bd3158cf28346e1ba4" + "reference": "21cc391f6f6a20b8765125cabee0c6195b5b8f10" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/cea6ee2fb64d486f709d80bd3158cf28346e1ba4", - "reference": "cea6ee2fb64d486f709d80bd3158cf28346e1ba4", + "url": "https://api.github.com/repos/symfony/mailer/zipball/21cc391f6f6a20b8765125cabee0c6195b5b8f10", + "reference": "21cc391f6f6a20b8765125cabee0c6195b5b8f10", "shasum": "" }, "require": { @@ -10838,7 +10839,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v6.4.44" + "source": "https://github.com/symfony/mailer/tree/v6.4.46" }, "funding": [ { @@ -10858,20 +10859,20 @@ "type": "tidelift" } ], - "time": "2026-08-21T13:01:07+00:00" + "time": "2026-09-14T23:26:17+00:00" }, { "name": "symfony/messenger", - "version": "v6.4.45", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/messenger.git", - "reference": "f0da814e78084005644e891e100773f2b07a69bb" + "reference": "6aa31b55986cbd00a0aa1f8d8507201fef9beaeb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/messenger/zipball/f0da814e78084005644e891e100773f2b07a69bb", - "reference": "f0da814e78084005644e891e100773f2b07a69bb", + "url": "https://api.github.com/repos/symfony/messenger/zipball/6aa31b55986cbd00a0aa1f8d8507201fef9beaeb", + "reference": "6aa31b55986cbd00a0aa1f8d8507201fef9beaeb", "shasum": "" }, "require": { @@ -10929,7 +10930,7 @@ "description": "Helps applications send and receive messages to/from other applications or via message queues", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/messenger/tree/v6.4.45" + "source": "https://github.com/symfony/messenger/tree/v6.4.46" }, "funding": [ { @@ -10949,20 +10950,20 @@ "type": "tidelift" } ], - "time": "2026-08-30T00:27:22+00:00" + "time": "2026-09-13T07:04:28+00:00" }, { "name": "symfony/mime", - "version": "v6.4.45", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "3f850171f3bb396a84117ca97d3d474fc4b83deb" + "reference": "e700a9819829e1395ae95b508c6355053b5644fe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/3f850171f3bb396a84117ca97d3d474fc4b83deb", - "reference": "3f850171f3bb396a84117ca97d3d474fc4b83deb", + "url": "https://api.github.com/repos/symfony/mime/zipball/e700a9819829e1395ae95b508c6355053b5644fe", + "reference": "e700a9819829e1395ae95b508c6355053b5644fe", "shasum": "" }, "require": { @@ -11018,7 +11019,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v6.4.45" + "source": "https://github.com/symfony/mime/tree/v6.4.46" }, "funding": [ { @@ -11038,7 +11039,7 @@ "type": "tidelift" } ], - "time": "2026-08-22T07:48:48+00:00" + "time": "2026-09-04T10:11:03+00:00" }, { "name": "symfony/monolog-bridge", @@ -12274,16 +12275,16 @@ }, { "name": "symfony/process", - "version": "v6.4.45", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "0b0c5b7d895211b82021469d1cb8ef2448caed96" + "reference": "b9f3d9ddf67543b92dbfa6d210c407d7a7982781" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/0b0c5b7d895211b82021469d1cb8ef2448caed96", - "reference": "0b0c5b7d895211b82021469d1cb8ef2448caed96", + "url": "https://api.github.com/repos/symfony/process/zipball/b9f3d9ddf67543b92dbfa6d210c407d7a7982781", + "reference": "b9f3d9ddf67543b92dbfa6d210c407d7a7982781", "shasum": "" }, "require": { @@ -12315,7 +12316,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v6.4.45" + "source": "https://github.com/symfony/process/tree/v6.4.46" }, "funding": [ { @@ -12335,7 +12336,7 @@ "type": "tidelift" } ], - "time": "2026-08-20T17:31:17+00:00" + "time": "2026-09-02T12:35:55+00:00" }, { "name": "symfony/property-access", @@ -12420,16 +12421,16 @@ }, { "name": "symfony/property-info", - "version": "v6.4.45", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/property-info.git", - "reference": "003ec301822cf2cf1b44b217fba1efdf4b4503f9" + "reference": "4789c9a2650f9285da6db0363ef40e5a21b42ce9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/003ec301822cf2cf1b44b217fba1efdf4b4503f9", - "reference": "003ec301822cf2cf1b44b217fba1efdf4b4503f9", + "url": "https://api.github.com/repos/symfony/property-info/zipball/4789c9a2650f9285da6db0363ef40e5a21b42ce9", + "reference": "4789c9a2650f9285da6db0363ef40e5a21b42ce9", "shasum": "" }, "require": { @@ -12486,7 +12487,7 @@ "validator" ], "support": { - "source": "https://github.com/symfony/property-info/tree/v6.4.45" + "source": "https://github.com/symfony/property-info/tree/v6.4.46" }, "funding": [ { @@ -12506,7 +12507,7 @@ "type": "tidelift" } ], - "time": "2026-08-24T07:52:32+00:00" + "time": "2026-09-03T19:31:46+00:00" }, { "name": "symfony/proxy-manager-bridge", @@ -12947,16 +12948,16 @@ }, { "name": "symfony/security-http", - "version": "v6.4.45", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/security-http.git", - "reference": "acc78bd71a614fc15a2931b08373ad6fd0fac9db" + "reference": "a8ad51e48f20b515e589b5927ee7cde7e1e37453" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-http/zipball/acc78bd71a614fc15a2931b08373ad6fd0fac9db", - "reference": "acc78bd71a614fc15a2931b08373ad6fd0fac9db", + "url": "https://api.github.com/repos/symfony/security-http/zipball/a8ad51e48f20b515e589b5927ee7cde7e1e37453", + "reference": "a8ad51e48f20b515e589b5927ee7cde7e1e37453", "shasum": "" }, "require": { @@ -13015,7 +13016,7 @@ "description": "Symfony Security Component - HTTP Integration", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-http/tree/v6.4.45" + "source": "https://github.com/symfony/security-http/tree/v6.4.46" }, "funding": [ { @@ -13035,20 +13036,20 @@ "type": "tidelift" } ], - "time": "2026-08-30T00:30:12+00:00" + "time": "2026-09-15T05:55:29+00:00" }, { "name": "symfony/serializer", - "version": "v6.4.45", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "ad3c87d8d47e05e60fb8cd347348f2246c5e3aea" + "reference": "5febca192427e0d788848abad61be565c68b9645" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/ad3c87d8d47e05e60fb8cd347348f2246c5e3aea", - "reference": "ad3c87d8d47e05e60fb8cd347348f2246c5e3aea", + "url": "https://api.github.com/repos/symfony/serializer/zipball/5febca192427e0d788848abad61be565c68b9645", + "reference": "5febca192427e0d788848abad61be565c68b9645", "shasum": "" }, "require": { @@ -13117,7 +13118,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v6.4.45" + "source": "https://github.com/symfony/serializer/tree/v6.4.46" }, "funding": [ { @@ -13137,7 +13138,7 @@ "type": "tidelift" } ], - "time": "2026-08-29T08:24:03+00:00" + "time": "2026-09-08T09:57:00+00:00" }, { "name": "symfony/service-contracts", @@ -13367,16 +13368,16 @@ }, { "name": "symfony/string", - "version": "v6.4.43", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "2a8d515c3eaa5d33cf76d5fa277cdadd0a4e5b49" + "reference": "4513ab2b0ad2d7845a2f920e2f30b61633c9e606" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/2a8d515c3eaa5d33cf76d5fa277cdadd0a4e5b49", - "reference": "2a8d515c3eaa5d33cf76d5fa277cdadd0a4e5b49", + "url": "https://api.github.com/repos/symfony/string/zipball/4513ab2b0ad2d7845a2f920e2f30b61633c9e606", + "reference": "4513ab2b0ad2d7845a2f920e2f30b61633c9e606", "shasum": "" }, "require": { @@ -13432,7 +13433,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v6.4.43" + "source": "https://github.com/symfony/string/tree/v6.4.46" }, "funding": [ { @@ -13452,7 +13453,7 @@ "type": "tidelift" } ], - "time": "2026-07-28T07:28:15+00:00" + "time": "2026-09-11T09:03:57+00:00" }, { "name": "symfony/translation", @@ -13637,16 +13638,16 @@ }, { "name": "symfony/twig-bridge", - "version": "v6.4.44", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/twig-bridge.git", - "reference": "3036bcbdd27da00ded851b5049e1f73589c8d506" + "reference": "5b38d19f327f32507f96c9316aef90e7ad4962ac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/3036bcbdd27da00ded851b5049e1f73589c8d506", - "reference": "3036bcbdd27da00ded851b5049e1f73589c8d506", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/5b38d19f327f32507f96c9316aef90e7ad4962ac", + "reference": "5b38d19f327f32507f96c9316aef90e7ad4962ac", "shasum": "" }, "require": { @@ -13726,7 +13727,7 @@ "description": "Provides integration for Twig with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bridge/tree/v6.4.44" + "source": "https://github.com/symfony/twig-bridge/tree/v6.4.46" }, "funding": [ { @@ -13746,20 +13747,20 @@ "type": "tidelift" } ], - "time": "2026-08-22T07:48:48+00:00" + "time": "2026-09-14T10:25:06+00:00" }, { "name": "symfony/twig-bundle", - "version": "v6.4.43", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/twig-bundle.git", - "reference": "15bad71434a8bbc64db852a14ac6360af7f4ea4a" + "reference": "d5996b3226a441ff558751646aa7c0775b90e2ee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/15bad71434a8bbc64db852a14ac6360af7f4ea4a", - "reference": "15bad71434a8bbc64db852a14ac6360af7f4ea4a", + "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/d5996b3226a441ff558751646aa7c0775b90e2ee", + "reference": "d5996b3226a441ff558751646aa7c0775b90e2ee", "shasum": "" }, "require": { @@ -13814,7 +13815,7 @@ "description": "Provides a tight integration of Twig into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bundle/tree/v6.4.43" + "source": "https://github.com/symfony/twig-bundle/tree/v6.4.46" }, "funding": [ { @@ -13834,7 +13835,7 @@ "type": "tidelift" } ], - "time": "2026-07-06T08:03:42+00:00" + "time": "2026-09-11T10:34:36+00:00" }, { "name": "symfony/type-info", @@ -14378,16 +14379,16 @@ }, { "name": "symfony/validator", - "version": "v6.4.45", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/validator.git", - "reference": "8acf2afad9c26cd2386d760ef9f0ec94c1c55b90" + "reference": "496589b7ac1be8ed2da92310f65f05e3989baef4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/validator/zipball/8acf2afad9c26cd2386d760ef9f0ec94c1c55b90", - "reference": "8acf2afad9c26cd2386d760ef9f0ec94c1c55b90", + "url": "https://api.github.com/repos/symfony/validator/zipball/496589b7ac1be8ed2da92310f65f05e3989baef4", + "reference": "496589b7ac1be8ed2da92310f65f05e3989baef4", "shasum": "" }, "require": { @@ -14455,7 +14456,7 @@ "description": "Provides tools to validate values", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/validator/tree/v6.4.45" + "source": "https://github.com/symfony/validator/tree/v6.4.46" }, "funding": [ { @@ -14475,7 +14476,7 @@ "type": "tidelift" } ], - "time": "2026-08-30T00:30:12+00:00" + "time": "2026-09-09T10:57:39+00:00" }, { "name": "symfony/var-dumper", @@ -14735,16 +14736,16 @@ }, { "name": "symfony/webpack-encore-bundle", - "version": "v2.4.1", + "version": "2.4.2", "source": { "type": "git", "url": "https://github.com/symfony/webpack-encore-bundle.git", - "reference": "cac8d6c722999c8add9272f9de6e8079628df4f5" + "reference": "0cbc3485f127cd9f85e395f8177607ef00535634" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/webpack-encore-bundle/zipball/cac8d6c722999c8add9272f9de6e8079628df4f5", - "reference": "cac8d6c722999c8add9272f9de6e8079628df4f5", + "url": "https://api.github.com/repos/symfony/webpack-encore-bundle/zipball/0cbc3485f127cd9f85e395f8177607ef00535634", + "reference": "0cbc3485f127cd9f85e395f8177607ef00535634", "shasum": "" }, "require": { @@ -14787,7 +14788,7 @@ "description": "Integration of your Symfony app with Webpack Encore", "support": { "issues": "https://github.com/symfony/webpack-encore-bundle/issues", - "source": "https://github.com/symfony/webpack-encore-bundle/tree/v2.4.1" + "source": "https://github.com/symfony/webpack-encore-bundle/tree/2.4.2" }, "funding": [ { @@ -14807,7 +14808,7 @@ "type": "tidelift" } ], - "time": "2026-06-24T07:21:58+00:00" + "time": "2026-09-17T12:45:02+00:00" }, { "name": "symfony/workflow", @@ -15033,16 +15034,16 @@ }, { "name": "twig/extra-bundle", - "version": "v3.24.0", + "version": "v3.29.0", "source": { "type": "git", "url": "https://github.com/twigphp/twig-extra-bundle.git", - "reference": "6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9" + "reference": "aaa2993e19293a99240c4c61aa461d743b0dd569" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9", - "reference": "6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9", + "url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/aaa2993e19293a99240c4c61aa461d743b0dd569", + "reference": "aaa2993e19293a99240c4c61aa461d743b0dd569", "shasum": "" }, "require": { @@ -15091,7 +15092,7 @@ "twig" ], "support": { - "source": "https://github.com/twigphp/twig-extra-bundle/tree/v3.24.0" + "source": "https://github.com/twigphp/twig-extra-bundle/tree/v3.29.0" }, "funding": [ { @@ -15103,20 +15104,20 @@ "type": "tidelift" } ], - "time": "2026-02-07T08:07:38+00:00" + "time": "2026-09-11T08:59:50+00:00" }, { "name": "twig/intl-extra", - "version": "v3.26.0", + "version": "v3.30.0", "source": { "type": "git", "url": "https://github.com/twigphp/intl-extra.git", - "reference": "98f5ad5bff13230fcd2d834d9e79b50adf3ccda9" + "reference": "211c603b3f7d5a8f6c6543a02fe9f82c09b18025" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/intl-extra/zipball/98f5ad5bff13230fcd2d834d9e79b50adf3ccda9", - "reference": "98f5ad5bff13230fcd2d834d9e79b50adf3ccda9", + "url": "https://api.github.com/repos/twigphp/intl-extra/zipball/211c603b3f7d5a8f6c6543a02fe9f82c09b18025", + "reference": "211c603b3f7d5a8f6c6543a02fe9f82c09b18025", "shasum": "" }, "require": { @@ -15155,7 +15156,7 @@ "twig" ], "support": { - "source": "https://github.com/twigphp/intl-extra/tree/v3.26.0" + "source": "https://github.com/twigphp/intl-extra/tree/v3.30.0" }, "funding": [ { @@ -15167,7 +15168,7 @@ "type": "tidelift" } ], - "time": "2026-05-19T20:44:48+00:00" + "time": "2026-09-25T05:50:11+00:00" }, { "name": "twig/string-extra", @@ -15238,16 +15239,16 @@ }, { "name": "twig/twig", - "version": "v3.28.0", + "version": "v3.30.0", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b" + "reference": "8c737079b726af72ff8ef3c595be9f6a810ea1ef" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", - "reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/8c737079b726af72ff8ef3c595be9f6a810ea1ef", + "reference": "8c737079b726af72ff8ef3c595be9f6a810ea1ef", "shasum": "" }, "require": { @@ -15302,7 +15303,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.28.0" + "source": "https://github.com/twigphp/Twig/tree/v3.30.0" }, "funding": [ { @@ -15314,7 +15315,7 @@ "type": "tidelift" } ], - "time": "2026-07-03T20:44:34+00:00" + "time": "2026-09-25T13:20:01+00:00" }, { "name": "webmozart/assert", @@ -16344,16 +16345,16 @@ }, { "name": "behat/gherkin", - "version": "v4.17.0", + "version": "v4.18.0", "source": { "type": "git", "url": "https://github.com/Behat/Gherkin.git", - "reference": "5c8b3149fac39b5a79942b64eeec59a5ee4001c0" + "reference": "496248b29d9d7848311a263c2d17c1860e4c7b6c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Behat/Gherkin/zipball/5c8b3149fac39b5a79942b64eeec59a5ee4001c0", - "reference": "5c8b3149fac39b5a79942b64eeec59a5ee4001c0", + "url": "https://api.github.com/repos/Behat/Gherkin/zipball/496248b29d9d7848311a263c2d17c1860e4c7b6c", + "reference": "496248b29d9d7848311a263c2d17c1860e4c7b6c", "shasum": "" }, "require": { @@ -16361,11 +16362,12 @@ "php": ">=8.1 <8.6" }, "require-dev": { - "cucumber/gherkin-monorepo": "dev-gherkin-v39.1.0", + "cucumber/gherkin-monorepo": "dev-gherkin-v42.0.1", "friendsofphp/php-cs-fixer": "^3.77", "mikey179/vfsstream": "^1.6", "phpstan/extension-installer": "^1", - "phpstan/phpstan": "^2", + "phpstan/phpstan": "2.2.14", + "phpstan/phpstan-deprecation-rules": "^2.0", "phpstan/phpstan-phpunit": "^2", "phpunit/phpunit": "^10.5", "symfony/yaml": "^5.4 || ^6.4 || ^7.0" @@ -16407,7 +16409,7 @@ ], "support": { "issues": "https://github.com/Behat/Gherkin/issues", - "source": "https://github.com/Behat/Gherkin/tree/v4.17.0" + "source": "https://github.com/Behat/Gherkin/tree/v4.18.0" }, "funding": [ { @@ -16423,7 +16425,7 @@ "type": "github" } ], - "time": "2026-05-18T09:33:47+00:00" + "time": "2026-09-25T21:26:10+00:00" }, { "name": "behat/mink-selenium2-driver", @@ -17723,16 +17725,16 @@ }, { "name": "laravel/serializable-closure", - "version": "v2.0.16", + "version": "v2.1.0", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed" + "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", - "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/2d5869a838bbcf37e0d8b0568fc41914e81374b5", + "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5", "shasum": "" }, "require": { @@ -17780,7 +17782,7 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-08-18T20:28:54+00:00" + "time": "2026-09-22T14:32:34+00:00" }, { "name": "masterminds/html5", @@ -18089,16 +18091,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.8.0", + "version": "v5.9.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", - "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", "shasum": "" }, "require": { @@ -18140,9 +18142,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0" }, - "time": "2026-07-04T14:30:18+00:00" + "time": "2026-09-13T18:51:52+00:00" }, { "name": "ondram/ci-detector", @@ -18549,16 +18551,16 @@ }, { "name": "phpro/grumphp", - "version": "v2.23.0", + "version": "v2.24.0", "source": { "type": "git", "url": "https://github.com/phpro/grumphp.git", - "reference": "c8a173464e319c81d530290c7642bfce482e05bf" + "reference": "8abcadb4eb39a2c0124b9a220de12f8cf268e4c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpro/grumphp/zipball/c8a173464e319c81d530290c7642bfce482e05bf", - "reference": "c8a173464e319c81d530290c7642bfce482e05bf", + "url": "https://api.github.com/repos/phpro/grumphp/zipball/8abcadb4eb39a2c0124b9a220de12f8cf268e4c4", + "reference": "8abcadb4eb39a2c0124b9a220de12f8cf268e4c4", "shasum": "" }, "require": { @@ -18664,9 +18666,9 @@ "description": "A composer plugin that enables source code quality checks.", "support": { "issues": "https://github.com/phpro/grumphp/issues", - "source": "https://github.com/phpro/grumphp/tree/v2.23.0" + "source": "https://github.com/phpro/grumphp/tree/v2.24.0" }, - "time": "2026-07-22T11:36:59+00:00" + "time": "2026-09-25T08:41:10+00:00" }, { "name": "phpstan/extension-installer", @@ -18718,16 +18720,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.5", + "version": "2.3.6", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9" + "reference": "1427af4647235b4a73b13940b1f1258d3584f36e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/148cefffaf0233e4c08cc13db8a195a56dd6dfe9", - "reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1427af4647235b4a73b13940b1f1258d3584f36e", + "reference": "1427af4647235b4a73b13940b1f1258d3584f36e", "shasum": "" }, "require": { @@ -18759,9 +18761,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.5" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.6" }, - "time": "2026-08-31T16:05:28+00:00" + "time": "2026-09-27T20:07:45+00:00" }, { "name": "phpstan/phpstan", @@ -19312,16 +19314,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.36", + "version": "9.6.37", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "abab27ed286d3e1246fbbfe6b56bfd732d945ec9" + "reference": "aceaa623912caf8e80bffea22fc4103a0a01565f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/abab27ed286d3e1246fbbfe6b56bfd732d945ec9", - "reference": "abab27ed286d3e1246fbbfe6b56bfd732d945ec9", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/aceaa623912caf8e80bffea22fc4103a0a01565f", + "reference": "aceaa623912caf8e80bffea22fc4103a0a01565f", "shasum": "" }, "require": { @@ -19395,7 +19397,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.36" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.37" }, "funding": [ { @@ -19403,7 +19405,7 @@ "type": "other" } ], - "time": "2026-08-11T06:25:15+00:00" + "time": "2026-09-23T09:59:59+00:00" }, { "name": "phrity/net-stream", @@ -21632,16 +21634,16 @@ }, { "name": "symfony/web-profiler-bundle", - "version": "v6.4.44", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/web-profiler-bundle.git", - "reference": "a1a4e508411254392dbf5a2fb2390b11d790481d" + "reference": "bab4798b3d9084431b589cb95772671c66cc6c32" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/a1a4e508411254392dbf5a2fb2390b11d790481d", - "reference": "a1a4e508411254392dbf5a2fb2390b11d790481d", + "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/bab4798b3d9084431b589cb95772671c66cc6c32", + "reference": "bab4798b3d9084431b589cb95772671c66cc6c32", "shasum": "" }, "require": { @@ -21694,7 +21696,7 @@ "dev" ], "support": { - "source": "https://github.com/symfony/web-profiler-bundle/tree/v6.4.44" + "source": "https://github.com/symfony/web-profiler-bundle/tree/v6.4.46" }, "funding": [ { @@ -21714,7 +21716,7 @@ "type": "tidelift" } ], - "time": "2026-08-22T06:28:57+00:00" + "time": "2026-09-02T17:01:51+00:00" }, { "name": "symplify/easy-coding-standard", diff --git a/config/routing/admin.yaml b/config/routing/admin.yaml index 4800b8e9..f25c4ed1 100644 --- a/config/routing/admin.yaml +++ b/config/routing/admin.yaml @@ -2,4 +2,20 @@ controllers: resource: path: '../../src/Action/Admin/' namespace: 'PayPlug\SyliusPayPlugPlugin\Action\Admin' - type: attribute \ No newline at end of file + type: attribute +# Declared here rather than with #[Route] attributes — see AuthorizedPaymentController's docblock. +payplug_sylius_admin_payment_capture: + path: /payplug/orders/{orderId}/payments/{paymentId}/capture + methods: [POST] + controller: PayPlug\SyliusPayPlugPlugin\Action\Admin\AuthorizedPaymentController::capture + requirements: + orderId: \d+ + paymentId: \d+ + +payplug_sylius_admin_payment_cancel: + path: /payplug/orders/{orderId}/payments/{paymentId}/cancel + methods: [POST] + controller: PayPlug\SyliusPayPlugPlugin\Action\Admin\AuthorizedPaymentController::cancel + requirements: + orderId: \d+ + paymentId: \d+ diff --git a/config/services.yaml b/config/services.yaml index 5ca21497..ac50d0bd 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -37,6 +37,10 @@ services: arguments: $csrfTokenManager: '@?security.csrf.token_manager' + PayPlug\SyliusPayPlugPlugin\Action\Admin\AuthorizedPaymentController: + arguments: + $csrfTokenManager: '@?security.csrf.token_manager' + PayPlug\SyliusPayPlugPlugin\Repository\PaymentRepositoryInterface: class: PayPlug\SyliusPayPlugPlugin\Repository\PaymentRepository parent: sylius.repository.payment @@ -90,6 +94,9 @@ services: PayPlug\SyliusPayPlugPlugin\Upc\RefundCreatorInterface: alias: PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiRefundCreator + PayPlug\SyliusPayPlugPlugin\Upc\AuthorizationOperatorInterface: + alias: PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiAuthorizationOperator + payplug_sylius_payplug_plugin.action.capture: class: PayPlug\SyliusPayPlugPlugin\Action\CaptureAction diff --git a/config/twig_hooks/admin.yaml b/config/twig_hooks/admin.yaml index 048f4775..0ad4fb89 100644 --- a/config/twig_hooks/admin.yaml +++ b/config/twig_hooks/admin.yaml @@ -82,3 +82,12 @@ sylius_twig_hooks: <<: *weroGateway renew_oauth: *renewOAuth connected_account: *connectedAccount + + 'sylius_admin.order.show.content.sections#left': + payplug_authorization: + template: '@PayPlugSyliusPayPlugPlugin/admin/order/show/authorization.html.twig' + priority: 50 + 'sylius_admin.order.show.content.sections.payments.item.actions': + complete: + template: '@PayPlugSyliusPayPlugPlugin/admin/order/show/payment_complete.html.twig' + priority: 100 diff --git a/doc/authorized_payment.md b/doc/authorized_payment.md index 83332706..5c0a7e3b 100644 --- a/doc/authorized_payment.md +++ b/doc/authorized_payment.md @@ -6,6 +6,13 @@ The payment is authorized and the capture can be done later. > [!IMPORTANT] > The authorized payment feature is only available for the "PayPlug" payment gateway. +Two flows exist, depending on the payment method's display mode: + +- **Hosted Fields** (Unified API): capture — full, partial, several times — and cancellation are + driven from the admin order screen. See [Hosted Fields: capture and cancel from the order screen](#hosted-fields-capture-and-cancel-from-the-order-screen). +- **Redirected / Integrated Payment** (legacy API): the whole authorized amount is captured at + once, by the command or the state machine triggers described in [Trigger the capture](#trigger-the-capture). + ## Activation On the payment method configuration, you can enable the deferred capture feature. @@ -153,3 +160,76 @@ final class CaptureOrderProcessor } } ``` + +## Hosted Fields: capture and cancel from the order screen + +With **deferred capture** enabled on a Hosted Fields payment method, each payment is created as an +authorization only: the customer's funds are held, not debited, and the Sylius payment is +`authorized` (the order payment state reads *authorized*, not *paid*). + +On the order screen (**Sales › Orders › an order**), a **PayPlug — Deferred capture** block is shown +under *Payments* for each such payment. It displays: + +| Field | Meaning | +|---|---| +| Authorized amount | What the customer's bank agreed to hold | +| Already captured | Sum of the captures performed so far | +| Cancelled | Sum of the cancellations performed so far (shown once there is one) | +| Remaining capturable | What can still be captured or cancelled | +| Capture deadline | The date after which the authorization lapses and the funds are released. A warning is shown 48 hours before it; after it, no action is offered any more | + +### Capture + +Leave the amount blank to capture everything that remains, or type an amount (`12.50` or `12,50`) +for a partial capture. Captures can be chained as long as their total stays within the authorized +amount. + +| After the capture | Sylius payment state | +|---|---| +| Something is still capturable | stays `authorized` | +| Nothing is left | `completed` (the order becomes *paid*) | + +Sylius's own **Complete** button is hidden for these payments. A `complete` transition applied by +any other means — the `payplug:capture-authorized-payments` command, or your own shipping listener +as described above — captures the whole remaining amount first; if that capture is refused, the +transition is aborted and the payment stays `authorized`. + +### Cancel + +Cancellation is only offered **before any capture**. Leave the amount blank to cancel everything, +or type an amount for a partial cancellation — which must be enabled on the merchant's PayPlug +contract; otherwise PayPlug refuses it and the admin explains why. + +| After the cancellation | Sylius payment state | +|---|---| +| Something is still authorized | stays `authorized` (the rest can still be captured) | +| Nothing is left | `cancelled` | + +Once part of the authorization is captured, use a refund to give money back. + +### Refusals + +When PayPlug refuses an operation, nothing is recorded and the payment state is left unchanged; the +admin shows an explicit message, e.g.: + +- the authorization has expired; +- the amount exceeds what remains; +- partial cancellation is not enabled on the contract; +- the customer's bank refused the operation; +- another operation is already in progress on this payment. + +A form submitted twice (double click, browser back + resubmit) is refused as *stale*: each form +carries the state it was built against, and operations on a same payment are serialized. + +### Notifications and operations performed outside Sylius + +The webhook confirming each capture or cancellation is matched to the operation Sylius triggered. +If PayPlug later reports that an operation accepted earlier did **not** go through, it is flagged +as failed in the payment details (the remaining amount counts it back in) and a `critical` entry is +written to the `payplug` log channel so that the payment can be reconciled manually. + +> [!WARNING] +> Captures and cancellations performed **outside Sylius** (PayPlug portal, PayPlug support, another +> integration using the same account) are not reflected in Sylius: they carry operation ids Sylius +> does not know. Perform them from the Sylius order screen so that the payment state stays in step. + diff --git a/src/Action/Admin/AuthorizedPaymentController.php b/src/Action/Admin/AuthorizedPaymentController.php new file mode 100644 index 00000000..a101d9d7 --- /dev/null +++ b/src/Action/Admin/AuthorizedPaymentController.php @@ -0,0 +1,181 @@ +handle($request, $orderId, $paymentId, 'capture', function (PaymentInterface $payment, ?int $amount, ?int $version): void { + $this->processor->capture($payment, $amount, $version); + }); + } + + public function cancel(Request $request, int $orderId, int $paymentId): Response + { + return $this->handle($request, $orderId, $paymentId, 'cancel', function (PaymentInterface $payment, ?int $amount, ?int $version): void { + $this->processor->cancel($payment, $amount, $version); + }); + } + + private function handle( + Request $request, + int $orderId, + int $paymentId, + string $action, + \Closure $operation, + ): Response + { + if (!$this->authorizationChecker->isGranted(self::ADMIN_ROLE)) { + throw new AccessDeniedHttpException('Only an administrator may capture or cancel a payment.'); + } + + $this->denyUnlessCsrfTokenIsValid($request, $paymentId); + $payment = $this->findOrderPayment($orderId, $paymentId); + + try { + $operation($payment, self::parseAmount($request->request->getString('amount')), self::parseVersion($request)); + $this->entityManager->flush(); + $this->addFlashMessage($request, 'success', self::FLASH_PREFIX . $action . '_success'); + } catch (AuthorizationOperationException $exception) { + $this->addFlashMessage($request, 'error', $exception->getTranslationKey(), $exception->getTranslationParameters()); + } catch (\Throwable $exception) { + // Reached only after the Unified API accepted the operation (e.g. the flush failed): + // the money moved, but Sylius may not reflect it — worth a human's attention. + $this->logger->critical('[PayPlug][UPC] Unexpected error during an authorization operation.', [ + 'sylius_payment_id' => $paymentId, + 'action' => $action, + 'exception' => $exception, + ]); + $this->addFlashMessage($request, 'error', self::FLASH_PREFIX . 'error.api_error'); + } + + return new RedirectResponse($this->router->generate('sylius_admin_order_show', ['id' => $orderId])); + } + + /** + * Blank means "the whole remaining amount". Accepts a decimal comma as well as a dot, since + * that is what a French-speaking merchant types; anything else malformed is sent on as an + * invalid amount (0) for the processor to refuse with its own explicit message. + */ + public static function parseAmount(string $raw): ?int + { + $raw = \str_replace([' ', "\u{00A0}", ','], ['', '', '.'], \trim($raw)); + if ('' === $raw) { + return null; + } + + if (1 !== \preg_match('/^(\d+)(?:\.(\d{1,2}))?$/', $raw, $matches)) { + return 0; + } + + return (int) $matches[1] * 100 + (int) \str_pad($matches[2] ?? '0', 2, '0'); + } + + private static function parseVersion(Request $request): ?int + { + $version = $request->request->get('version'); + + return \is_numeric($version) ? (int) $version : null; + } + + /** + * @param array $parameters + */ + private function addFlashMessage(Request $request, string $type, string $message, array $parameters = []): void + { + $session = $request->getSession(); + + if ($session instanceof FlashBagAwareSessionInterface) { + // Sylius's admin flash template translates a ['message', 'parameters'] pair itself. + $session->getFlashBag()->add($type, [] === $parameters ? $message : ['message' => $message, 'parameters' => $parameters]); + } + } + + /** + * See UnifiedLogoutController::denyUnlessCsrfTokenIsValid() for why a null token manager + * skips the check rather than failing it. + */ + private function denyUnlessCsrfTokenIsValid(Request $request, int $paymentId): void + { + if (null === $this->csrfTokenManager) { + return; + } + + $token = new CsrfToken(self::CSRF_TOKEN_ID_PREFIX . $paymentId, $request->request->getString('_csrf_token')); + + if (!$this->csrfTokenManager->isTokenValid($token)) { + throw new BadRequestHttpException('Invalid CSRF token for the PayPlug authorization request.'); + } + } + + private function findOrderPayment(int $orderId, int $paymentId): PaymentInterface + { + $payment = $this->paymentRepository->find($paymentId); + + if (!$payment instanceof PaymentInterface || $payment->getOrder()?->getId() !== $orderId) { + throw new NotFoundHttpException(\sprintf('No payment %d found on order %d.', $paymentId, $orderId)); + } + + return $payment; + } +} diff --git a/src/Command/Handler/CaptureAliasPaymentRequestHandler.php b/src/Command/Handler/CaptureAliasPaymentRequestHandler.php index 10f27991..93cf61c7 100644 --- a/src/Command/Handler/CaptureAliasPaymentRequestHandler.php +++ b/src/Command/Handler/CaptureAliasPaymentRequestHandler.php @@ -8,6 +8,7 @@ use PayPlug\SyliusPayPlugPlugin\Command\PaymentCaptureFlow; use PayPlug\SyliusPayPlugPlugin\Entity\Card; use PayPlug\SyliusPayPlugPlugin\Resolver\SelectedCardResolver; +use PayPlug\SyliusPayPlugPlugin\Upc\AuthorizationDetails; use PayPlug\SyliusPayPlugPlugin\Upc\PaymentCaptureContextBuilder; use PayPlug\SyliusPayPlugPlugin\Upc\PaymentCaptureOutcomeApplier; use PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiPaymentCreatorInterface; @@ -68,12 +69,14 @@ public function __invoke(CaptureAliasPaymentRequest $captureAliasPaymentRequest) return; } - $payment->setDetails([ + $createdDetails = [ ...$payment->getDetails(), 'alias_id' => $card->getExternalId(), 'alias_payment_created_at' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM), ...$this->contextBuilder->resolveHostedFieldsIds($output->body), - ]); + ]; + // Before applyOutcome() below — see CaptureHostedPaymentRequestHandler's own comment. + $payment->setDetails($common->capture ? $createdDetails : AuthorizationDetails::open($createdDetails, $output, $amount)); $this->outcomeApplier->applyOutcome($paymentRequest, $payment, $output); diff --git a/src/Command/Handler/CaptureHostedPaymentRequestHandler.php b/src/Command/Handler/CaptureHostedPaymentRequestHandler.php index 9423f17d..bf75760f 100644 --- a/src/Command/Handler/CaptureHostedPaymentRequestHandler.php +++ b/src/Command/Handler/CaptureHostedPaymentRequestHandler.php @@ -6,6 +6,7 @@ use PayPlug\SyliusPayPlugPlugin\Command\CaptureHostedPaymentRequest; use PayPlug\SyliusPayPlugPlugin\Command\PaymentCaptureFlow; +use PayPlug\SyliusPayPlugPlugin\Upc\AuthorizationDetails; use PayPlug\SyliusPayPlugPlugin\Upc\CardDataFromPaymentMethodExtractor; use PayPlug\SyliusPayPlugPlugin\Upc\OperationStatusFetcherInterface; use PayPlug\SyliusPayPlugPlugin\Upc\PaymentCaptureContextBuilder; @@ -73,11 +74,14 @@ public function __invoke(CaptureHostedPaymentRequest $captureHostedPaymentReques } $hostedFieldsIds = $this->contextBuilder->resolveHostedFieldsIds($output->body); - $payment->setDetails([ + $createdDetails = [ ...$details, 'hosted_fields_created_at' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM), ...$hostedFieldsIds, - ]); + ]; + // Before applyOutcome() below, which reads this back to apply "authorize" rather than + // "complete" for an authorization-only payment. + $payment->setDetails($dto->common->capture ? $createdDetails : AuthorizationDetails::open($createdDetails, $output, $amountAndCurrency[0])); $this->outcomeApplier->applyOutcome($paymentRequest, $payment, $output); diff --git a/src/Command/Handler/NotifyHostedPaymentRequestHandler.php b/src/Command/Handler/NotifyHostedPaymentRequestHandler.php index e41b9f59..0b46fbf6 100644 --- a/src/Command/Handler/NotifyHostedPaymentRequestHandler.php +++ b/src/Command/Handler/NotifyHostedPaymentRequestHandler.php @@ -5,6 +5,7 @@ namespace PayPlug\SyliusPayPlugPlugin\Command\Handler; use PayPlug\SyliusPayPlugPlugin\Command\NotifyHostedPaymentRequest; +use PayPlug\SyliusPayPlugPlugin\Upc\AuthorizationDetails; use PayPlug\SyliusPayPlugPlugin\Upc\ScopedConfigurationRepositoryInterface; use PayplugUnifiedCore\Contracts\ILock; use PayplugUnifiedCore\Contracts\IOrderStateMutator; @@ -82,7 +83,10 @@ public function __invoke(NotifyHostedPaymentRequest $notifyHostedPaymentRequest) } $this->paymentRepository->save($operationData); - $this->orderStateMutator->apply($operationData->orderId, $operationData->outcome); + // This per-request notification only ever confirms the payment's own creation, so an + // authorization-only payment's success is "authorized", never "paid". + $outcome = AuthorizationDetails::fromDetails($paymentRequest->getPayment()->getDetails())->resolveCreationOutcome($operationData->outcome); + $this->orderStateMutator->apply($operationData->orderId, $outcome); $this->paymentRepository->markTreated($operationData->operationId); $this->stateMachine->apply($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE); diff --git a/src/Exception/Payment/AuthorizationOperationException.php b/src/Exception/Payment/AuthorizationOperationException.php new file mode 100644 index 00000000..1f7aed60 --- /dev/null +++ b/src/Exception/Payment/AuthorizationOperationException.php @@ -0,0 +1,38 @@ + $translationParameters + */ + public function __construct( + private string $translationKey, + private array $translationParameters = [], + ?\Throwable $previous = null, + ) { + parent::__construct($translationKey, 0, $previous); + } + + public function getTranslationKey(): string + { + return $this->translationKey; + } + + /** + * @return array + */ + public function getTranslationParameters(): array + { + return $this->translationParameters; + } +} diff --git a/src/Gateway/PayPlugGatewayFactory.php b/src/Gateway/PayPlugGatewayFactory.php index 5321a757..8e0b37eb 100644 --- a/src/Gateway/PayPlugGatewayFactory.php +++ b/src/Gateway/PayPlugGatewayFactory.php @@ -103,6 +103,18 @@ public static function isHostedFieldsConfig(?GatewayConfigInterface $gatewayConf true === ($gatewayConfig->getConfig()[self::HOSTED_FIELDS] ?? false); } + /** + * True for a Hosted Fields config whose merchant opted into deferred capture: its payments are + * then created as authorizations only (UPC capture=false), captured or cancelled later from the + * admin order screen. The legacy SDK flow reads the same DEFERRED_CAPTURE flag on its own + * (PayPlugPaymentDataCreator), so this deliberately excludes it. + */ + public static function isDeferredCaptureHostedFieldsConfig(?GatewayConfigInterface $gatewayConfig): bool + { + return self::isHostedFieldsConfig($gatewayConfig) && + true === ($gatewayConfig?->getConfig()[self::DEFERRED_CAPTURE] ?? false); + } + private static function isBlank(mixed $value): bool { if (!is_scalar($value)) { diff --git a/src/Handler/HostedFieldsWebhookNotificationHandler.php b/src/Handler/HostedFieldsWebhookNotificationHandler.php index 02375e71..12a19b5f 100644 --- a/src/Handler/HostedFieldsWebhookNotificationHandler.php +++ b/src/Handler/HostedFieldsWebhookNotificationHandler.php @@ -4,6 +4,7 @@ namespace PayPlug\SyliusPayPlugPlugin\Handler; +use PayPlug\SyliusPayPlugPlugin\Upc\AuthorizationDetails; use PayPlug\SyliusPayPlugPlugin\Upc\CardDataFromPaymentMethodExtractor; use PayPlug\SyliusPayPlugPlugin\Upc\PaymentOrderIdResolver; use PayPlug\SyliusPayPlugPlugin\Upc\PayplugCardPersister; @@ -104,6 +105,24 @@ public function treat(PaymentInterface $payment, string $rawBody, array $headers // (see ExecCodeMapper), so this classification is made locally, from ids this plugin // itself generated and already knows the meaning of. $refundAmount = self::findMatchingRefundAmount($payment, $operationData->operationId); + + // Same local classification for a deferred-capture payment's captures/cancellations: + // AuthorizedPaymentOperationProcessor recorded each one's operation id when it triggered + // it, and already applied its effect on the payment state synchronously — the webhook + // only confirms it. + $authorizationOperation = null === $refundAmount + ? AuthorizationDetails::fromDetails($payment->getDetails())->findOperation($operationData->operationId) + : null; + if (null !== $authorizationOperation) { + $this->treatAuthorizationOperation($payment, $rawBody, $operationData, $authorizationOperation); + + return; + } + + if (null === $refundAmount) { + $this->resolveAuthorizationOutcome($payment, $rawBody, $operationData); + } + $expectedAmount = $refundAmount ?? $payment->getAmount(); if (!$this->matchesPayment($payment, $operationData, $expectedAmount)) { @@ -148,6 +167,76 @@ public function treat(PaymentInterface $payment, string $rawBody, array $headers $this->applyLocked($payment, $rawBody, $operationData); } + /** + * For an authorization-only payment, the confirmation of its own creation reads "authorized", + * never "paid" (see AuthorizationDetails::resolveCreationOutcome()) — and, after a 3DS + * challenge, is also the first place its capture deadline shows up. Any other operation id + * reaching here on such a payment is one Sylius never triggered: mapping it to AUTHORIZED + * too keeps it from ever completing the payment on a guess. + */ + private function resolveAuthorizationOutcome( + PaymentInterface $payment, + string $rawBody, + OperationData $operationData, + ): void + { + $authorization = AuthorizationDetails::fromDetails($payment->getDetails()); + if (!$authorization->isDeferred()) { + return; + } + + $operationData->outcome = $authorization->resolveCreationOutcome($operationData->outcome); + if (PaymentOutcome::AUTHORIZED === $operationData->outcome) { + $payment->setDetails(AuthorizationDetails::withMaxCaptureDateFromBody($payment->getDetails(), $rawBody)); + } + } + + /** + * A success confirms what the payment state already reflects, so it is only tracked as + * treated. A failure means an operation accepted synchronously did not actually go through: + * its entry is flagged failed (so the remaining capturable amount counts it back in) and, since + * the payment may already have moved on to "completed"/"cancelled" on the strength of it, + * logged critically for the merchant to reconcile — no automatic transition can undo those. + * + * @param array{operation: string, amount: int} $authorizationOperation + */ + private function treatAuthorizationOperation( + PaymentInterface $payment, + string $rawBody, + OperationData $operationData, + array $authorizationOperation, + ): void { + if (!$this->matchesPayment($payment, $operationData, $authorizationOperation['amount'])) { + return; + } + + if (PaymentOutcome::PAID !== $operationData->outcome) { + $this->logger->critical('[PayPlug][UPC] An authorization operation accepted earlier is reported as not completed; the payment needs manual reconciliation.', [ + 'sylius_payment_id' => $payment->getId(), + 'payment_state' => $payment->getState(), + 'operation' => $authorizationOperation['operation'], + 'operation_id' => $operationData->operationId, + 'outcome' => $operationData->outcome, + 'exec_code' => $operationData->execCode, + ]); + + $lockKey = AuthorizationDetails::lockKey($payment->getId()); + if (!$this->lock->acquire($lockKey, self::LOCK_TTL_SECONDS)) { + // An operation on this payment is in progress; leave this notification untreated + // so its redelivery flags the entry once the lock is free. + return; + } + + try { + $payment->setDetails(AuthorizationDetails::withOperationFailed($payment->getDetails(), $authorizationOperation['operation'], $operationData->operationId)); + } finally { + $this->lock->release($lockKey); + } + } + + $this->applyLocked($payment, $rawBody, $operationData, applyOutcome: false); + } + // Split out of treat() to keep its own return count within SonarCloud's limit (php:S1142) — // same rationale as matchesPayment() below: this is its own self-contained "acquire, check // idempotency, apply" unit, not a fragment that needs to share treat()'s return budget. @@ -180,7 +269,11 @@ private function applyLocked( } $this->paymentRepository->markTreated($operationData->operationId); - if (PaymentOutcome::PAID === $operationData->outcome) { + // AUTHORIZED too: a deferred-capture payment's card is saved once its authorization + // is confirmed, exactly like an immediate one's once it is paid. Never for a + // notification only tracked ($applyOutcome false) — a capture/cancellation + // confirmation also reads PAID, and has no card to save. + if ($applyOutcome && \in_array($operationData->outcome, [PaymentOutcome::PAID, PaymentOutcome::AUTHORIZED], true)) { $this->maybeSaveCard($payment, $rawBody); } } finally { diff --git a/src/PaymentProcessing/AuthorizedPaymentOperationProcessor.php b/src/PaymentProcessing/AuthorizedPaymentOperationProcessor.php new file mode 100644 index 00000000..b1c90286 --- /dev/null +++ b/src/PaymentProcessing/AuthorizedPaymentOperationProcessor.php @@ -0,0 +1,396 @@ +getMethod(); + + return $method instanceof PaymentMethodInterface && + PayPlugGatewayFactory::isHostedFieldsConfig($method->getGatewayConfig()) && + AuthorizationDetails::fromDetails($payment->getDetails())->isDeferred(); + } + + public function canCapture(PaymentInterface $payment): bool + { + $authorization = AuthorizationDetails::fromDetails($payment->getDetails()); + + return $this->isAuthorized($payment) && + !$authorization->isExpired($this->clock->now()) && + $authorization->remainingAmount() > 0; + } + + public function canCancel(PaymentInterface $payment): bool + { + return $this->canCapture($payment) && !AuthorizationDetails::fromDetails($payment->getDetails())->hasCaptures(); + } + + /** + * @param int|null $amount null captures everything still capturable + * @param int|null $expectedVersion the AuthorizationDetails::version() the caller's form was + * built against; null skips the replay check (programmatic use) + * + * @throws AuthorizationOperationException + */ + public function capture(PaymentInterface $payment, ?int $amount, ?int $expectedVersion = null): void + { + $this->runLocked($payment, function () use ($payment, $amount, $expectedVersion): void { + $this->assertOperable($payment, $expectedVersion, AuthorizationDetails::OPERATION_CAPTURE); + $this->captureRecorded($payment, $amount); + + if (0 === AuthorizationDetails::fromDetails($payment->getDetails())->remainingAmount()) { + $this->applyTransition($payment, PaymentTransitions::TRANSITION_COMPLETE); + } + }); + } + + /** + * @param int|null $amount null cancels everything still authorized + * + * @throws AuthorizationOperationException + */ + public function cancel(PaymentInterface $payment, ?int $amount, ?int $expectedVersion = null): void + { + $this->runLocked($payment, function () use ($payment, $amount, $expectedVersion): void { + $this->assertOperable($payment, $expectedVersion, AuthorizationDetails::OPERATION_CANCELLATION); + + $authorization = AuthorizationDetails::fromDetails($payment->getDetails()); + $remaining = $authorization->remainingAmount(); + $amount = $this->resolveAmount($payment, $amount, $remaining); + + try { + $output = $this->authorizationOperator->cancel( + $this->resolveMethod($payment), + $this->resolveUnifiedApiPaymentId($payment), + PaymentOrderIdResolver::resolve($payment->getOrder(), $payment->getId()), + // Without an amount only on an untouched authorization: an explicit amount is + // what UPC reads as a partial cancellation, which the merchant's contract may + // not allow even when it equals the whole remainder. Once part of it has been + // cancelled, "no amount" would target the original authorization again, so the + // exact remainder is sent instead. + $amount === $remaining && 0 === $authorization->version() ? null : $amount, + $authorization->version() + 1, + $payment->getCurrencyCode(), + ); + } catch (PayplugException $exception) { + throw $this->refusal($payment, AuthorizationDetails::OPERATION_CANCELLATION, $exception); + } + + $this->record($payment, AuthorizationDetails::OPERATION_CANCELLATION, $output->body, $amount); + + if ($amount === $remaining) { + $this->applyTransition($payment, PaymentTransitions::TRANSITION_CANCEL); + } + }); + } + + /** + * Keeps Sylius's own "complete" transition meaningful for a deferred Hosted Fields payment: + * applied while money is still only authorized — by the native admin "Complete" button, by + * payplug:capture-authorized-payments, or by a merchant's own shipping listener (see + * doc/authorized_payment.md) — it first captures whatever is still capturable. Throwing here + * aborts the transition, so the payment never reads "completed" without the capture behind it. + * + * A no-op when this processor applies "complete" itself after the last capture, since nothing + * is left capturable by then. + */ + #[AsTransitionListener(workflow: PaymentTransitions::GRAPH, transition: PaymentTransitions::TRANSITION_COMPLETE)] + public function onCompleteTransition(TransitionEvent $event): void + { + $payment = $event->getSubject(); + if (!$payment instanceof PaymentInterface || !self::supports($payment) || !$this->isAuthorized($payment)) { + return; + } + + if (0 === AuthorizationDetails::fromDetails($payment->getDetails())->remainingAmount()) { + return; + } + + $this->runLocked($payment, function () use ($payment): void { + $this->assertOperable($payment, null, AuthorizationDetails::OPERATION_CAPTURE); + $this->captureRecorded($payment, null); + }); + } + + /** + * @throws AuthorizationOperationException + */ + private function captureRecorded(PaymentInterface $payment, ?int $amount): void + { + $remaining = AuthorizationDetails::fromDetails($payment->getDetails())->remainingAmount(); + $amount = $this->resolveAmount($payment, $amount, $remaining); + + try { + $output = $this->authorizationOperator->capture( + $this->resolveMethod($payment), + $this->resolveUnifiedApiPaymentId($payment), + PaymentOrderIdResolver::resolve($payment->getOrder(), $payment->getId()), + // Always explicit: without an amount the Unified API captures the ORIGINAL + // authorized amount, not what remains — after a partial capture that is refused as + // a duplicate (execCode 4011, staging 2026-09-24). + $amount, + $payment->getCurrencyCode(), + AuthorizationDetails::fromDetails($payment->getDetails())->version() + 1, + ); + } catch (PayplugException $exception) { + throw $this->refusal($payment, AuthorizationDetails::OPERATION_CAPTURE, $exception); + } + + $this->record($payment, AuthorizationDetails::OPERATION_CAPTURE, $output->body, $amount); + + if (null !== $output->maxCaptureDate) { + $payment->setDetails([...$payment->getDetails(), AuthorizationDetails::MAX_CAPTURE_DATE => $output->maxCaptureDate]); + } + } + + /** + * @throws AuthorizationOperationException + */ + private function assertOperable(PaymentInterface $payment, ?int $expectedVersion, string $operation): void + { + if (!self::supports($payment) || !$this->isAuthorized($payment)) { + throw new AuthorizationOperationException(self::ERROR_KEY_PREFIX . 'not_authorized'); + } + + $authorization = AuthorizationDetails::fromDetails($payment->getDetails()); + + if (null !== $expectedVersion && $expectedVersion !== $authorization->version()) { + throw new AuthorizationOperationException(self::ERROR_KEY_PREFIX . 'stale'); + } + + if ($authorization->isExpired($this->clock->now())) { + throw new AuthorizationOperationException(self::ERROR_KEY_PREFIX . 'expired'); + } + + if (AuthorizationDetails::OPERATION_CANCELLATION === $operation && $authorization->hasCaptures()) { + throw new AuthorizationOperationException(self::ERROR_KEY_PREFIX . 'cancel_after_capture'); + } + + if (0 === $authorization->remainingAmount()) { + throw new AuthorizationOperationException(self::ERROR_KEY_PREFIX . 'nothing_remaining'); + } + } + + /** + * @throws AuthorizationOperationException + */ + private function resolveAmount(PaymentInterface $payment, ?int $amount, int $remaining): int + { + $amount ??= $remaining; + + if ($amount <= 0) { + throw new AuthorizationOperationException(self::ERROR_KEY_PREFIX . 'invalid_amount'); + } + + if ($amount > $remaining) { + throw new AuthorizationOperationException(self::ERROR_KEY_PREFIX . 'amount_exceeds_remaining', [ + '%remaining%' => self::formatAmount($remaining, $payment->getCurrencyCode()), + ]); + } + + return $amount; + } + + /** + * The operation id is what lets HostedFieldsWebhookNotificationHandler recognize this + * operation's own webhook later on (and UnifiedApiIpnAction resolve the payment for it at + * all). Its absence is logged rather than failing the call: the operation did happen, and + * refusing to record it would leave the payment out of step with the transaction. + */ + private function record(PaymentInterface $payment, string $operation, string $responseBody, int $amount): void + { + $operationId = self::extractFirstOperationId($responseBody); + if (null === $operationId) { + $this->logger->error('[PayPlug][UPC] Authorization operation succeeded but the response carried no operationIds.', [ + 'sylius_payment_id' => $payment->getId(), + 'operation' => $operation, + 'response_body' => $responseBody, + ]); + } + + $payment->setDetails(AuthorizationDetails::withOperation($payment->getDetails(), $operation, $operationId, $amount)); + + $this->logger->info('[PayPlug][UPC] Authorization operation recorded.', [ + 'sylius_payment_id' => $payment->getId(), + 'operation' => $operation, + 'operation_id' => $operationId, + 'amount' => $amount, + ]); + } + + private function refusal( + PaymentInterface $payment, + string $operation, + PayplugException $exception, + ): AuthorizationOperationException + { + $this->logger->error('[PayPlug][UPC] Authorization operation refused.', [ + 'sylius_payment_id' => $payment->getId(), + 'operation' => $operation, + 'exception' => $exception::class, + 'error' => $exception->getMessage(), + ]); + + $reason = match (true) { + $exception instanceof AuthorizationExpiredException => 'expired', + $exception instanceof AmountExceedsAvailableException, + $exception instanceof CaptureAmountException, + $exception instanceof CancellationAmountException => 'amount_refused', + $exception instanceof PaymentAlreadyCapturedException => 'already_captured', + $exception instanceof PaymentAlreadyCancelledException => 'already_cancelled', + // Same message from the API whether the payment was already captured or cancelled. + $exception instanceof PaymentNotCapturableException, + $exception instanceof PaymentNotVoidableException => 'not_operable', + $exception instanceof MultipleCaptureNotAllowedException => 'multiple_capture_not_allowed', + $exception instanceof PartialCancellationNotAllowedException => 'partial_cancellation_not_allowed', + $exception instanceof OperationConflictException => 'conflict', + $exception instanceof CardOperationException => 'refused_by_issuer', + $exception instanceof PaymentNotFoundException => 'payment_not_found', + default => 'api_error', + }; + + return new AuthorizationOperationException(self::ERROR_KEY_PREFIX . $reason, [], $exception); + } + + /** + * @throws AuthorizationOperationException + */ + private function runLocked(PaymentInterface $payment, \Closure $operation): void + { + $lockKey = AuthorizationDetails::lockKey($payment->getId()); + if (!$this->lock->acquire($lockKey, self::LOCK_TTL_SECONDS)) { + throw new AuthorizationOperationException(self::ERROR_KEY_PREFIX . 'in_progress'); + } + + try { + $operation(); + } finally { + $this->lock->release($lockKey); + } + } + + private function applyTransition(PaymentInterface $payment, string $transition): void + { + if (!$this->stateMachine->can($payment, PaymentTransitions::GRAPH, $transition)) { + $this->logger->warning('[PayPlug][UPC] Cannot apply payment transition after an authorization operation.', [ + 'sylius_payment_id' => $payment->getId(), + 'current_state' => $payment->getState(), + 'transition' => $transition, + ]); + + return; + } + + $this->stateMachine->apply($payment, PaymentTransitions::GRAPH, $transition); + } + + private function isAuthorized(PaymentInterface $payment): bool + { + return PaymentInterface::STATE_AUTHORIZED === $payment->getState(); + } + + private function resolveMethod(PaymentInterface $payment): PaymentMethodInterface + { + $method = $payment->getMethod(); + if (!$method instanceof PaymentMethodInterface) { + throw new \LogicException('Payment method is not set for the payment.'); + } + + return $method; + } + + private function resolveUnifiedApiPaymentId(PaymentInterface $payment): string + { + $paymentId = $payment->getDetails()['hosted_fields_payment_id'] ?? null; + if (!\is_string($paymentId) || '' === $paymentId) { + throw new AuthorizationOperationException(self::ERROR_KEY_PREFIX . 'payment_not_found'); + } + + return $paymentId; + } + + private static function extractFirstOperationId(string $body): ?string + { + $decoded = \json_decode($body, true); + $operationIds = \is_array($decoded) ? ($decoded['operationIds'] ?? null) : null; + $operationId = \is_array($operationIds) ? ($operationIds[0] ?? null) : null; + + return \is_string($operationId) && '' !== $operationId ? $operationId : null; + } + + private static function formatAmount(int $amount, ?string $currencyCode): string + { + return \trim(\number_format($amount / 100, 2, '.', ' ') . ' ' . ($currencyCode ?? '')); + } +} diff --git a/src/PaymentProcessing/RefundPaymentProcessor.php b/src/PaymentProcessing/RefundPaymentProcessor.php index b7fb4e9a..275c8fe1 100644 --- a/src/PaymentProcessing/RefundPaymentProcessor.php +++ b/src/PaymentProcessing/RefundPaymentProcessor.php @@ -16,6 +16,7 @@ use PayPlug\SyliusPayPlugPlugin\Gateway\ScalapayGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\WeroGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Repository\RefundHistoryRepositoryInterface; +use PayPlug\SyliusPayPlugPlugin\Upc\AuthorizationDetails; use PayPlug\SyliusPayPlugPlugin\Upc\PaymentOrderIdResolver; use PayPlug\SyliusPayPlugPlugin\Upc\RefundCreatorInterface; use PayPlug\SyliusPayPlugPlugin\Upc\RefundDetailsLockKey; @@ -195,7 +196,11 @@ private function processHostedFields(PaymentInterface $payment): void // Subtracting whatever this plugin already recorded as refunded (computed BEFORE // appending the new entry below) keeps this one accurate, which matchesPayment() then // relies on to ever match this refund's own webhook confirmation. - $refundedAmount = $originalAmount - self::sumRecordedRefunds($refunds); + // A deferred-capture payment only ever received what was captured — less than its + // own amount once part of the authorization was cancelled. + $authorization = AuthorizationDetails::fromDetails($details); + $receivedAmount = $authorization->isDeferred() ? $authorization->capturedAmount() : $originalAmount; + $refundedAmount = $receivedAmount - self::sumRecordedRefunds($refunds); self::appendRefundEntry($payment, $details, $refunds, null, $externalId, $refundedAmount); }); } diff --git a/src/Twig/AuthorizationExtension.php b/src/Twig/AuthorizationExtension.php new file mode 100644 index 00000000..47935470 --- /dev/null +++ b/src/Twig/AuthorizationExtension.php @@ -0,0 +1,71 @@ +describe(...)), + ]; + } + + /** + * @return array{ + * authorized_amount: int, + * captured_amount: int, + * cancelled_amount: int, + * remaining_amount: int, + * max_capture_date: \DateTimeImmutable|null, + * expired: bool, + * expiring_soon: bool, + * can_capture: bool, + * can_cancel: bool, + * version: int, + * }|null null for any payment that isn't a deferred-capture Hosted Fields one + */ + public function describe(PaymentInterface $payment): ?array + { + if (!AuthorizedPaymentOperationProcessor::supports($payment)) { + return null; + } + + $authorization = AuthorizationDetails::fromDetails($payment->getDetails()); + $now = $this->clock->now(); + + return [ + 'authorized_amount' => $authorization->authorizedAmount(), + 'captured_amount' => $authorization->capturedAmount(), + 'cancelled_amount' => $authorization->cancelledAmount(), + 'remaining_amount' => $authorization->remainingAmount(), + 'max_capture_date' => $authorization->maxCaptureDate(), + 'expired' => $authorization->isExpired($now), + 'expiring_soon' => $authorization->isExpiringSoon($now), + 'can_capture' => $this->processor->canCapture($payment), + 'can_cancel' => $this->processor->canCancel($payment), + 'version' => $authorization->version(), + ]; + } +} diff --git a/src/Upc/AuthorizationDetails.php b/src/Upc/AuthorizationDetails.php new file mode 100644 index 00000000..66e8a9ce --- /dev/null +++ b/src/Upc/AuthorizationDetails.php @@ -0,0 +1,296 @@ + true (its synchronous call was accepted but its webhook later reported it did not + * go through) no longer counts toward the totals, mirroring RefundPaymentProcessor's handling of + * a failed refund. + */ +final class AuthorizationDetails +{ + public const DEFERRED = 'hosted_fields_deferred_capture'; + + public const AUTHORIZED_AMOUNT = 'hosted_fields_authorized_amount'; + + public const MAX_CAPTURE_DATE = 'hosted_fields_max_capture_date'; + + public const CAPTURES = 'hosted_fields_captures'; + + public const CANCELLATIONS = 'hosted_fields_cancellations'; + + public const OPERATION_CAPTURE = 'capture'; + + public const OPERATION_CANCELLATION = 'cancellation'; + + private const LOCK_KEY_PREFIX = 'payplug_upc_authorization_'; + + /** + * How long before maxCaptureDate the admin starts warning the merchant that the + * authorization is about to lapse. + */ + private const EXPIRY_WARNING_INTERVAL = 'PT48H'; + + /** + * @param mixed[] $details + */ + private function __construct(private array $details) + { + } + + /** + * @param mixed[] $details + */ + public static function fromDetails(array $details): self + { + return new self($details); + } + + /** + * The lock guarding read-modify-write access to this bookkeeping — shared by + * AuthorizedPaymentOperationProcessor (whose read-modify-write spans the capture/cancel + * network call) and HostedFieldsWebhookNotificationHandler (flagging one of those operations + * failed), for the same lost-update reason RefundDetailsLockKey documents for refunds. + */ + public static function lockKey(mixed $paymentId): string + { + return self::LOCK_KEY_PREFIX . ResourceIdentifier::toString($paymentId); + } + + /** + * The details a freshly created authorization-only payment starts with. $fallbackAmount (the + * Sylius payment's own amount) only stands in when the creation response carried no amount + * at all — e.g. a 3DS-pending response, whose authorization is not granted yet. + * + * @param mixed[] $details + * + * @return mixed[] + */ + public static function open(array $details, PaymentOutput $output, int $fallbackAmount): array + { + return [ + ...$details, + self::DEFERRED => true, + self::AUTHORIZED_AMOUNT => $output->remainingCapturableAmount ?? $fallbackAmount, + self::MAX_CAPTURE_DATE => $output->maxCaptureDate, + self::CAPTURES => [], + self::CANCELLATIONS => [], + ]; + } + + /** + * A 3DS authorization only learns its deadline from the webhook confirming it, not from the + * creation response — read it off that notification's own raw body when present. + * + * @param mixed[] $details + * + * @return mixed[] + */ + public static function withMaxCaptureDateFromBody(array $details, string $rawBody): array + { + $decoded = \json_decode($rawBody, true); + $maxCaptureDate = \is_array($decoded) ? ($decoded['maxCaptureDate'] ?? null) : null; + if (!\is_string($maxCaptureDate) || '' === $maxCaptureDate) { + return $details; + } + + return [...$details, self::MAX_CAPTURE_DATE => $maxCaptureDate]; + } + + /** + * @param mixed[] $details + * + * @return mixed[] + */ + public static function withOperation(array $details, string $operation, ?string $operationId, int $amount): array + { + $key = self::keyFor($operation); + $entries = \is_array($details[$key] ?? null) ? $details[$key] : []; + $entries[] = ['id' => $operationId, 'amount' => $amount]; + + return [...$details, $key => $entries]; + } + + /** + * @param mixed[] $details + * + * @return mixed[] + */ + public static function withOperationFailed(array $details, string $operation, string $operationId): array + { + $key = self::keyFor($operation); + $entries = \is_array($details[$key] ?? null) ? $details[$key] : []; + foreach ($entries as $index => $entry) { + if (\is_array($entry) && ($entry['id'] ?? null) === $operationId) { + $entries[$index] = [...$entry, 'failed' => true]; + } + } + + return [...$details, $key => $entries]; + } + + public function isDeferred(): bool + { + return true === ($this->details[self::DEFERRED] ?? false); + } + + /** + * ExecCodeMapper reads every successful execCode as PAID; for the authorization-only + * creation of a deferred payment, that success means "authorized", never "paid" — no money + * has moved yet. + */ + public function resolveCreationOutcome(string $outcome): string + { + return $this->isDeferred() && PaymentOutcome::PAID === $outcome ? PaymentOutcome::AUTHORIZED : $outcome; + } + + /** + * @return array{operation: string, amount: int}|null which of this payment's own recorded + * captures/cancellations $operationId belongs to, if any + */ + public function findOperation(string $operationId): ?array + { + foreach ([self::OPERATION_CAPTURE, self::OPERATION_CANCELLATION] as $operation) { + foreach ($this->entries($operation, includeFailed: true) as $entry) { + if ($entry['id'] === $operationId) { + return ['operation' => $operation, 'amount' => $entry['amount']]; + } + } + } + + return null; + } + + public function authorizedAmount(): int + { + $amount = $this->details[self::AUTHORIZED_AMOUNT] ?? 0; + + return \is_int($amount) ? $amount : 0; + } + + public function capturedAmount(): int + { + return self::sum($this->entries(self::OPERATION_CAPTURE)); + } + + public function cancelledAmount(): int + { + return self::sum($this->entries(self::OPERATION_CANCELLATION)); + } + + public function remainingAmount(): int + { + return \max(0, $this->authorizedAmount() - $this->capturedAmount() - $this->cancelledAmount()); + } + + public function hasCaptures(): bool + { + return [] !== $this->entries(self::OPERATION_CAPTURE); + } + + public function maxCaptureDate(): ?\DateTimeImmutable + { + $value = $this->details[self::MAX_CAPTURE_DATE] ?? null; + if (!\is_string($value) || '' === $value) { + return null; + } + + try { + return new \DateTimeImmutable($value); + } catch (\Exception) { + return null; + } + } + + /** + * Unknown deadline is never "expired": the Unified API stays the authority and refuses a + * capture on a lapsed authorization itself (AuthorizationExpiredException). + */ + public function isExpired(\DateTimeImmutable $now): bool + { + $maxCaptureDate = $this->maxCaptureDate(); + + return null !== $maxCaptureDate && $now >= $maxCaptureDate; + } + + public function isExpiringSoon(\DateTimeImmutable $now): bool + { + $maxCaptureDate = $this->maxCaptureDate(); + + return null !== $maxCaptureDate && + !$this->isExpired($now) && + $now->add(new \DateInterval(self::EXPIRY_WARNING_INTERVAL)) >= $maxCaptureDate; + } + + /** + * Changes with every capture/cancellation recorded, failed or not. Carried by the admin forms + * and checked again under the lock, so a replayed or double-submitted form — built against a + * state that has since moved on — is refused rather than triggering a second real operation. + */ + public function version(): int + { + return \count($this->entries(self::OPERATION_CAPTURE, includeFailed: true)) + + \count($this->entries(self::OPERATION_CANCELLATION, includeFailed: true)); + } + + private static function keyFor(string $operation): string + { + return match ($operation) { + self::OPERATION_CAPTURE => self::CAPTURES, + self::OPERATION_CANCELLATION => self::CANCELLATIONS, + default => throw new \InvalidArgumentException(\sprintf('Unknown authorization operation "%s".', $operation)), + }; + } + + /** + * @return list + */ + private function entries(string $operation, bool $includeFailed = false): array + { + $raw = $this->details[self::keyFor($operation)] ?? []; + if (!\is_array($raw)) { + return []; + } + + $entries = []; + foreach ($raw as $entry) { + if (!\is_array($entry) || !\is_int($entry['amount'] ?? null)) { + continue; + } + if (!$includeFailed && true === ($entry['failed'] ?? false)) { + continue; + } + + $id = $entry['id'] ?? null; + $entries[] = ['id' => \is_string($id) ? $id : null, 'amount' => $entry['amount']]; + } + + return $entries; + } + + /** + * @param list $entries + */ + private static function sum(array $entries): int + { + return \array_sum(\array_column($entries, 'amount')); + } +} diff --git a/src/Upc/AuthorizationOperatorInterface.php b/src/Upc/AuthorizationOperatorInterface.php new file mode 100644 index 00000000..6b2b0af5 --- /dev/null +++ b/src/Upc/AuthorizationOperatorInterface.php @@ -0,0 +1,49 @@ +description = $this->resolveDescription($order); + // Deferred capture: open an authorization only, captured/cancelled later from the admin + // order screen (see AuthorizedPaymentOperationProcessor). + $common->capture = !PayPlugGatewayFactory::isDeferredCaptureHostedFieldsConfig( + $paymentRequest->getPayment()->getMethod()?->getGatewayConfig(), + ); // Confirmed with PayPlug: this field has no effect on their side regardless of value for // Hosted Fields/UPC — the only working notification path is the static Cockpit-configured // Receiver at /payplug/v2/ipn (see UnifiedApiIpnAction's docblock). Set anyway to keep the diff --git a/src/Upc/PaymentCaptureOutcomeApplier.php b/src/Upc/PaymentCaptureOutcomeApplier.php index bb24c40b..c688a950 100644 --- a/src/Upc/PaymentCaptureOutcomeApplier.php +++ b/src/Upc/PaymentCaptureOutcomeApplier.php @@ -113,7 +113,10 @@ public function applyOutcome( $responseBody = \json_decode($output->body, true); $execCode = \is_array($responseBody) ? ($responseBody['execCode'] ?? null) : null; if (\is_string($execCode)) { - $this->orderStateMutator->apply(ResourceIdentifier::toString($payment->getId()), ExecCodeMapper::toPaymentOutcome($execCode)); + // An authorization-only payment's success is "authorized", not "paid" — see + // AuthorizationDetails::resolveCreationOutcome(). + $outcome = AuthorizationDetails::fromDetails($payment->getDetails())->resolveCreationOutcome(ExecCodeMapper::toPaymentOutcome($execCode)); + $this->orderStateMutator->apply(ResourceIdentifier::toString($payment->getId()), $outcome); } } } diff --git a/src/Upc/UnifiedApiAuthorizationOperator.php b/src/Upc/UnifiedApiAuthorizationOperator.php new file mode 100644 index 00000000..b0131358 --- /dev/null +++ b/src/Upc/UnifiedApiAuthorizationOperator.php @@ -0,0 +1,76 @@ +createService($method)->capturePayment( + $paymentId, + GatewayCredentialsResolver::resolve($method), + $orderId, + \sprintf('Capture #%d for order %s', $sequence, $orderId), + $amount, + null, + $currency, + ); + } + + public function cancel( + PaymentMethodInterface $method, + string $paymentId, + string $orderId, + ?int $amount, + int $sequence = 1, + ?string $currency = null, + ): CancellationOutput + { + return $this->createService($method)->cancelPayment( + $paymentId, + GatewayCredentialsResolver::resolve($method), + $orderId, + \sprintf('Cancellation #%d for order %s', $sequence, $orderId), + $amount, + null, + $currency, + ); + } + + private function createService(PaymentMethodInterface $method): UnifiedApiPaymentService + { + $configuration = $this->configurationRepository->forPaymentMethod($method); + + return new UnifiedApiPaymentService( + $this->httpClient, + $this->tokenManager, + $this->unifiedApiBaseUrl, + $configuration->getClientId(), + $configuration->getClientSecret(), + ); + } +} diff --git a/templates/admin/order/show/authorization.html.twig b/templates/admin/order/show/authorization.html.twig new file mode 100644 index 00000000..234b0b4f --- /dev/null +++ b/templates/admin/order/show/authorization.html.twig @@ -0,0 +1,110 @@ +{% import '@SyliusAdmin/shared/helper/money.html.twig' as money %} + +{# One card per deferred-capture Hosted Fields payment of the order; nothing at all for any other + payment. Which actions are offered comes from payplug_authorization() — the same rules + AuthorizedPaymentController enforces — so a form is only ever shown when it can succeed as + far as Sylius knows; the Unified API stays the final judge, and its refusal comes back as a + flash message. #} +{% set order = hookable_metadata.context.resource %} + +{% for payment in order.payments %} + {% set authorization = payplug_authorization(payment) %} + {% if authorization is not null %} + {% set currency = payment.currencyCode %} + {% set csrf_token_id = constant('PayPlug\\SyliusPayPlugPlugin\\Action\\Admin\\AuthorizedPaymentController::CSRF_TOKEN_ID_PREFIX') ~ payment.id %} + {% set csrf_token_value = sylius_csrf_protection_enabled() ? csrf_token(csrf_token_id) : null %} + {# Disables the button on the first submit, so a double click sends one request; the server + side (lock + version) is what actually guarantees it. #} + {% set single_submit = "this.querySelector('button[type=submit]').disabled = true;" %} + +
+
+
{{ 'payplug_sylius_payplug_plugin.admin.authorization.title'|trans }}
+
+
+
+
{{ 'payplug_sylius_payplug_plugin.admin.authorization.authorized_amount'|trans }}
+
{{ money.format(authorization.authorized_amount, currency) }}
+
{{ 'payplug_sylius_payplug_plugin.admin.authorization.captured_amount'|trans }}
+
{{ money.format(authorization.captured_amount, currency) }}
+ {% if authorization.cancelled_amount > 0 %} +
{{ 'payplug_sylius_payplug_plugin.admin.authorization.cancelled_amount'|trans }}
+
{{ money.format(authorization.cancelled_amount, currency) }}
+ {% endif %} +
{{ 'payplug_sylius_payplug_plugin.admin.authorization.remaining_amount'|trans }}
+
+ {{ money.format(authorization.remaining_amount, currency) }} +
+
{{ 'payplug_sylius_payplug_plugin.admin.authorization.max_capture_date'|trans }}
+
+ {% if authorization.max_capture_date is not null %} + {{ authorization.max_capture_date|date('d/m/Y H:i') }} + {% else %} + {{ 'payplug_sylius_payplug_plugin.admin.authorization.max_capture_date_unknown'|trans }} + {% endif %} +
+
+ + {% if authorization.expired %} + + {% elseif authorization.expiring_soon and authorization.can_capture %} + + {% endif %} + + {% if authorization.can_capture %} +
+ + + +
+ + {{ currency }} + +
+
{{ 'payplug_sylius_payplug_plugin.admin.authorization.capture_help'|trans }}
+
+ {% endif %} + + {% if authorization.can_cancel %} +
+ + + +
+ + {{ currency }} + +
+
{{ 'payplug_sylius_payplug_plugin.admin.authorization.cancel_help'|trans }}
+
+ {% endif %} + + {% if not authorization.can_capture and not authorization.expired %} +
+ {% if authorization.remaining_amount == 0 %} + {{ 'payplug_sylius_payplug_plugin.admin.authorization.nothing_remaining'|trans }} + {% else %} + {{ 'payplug_sylius_payplug_plugin.admin.authorization.not_authorized'|trans }} + {% endif %} +
+ {% endif %} +
+
+ {% endif %} +{% endfor %} diff --git a/templates/admin/order/show/payment_complete.html.twig b/templates/admin/order/show/payment_complete.html.twig new file mode 100644 index 00000000..174dbcd1 --- /dev/null +++ b/templates/admin/order/show/payment_complete.html.twig @@ -0,0 +1,7 @@ +{# Replaces Sylius's own "Complete" payment action only for a deferred-capture Hosted Fields + payment, whose capture is driven from the PayPlug authorization block instead (partial amounts, + remaining amount, deadline, explicit refusal messages). Every other payment keeps Sylius's + action untouched. #} +{% if payplug_authorization(hookable_metadata.context.payment) is null %} + {% include '@SyliusAdmin/order/show/content/sections/payments/item/actions/complete.html.twig' %} +{% endif %} diff --git a/tests/PHPUnit/Action/Admin/AuthorizedPaymentControllerTest.php b/tests/PHPUnit/Action/Admin/AuthorizedPaymentControllerTest.php new file mode 100644 index 00000000..8942e869 --- /dev/null +++ b/tests/PHPUnit/Action/Admin/AuthorizedPaymentControllerTest.php @@ -0,0 +1,258 @@ +operator = $this->createMock(AuthorizationOperatorInterface::class); + $this->paymentRepository = $this->createMock(PaymentRepositoryInterface::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class); + $this->authorizationChecker->method('isGranted')->with('ROLE_ADMINISTRATION_ACCESS')->willReturn(true); + + $lock = $this->createMock(ILock::class); + $lock->method('acquire')->willReturn(true); + $stateMachine = $this->createMock(StateMachineInterface::class); + $stateMachine->method('can')->willReturn(true); + + $router = $this->createMock(RouterInterface::class); + $router->method('generate')->willReturnCallback( + static fn (string $route, array $parameters = []): string => '/admin/' . $route . '/' . ($parameters['id'] ?? ''), + ); + + $csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class); + $csrfTokenManager->method('isTokenValid')->willReturnCallback( + static fn (CsrfToken $token): bool => 'payplug_authorization_42' === $token->getId() && self::VALID_TOKEN === $token->getValue(), + ); + + $this->controller = new AuthorizedPaymentController( + $this->paymentRepository, + new AuthorizedPaymentOperationProcessor( + $this->operator, + $lock, + $stateMachine, + new MockClock('2026-09-24T10:00:00+00:00'), + $this->createMock(LoggerInterface::class), + ), + $this->entityManager, + $router, + $this->authorizationChecker, + $csrfTokenManager, + $this->createMock(LoggerInterface::class), + ); + } + + /** + * @return iterable + */ + public static function amounts(): iterable + { + yield 'blank means the whole remainder' => ['', null]; + yield 'integer' => ['12', 1200]; + yield 'dot decimal' => ['12.5', 1250]; + yield 'comma decimal' => ['12,50', 1250]; + yield 'thousands space' => ['1 234,56', 123456]; + yield 'too many decimals is refused downstream' => ['12.345', 0]; + yield 'negative is refused downstream' => ['-5', 0]; + yield 'garbage is refused downstream' => ['abc', 0]; + } + + /** + * @dataProvider amounts + */ + public function testParseAmount(string $raw, ?int $expected): void + { + self::assertSame($expected, AuthorizedPaymentController::parseAmount($raw)); + } + + public function testCapture_capturesTheTypedAmountFlushesAndRedirectsToTheOrder(): void + { + $payment = $this->authorizedPayment(); + $this->paymentRepository->method('find')->with(42)->willReturn($payment); + $this->operator->expects(self::once())->method('capture') + ->with(self::anything(), 'pay_1', self::anything(), 250) + ->willReturn(new CaptureOutput(200, '{"operationIds":["op_c1"]}', null, null, null)); + $this->entityManager->expects(self::once())->method('flush'); + + $request = $this->request(['amount' => '2,50', 'version' => '0']); + $response = $this->controller->capture($request, 7, 42); + + self::assertSame('/admin/sylius_admin_order_show/7', $response->headers->get('Location')); + self::assertSame(['payplug_sylius_payplug_plugin.admin.authorization.capture_success'], $this->flashes($request, 'success')); + self::assertSame(250, AuthorizationDetails::fromDetails($payment->getDetails())->capturedAmount()); + } + + public function testCapture_refusedByUpc_showsTheReasonAndFlushesNothing(): void + { + $this->paymentRepository->method('find')->willReturn($this->authorizedPayment()); + $this->operator->method('capture')->willThrowException(new AuthorizationExpiredException('expired')); + $this->entityManager->expects(self::never())->method('flush'); + + $request = $this->request(['amount' => '']); + $this->controller->capture($request, 7, 42); + + self::assertSame(['payplug_sylius_payplug_plugin.admin.authorization.error.expired'], $this->flashes($request, 'error')); + } + + public function testCapture_aboveTheRemainder_showsTheRemainingAmount(): void + { + $this->paymentRepository->method('find')->willReturn($this->authorizedPayment()); + + $request = $this->request(['amount' => '50']); + $this->controller->capture($request, 7, 42); + + self::assertSame([[ + 'message' => 'payplug_sylius_payplug_plugin.admin.authorization.error.amount_exceeds_remaining', + 'parameters' => ['%remaining%' => '10.00 EUR'], + ]], $this->flashes($request, 'error')); + } + + public function testCapture_withAnInvalidCsrfToken_movesNoMoney(): void + { + $this->operator->expects(self::never())->method('capture'); + + $this->expectException(BadRequestHttpException::class); + + $this->controller->capture($this->request(['amount' => ''], 'forged'), 7, 42); + } + + public function testCapture_withoutAdminRole_movesNoMoney(): void + { + $authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authorizationChecker->method('isGranted')->willReturn(false); + $controller = new AuthorizedPaymentController( + $this->paymentRepository, + new AuthorizedPaymentOperationProcessor($this->operator, $this->createMock(ILock::class), $this->createMock(StateMachineInterface::class), new MockClock(), $this->createMock(LoggerInterface::class)), + $this->entityManager, + $this->createMock(RouterInterface::class), + $authorizationChecker, + null, + $this->createMock(LoggerInterface::class), + ); + $this->operator->expects(self::never())->method('capture'); + + $this->expectException(AccessDeniedHttpException::class); + + $controller->capture($this->request(['amount' => '']), 7, 42); + } + + public function testCapture_ofAPaymentFromAnotherOrder_is404(): void + { + $this->paymentRepository->method('find')->willReturn($this->authorizedPayment()); + $this->operator->expects(self::never())->method('capture'); + + $this->expectException(NotFoundHttpException::class); + + $this->controller->capture($this->request(['amount' => '']), 8, 42); + } + + /** + * @param array $body + */ + private function request(array $body, string $csrfToken = self::VALID_TOKEN): Request + { + $request = new Request([], [...$body, '_csrf_token' => $csrfToken]); + $request->setSession(new Session(new MockArraySessionStorage())); + + return $request; + } + + /** + * @return mixed[] + */ + private function flashes(Request $request, string $type): array + { + $session = $request->getSession(); + self::assertInstanceOf(Session::class, $session); + + return $session->getFlashBag()->get($type); + } + + private function authorizedPayment(): Payment + { + $gatewayConfig = new GatewayConfig(); + $gatewayConfig->setFactoryName(PayPlugGatewayFactory::FACTORY_NAME); + $gatewayConfig->setConfig([PayPlugGatewayFactory::HOSTED_FIELDS => true, PayPlugGatewayFactory::HF_IDENTIFIER => 'acct_1']); + $method = new PaymentMethod(); + $method->setGatewayConfig($gatewayConfig); + + $order = new class() extends Order { + public function getId(): int + { + return 7; + } + }; + + $payment = new class() extends Payment { + public function getId(): int + { + return 42; + } + }; + $payment->setOrder($order); + $payment->setMethod($method); + $payment->setAmount(1000); + $payment->setCurrencyCode('EUR'); + $payment->setState(PaymentInterface::STATE_AUTHORIZED); + $payment->setDetails(AuthorizationDetails::open( + ['hosted_fields_payment_id' => 'pay_1'], + new PaymentOutput(201, '{}', null, null, null, '2026-09-30T10:00:00+00:00', 1000), + 1000, + )); + + return $payment; + } +} diff --git a/tests/PHPUnit/Handler/HostedFieldsWebhookNotificationHandlerTest.php b/tests/PHPUnit/Handler/HostedFieldsWebhookNotificationHandlerTest.php index 44855b18..a112fa7b 100644 --- a/tests/PHPUnit/Handler/HostedFieldsWebhookNotificationHandlerTest.php +++ b/tests/PHPUnit/Handler/HostedFieldsWebhookNotificationHandlerTest.php @@ -7,6 +7,7 @@ use Doctrine\Persistence\ManagerRegistry; use PayPlug\SyliusPayPlugPlugin\Entity\Card; use PayPlug\SyliusPayPlugPlugin\Handler\HostedFieldsWebhookNotificationHandler; +use PayPlug\SyliusPayPlugPlugin\Upc\AuthorizationDetails; use PayPlug\SyliusPayPlugPlugin\Upc\PayplugCardPersister; use PayPlug\SyliusPayPlugPlugin\Upc\ScopedConfigurationRepositoryInterface; use PayplugUnifiedCore\Contracts\ILock; @@ -640,4 +641,61 @@ public function testTreat_onRedeliveredPaymentNotification_isNotMisclassifiedAsT $this->handler->treat($this->payment(42, 1000, null, $details), $body, ['Authorization' => 'Bearer shared-secret']); } + + public function testTreat_onADeferredCapturePaymentsOwnConfirmation_appliesAuthorizedNotPaid(): void + { + $body = \json_encode(['id' => 'op_auth', 'execCode' => '0000', 'orderId' => '42', 'amount' => 1000, 'maxCaptureDate' => '2026-10-01T12:00:00+00:00']); + $payment = $this->payment(42, 1000, null, [ + 'hosted_fields_operation_id' => 'op_auth', + AuthorizationDetails::DEFERRED => true, + AuthorizationDetails::AUTHORIZED_AMOUNT => 1000, + ]); + + $this->paymentRepository->method('isTreated')->willReturn(false); + $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::AUTHORIZED); + // A 3DS authorization only learns its capture deadline here. + $payment->expects(self::once())->method('setDetails')->with(self::callback( + static fn (array $details): bool => '2026-10-01T12:00:00+00:00' === $details[AuthorizationDetails::MAX_CAPTURE_DATE], + )); + + $this->handler->treat($payment, (string) $body, []); + } + + public function testTreat_onACaptureConfirmation_onlyTracksItWithoutTouchingThePaymentState(): void + { + $body = \json_encode(['id' => 'op_c1', 'execCode' => '0000', 'orderId' => '42', 'amount' => 300]); + $payment = $this->payment(42, 1000, null, [ + AuthorizationDetails::DEFERRED => true, + AuthorizationDetails::AUTHORIZED_AMOUNT => 1000, + AuthorizationDetails::CAPTURES => [['id' => 'op_c1', 'amount' => 300]], + ]); + + $this->paymentRepository->method('isTreated')->willReturn(false); + $this->paymentRepository->expects(self::once())->method('markTreated')->with('op_c1'); + // Its "0000" must never reach the payment as PAID: the capture's effect was already + // applied synchronously, and a partial capture leaves the payment authorized. + $this->orderStateMutator->expects(self::never())->method('apply'); + $payment->expects(self::never())->method('setDetails'); + + $this->handler->treat($payment, (string) $body, []); + } + + public function testTreat_onAFailedCaptureConfirmation_flagsTheCaptureFailedAndAlertsTheMerchant(): void + { + $body = \json_encode(['id' => 'op_c1', 'execCode' => '4001', 'orderId' => '42', 'amount' => 300]); + $payment = $this->payment(42, 1000, null, [ + AuthorizationDetails::DEFERRED => true, + AuthorizationDetails::AUTHORIZED_AMOUNT => 1000, + AuthorizationDetails::CAPTURES => [['id' => 'op_c1', 'amount' => 300]], + ]); + + $this->paymentRepository->method('isTreated')->willReturn(false); + $this->orderStateMutator->expects(self::never())->method('apply'); + $this->logger->expects(self::once())->method('critical'); + $payment->expects(self::once())->method('setDetails')->with(self::callback( + static fn (array $details): bool => true === $details[AuthorizationDetails::CAPTURES][0]['failed'], + )); + + $this->handler->treat($payment, (string) $body, []); + } } diff --git a/tests/PHPUnit/PaymentProcessing/AuthorizedPaymentOperationProcessorTest.php b/tests/PHPUnit/PaymentProcessing/AuthorizedPaymentOperationProcessorTest.php new file mode 100644 index 00000000..c5bba897 --- /dev/null +++ b/tests/PHPUnit/PaymentProcessing/AuthorizedPaymentOperationProcessorTest.php @@ -0,0 +1,364 @@ +operator = $this->createMock(AuthorizationOperatorInterface::class); + $this->lock = $this->createMock(ILock::class); + $this->lock->method('acquire')->willReturn(true); + $this->stateMachine = $this->createMock(StateMachineInterface::class); + $this->stateMachine->method('can')->willReturn(true); + $this->clock = new MockClock('2026-09-24T10:00:00+00:00'); + + $this->processor = new AuthorizedPaymentOperationProcessor( + $this->operator, + $this->lock, + $this->stateMachine, + $this->clock, + $this->createMock(LoggerInterface::class), + ); + } + + public function testCapture_partial_recordsTheOperationAndKeepsThePaymentAuthorized(): void + { + $payment = $this->authorizedPayment(1000); + + $this->operator->expects(self::once())->method('capture') + ->with(self::isInstanceOf(PaymentMethod::class), 'pay_1', '42', 300) + ->willReturn($this->captureOutput('op_c1')); + $this->stateMachine->expects(self::never())->method('apply'); + + $this->processor->capture($payment, 300, 0); + + $authorization = AuthorizationDetails::fromDetails($payment->getDetails()); + self::assertSame(300, $authorization->capturedAmount()); + self::assertSame(700, $authorization->remainingAmount()); + self::assertSame(['operation' => 'capture', 'amount' => 300], $authorization->findOperation('op_c1')); + } + + public function testCapture_ofTheRemainder_sendsTheExactRemainderAndCompletesThePayment(): void + { + $payment = $this->authorizedPayment(1000); + $payment->setDetails(AuthorizationDetails::withOperation($payment->getDetails(), AuthorizationDetails::OPERATION_CAPTURE, 'op_c1', 300)); + + // Never "no amount": the Unified API reads that as the ORIGINAL authorized amount and + // refuses it as a duplicate once part of it has been captured. + $this->operator->expects(self::once())->method('capture') + // Second operation on this authorization: numbered so Payplug doesn't see a duplicate. + ->with(self::anything(), 'pay_1', '42', 700, 'EUR', 2) + ->willReturn($this->captureOutput('op_c2')); + $this->stateMachine->expects(self::once())->method('apply') + ->with($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_COMPLETE); + + $this->processor->capture($payment, null, 1); + + self::assertSame(0, AuthorizationDetails::fromDetails($payment->getDetails())->remainingAmount()); + } + + public function testCancel_ofTheRestAfterAPartialCancellation_sendsTheExactRemainder(): void + { + $payment = $this->authorizedPayment(1000); + $payment->setDetails(AuthorizationDetails::withOperation($payment->getDetails(), AuthorizationDetails::OPERATION_CANCELLATION, 'op_v1', 400)); + + $this->operator->expects(self::once())->method('cancel') + ->with(self::anything(), 'pay_1', '42', 600) + ->willReturn(new CancellationOutput(200, '{"execCode":"0000","operationIds":["op_v2"]}', null, null)); + $this->stateMachine->expects(self::once())->method('apply') + ->with($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_CANCEL); + + $this->processor->cancel($payment, null, 1); + } + + public function testCapture_takesTheDeadlineUpcReturns(): void + { + $payment = $this->authorizedPayment(1000); + $this->operator->method('capture')->willReturn( + new CaptureOutput(200, '{"operationIds":["op_c1"]}', 300, 1000, '2026-09-30T00:00:00+00:00'), + ); + + $this->processor->capture($payment, 300); + + self::assertSame('2026-09-30T00:00:00+00:00', $payment->getDetails()[AuthorizationDetails::MAX_CAPTURE_DATE]); + } + + public function testCapture_aboveTheRemainder_isRefusedWithoutCallingUpc(): void + { + $payment = $this->authorizedPayment(1000); + $this->operator->expects(self::never())->method('capture'); + + $exception = $this->catchRefusal(fn () => $this->processor->capture($payment, 1001)); + + self::assertSame('payplug_sylius_payplug_plugin.admin.authorization.error.amount_exceeds_remaining', $exception->getTranslationKey()); + self::assertSame(['%remaining%' => '10.00 EUR'], $exception->getTranslationParameters()); + } + + public function testCapture_withAStaleVersion_isRefusedSoAReplayedFormCannotCaptureTwice(): void + { + $payment = $this->authorizedPayment(1000); + $payment->setDetails(AuthorizationDetails::withOperation($payment->getDetails(), AuthorizationDetails::OPERATION_CAPTURE, 'op_c1', 300)); + $this->operator->expects(self::never())->method('capture'); + + $exception = $this->catchRefusal(fn () => $this->processor->capture($payment, 300, 0)); + + self::assertSame('payplug_sylius_payplug_plugin.admin.authorization.error.stale', $exception->getTranslationKey()); + } + + public function testCapture_whileAnotherOperationHoldsTheLock_isRefused(): void + { + $lock = $this->createMock(ILock::class); + $lock->method('acquire')->with('payplug_upc_authorization_42', 30)->willReturn(false); + $processor = new AuthorizedPaymentOperationProcessor($this->operator, $lock, $this->stateMachine, $this->clock, $this->createMock(LoggerInterface::class)); + $this->operator->expects(self::never())->method('capture'); + + $exception = $this->catchRefusal(fn () => $processor->capture($this->authorizedPayment(1000), 300)); + + self::assertSame('payplug_sylius_payplug_plugin.admin.authorization.error.in_progress', $exception->getTranslationKey()); + } + + public function testCapture_afterTheDeadline_isRefusedWithoutCallingUpc(): void + { + $payment = $this->authorizedPayment(1000, '2026-09-24T09:00:00+00:00'); + $this->operator->expects(self::never())->method('capture'); + + $exception = $this->catchRefusal(fn () => $this->processor->capture($payment, null)); + + self::assertSame('payplug_sylius_payplug_plugin.admin.authorization.error.expired', $exception->getTranslationKey()); + self::assertFalse($this->processor->canCapture($payment)); + } + + public function testCapture_onAPaymentNotAuthorized_isRefused(): void + { + $payment = $this->authorizedPayment(1000); + $payment->setState(PaymentInterface::STATE_COMPLETED); + + $exception = $this->catchRefusal(fn () => $this->processor->capture($payment, null)); + + self::assertSame('payplug_sylius_payplug_plugin.admin.authorization.error.not_authorized', $exception->getTranslationKey()); + } + + /** + * @return iterable + */ + public static function upcRefusals(): iterable + { + yield 'expired' => [new AuthorizationExpiredException('Authorization expired'), 'expired']; + yield 'not capturable' => [new PaymentNotCapturableException('Payment is not capturable'), 'not_operable']; + // Staging, 2026-09-24: every capture after the first on this account → HTTP 200 + 4011. + yield 'second capture' => [new MultipleCaptureNotAllowedException('Duplicate request.'), 'multiple_capture_not_allowed']; + yield 'generic' => [new ApiException('HTTP 500'), 'api_error']; + } + + /** + * @dataProvider upcRefusals + */ + public function testCapture_refusedByUpc_leavesDetailsAndStateUntouched( + \Throwable $upcException, + string $reason, + ): void + { + $payment = $this->authorizedPayment(1000); + $detailsBefore = $payment->getDetails(); + $this->operator->method('capture')->willThrowException($upcException); + $this->stateMachine->expects(self::never())->method('apply'); + $this->lock->expects(self::once())->method('release')->with('payplug_upc_authorization_42'); + + $exception = $this->catchRefusal(fn () => $this->processor->capture($payment, null)); + + self::assertSame('payplug_sylius_payplug_plugin.admin.authorization.error.' . $reason, $exception->getTranslationKey()); + self::assertSame($detailsBefore, $payment->getDetails()); + self::assertSame(PaymentInterface::STATE_AUTHORIZED, $payment->getState()); + } + + public function testCancel_full_sendsNoAmountAndCancelsThePayment(): void + { + $payment = $this->authorizedPayment(1000); + + $this->operator->expects(self::once())->method('cancel') + ->with(self::anything(), 'pay_1', '42', null) + ->willReturn(new CancellationOutput(200, '{"operationIds":["op_v1"]}', 1000, 1000)); + $this->stateMachine->expects(self::once())->method('apply') + ->with($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_CANCEL); + + $this->processor->cancel($payment, null, 0); + + self::assertSame(1000, AuthorizationDetails::fromDetails($payment->getDetails())->cancelledAmount()); + } + + public function testCancel_partial_keepsTheRestCapturable(): void + { + $payment = $this->authorizedPayment(1000); + + $this->operator->expects(self::once())->method('cancel') + ->with(self::anything(), 'pay_1', '42', 400) + ->willReturn(new CancellationOutput(200, '{"operationIds":["op_v1"]}', 400, 1000)); + $this->stateMachine->expects(self::never())->method('apply'); + + $this->processor->cancel($payment, 400); + + self::assertSame(600, AuthorizationDetails::fromDetails($payment->getDetails())->remainingAmount()); + self::assertTrue($this->processor->canCapture($payment)); + } + + public function testCancel_partialNotAllowedByTheContract_isExplained(): void + { + $payment = $this->authorizedPayment(1000); + $this->operator->method('cancel')->willThrowException(new PartialCancellationNotAllowedException('not enabled')); + + $exception = $this->catchRefusal(fn () => $this->processor->cancel($payment, 400)); + + self::assertSame('payplug_sylius_payplug_plugin.admin.authorization.error.partial_cancellation_not_allowed', $exception->getTranslationKey()); + } + + public function testCancel_afterACapture_isNeitherOfferedNorSent(): void + { + $payment = $this->authorizedPayment(1000); + $payment->setDetails(AuthorizationDetails::withOperation($payment->getDetails(), AuthorizationDetails::OPERATION_CAPTURE, 'op_c1', 300)); + $this->operator->expects(self::never())->method('cancel'); + + self::assertFalse($this->processor->canCancel($payment)); + self::assertTrue($this->processor->canCapture($payment)); + + $exception = $this->catchRefusal(fn () => $this->processor->cancel($payment, null)); + self::assertSame('payplug_sylius_payplug_plugin.admin.authorization.error.cancel_after_capture', $exception->getTranslationKey()); + } + + public function testOnCompleteTransition_capturesWhatIsStillAuthorized(): void + { + $payment = $this->authorizedPayment(1000); + $this->operator->expects(self::once())->method('capture') + ->with(self::anything(), 'pay_1', '42', 1000, 'EUR') + ->willReturn($this->captureOutput('op_c1')); + // Already inside the "complete" transition: must not apply it a second time. + $this->stateMachine->expects(self::never())->method('apply'); + + $this->processor->onCompleteTransition($this->transitionEvent($payment)); + + self::assertSame(1000, AuthorizationDetails::fromDetails($payment->getDetails())->capturedAmount()); + } + + public function testOnCompleteTransition_isANoOpOnceNothingIsLeftToCapture(): void + { + $payment = $this->authorizedPayment(1000); + $payment->setDetails(AuthorizationDetails::withOperation($payment->getDetails(), AuthorizationDetails::OPERATION_CAPTURE, 'op_c1', 1000)); + $this->operator->expects(self::never())->method('capture'); + + $this->processor->onCompleteTransition($this->transitionEvent($payment)); + } + + public function testOnCompleteTransition_ignoresAnImmediateCapturePayment(): void + { + $payment = $this->authorizedPayment(1000); + $payment->setDetails(['hosted_fields_payment_id' => 'pay_1']); + $this->operator->expects(self::never())->method('capture'); + + $this->processor->onCompleteTransition($this->transitionEvent($payment)); + } + + public function testOnCompleteTransition_abortsTheTransitionWhenTheCaptureIsRefused(): void + { + $payment = $this->authorizedPayment(1000); + $this->operator->method('capture')->willThrowException(new ApiException('HTTP 500')); + + $this->expectException(AuthorizationOperationException::class); + + $this->processor->onCompleteTransition($this->transitionEvent($payment)); + } + + private function authorizedPayment(int $amount, ?string $maxCaptureDate = '2026-09-30T10:00:00+00:00'): Payment + { + $gatewayConfig = new GatewayConfig(); + $gatewayConfig->setFactoryName(PayPlugGatewayFactory::FACTORY_NAME); + $gatewayConfig->setConfig([ + PayPlugGatewayFactory::HOSTED_FIELDS => true, + PayPlugGatewayFactory::HF_IDENTIFIER => 'acct_1', + PayPlugGatewayFactory::DEFERRED_CAPTURE => true, + ]); + $method = new PaymentMethod(); + $method->setGatewayConfig($gatewayConfig); + + $payment = new class() extends Payment { + public function getId(): int + { + return 42; + } + }; + // Sylius's core Payment asserts it has an order; one without a number yet makes the + // orderId sent to UPC fall back to the payment id ("42"). + $payment->setOrder(new Order()); + $payment->setMethod($method); + $payment->setAmount($amount); + $payment->setCurrencyCode('EUR'); + $payment->setState(PaymentInterface::STATE_AUTHORIZED); + $payment->setDetails(AuthorizationDetails::open( + ['hosted_fields_payment_id' => 'pay_1'], + new PaymentOutput(201, '{}', null, null, null, $maxCaptureDate, $amount), + $amount, + )); + + return $payment; + } + + private function captureOutput(string $operationId): CaptureOutput + { + return new CaptureOutput(200, (string) \json_encode(['operationIds' => [$operationId]]), null, null, null); + } + + private function transitionEvent(PaymentInterface $payment): TransitionEvent + { + return new TransitionEvent($payment, new Marking([PaymentInterface::STATE_AUTHORIZED => 1])); + } + + private function catchRefusal(\Closure $operation): AuthorizationOperationException + { + try { + $operation(); + } catch (AuthorizationOperationException $exception) { + return $exception; + } + + self::fail('Expected an AuthorizationOperationException.'); + } +} diff --git a/tests/PHPUnit/PaymentProcessing/RefundPaymentProcessorTest.php b/tests/PHPUnit/PaymentProcessing/RefundPaymentProcessorTest.php index 1b05e29f..c567e4bc 100644 --- a/tests/PHPUnit/PaymentProcessing/RefundPaymentProcessorTest.php +++ b/tests/PHPUnit/PaymentProcessing/RefundPaymentProcessorTest.php @@ -14,6 +14,7 @@ use PayPlug\SyliusPayPlugPlugin\Gateway\WeroGatewayFactory; use PayPlug\SyliusPayPlugPlugin\PaymentProcessing\RefundPaymentProcessor; use PayPlug\SyliusPayPlugPlugin\Repository\RefundHistoryRepositoryInterface; +use PayPlug\SyliusPayPlugPlugin\Upc\AuthorizationDetails; use PayPlug\SyliusPayPlugPlugin\Upc\RefundCreatorInterface; use PayplugUnifiedCore\Contracts\ILock; use PayplugUnifiedCore\Exceptions\ApiException; @@ -333,6 +334,30 @@ static function (array $details) use ($existingRefunds): bool { $this->processor->process($payment); } + /** + * A deferred-capture payment whose authorization was partly cancelled before being captured + * only ever received the captured amount (1700 of 2400 here): that, not the payment's own + * amount, is what a full refund actually returns. + */ + public function testProcess_hostedFields_onAPartlyCancelledDeferredPayment_recordsTheCapturedAmount(): void + { + $payment = $this->buildHostedFieldsPayment([ + 'hosted_fields_payment_id' => 'pay_hf_123', + AuthorizationDetails::DEFERRED => true, + AuthorizationDetails::AUTHORIZED_AMOUNT => 2400, + AuthorizationDetails::CANCELLATIONS => [['id' => 'op_v1', 'amount' => 700]], + AuthorizationDetails::CAPTURES => [['id' => 'op_c1', 'amount' => 1700]], + ]); + $payment->expects(self::once())->method('setDetails')->with(self::callback( + static fn (array $details): bool => [['internal_id' => null, 'id' => 'op_ref_full', 'amount' => 1700]] === $details['refunds'], + )); + + $this->refundCreator->method('createRefund') + ->willReturn(['status' => 200, 'body' => json_encode(['operationIds' => ['op_ref_full']])]); + + $this->processor->process($payment); + } + /** * An earlier refund attempt flagged 'failed' => true by * HostedFieldsWebhookNotificationHandler::markMatchedRefundAsFailed() (its createRefund() call diff --git a/tests/PHPUnit/Upc/AuthorizationDetailsTest.php b/tests/PHPUnit/Upc/AuthorizationDetailsTest.php new file mode 100644 index 00000000..3f5eb26f --- /dev/null +++ b/tests/PHPUnit/Upc/AuthorizationDetailsTest.php @@ -0,0 +1,120 @@ + 'pay_1'], $output, 1000); + $authorization = AuthorizationDetails::fromDetails($details); + + self::assertSame('pay_1', $details['hosted_fields_payment_id']); + self::assertTrue($authorization->isDeferred()); + // The response's amount wins over the Sylius one: the issuer may authorize less. + self::assertSame(900, $authorization->authorizedAmount()); + self::assertSame(900, $authorization->remainingAmount()); + self::assertEquals(new \DateTimeImmutable('2026-10-01T12:00:00+00:00'), $authorization->maxCaptureDate()); + self::assertSame(0, $authorization->version()); + } + + public function testOpen_fallsBackToThePaymentAmountWhenTheResponseCarriesNone(): void + { + // A 3DS-pending response: the authorization isn't granted yet, so it carries no amount. + $output = new PaymentOutput(201, '{}', null, '
', null); + + $authorization = AuthorizationDetails::fromDetails(AuthorizationDetails::open([], $output, 1000)); + + self::assertSame(1000, $authorization->authorizedAmount()); + self::assertNull($authorization->maxCaptureDate()); + } + + public function testRemainingAmount_subtractsCapturesAndCancellationsButNotFailedOnes(): void + { + $details = $this->openedDetails(1000); + $details = AuthorizationDetails::withOperation($details, AuthorizationDetails::OPERATION_CAPTURE, 'op_c1', 300); + $details = AuthorizationDetails::withOperation($details, AuthorizationDetails::OPERATION_CAPTURE, 'op_c2', 200); + $details = AuthorizationDetails::withOperation($details, AuthorizationDetails::OPERATION_CANCELLATION, 'op_v1', 100); + $details = AuthorizationDetails::withOperationFailed($details, AuthorizationDetails::OPERATION_CAPTURE, 'op_c2'); + + $authorization = AuthorizationDetails::fromDetails($details); + + self::assertSame(300, $authorization->capturedAmount()); + self::assertSame(100, $authorization->cancelledAmount()); + self::assertSame(600, $authorization->remainingAmount()); + self::assertTrue($authorization->hasCaptures()); + // A failed operation still counts toward the version: it was attempted, and a form built + // before it is stale either way. + self::assertSame(3, $authorization->version()); + } + + public function testFindOperation_tellsCapturesAndCancellationsApart(): void + { + $details = $this->openedDetails(1000); + $details = AuthorizationDetails::withOperation($details, AuthorizationDetails::OPERATION_CAPTURE, 'op_c1', 300); + $details = AuthorizationDetails::withOperation($details, AuthorizationDetails::OPERATION_CANCELLATION, 'op_v1', 100); + + $authorization = AuthorizationDetails::fromDetails($details); + + self::assertSame(['operation' => 'capture', 'amount' => 300], $authorization->findOperation('op_c1')); + self::assertSame(['operation' => 'cancellation', 'amount' => 100], $authorization->findOperation('op_v1')); + self::assertNull($authorization->findOperation('op_unknown')); + } + + public function testResolveCreationOutcome_readsSuccessAsAuthorizedOnlyForADeferredPayment(): void + { + $deferred = AuthorizationDetails::fromDetails($this->openedDetails(1000)); + $immediate = AuthorizationDetails::fromDetails([]); + + self::assertSame(PaymentOutcome::AUTHORIZED, $deferred->resolveCreationOutcome(PaymentOutcome::PAID)); + self::assertSame(PaymentOutcome::FAILED, $deferred->resolveCreationOutcome(PaymentOutcome::FAILED)); + self::assertSame(PaymentOutcome::PAID, $immediate->resolveCreationOutcome(PaymentOutcome::PAID)); + } + + public function testExpiry_isComputedAgainstTheDeadline(): void + { + $details = [...$this->openedDetails(1000), AuthorizationDetails::MAX_CAPTURE_DATE => '2026-10-01T12:00:00+00:00']; + $authorization = AuthorizationDetails::fromDetails($details); + + self::assertFalse($authorization->isExpired(new \DateTimeImmutable('2026-09-25T12:00:00+00:00'))); + self::assertFalse($authorization->isExpiringSoon(new \DateTimeImmutable('2026-09-25T12:00:00+00:00'))); + self::assertTrue($authorization->isExpiringSoon(new \DateTimeImmutable('2026-09-30T13:00:00+00:00'))); + self::assertTrue($authorization->isExpired(new \DateTimeImmutable('2026-10-01T12:00:00+00:00'))); + self::assertFalse($authorization->isExpiringSoon(new \DateTimeImmutable('2026-10-02T00:00:00+00:00'))); + } + + public function testExpiry_isNeverAssumedWithoutAKnownDeadline(): void + { + $authorization = AuthorizationDetails::fromDetails($this->openedDetails(1000)); + + self::assertFalse($authorization->isExpired(new \DateTimeImmutable('2100-01-01'))); + self::assertFalse($authorization->isExpiringSoon(new \DateTimeImmutable('2100-01-01'))); + } + + public function testWithMaxCaptureDateFromBody_onlyOverwritesWithAPresentValue(): void + { + $details = $this->openedDetails(1000); + + $updated = AuthorizationDetails::withMaxCaptureDateFromBody($details, '{"maxCaptureDate":"2026-10-01T12:00:00+00:00"}'); + $untouched = AuthorizationDetails::withMaxCaptureDateFromBody($updated, '{"execCode":"0000"}'); + + self::assertSame('2026-10-01T12:00:00+00:00', $untouched[AuthorizationDetails::MAX_CAPTURE_DATE]); + } + + /** + * @return mixed[] + */ + private function openedDetails(int $amount): array + { + return AuthorizationDetails::open([], new PaymentOutput(201, '{}', null, null, null, null, $amount), $amount); + } +} diff --git a/tests/PHPUnit/Upc/PaymentCaptureContextBuilderTest.php b/tests/PHPUnit/Upc/PaymentCaptureContextBuilderTest.php index 533dc551..8615f31e 100644 --- a/tests/PHPUnit/Upc/PaymentCaptureContextBuilderTest.php +++ b/tests/PHPUnit/Upc/PaymentCaptureContextBuilderTest.php @@ -5,8 +5,10 @@ namespace Tests\PayPlug\SyliusPayPlugPlugin\PHPUnit\Upc; use Doctrine\Common\Collections\ArrayCollection; +use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Upc\OrderAddressDtoCreator; use PayPlug\SyliusPayPlugPlugin\Upc\PaymentCaptureContextBuilder; +use PayplugUnifiedCore\Dto\CommonFieldsDto; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Sylius\Bundle\CoreBundle\OrderPay\Provider\UrlProviderInterface; @@ -16,6 +18,8 @@ use Sylius\Component\Core\Model\OrderInterface; use Sylius\Component\Core\Model\OrderItemInterface; use Sylius\Component\Core\Model\PaymentInterface; +use Sylius\Component\Core\Model\PaymentMethod; +use Sylius\Component\Payment\Model\GatewayConfig; use Sylius\Component\Payment\Model\PaymentMethodInterface; use Sylius\Component\Payment\Model\PaymentRequestInterface; use Symfony\Component\HttpFoundation\Request; @@ -220,6 +224,39 @@ public function testBuildCommonFields_withNoOrder_fallsBackToPaymentIdAsOrderId( self::assertNull($common->shipping); } + public function testBuildCommonFields_capturesImmediatelyUnlessDeferredCaptureIsEnabledOnHostedFields(): void + { + $this->urlGenerator->method('generate')->willReturn('https://shop.test/payplug/notify/abc'); + + self::assertTrue($this->commonFieldsForGatewayConfig([PayPlugGatewayFactory::HOSTED_FIELDS => true])->capture); + self::assertFalse($this->commonFieldsForGatewayConfig([ + PayPlugGatewayFactory::HOSTED_FIELDS => true, + PayPlugGatewayFactory::DEFERRED_CAPTURE => true, + ])->capture); + } + + /** + * @param array $config + */ + private function commonFieldsForGatewayConfig(array $config): CommonFieldsDto + { + $gatewayConfig = new GatewayConfig(); + $gatewayConfig->setFactoryName(PayPlugGatewayFactory::FACTORY_NAME); + $gatewayConfig->setConfig($config); + $method = new PaymentMethod(); + $method->setGatewayConfig($gatewayConfig); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getId')->willReturn(42); + $payment->method('getMethod')->willReturn($method); + + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $paymentRequest->method('getPayment')->willReturn($payment); + $paymentRequest->method('getHash')->willReturn(Uuid::v4()); + + return $this->builder->buildCommonFields('acct_123', 1000, 'eur', $paymentRequest, null); + } + public function testBuildCommonFields_withAnOrderItem_usesItsProductNameAsDescription(): void { $this->urlGenerator->method('generate')->willReturn('https://shop.test/payplug/notify/abc'); diff --git a/tests/PHPUnit/Upc/PaymentCaptureOutcomeApplierTest.php b/tests/PHPUnit/Upc/PaymentCaptureOutcomeApplierTest.php index 801d685b..804e1f5e 100644 --- a/tests/PHPUnit/Upc/PaymentCaptureOutcomeApplierTest.php +++ b/tests/PHPUnit/Upc/PaymentCaptureOutcomeApplierTest.php @@ -5,6 +5,7 @@ namespace Tests\PayPlug\SyliusPayPlugPlugin\PHPUnit\Upc; use PayPlug\SyliusPayPlugPlugin\Command\PaymentCaptureFlow; +use PayPlug\SyliusPayPlugPlugin\Upc\AuthorizationDetails; use PayPlug\SyliusPayPlugPlugin\Upc\PaymentCaptureOutcomeApplier; use PayplugUnifiedCore\Contracts\IOrderStateMutator; use PayplugUnifiedCore\DataValues\PaymentOutcome; @@ -181,6 +182,18 @@ public function testApplyOutcome_withDirectSuccessExecCode_appliesPaidOutcomeToO $this->applier->applyOutcome($paymentRequest, $payment, $output); } + public function testApplyOutcome_withDirectSuccessOnADeferredCapturePayment_appliesAuthorizedNotPaid(): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getId')->willReturn(42); + $payment->method('getDetails')->willReturn([AuthorizationDetails::DEFERRED => true]); + $output = new PaymentOutput(201, '{"id":"pay_1","execCode":"0000"}', null, null, null); + + $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::AUTHORIZED); + + $this->applier->applyOutcome($this->createMock(PaymentRequestInterface::class), $payment, $output); + } + public function testApplyOutcome_withNoExecCodeInResponseBody_neverAppliesOrderStateMutator(): void { $payment = $this->createMock(PaymentInterface::class); diff --git a/tests/PHPUnit/Upc/UnifiedApiAuthorizationOperatorTest.php b/tests/PHPUnit/Upc/UnifiedApiAuthorizationOperatorTest.php new file mode 100644 index 00000000..d919e3ae --- /dev/null +++ b/tests/PHPUnit/Upc/UnifiedApiAuthorizationOperatorTest.php @@ -0,0 +1,155 @@ + 'Bearer cached-jwt', 'Content-Type' => 'application/json']; + + private IUnifiedApiHttpClient&MockObject $unifiedApiHttpClient; + + private ScopedConfigurationRepositoryInterface&MockObject $configurationRepository; + + private UnifiedApiAuthorizationOperator $operator; + + protected function setUp(): void + { + $this->unifiedApiHttpClient = $this->createMock(IUnifiedApiHttpClient::class); + $tokenCache = $this->createMock(ITokenCache::class); + $tokenCache->method('get')->willReturn('cached-jwt'); + + $scoped = $this->createMock(ScopedConfigurationRepositoryInterface::class); + $scoped->method('getClientId')->willReturn('client_abc'); + $scoped->method('getClientSecret')->willReturn('secret_xyz'); + $this->configurationRepository = $this->createMock(ScopedConfigurationRepositoryInterface::class); + $this->configurationRepository->method('forPaymentMethod')->willReturn($scoped); + + $oauth2Client = new OAuth2Client($this->createMock(IOAuthHttpClient::class), 'https://api.payplug.com', '', '', 'https://www.payplug.com'); + + $this->operator = new UnifiedApiAuthorizationOperator( + $this->unifiedApiHttpClient, + new TokenManager($tokenCache, $oauth2Client), + $this->configurationRepository, + 'https://api.payplug.com', + ); + } + + public function testCapture_partial_postsTheAmountToTheCaptureEndpointOfTheMethodsOwnAccount(): void + { + $method = $this->hostedFieldsPaymentMethod('acct_123'); + $this->configurationRepository->expects(self::once())->method('forPaymentMethod')->with($method); + + $this->unifiedApiHttpClient->expects(self::once()) + ->method('postJson') + ->with( + 'https://api.payplug.com/api/payment-gateway/payments/pay_123/capture', + [ + 'account' => ['id' => 'acct_123'], + 'orderId' => 'order_1', + 'description' => 'Capture #1 for order order_1', + 'amount' => 300, + 'currency' => 'USD', + ], + self::AUTH_HEADERS, + ) + ->willReturn(['status' => 200, 'body' => '{"amount":300,"requestedAmount":1000,"operationIds":["op_c1"]}']); + + $output = $this->operator->capture($method, 'pay_123', 'order_1', 300, 'USD'); + + self::assertSame(700, $output->remainingCapturableAmount); + } + + public function testCancel_full_postsAVoidWithoutAmount(): void + { + $this->unifiedApiHttpClient->expects(self::once()) + ->method('postJson') + ->with( + 'https://api.payplug.com/api/payment-gateway/payments/pay_123/void', + [ + 'account' => ['id' => 'acct_123'], + 'orderId' => 'order_1', + 'description' => 'Cancellation #1 for order order_1', + ], + self::AUTH_HEADERS, + ) + ->willReturn(['status' => 200, 'body' => '{"operationIds":["op_v1"]}']); + + $this->operator->cancel($this->hostedFieldsPaymentMethod('acct_123'), 'pay_123', 'order_1', null); + } + + public function testCancel_partialRefusedByTheContract_surfacesUpcsOwnException(): void + { + // The real staging answer (2026-09-24) to a partial void on an account without the option. + $this->unifiedApiHttpClient->method('postJson')->willReturn([ + 'status' => 403, + 'body' => '{"status":403,"error":"Forbidden","errorCategory":"INVALID_REQUEST","message":"The operation is not allowed."}', + ]); + + $this->expectException(PartialCancellationNotAllowedException::class); + + $this->operator->cancel($this->hostedFieldsPaymentMethod('acct_123'), 'pay_123', 'order_1', 400, 1, 'USD'); + } + + /** + * Staging, 2026-09-24: a second capture on the same authorization comes back as HTTP 200 with + * a failure execCode. UPC must not let that through as a successful CaptureOutput. + */ + public function testCapture_secondCaptureRefusedWithA200_surfacesMultipleCaptureNotAllowed(): void + { + $this->unifiedApiHttpClient->method('postJson')->willReturn([ + 'status' => 200, + 'body' => '{"descriptor":"Plug","execCode":"4011","message":"Duplicate request.","operationIds":["op_dup"]}', + ]); + + $this->expectException(MultipleCaptureNotAllowedException::class); + + $this->operator->capture($this->hostedFieldsPaymentMethod('acct_123'), 'pay_123', 'order_1', 6201, 'USD', 2); + } + + public function testCancel_partial_sendsTheCurrencyUpcRequiresAlongsideAnAmount(): void + { + $this->unifiedApiHttpClient->expects(self::once()) + ->method('postJson') + ->with(self::anything(), self::callback( + static fn (array $body): bool => 1000 === $body['amount'] && 'USD' === $body['currency'], + )) + ->willReturn(['status' => 200, 'body' => '{"execCode":"0000","operationIds":["op_v1"]}']); + + $this->operator->cancel($this->hostedFieldsPaymentMethod('acct_123'), 'pay_123', 'order_1', 1000, 1, 'USD'); + } + + private function hostedFieldsPaymentMethod(string $accountId): PaymentMethodInterface&MockObject + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getConfig')->willReturn([ + PayPlugGatewayFactory::HOSTED_FIELDS => true, + PayPlugGatewayFactory::HF_IDENTIFIER => $accountId, + ]); + + $method = $this->createMock(PaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn($gatewayConfig); + + return $method; + } +} diff --git a/translations/flashes.en.yml b/translations/flashes.en.yml index 7c66cfac..511b2ccd 100644 --- a/translations/flashes.en.yml +++ b/translations/flashes.en.yml @@ -12,3 +12,25 @@ payplug_sylius_payplug_plugin: oauth_setup_error: An error occurred while setting up the OAuth connection. Please try again. logout_success: The PayPlug account has been disconnected and this payment method has been disabled. logout_error: An error occurred while disconnecting the PayPlug account. Please try again. + authorization: + capture_success: The capture has been performed. + cancel_success: The cancellation has been performed. + error: + not_authorized: This payment is not (or no longer) an authorization awaiting capture. Reload the page to see its current state. + stale: The order changed since this page was displayed (operation already performed?). Reload the page and check the remaining amount before trying again. + in_progress: Another operation is already in progress on this payment. Wait a few seconds, then reload the page. + expired: The authorization has expired and the funds have been released. No capture is possible any more; ask the customer for a new payment if needed. + cancel_after_capture: This payment has already been partly captured, so it can no longer be cancelled. Use a refund to return all or part of the captured amount. + nothing_remaining: Nothing is left to capture or cancel on this authorization. + invalid_amount: "Invalid amount. Enter a positive amount, e.g. 12.50, or leave the field blank for the remaining amount." + amount_exceeds_remaining: "The amount exceeds what is still available (%remaining%). Enter a lower or equal amount." + amount_refused: PayPlug refused this amount (above the available amount, or invalid). Reload the page and check the remaining amount. + already_captured: PayPlug reports this payment as already captured. Reload the page; if the displayed state does not match, contact PayPlug support. + already_cancelled: PayPlug reports this payment as already cancelled. Reload the page; if the displayed state does not match, contact PayPlug support. + not_operable: PayPlug reports this payment is no longer in a state that allows this operation (already captured or cancelled). Reload the page; if the displayed state does not match, contact PayPlug support. + partial_cancellation_not_allowed: Partial cancellation is not enabled on your PayPlug contract. Cancel the whole amount (blank amount field) or contact PayPlug to enable the option. + conflict: Another operation is being processed by PayPlug for this payment. Try again in a moment. + refused_by_issuer: The customer's bank refused the operation. The payment was not changed. + multiple_capture_not_allowed: Only one capture is possible on this authorization with your PayPlug account, and it has already been performed. The remaining amount cannot be captured. For future orders, capture the final amount in one go, or contact PayPlug to enable multiple captures. + payment_not_found: This payment could not be found at PayPlug. Check that the payment method is connected to the right PayPlug account. + api_error: The operation could not be performed because of a PayPlug error. The payment was not changed; try again in a moment. diff --git a/translations/flashes.fr.yml b/translations/flashes.fr.yml index f844ee18..8d083f3e 100644 --- a/translations/flashes.fr.yml +++ b/translations/flashes.fr.yml @@ -12,3 +12,25 @@ payplug_sylius_payplug_plugin: oauth_setup_error: Une erreur s'est produite lors de la configuration de la connexion OAuth. Veuillez réessayer. logout_success: Le compte PayPlug a été déconnecté et ce moyen de paiement a été désactivé. logout_error: Une erreur s’est produite lors de la déconnexion du compte PayPlug. Veuillez réessayer. + authorization: + capture_success: La capture a été effectuée. + cancel_success: L'annulation a été effectuée. + error: + not_authorized: Ce paiement n'est pas (ou plus) une autorisation en attente de capture. Rechargez la page pour voir son état actuel. + stale: La commande a changé depuis l'affichage de la page (opération déjà effectuée ?). Rechargez la page et vérifiez le montant restant avant de réessayer. + in_progress: Une opération est déjà en cours sur ce paiement. Patientez quelques secondes puis rechargez la page. + expired: L'autorisation a expiré, les fonds ont été libérés. Aucune capture n'est plus possible ; demandez un nouveau paiement au client si nécessaire. + cancel_after_capture: Une capture a déjà été effectuée sur ce paiement, il ne peut plus être annulé. Utilisez le remboursement pour rendre tout ou partie du montant capturé. + nothing_remaining: Il ne reste aucun montant à capturer ou annuler sur cette autorisation. + invalid_amount: "Montant invalide. Saisissez un montant positif, par exemple 12,50, ou laissez le champ vide pour le montant restant." + amount_exceeds_remaining: "Le montant dépasse le restant disponible (%remaining%). Saisissez un montant inférieur ou égal." + amount_refused: PayPlug a refusé ce montant (supérieur au montant disponible ou invalide). Rechargez la page et vérifiez le montant restant. + already_captured: PayPlug indique que ce paiement a déjà été capturé. Rechargez la page ; si l'état affiché ne correspond pas, contactez le support PayPlug. + already_cancelled: PayPlug indique que ce paiement a déjà été annulé. Rechargez la page ; si l'état affiché ne correspond pas, contactez le support PayPlug. + not_operable: PayPlug indique que ce paiement n'est plus dans un état permettant cette opération (déjà capturé ou annulé). Rechargez la page ; si l'état affiché ne correspond pas, contactez le support PayPlug. + partial_cancellation_not_allowed: L'annulation partielle n'est pas activée sur votre contrat PayPlug. Annulez la totalité (champ montant vide) ou contactez PayPlug pour activer l'option. + conflict: Une autre opération est en cours de traitement chez PayPlug pour ce paiement. Réessayez dans quelques instants. + refused_by_issuer: La banque du client a refusé l'opération. Le paiement n'a pas été modifié. + multiple_capture_not_allowed: Une seule capture est possible sur cette autorisation avec votre compte PayPlug ; la capture précédente a déjà été effectuée. Le montant restant ne peut pas être capturé. Pour les prochaines commandes, capturez le montant final en une seule fois, ou contactez PayPlug pour activer la capture multiple. + payment_not_found: Ce paiement est introuvable chez PayPlug. Vérifiez que le moyen de paiement est connecté au bon compte PayPlug. + api_error: L'opération n'a pas pu être effectuée à cause d'une erreur PayPlug. Le paiement n'a pas été modifié ; réessayez dans quelques instants. diff --git a/translations/flashes.it.yml b/translations/flashes.it.yml index 0a03e5b9..4de68187 100644 --- a/translations/flashes.it.yml +++ b/translations/flashes.it.yml @@ -12,3 +12,25 @@ payplug_sylius_payplug_plugin: oauth_setup_error: Si è verificato un errore durante la configurazione della connessione OAuth. Per favore riprova. logout_success: L’account PayPlug è stato disconnesso e questo metodo di pagamento è stato disattivato. logout_error: Si è verificato un errore durante la disconnessione dell’account PayPlug. Per favore riprova. + authorization: + capture_success: L'incasso è stato effettuato. + cancel_success: L'annullamento è stato effettuato. + error: + not_authorized: Questo pagamento non è (o non è più) un'autorizzazione in attesa di incasso. Ricarica la pagina per vederne lo stato attuale. + stale: L'ordine è cambiato dalla visualizzazione della pagina (operazione già eseguita?). Ricarica la pagina e verifica l'importo residuo prima di riprovare. + in_progress: Un'altra operazione è già in corso su questo pagamento. Attendi qualche secondo, poi ricarica la pagina. + expired: L'autorizzazione è scaduta e i fondi sono stati rilasciati. Non è più possibile alcun incasso; se necessario, chiedi al cliente un nuovo pagamento. + cancel_after_capture: Su questo pagamento è già stato effettuato un incasso, quindi non può più essere annullato. Usa un rimborso per restituire tutto o parte dell'importo incassato. + nothing_remaining: Non resta alcun importo da incassare o annullare su questa autorizzazione. + invalid_amount: "Importo non valido. Inserisci un importo positivo, ad es. 12,50, oppure lascia il campo vuoto per l'importo residuo." + amount_exceeds_remaining: "L'importo supera quello ancora disponibile (%remaining%). Inserisci un importo inferiore o uguale." + amount_refused: PayPlug ha rifiutato questo importo (superiore a quello disponibile o non valido). Ricarica la pagina e verifica l'importo residuo. + already_captured: PayPlug indica che questo pagamento è già stato incassato. Ricarica la pagina; se lo stato mostrato non corrisponde, contatta il supporto PayPlug. + already_cancelled: PayPlug indica che questo pagamento è già stato annullato. Ricarica la pagina; se lo stato mostrato non corrisponde, contatta il supporto PayPlug. + not_operable: PayPlug indica che questo pagamento non è più in uno stato che consente questa operazione (già incassato o annullato). Ricarica la pagina; se lo stato mostrato non corrisponde, contatta il supporto PayPlug. + partial_cancellation_not_allowed: L'annullamento parziale non è attivo sul tuo contratto PayPlug. Annulla l'intero importo (campo importo vuoto) o contatta PayPlug per attivare l'opzione. + conflict: Un'altra operazione è in corso di elaborazione presso PayPlug per questo pagamento. Riprova tra qualche istante. + refused_by_issuer: La banca del cliente ha rifiutato l'operazione. Il pagamento non è stato modificato. + multiple_capture_not_allowed: Con il tuo account PayPlug è possibile un solo incasso su questa autorizzazione, ed è già stato effettuato. L'importo residuo non può essere incassato. Per i prossimi ordini, incassa l'importo finale in una sola volta, oppure contatta PayPlug per attivare l'incasso multiplo. + payment_not_found: Questo pagamento non è stato trovato presso PayPlug. Verifica che il metodo di pagamento sia collegato all'account PayPlug corretto. + api_error: L'operazione non è stata eseguita a causa di un errore PayPlug. Il pagamento non è stato modificato; riprova tra qualche istante. diff --git a/translations/messages.en.yml b/translations/messages.en.yml index 7666ec43..b252167c 100644 --- a/translations/messages.en.yml +++ b/translations/messages.en.yml @@ -5,6 +5,25 @@ payplug_sylius_payplug_plugin: oney_x4_with_fees_payment: Oney 4x oney_x3_without_fees_payment: Oney 3x Without Fees oney_x4_without_fees_payment: Oney 4x Without Fees + authorization: + title: PayPlug — Deferred capture + authorized_amount: Authorized amount + captured_amount: Already captured + cancelled_amount: Cancelled + remaining_amount: Remaining capturable + max_capture_date: Capture deadline + max_capture_date_unknown: Not provided + expired: The authorization has expired and the funds have been released. No capture is possible any more. + expiring_soon: The authorization expires soon. Capture the amount you need before the deadline, or the funds will be released. + capture_amount: Amount to capture + capture: Capture + capture_help: Leave blank to capture the whole remaining amount. You may capture several times as long as the total stays within the authorized amount. + cancel_amount: Amount to cancel + cancel: Cancel authorization + cancel_help: Leave blank to cancel everything. Partial cancellation must be enabled on your PayPlug contract. Not possible after a first capture. + cancel_confirm: Confirm the cancellation? The cancelled funds will be released and can no longer be captured. + nothing_remaining: Nothing left to capture on this authorization. + not_authorized: No action available in the payment's current state. ui: account: saved_cards: diff --git a/translations/messages.fr.yml b/translations/messages.fr.yml index 0f04518b..daea076a 100644 --- a/translations/messages.fr.yml +++ b/translations/messages.fr.yml @@ -5,6 +5,25 @@ payplug_sylius_payplug_plugin: oney_x4_with_fees_payment: Oney 4x oney_x3_without_fees_payment: Oney 3x Sans Frais oney_x4_without_fees_payment: Oney 4x Sans Frais + authorization: + title: PayPlug — Capture différée + authorized_amount: Montant autorisé + captured_amount: Déjà capturé + cancelled_amount: Annulé + remaining_amount: Restant capturable + max_capture_date: Date limite de capture + max_capture_date_unknown: Non communiquée + expired: L'autorisation a expiré, les fonds ont été libérés. Aucune capture n'est plus possible. + expiring_soon: L'autorisation expire bientôt. Capturez le montant souhaité avant la date limite, sinon les fonds seront libérés. + capture_amount: Montant à capturer + capture: Capturer + capture_help: Laissez vide pour capturer tout le restant. Vous pouvez capturer en plusieurs fois tant que le cumul reste dans le montant autorisé. + cancel_amount: Montant à annuler + cancel: Annuler l'autorisation + cancel_help: Laissez vide pour tout annuler. L'annulation partielle doit être activée sur votre contrat PayPlug. Impossible après une première capture. + cancel_confirm: Confirmer l'annulation ? Les fonds annulés seront libérés et ne pourront plus être capturés. + nothing_remaining: Plus rien à capturer sur cette autorisation. + not_authorized: Aucune action disponible dans l'état actuel du paiement. ui: account: saved_cards: diff --git a/translations/messages.it.yml b/translations/messages.it.yml index cc088347..eb539173 100644 --- a/translations/messages.it.yml +++ b/translations/messages.it.yml @@ -5,6 +5,25 @@ payplug_sylius_payplug_plugin: oney_x4_with_fees_payment: Oney 4x oney_x3_without_fees_payment: Oney 3x Nessuna Commissione oney_x4_without_fees_payment: Oney 4x Nessuna Commissione + authorization: + title: PayPlug — Incasso differito + authorized_amount: Importo autorizzato + captured_amount: Già incassato + cancelled_amount: Annullato + remaining_amount: Residuo incassabile + max_capture_date: Scadenza per l'incasso + max_capture_date_unknown: Non comunicata + expired: L'autorizzazione è scaduta e i fondi sono stati rilasciati. Non è più possibile alcun incasso. + expiring_soon: L'autorizzazione scade a breve. Incassa l'importo desiderato prima della scadenza, altrimenti i fondi verranno rilasciati. + capture_amount: Importo da incassare + capture: Incassa + capture_help: Lascia vuoto per incassare tutto il residuo. Puoi incassare più volte finché il totale resta entro l'importo autorizzato. + cancel_amount: Importo da annullare + cancel: Annulla autorizzazione + cancel_help: Lascia vuoto per annullare tutto. L'annullamento parziale deve essere attivo sul tuo contratto PayPlug. Non possibile dopo un primo incasso. + cancel_confirm: Confermare l'annullamento? I fondi annullati verranno rilasciati e non potranno più essere incassati. + nothing_remaining: Non resta nulla da incassare su questa autorizzazione. + not_authorized: Nessuna azione disponibile nello stato attuale del pagamento. ui: account: saved_cards: From 10ad25b60d3484588584356fb67a296e1cbb91af Mon Sep 17 00:00:00 2001 From: adumont-payplug Date: Tue, 29 Sep 2026 16:14:27 +0200 Subject: [PATCH 2/3] PRE-3673: Address review on capture/cancel from admin - Flush recorded operation before releasing the lock so a second request cannot capture the same money twice - Warn merchant that money may have moved after an unexpected error instead of saying nothing changed - Require version on capture and cancel requests, a missing value now returns a 400 - Cap amount at 9 integer digits so an overflow is refused as invalid, not logged as critical - Convert amounts with UPC AmountHelper - Align cancel parameters order with capture - Log only status and execCode when no operationIds - Document lock TTL and why Payment amount is untouched --- doc/authorized_payment.md | 17 +++- .../Admin/AuthorizedPaymentController.php | 49 +++++++---- .../AuthorizedPaymentOperationProcessor.php | 58 +++++++++--- src/Twig/AuthorizationExtension.php | 4 + src/Upc/AuthorizationOperatorInterface.php | 2 +- src/Upc/UnifiedApiAuthorizationOperator.php | 8 +- .../admin/order/show/authorization.html.twig | 4 +- .../Admin/AuthorizedPaymentControllerTest.php | 58 +++++++++++- ...uthorizedPaymentOperationProcessorTest.php | 88 ++++++++++++++++++- .../Twig/AuthorizationExtensionTest.php | 74 ++++++++++++++++ .../UnifiedApiAuthorizationOperatorTest.php | 4 +- translations/flashes.en.yml | 1 + translations/flashes.fr.yml | 1 + translations/flashes.it.yml | 1 + 14 files changed, 325 insertions(+), 44 deletions(-) create mode 100644 tests/PHPUnit/Twig/AuthorizationExtensionTest.php diff --git a/doc/authorized_payment.md b/doc/authorized_payment.md index 5c0a7e3b..ba47e8af 100644 --- a/doc/authorized_payment.md +++ b/doc/authorized_payment.md @@ -219,7 +219,22 @@ admin shows an explicit message, e.g.: - another operation is already in progress on this payment. A form submitted twice (double click, browser back + resubmit) is refused as *stale*: each form -carries the state it was built against, and operations on a same payment are serialized. +carries the state it was built against, and operations on a same payment are serialized. The +operation is saved before that serialization lock is released, so a second request can never see +the state from before it. + +If PayPlug accepted the operation but Sylius failed to save it (database error), the admin says the +operation **may have been performed** and asks to check the payment in the PayPlug portal before +retrying, and a `critical` entry is written to the log. Retrying blindly would capture twice. + +### Partial operations and Sylius refunds + +Sylius has no notion of a partial capture or a partial cancellation, so the plugin does not try to +bend it: `Payment::amount` stays at the authorized amount, and Sylius's order payment state and +RefundPlugin's refundable total are left untouched. What was really captured is tracked in the +payment details; refunds sent to PayPlug are computed from it. After a partial cancellation followed +by a capture of the rest, the order therefore reads as paid for the authorized amount, while the +captured amount is the smaller one shown in the block on the order screen. ### Notifications and operations performed outside Sylius diff --git a/src/Action/Admin/AuthorizedPaymentController.php b/src/Action/Admin/AuthorizedPaymentController.php index a101d9d7..09509d38 100644 --- a/src/Action/Admin/AuthorizedPaymentController.php +++ b/src/Action/Admin/AuthorizedPaymentController.php @@ -4,10 +4,10 @@ namespace PayPlug\SyliusPayPlugPlugin\Action\Admin; -use Doctrine\ORM\EntityManagerInterface; use PayPlug\SyliusPayPlugPlugin\Exception\Payment\AuthorizationOperationException; use PayPlug\SyliusPayPlugPlugin\PaymentProcessing\AuthorizedPaymentOperationProcessor; use PayPlug\SyliusPayPlugPlugin\Repository\PaymentRepositoryInterface; +use PayplugUnifiedCore\Utilities\Helpers\AmountHelper; use Psr\Log\LoggerInterface; use Sylius\Component\Core\Model\PaymentInterface; use Symfony\Component\HttpFoundation\RedirectResponse; @@ -55,7 +55,6 @@ final class AuthorizedPaymentController public function __construct( private PaymentRepositoryInterface $paymentRepository, private AuthorizedPaymentOperationProcessor $processor, - private EntityManagerInterface $entityManager, private RouterInterface $router, private AuthorizationCheckerInterface $authorizationChecker, private ?CsrfTokenManagerInterface $csrfTokenManager, @@ -65,14 +64,14 @@ public function __construct( public function capture(Request $request, int $orderId, int $paymentId): Response { - return $this->handle($request, $orderId, $paymentId, 'capture', function (PaymentInterface $payment, ?int $amount, ?int $version): void { + return $this->handle($request, $orderId, $paymentId, 'capture', function (PaymentInterface $payment, ?int $amount, int $version): void { $this->processor->capture($payment, $amount, $version); }); } public function cancel(Request $request, int $orderId, int $paymentId): Response { - return $this->handle($request, $orderId, $paymentId, 'cancel', function (PaymentInterface $payment, ?int $amount, ?int $version): void { + return $this->handle($request, $orderId, $paymentId, 'cancel', function (PaymentInterface $payment, ?int $amount, int $version): void { $this->processor->cancel($payment, $amount, $version); }); } @@ -83,30 +82,34 @@ private function handle( int $paymentId, string $action, \Closure $operation, - ): Response - { + ): Response { if (!$this->authorizationChecker->isGranted(self::ADMIN_ROLE)) { throw new AccessDeniedHttpException('Only an administrator may capture or cancel a payment.'); } $this->denyUnlessCsrfTokenIsValid($request, $paymentId); $payment = $this->findOrderPayment($orderId, $paymentId); + // Outside the try below: a malformed request is a 400, not something to swallow as an + // unexpected error. + $version = self::parseVersion($request); try { - $operation($payment, self::parseAmount($request->request->getString('amount')), self::parseVersion($request)); - $this->entityManager->flush(); + // The processor flushes while it still holds its lock, see there. + $operation($payment, self::parseAmount($request->request->getString('amount')), $version); $this->addFlashMessage($request, 'success', self::FLASH_PREFIX . $action . '_success'); } catch (AuthorizationOperationException $exception) { $this->addFlashMessage($request, 'error', $exception->getTranslationKey(), $exception->getTranslationParameters()); } catch (\Throwable $exception) { - // Reached only after the Unified API accepted the operation (e.g. the flush failed): - // the money moved, but Sylius may not reflect it — worth a human's attention. + // Anything but a refusal: typically the flush failing after the Unified API accepted the + // operation. The money may have moved without Sylius recording it, so the merchant is + // told to check at PayPlug before retrying (a retry would capture a second time) and + // a human is alerted. $this->logger->critical('[PayPlug][UPC] Unexpected error during an authorization operation.', [ 'sylius_payment_id' => $paymentId, 'action' => $action, 'exception' => $exception, ]); - $this->addFlashMessage($request, 'error', self::FLASH_PREFIX . 'error.api_error'); + $this->addFlashMessage($request, 'error', self::FLASH_PREFIX . 'error.unexpected_error'); } return new RedirectResponse($this->router->generate('sylius_admin_order_show', ['id' => $orderId])); @@ -114,8 +117,10 @@ private function handle( /** * Blank means "the whole remaining amount". Accepts a decimal comma as well as a dot, since - * that is what a French-speaking merchant types; anything else malformed is sent on as an - * invalid amount (0) for the processor to refuse with its own explicit message. + * that is what a French-speaking merchant types; anything else malformed — or longer than nine + * digits before the decimal point, which no real capture reaches and which would overflow — + * is sent on as an invalid amount (0) for the processor to refuse with its own explicit + * message. */ public static function parseAmount(string $raw): ?int { @@ -124,18 +129,26 @@ public static function parseAmount(string $raw): ?int return null; } - if (1 !== \preg_match('/^(\d+)(?:\.(\d{1,2}))?$/', $raw, $matches)) { + if (1 !== \preg_match('/^\d{1,9}(?:\.\d{1,2})?$/', $raw)) { return 0; } - return (int) $matches[1] * 100 + (int) \str_pad($matches[2] ?? '0', 2, '0'); + return AmountHelper::toCents((float) $raw); } - private static function parseVersion(Request $request): ?int + /** + * The AuthorizationDetails::version() the form was built against. Mandatory here: it is the + * replay guard, and a hand-crafted POST must not be able to skip it. + */ + private static function parseVersion(Request $request): int { - $version = $request->request->get('version'); + $version = $request->request->getString('version'); + + if (1 !== \preg_match('/^\d{1,9}$/', $version)) { + throw new BadRequestHttpException('Missing or invalid version for the PayPlug authorization request.'); + } - return \is_numeric($version) ? (int) $version : null; + return (int) $version; } /** diff --git a/src/PaymentProcessing/AuthorizedPaymentOperationProcessor.php b/src/PaymentProcessing/AuthorizedPaymentOperationProcessor.php index b1c90286..aec3852b 100644 --- a/src/PaymentProcessing/AuthorizedPaymentOperationProcessor.php +++ b/src/PaymentProcessing/AuthorizedPaymentOperationProcessor.php @@ -4,6 +4,7 @@ namespace PayPlug\SyliusPayPlugPlugin\PaymentProcessing; +use Doctrine\ORM\EntityManagerInterface; use PayPlug\SyliusPayPlugPlugin\Exception\Payment\AuthorizationOperationException; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Upc\AuthorizationDetails; @@ -24,6 +25,7 @@ use PayplugUnifiedCore\Exceptions\PaymentNotFoundException; use PayplugUnifiedCore\Exceptions\PaymentNotVoidableException; use PayplugUnifiedCore\Exceptions\PayplugException; +use PayplugUnifiedCore\Utilities\Helpers\AmountHelper; use Psr\Clock\ClockInterface; use Psr\Log\LoggerInterface; use Sylius\Abstraction\StateMachine\StateMachineInterface; @@ -51,10 +53,25 @@ * Guarded by $lock per payment plus AuthorizationDetails::version(): UPC's capture/cancel calls * take no idempotency key, so a double click or a replayed form would otherwise move real money * twice. The lock serializes concurrent submissions; the version, checked once the lock is held, - * rejects a submission built against a state that has since moved on. + * rejects a submission built against a state that has since moved on. The recorded operation is + * flushed while the lock is still held: released any earlier, a second request could load the + * not-yet-persisted details, pass the version check and capture the same money again. + * + * Sylius has no notion of a partial capture or a partial void, and this deliberately does not + * pretend otherwise: Payment::amount is left at the authorized amount, so RefundPlugin's + * refundable total is not bent to fit an operation it wasn't designed for. What was actually + * captured is tracked in the payment details (AuthorizationDetails), and RefundPaymentProcessor + * refunds that captured amount. See doc/authorized_payment.md. + * + * Amounts are integers in the minor unit Sylius stores everywhere (two decimals, whatever the + * currency), converted for display and input with UPC's AmountHelper. */ final class AuthorizedPaymentOperationProcessor { + // Three times SyliusUnifiedApiHttpClient's 10 s request timeout, which bounds the capture or + // cancel call itself. The OAuth token fetch (SyliusOAuthHttpClient) sets no timeout of its own, + // so a cold token on a slow network could in theory outlast this; were the lock to expire + // mid-call, a second request could take it and operate on the same authorization. private const LOCK_TTL_SECONDS = 30; private const ERROR_KEY_PREFIX = 'payplug_sylius_payplug_plugin.admin.authorization.error.'; @@ -62,6 +79,7 @@ final class AuthorizedPaymentOperationProcessor public function __construct( private AuthorizationOperatorInterface $authorizationOperator, private ILock $lock, + private EntityManagerInterface $entityManager, private StateMachineInterface $stateMachine, private ClockInterface $clock, private LoggerInterface $logger, @@ -111,6 +129,7 @@ public function capture(PaymentInterface $payment, ?int $amount, ?int $expectedV if (0 === AuthorizationDetails::fromDetails($payment->getDetails())->remainingAmount()) { $this->applyTransition($payment, PaymentTransitions::TRANSITION_COMPLETE); + $this->entityManager->flush(); } }); } @@ -140,17 +159,19 @@ public function cancel(PaymentInterface $payment, ?int $amount, ?int $expectedVe // cancelled, "no amount" would target the original authorization again, so the // exact remainder is sent instead. $amount === $remaining && 0 === $authorization->version() ? null : $amount, - $authorization->version() + 1, $payment->getCurrencyCode(), + $authorization->version() + 1, ); } catch (PayplugException $exception) { throw $this->refusal($payment, AuthorizationDetails::OPERATION_CANCELLATION, $exception); } - $this->record($payment, AuthorizationDetails::OPERATION_CANCELLATION, $output->body, $amount); + $this->record($payment, AuthorizationDetails::OPERATION_CANCELLATION, $output->status, $output->body, $amount); + $this->entityManager->flush(); if ($amount === $remaining) { $this->applyTransition($payment, PaymentTransitions::TRANSITION_CANCEL); + $this->entityManager->flush(); } }); } @@ -207,11 +228,14 @@ private function captureRecorded(PaymentInterface $payment, ?int $amount): void throw $this->refusal($payment, AuthorizationDetails::OPERATION_CAPTURE, $exception); } - $this->record($payment, AuthorizationDetails::OPERATION_CAPTURE, $output->body, $amount); + $this->record($payment, AuthorizationDetails::OPERATION_CAPTURE, $output->status, $output->body, $amount); if (null !== $output->maxCaptureDate) { $payment->setDetails([...$payment->getDetails(), AuthorizationDetails::MAX_CAPTURE_DATE => $output->maxCaptureDate]); } + + // Durable before anything else can throw, and before runLocked() lets go of the lock. + $this->entityManager->flush(); } /** @@ -268,14 +292,21 @@ private function resolveAmount(PaymentInterface $payment, ?int $amount, int $rem * all). Its absence is logged rather than failing the call: the operation did happen, and * refusing to record it would leave the payment out of step with the transaction. */ - private function record(PaymentInterface $payment, string $operation, string $responseBody, int $amount): void - { + private function record( + PaymentInterface $payment, + string $operation, + int $status, + string $responseBody, + int $amount, + ): void { $operationId = self::extractFirstOperationId($responseBody); if (null === $operationId) { $this->logger->error('[PayPlug][UPC] Authorization operation succeeded but the response carried no operationIds.', [ 'sylius_payment_id' => $payment->getId(), 'operation' => $operation, - 'response_body' => $responseBody, + // Status and execCode only: the body is the API's to shape, keep it out of the logs. + 'status' => $status, + 'exec_code' => self::extractExecCode($responseBody), ]); } @@ -293,8 +324,7 @@ private function refusal( PaymentInterface $payment, string $operation, PayplugException $exception, - ): AuthorizationOperationException - { + ): AuthorizationOperationException { $this->logger->error('[PayPlug][UPC] Authorization operation refused.', [ 'sylius_payment_id' => $payment->getId(), 'operation' => $operation, @@ -389,8 +419,16 @@ private static function extractFirstOperationId(string $body): ?string return \is_string($operationId) && '' !== $operationId ? $operationId : null; } + private static function extractExecCode(string $body): int|string|null + { + $decoded = \json_decode($body, true); + $execCode = \is_array($decoded) ? ($decoded['execCode'] ?? null) : null; + + return \is_int($execCode) || \is_string($execCode) ? $execCode : null; + } + private static function formatAmount(int $amount, ?string $currencyCode): string { - return \trim(\number_format($amount / 100, 2, '.', ' ') . ' ' . ($currencyCode ?? '')); + return \trim(\number_format(AmountHelper::fromCents($amount), 2, '.', ' ') . ' ' . ($currencyCode ?? '')); } } diff --git a/src/Twig/AuthorizationExtension.php b/src/Twig/AuthorizationExtension.php index 47935470..77d2d21a 100644 --- a/src/Twig/AuthorizationExtension.php +++ b/src/Twig/AuthorizationExtension.php @@ -6,6 +6,7 @@ use PayPlug\SyliusPayPlugPlugin\PaymentProcessing\AuthorizedPaymentOperationProcessor; use PayPlug\SyliusPayPlugPlugin\Upc\AuthorizationDetails; +use PayplugUnifiedCore\Utilities\Helpers\AmountHelper; use Psr\Clock\ClockInterface; use Sylius\Component\Core\Model\PaymentInterface; use Twig\Extension\AbstractExtension; @@ -38,6 +39,7 @@ public function getFunctions(): array * captured_amount: int, * cancelled_amount: int, * remaining_amount: int, + * remaining_amount_input: string, * max_capture_date: \DateTimeImmutable|null, * expired: bool, * expiring_soon: bool, @@ -60,6 +62,8 @@ public function describe(PaymentInterface $payment): ?array 'captured_amount' => $authorization->capturedAmount(), 'cancelled_amount' => $authorization->cancelledAmount(), 'remaining_amount' => $authorization->remainingAmount(), + // What the amount fields' placeholder shows: the same decimal notation they accept. + 'remaining_amount_input' => \number_format(AmountHelper::fromCents($authorization->remainingAmount()), 2, '.', ''), 'max_capture_date' => $authorization->maxCaptureDate(), 'expired' => $authorization->isExpired($now), 'expiring_soon' => $authorization->isExpiringSoon($now), diff --git a/src/Upc/AuthorizationOperatorInterface.php b/src/Upc/AuthorizationOperatorInterface.php index 6b2b0af5..09229bbe 100644 --- a/src/Upc/AuthorizationOperatorInterface.php +++ b/src/Upc/AuthorizationOperatorInterface.php @@ -43,7 +43,7 @@ public function cancel( string $paymentId, string $orderId, ?int $amount, - int $sequence = 1, ?string $currency = null, + int $sequence = 1, ): CancellationOutput; } diff --git a/src/Upc/UnifiedApiAuthorizationOperator.php b/src/Upc/UnifiedApiAuthorizationOperator.php index b0131358..8a2bc739 100644 --- a/src/Upc/UnifiedApiAuthorizationOperator.php +++ b/src/Upc/UnifiedApiAuthorizationOperator.php @@ -28,8 +28,7 @@ public function capture( ?int $amount, ?string $currency = null, int $sequence = 1, - ): CaptureOutput - { + ): CaptureOutput { return $this->createService($method)->capturePayment( $paymentId, GatewayCredentialsResolver::resolve($method), @@ -46,10 +45,9 @@ public function cancel( string $paymentId, string $orderId, ?int $amount, - int $sequence = 1, ?string $currency = null, - ): CancellationOutput - { + int $sequence = 1, + ): CancellationOutput { return $this->createService($method)->cancelPayment( $paymentId, GatewayCredentialsResolver::resolve($method), diff --git a/templates/admin/order/show/authorization.html.twig b/templates/admin/order/show/authorization.html.twig index 234b0b4f..1af73ed3 100644 --- a/templates/admin/order/show/authorization.html.twig +++ b/templates/admin/order/show/authorization.html.twig @@ -63,7 +63,7 @@
+ placeholder="{{ authorization.remaining_amount_input }}"> {{ currency }}