diff --git a/assets/shop/controllers/hosted-fields_controller.js b/assets/shop/controllers/hosted-fields_controller.js index f78b1f9a..e7a4c512 100644 --- a/assets/shop/controllers/hosted-fields_controller.js +++ b/assets/shop/controllers/hosted-fields_controller.js @@ -39,6 +39,19 @@ export default class extends Controller { }); } + // With saved cards on offer, selecting the payment method is no longer enough to mean "show + // the card form": the customer must also have chosen "pay with another card". Nothing else + // establishes that initial state — handleShow()/handleHide() are only ever reached from the + // change actions wired onto the choice radios in + // templates/form/sylius_checkout_select_payment_row.html.twig, and no change event fires on + // page load — so without this branch a pre-selected saved card still renders the fields open + // (the --loaded class alone drives their visibility). Mirrors integrated-payment_controller. + if (payplug_hosted_fields_params.has_saved_cards) { + this.watchCardChoice(); + + return; + } + // Stimulus connects as soon as the markup is in the DOM, even though the payment method // container starts hidden (see shop/select_payment/choice.html.twig). Mounting the // cross-origin Dalenys iframes into a display:none container breaks their rendering, so @@ -60,6 +73,43 @@ export default class extends Controller { }); } + // Opens the fields only while BOTH "pay with another card" and this payment method are + // selected — on load and on every subsequent change to either set of radios. Closing is left + // to handleHide(), which the choice radios already trigger and which also resets + // data-payment-inline-submit. + watchCardChoice() { + if (this.isOtherCardChosen()) { + this.openFields(); + } + + this.element + .querySelectorAll('.payment-choice__input, [id*="checkout_select_payment_payments"]') + .forEach((element) => { + element.addEventListener('change', () => { + if (this.isOtherCardChosen()) { + this.openFields(); + } + }); + }); + } + + isOtherCardChosen() { + if (true !== this.element.querySelector('#payplug_choice_card_other')?.checked) { + return false; + } + + // The payment method radio is absent when the shop offers only one method — Sylius renders + // no choice at all in that case, so a checked card choice is on its own enough to mean this + // method is the one being paid with. + const methodRadio = document.querySelector( + `[id*="checkout_select_payment_payments"][value="${payplug_hosted_fields_params.payment_method_code}"]`, + ); + + return null === methodRadio + ? null !== document.querySelector('.payplug-payment-choice__input:checked') + : methodRadio.checked; + } + handleShow(event) { if (this.hasContainerTarget) { import('jquery').then(({ default: $ }) => { diff --git a/composer.json b/composer.json index c3e7a991..43fd6812 100755 --- a/composer.json +++ b/composer.json @@ -27,6 +27,7 @@ "behat/mink-selenium2-driver": "1.7.0", "dmore/behat-chrome-extension": "1.4.0", "dmore/chrome-mink-driver": "2.9.3", + "doctrine/orm": ">=3.5 <3.7", "friends-of-behat/mink": "1.11.0", "friends-of-behat/mink-browserkit-driver": "1.6.2", "friends-of-behat/mink-debug-extension": "2.1.0", diff --git a/composer.lock b/composer.lock index 50db7607..743aa692 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": "a4230575f0ea5ede3f5d0e5d484bcd2a", + "content-hash": "de5c8fb480fe990a5976e263500c12e2", "packages": [ { "name": "alcohol/iso4217", @@ -70,16 +70,16 @@ }, { "name": "api-platform/doctrine-common", - "version": "v4.3.17", + "version": "v4.3.18", "source": { "type": "git", "url": "https://github.com/api-platform/doctrine-common.git", - "reference": "e4dee10c45bd701c5984321bc98adc0c3760ec48" + "reference": "45057f5226c3bdfbb0803220a9cfa667b5da83ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/doctrine-common/zipball/e4dee10c45bd701c5984321bc98adc0c3760ec48", - "reference": "e4dee10c45bd701c5984321bc98adc0c3760ec48", + "url": "https://api.github.com/repos/api-platform/doctrine-common/zipball/45057f5226c3bdfbb0803220a9cfa667b5da83ad", + "reference": "45057f5226c3bdfbb0803220a9cfa667b5da83ad", "shasum": "" }, "require": { @@ -154,22 +154,22 @@ "rest" ], "support": { - "source": "https://github.com/api-platform/doctrine-common/tree/v4.3.17" + "source": "https://github.com/api-platform/doctrine-common/tree/v4.3.18" }, - "time": "2026-06-16T14:59:18+00:00" + "time": "2026-08-16T07:56:59+00:00" }, { "name": "api-platform/doctrine-orm", - "version": "v4.3.17", + "version": "v4.3.18", "source": { "type": "git", "url": "https://github.com/api-platform/doctrine-orm.git", - "reference": "8da8676d5933235aa3592e613f837fe3c687a0cb" + "reference": "a8831db3ffed64cfc55ceac4f43e600eb6bf3bb5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/doctrine-orm/zipball/8da8676d5933235aa3592e613f837fe3c687a0cb", - "reference": "8da8676d5933235aa3592e613f837fe3c687a0cb", + "url": "https://api.github.com/repos/api-platform/doctrine-orm/zipball/a8831db3ffed64cfc55ceac4f43e600eb6bf3bb5", + "reference": "a8831db3ffed64cfc55ceac4f43e600eb6bf3bb5", "shasum": "" }, "require": { @@ -243,13 +243,13 @@ "rest" ], "support": { - "source": "https://github.com/api-platform/doctrine-orm/tree/v4.3.17" + "source": "https://github.com/api-platform/doctrine-orm/tree/v4.3.18" }, - "time": "2026-07-08T06:49:21+00:00" + "time": "2026-07-22T15:09:18+00:00" }, { "name": "api-platform/documentation", - "version": "v4.3.17", + "version": "v4.3.18", "source": { "type": "git", "url": "https://github.com/api-platform/documentation.git", @@ -312,7 +312,7 @@ }, { "name": "api-platform/http-cache", - "version": "v4.3.17", + "version": "v4.3.18", "source": { "type": "git", "url": "https://github.com/api-platform/http-cache.git", @@ -392,16 +392,16 @@ }, { "name": "api-platform/hydra", - "version": "v4.3.17", + "version": "v4.3.18", "source": { "type": "git", "url": "https://github.com/api-platform/hydra.git", - "reference": "2570883edf78970ad845f6eb7d69ae7894e6c480" + "reference": "6498a597510fbd6aa46a25cdfa867d415b28ed9f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/hydra/zipball/2570883edf78970ad845f6eb7d69ae7894e6c480", - "reference": "2570883edf78970ad845f6eb7d69ae7894e6c480", + "url": "https://api.github.com/repos/api-platform/hydra/zipball/6498a597510fbd6aa46a25cdfa867d415b28ed9f", + "reference": "6498a597510fbd6aa46a25cdfa867d415b28ed9f", "shasum": "" }, "require": { @@ -473,22 +473,22 @@ "rest" ], "support": { - "source": "https://github.com/api-platform/hydra/tree/v4.3.17" + "source": "https://github.com/api-platform/hydra/tree/v4.3.18" }, - "time": "2026-06-22T16:14:53+00:00" + "time": "2026-08-16T15:12:48+00:00" }, { "name": "api-platform/json-schema", - "version": "v4.3.17", + "version": "v4.3.18", "source": { "type": "git", "url": "https://github.com/api-platform/json-schema.git", - "reference": "d44aa7acae9e748b3ec55af19f2de4a9a7e79133" + "reference": "b97491371c40f9080bf0a4d24799d7779e1eb0b2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/json-schema/zipball/d44aa7acae9e748b3ec55af19f2de4a9a7e79133", - "reference": "d44aa7acae9e748b3ec55af19f2de4a9a7e79133", + "url": "https://api.github.com/repos/api-platform/json-schema/zipball/b97491371c40f9080bf0a4d24799d7779e1eb0b2", + "reference": "b97491371c40f9080bf0a4d24799d7779e1eb0b2", "shasum": "" }, "require": { @@ -554,13 +554,13 @@ "swagger" ], "support": { - "source": "https://github.com/api-platform/json-schema/tree/v4.3.17" + "source": "https://github.com/api-platform/json-schema/tree/v4.3.18" }, - "time": "2026-06-29T14:45:14+00:00" + "time": "2026-09-02T08:21:34+00:00" }, { "name": "api-platform/jsonld", - "version": "v4.3.17", + "version": "v4.3.18", "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.17" + "source": "https://github.com/api-platform/jsonld/tree/v4.3.18" }, "time": "2026-06-13T05:11:46+00:00" }, { "name": "api-platform/metadata", - "version": "v4.3.17", + "version": "v4.3.18", "source": { "type": "git", "url": "https://github.com/api-platform/metadata.git", - "reference": "748f495474b4deedcba286631c2adf96d7bcba0a" + "reference": "cff651763e34d72195f516e4c36790ace623dfe4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/metadata/zipball/748f495474b4deedcba286631c2adf96d7bcba0a", - "reference": "748f495474b4deedcba286631c2adf96d7bcba0a", + "url": "https://api.github.com/repos/api-platform/metadata/zipball/cff651763e34d72195f516e4c36790ace623dfe4", + "reference": "cff651763e34d72195f516e4c36790ace623dfe4", "shasum": "" }, "require": { @@ -732,13 +732,13 @@ "swagger" ], "support": { - "source": "https://github.com/api-platform/metadata/tree/v4.3.17" + "source": "https://github.com/api-platform/metadata/tree/v4.3.18" }, - "time": "2026-07-03T06:30:28+00:00" + "time": "2026-09-04T08:39:01+00:00" }, { "name": "api-platform/openapi", - "version": "v4.3.17", + "version": "v4.3.18", "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.17" + "source": "https://github.com/api-platform/openapi/tree/v4.3.18" }, "time": "2026-06-16T10:01:53+00:00" }, { "name": "api-platform/serializer", - "version": "v4.3.17", + "version": "v4.3.18", "source": { "type": "git", "url": "https://github.com/api-platform/serializer.git", - "reference": "946998cb8203e7dee5ddd716d4c0e20098d2bf69" + "reference": "7b1237b6fe3b5a84eba9f44a97f23b99ea267e0c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/serializer/zipball/946998cb8203e7dee5ddd716d4c0e20098d2bf69", - "reference": "946998cb8203e7dee5ddd716d4c0e20098d2bf69", + "url": "https://api.github.com/repos/api-platform/serializer/zipball/7b1237b6fe3b5a84eba9f44a97f23b99ea267e0c", + "reference": "7b1237b6fe3b5a84eba9f44a97f23b99ea267e0c", "shasum": "" }, "require": { @@ -860,7 +860,7 @@ "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.5 || ^12.2", "sebastian/exporter": "^6.3.2 || ^7.0.2", - "symfony/mercure-bundle": "*", + "symfony/mercure-bundle": "^0.4.3|^0.5", "symfony/type-info": "^7.3 || ^8.0", "symfony/var-dumper": "^6.4 || ^7.0 || ^8.0", "symfony/yaml": "^6.4 || ^7.0 || ^8.0" @@ -917,22 +917,22 @@ "serializer" ], "support": { - "source": "https://github.com/api-platform/serializer/tree/v4.3.17" + "source": "https://github.com/api-platform/serializer/tree/v4.3.18" }, - "time": "2026-07-11T23:00:58+00:00" + "time": "2026-08-16T16:40:51+00:00" }, { "name": "api-platform/state", - "version": "v4.3.17", + "version": "v4.3.18", "source": { "type": "git", "url": "https://github.com/api-platform/state.git", - "reference": "2a9a18aad1c3363a32381f8c6e21c41334e23b88" + "reference": "7a50bbef781fc0e98df30d0d99b630d1d27c5ab6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/state/zipball/2a9a18aad1c3363a32381f8c6e21c41334e23b88", - "reference": "2a9a18aad1c3363a32381f8c6e21c41334e23b88", + "url": "https://api.github.com/repos/api-platform/state/zipball/7a50bbef781fc0e98df30d0d99b630d1d27c5ab6", + "reference": "7a50bbef781fc0e98df30d0d99b630d1d27c5ab6", "shasum": "" }, "require": { @@ -1014,22 +1014,22 @@ "swagger" ], "support": { - "source": "https://github.com/api-platform/state/tree/v4.3.17" + "source": "https://github.com/api-platform/state/tree/v4.3.18" }, - "time": "2026-07-11T07:03:31+00:00" + "time": "2026-08-16T07:58:36+00:00" }, { "name": "api-platform/symfony", - "version": "v4.3.17", + "version": "v4.3.18", "source": { "type": "git", "url": "https://github.com/api-platform/symfony.git", - "reference": "55d387dc3d7384791015373030d1430c7c4d9f5e" + "reference": "42e4ff04ef9183f45ebaffcd17681a3d6710adb9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/symfony/zipball/55d387dc3d7384791015373030d1430c7c4d9f5e", - "reference": "55d387dc3d7384791015373030d1430c7c4d9f5e", + "url": "https://api.github.com/repos/api-platform/symfony/zipball/42e4ff04ef9183f45ebaffcd17681a3d6710adb9", + "reference": "42e4ff04ef9183f45ebaffcd17681a3d6710adb9", "shasum": "" }, "require": { @@ -1064,7 +1064,7 @@ "phpunit/phpunit": "^11.5 || ^12.2", "symfony/expression-language": "^6.4 || ^7.0 || ^8.0", "symfony/intl": "^6.4 || ^7.0 || ^8.0", - "symfony/mercure-bundle": "*", + "symfony/mercure-bundle": "^0.4.3|^0.5", "symfony/object-mapper": "^7.0 || ^8.0", "symfony/routing": "^6.4 || ^7.0 || ^8.0", "symfony/type-info": "^7.3 || ^8.0", @@ -1143,13 +1143,13 @@ "symfony" ], "support": { - "source": "https://github.com/api-platform/symfony/tree/v4.3.17" + "source": "https://github.com/api-platform/symfony/tree/v4.3.18" }, - "time": "2026-07-08T06:54:33+00:00" + "time": "2026-09-02T20:01:30+00:00" }, { "name": "api-platform/validator", - "version": "v4.3.17", + "version": "v4.3.18", "source": { "type": "git", "url": "https://github.com/api-platform/validator.git", @@ -1219,7 +1219,7 @@ "validator" ], "support": { - "source": "https://github.com/api-platform/validator/tree/v4.3.17" + "source": "https://github.com/api-platform/validator/tree/v4.3.18" }, "time": "2026-05-07T11:45:31+00:00" }, @@ -2635,16 +2635,16 @@ }, { "name": "doctrine/orm", - "version": "3.6.8", + "version": "3.6.9", "source": { "type": "git", "url": "https://github.com/doctrine/orm.git", - "reference": "a4d13ed5b11e7f7b4d654b1adf95432031ae3ffc" + "reference": "1ee072c5734f1d4e41d67eff6587f29c96db51c8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/orm/zipball/a4d13ed5b11e7f7b4d654b1adf95432031ae3ffc", - "reference": "a4d13ed5b11e7f7b4d654b1adf95432031ae3ffc", + "url": "https://api.github.com/repos/doctrine/orm/zipball/1ee072c5734f1d4e41d67eff6587f29c96db51c8", + "reference": "1ee072c5734f1d4e41d67eff6587f29c96db51c8", "shasum": "" }, "require": { @@ -2718,9 +2718,9 @@ ], "support": { "issues": "https://github.com/doctrine/orm/issues", - "source": "https://github.com/doctrine/orm/tree/3.6.8" + "source": "https://github.com/doctrine/orm/tree/3.6.9" }, - "time": "2026-08-05T19:05:32+00:00" + "time": "2026-09-07T18:59:09+00:00" }, { "name": "doctrine/persistence", @@ -4337,16 +4337,16 @@ }, { "name": "league/flysystem", - "version": "3.35.3", + "version": "3.36.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "5fc8404762179ae514678487b23494fd69b2309c" + "reference": "f7fb152932f30072d573510cbd4dd657d6475b25" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/5fc8404762179ae514678487b23494fd69b2309c", - "reference": "5fc8404762179ae514678487b23494fd69b2309c", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f7fb152932f30072d573510cbd4dd657d6475b25", + "reference": "f7fb152932f30072d573510cbd4dd657d6475b25", "shasum": "" }, "require": { @@ -4414,9 +4414,9 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.35.3" + "source": "https://github.com/thephpleague/flysystem/tree/3.36.0" }, - "time": "2026-08-22T12:55:54+00:00" + "time": "2026-09-02T08:00:27+00:00" }, { "name": "league/flysystem-bundle", @@ -5136,16 +5136,16 @@ }, { "name": "monolog/monolog", - "version": "3.10.0", + "version": "3.11.0", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" + "reference": "147f303310f06334f03f409e49d7ad1e275ff05a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", - "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/147f303310f06334f03f409e49d7ad1e275ff05a", + "reference": "147f303310f06334f03f409e49d7ad1e275ff05a", "shasum": "" }, "require": { @@ -5223,7 +5223,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/3.10.0" + "source": "https://github.com/Seldaek/monolog/tree/3.11.0" }, "funding": [ { @@ -5235,7 +5235,7 @@ "type": "tidelift" } ], - "time": "2026-01-02T08:56:05+00:00" + "time": "2026-09-02T12:39:56+00:00" }, { "name": "myclabs/php-enum", @@ -5747,16 +5747,16 @@ }, { "name": "payplug/unified-plugin-core", - "version": "1.1.0", + "version": "1.1.1", "source": { "type": "git", "url": "https://github.com/payplug/unified-plugin-core.git", - "reference": "f86247f8b52fba52c709cad206c50974927fa8dc" + "reference": "fd22a11adef6a3b9833fc986111ecf7973c59928" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/payplug/unified-plugin-core/zipball/f86247f8b52fba52c709cad206c50974927fa8dc", - "reference": "f86247f8b52fba52c709cad206c50974927fa8dc", + "url": "https://api.github.com/repos/payplug/unified-plugin-core/zipball/fd22a11adef6a3b9833fc986111ecf7973c59928", + "reference": "fd22a11adef6a3b9833fc986111ecf7973c59928", "shasum": "" }, "require": { @@ -5793,9 +5793,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.0" + "source": "https://github.com/payplug/unified-plugin-core/tree/1.1.1" }, - "time": "2026-08-28T14:15:47+00:00" + "time": "2026-09-07T12:40:07+00:00" }, { "name": "payum/core", @@ -8071,16 +8071,16 @@ }, { "name": "sylius/sylius", - "version": "v2.2.8", + "version": "v2.2.9", "source": { "type": "git", "url": "https://github.com/Sylius/Sylius.git", - "reference": "3c39f6ac93879f64f08ac7fec68cd4e3fadf5b63" + "reference": "253169df80a8c8babf479c6de5ce3d422e37dc6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Sylius/Sylius/zipball/3c39f6ac93879f64f08ac7fec68cd4e3fadf5b63", - "reference": "3c39f6ac93879f64f08ac7fec68cd4e3fadf5b63", + "url": "https://api.github.com/repos/Sylius/Sylius/zipball/253169df80a8c8babf479c6de5ce3d422e37dc6a", + "reference": "253169df80a8c8babf479c6de5ce3d422e37dc6a", "shasum": "" }, "require": { @@ -8213,7 +8213,7 @@ "conflict": { "api-platform/serializer": "4.2.17", "api-platform/symfony": "4.3.16", - "doctrine/orm": "2.20.7 || 3.5.3", + "doctrine/orm": "2.20.7 || 3.5.3 || 3.6.8", "symfony/ux-live-component": "2.28.0 || 2.28.1" }, "replace": { @@ -8354,7 +8354,7 @@ "homepage": "https://sylius.com", "support": { "issues": "https://github.com/Sylius/Sylius/issues", - "source": "https://github.com/Sylius/Sylius/tree/v2.2.8" + "source": "https://github.com/Sylius/Sylius/tree/v2.2.9" }, "funding": [ { @@ -8362,7 +8362,7 @@ "type": "github" } ], - "time": "2026-07-31T10:05:33+00:00" + "time": "2026-09-02T13:54:32+00:00" }, { "name": "sylius/telemetry", @@ -8747,16 +8747,16 @@ }, { "name": "symfony/cache", - "version": "v6.4.44", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "66c07f6399aaff49d6aff3ff62197f745741af6a" + "reference": "ea9758447aa48581e2e2c0d318782d89e2182136" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/66c07f6399aaff49d6aff3ff62197f745741af6a", - "reference": "66c07f6399aaff49d6aff3ff62197f745741af6a", + "url": "https://api.github.com/repos/symfony/cache/zipball/ea9758447aa48581e2e2c0d318782d89e2182136", + "reference": "ea9758447aa48581e2e2c0d318782d89e2182136", "shasum": "" }, "require": { @@ -8779,7 +8779,7 @@ "symfony/cache-implementation": "1.1|2.0|3.0" }, "require-dev": { - "cache/integration-tests": "^0.18@stable", + "cache/integration-tests": "^0.18", "doctrine/dbal": "^2.13.1|^3|^4", "predis/predis": "^1.1|^2.0", "psr/simple-cache": "^1.0|^2.0|^3.0", @@ -8823,7 +8823,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v6.4.44" + "source": "https://github.com/symfony/cache/tree/v6.4.45" }, "funding": [ { @@ -8843,7 +8843,7 @@ "type": "tidelift" } ], - "time": "2026-08-19T08:22:32+00:00" + "time": "2026-08-30T20:10:39+00:00" }, { "name": "symfony/cache-contracts", @@ -9084,16 +9084,16 @@ }, { "name": "symfony/console", - "version": "v6.4.44", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "60e3944c4859c487aa6ea2f0f7754917f70f7524" + "reference": "3b8473e0d14157f2d22b0a0d7259ad23483d1e6d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/60e3944c4859c487aa6ea2f0f7754917f70f7524", - "reference": "60e3944c4859c487aa6ea2f0f7754917f70f7524", + "url": "https://api.github.com/repos/symfony/console/zipball/3b8473e0d14157f2d22b0a0d7259ad23483d1e6d", + "reference": "3b8473e0d14157f2d22b0a0d7259ad23483d1e6d", "shasum": "" }, "require": { @@ -9158,7 +9158,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.44" + "source": "https://github.com/symfony/console/tree/v6.4.45" }, "funding": [ { @@ -9178,7 +9178,7 @@ "type": "tidelift" } ], - "time": "2026-08-21T07:42:12+00:00" + "time": "2026-08-25T13:08:31+00:00" }, { "name": "symfony/dependency-injection", @@ -9338,16 +9338,16 @@ }, { "name": "symfony/doctrine-bridge", - "version": "v6.4.44", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-bridge.git", - "reference": "f80901d137e312a21b53f719016be0584968df57" + "reference": "57ddd0c22de16bf22e10d810d323027bf4e532ac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/f80901d137e312a21b53f719016be0584968df57", - "reference": "f80901d137e312a21b53f719016be0584968df57", + "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/57ddd0c22de16bf22e10d810d323027bf4e532ac", + "reference": "57ddd0c22de16bf22e10d810d323027bf4e532ac", "shasum": "" }, "require": { @@ -9426,7 +9426,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.44" + "source": "https://github.com/symfony/doctrine-bridge/tree/v6.4.45" }, "funding": [ { @@ -9446,11 +9446,11 @@ "type": "tidelift" } ], - "time": "2026-08-21T19:37:17+00:00" + "time": "2026-08-30T18:34:07+00:00" }, { "name": "symfony/doctrine-messenger", - "version": "v6.4.42", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-messenger.git", @@ -9502,7 +9502,7 @@ "description": "Symfony Doctrine Messenger Bridge", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-messenger/tree/v6.4.42" + "source": "https://github.com/symfony/doctrine-messenger/tree/v6.4.45" }, "funding": [ { @@ -9769,16 +9769,16 @@ }, { "name": "symfony/expression-language", - "version": "v6.4.42", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/expression-language.git", - "reference": "728f0c48560a851e83681dc03efefa65d5fc076e" + "reference": "91edabba1aff9da3326eee2a6e7666d1c1270751" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/expression-language/zipball/728f0c48560a851e83681dc03efefa65d5fc076e", - "reference": "728f0c48560a851e83681dc03efefa65d5fc076e", + "url": "https://api.github.com/repos/symfony/expression-language/zipball/91edabba1aff9da3326eee2a6e7666d1c1270751", + "reference": "91edabba1aff9da3326eee2a6e7666d1c1270751", "shasum": "" }, "require": { @@ -9813,7 +9813,7 @@ "description": "Provides an engine that can compile and evaluate expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/expression-language/tree/v6.4.42" + "source": "https://github.com/symfony/expression-language/tree/v6.4.45" }, "funding": [ { @@ -9833,20 +9833,20 @@ "type": "tidelift" } ], - "time": "2026-06-08T07:06:12+00:00" + "time": "2026-08-30T00:27:22+00:00" }, { "name": "symfony/filesystem", - "version": "v6.4.44", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "e84ffb3e77e3a7144727a0d0735d0d9cc0ca77a7" + "reference": "6dabf41c21957a2060f101bd7bf7e322989866bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/e84ffb3e77e3a7144727a0d0735d0d9cc0ca77a7", - "reference": "e84ffb3e77e3a7144727a0d0735d0d9cc0ca77a7", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/6dabf41c21957a2060f101bd7bf7e322989866bd", + "reference": "6dabf41c21957a2060f101bd7bf7e322989866bd", "shasum": "" }, "require": { @@ -9883,7 +9883,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v6.4.44" + "source": "https://github.com/symfony/filesystem/tree/v6.4.45" }, "funding": [ { @@ -9903,7 +9903,7 @@ "type": "tidelift" } ], - "time": "2026-08-20T20:21:47+00:00" + "time": "2026-08-23T07:59:31+00:00" }, { "name": "symfony/finder", @@ -9975,16 +9975,16 @@ }, { "name": "symfony/form", - "version": "v6.4.44", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/form.git", - "reference": "8e28c46ad733ee40c42b9cf242cb52b2d6b1ffef" + "reference": "03810456895dfc7e7fca450806e4eb21669927c8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/form/zipball/8e28c46ad733ee40c42b9cf242cb52b2d6b1ffef", - "reference": "8e28c46ad733ee40c42b9cf242cb52b2d6b1ffef", + "url": "https://api.github.com/repos/symfony/form/zipball/03810456895dfc7e7fca450806e4eb21669927c8", + "reference": "03810456895dfc7e7fca450806e4eb21669927c8", "shasum": "" }, "require": { @@ -10052,7 +10052,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.44" + "source": "https://github.com/symfony/form/tree/v6.4.45" }, "funding": [ { @@ -10072,20 +10072,20 @@ "type": "tidelift" } ], - "time": "2026-08-21T16:18:55+00:00" + "time": "2026-08-29T12:19:25+00:00" }, { "name": "symfony/framework-bundle", - "version": "v6.4.44", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "a1d129830823409ff9c4b0cd88b8c75ee8601a5d" + "reference": "a19f20ad64c9b727ce21421638e7c92fa381ae9d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/a1d129830823409ff9c4b0cd88b8c75ee8601a5d", - "reference": "a1d129830823409ff9c4b0cd88b8c75ee8601a5d", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/a19f20ad64c9b727ce21421638e7c92fa381ae9d", + "reference": "a19f20ad64c9b727ce21421638e7c92fa381ae9d", "shasum": "" }, "require": { @@ -10205,7 +10205,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.44" + "source": "https://github.com/symfony/framework-bundle/tree/v6.4.45" }, "funding": [ { @@ -10225,20 +10225,20 @@ "type": "tidelift" } ], - "time": "2026-08-21T19:37:17+00:00" + "time": "2026-08-30T20:10:39+00:00" }, { "name": "symfony/http-client", - "version": "v6.4.44", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "ba6237fa906710149b6b7881cff59edde2b4abfc" + "reference": "149b874c2f65b68032a0409288ca48667864e43d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/ba6237fa906710149b6b7881cff59edde2b4abfc", - "reference": "ba6237fa906710149b6b7881cff59edde2b4abfc", + "url": "https://api.github.com/repos/symfony/http-client/zipball/149b874c2f65b68032a0409288ca48667864e43d", + "reference": "149b874c2f65b68032a0409288ca48667864e43d", "shasum": "" }, "require": { @@ -10303,7 +10303,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v6.4.44" + "source": "https://github.com/symfony/http-client/tree/v6.4.45" }, "funding": [ { @@ -10323,20 +10323,20 @@ "type": "tidelift" } ], - "time": "2026-08-20T17:31:15+00:00" + "time": "2026-08-30T13:44:49+00:00" }, { "name": "symfony/http-client-contracts", - "version": "v3.7.1", + "version": "v3.7.3", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "41fc42d276aeff21192465331ebbab7d83a743c0" + "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/41fc42d276aeff21192465331ebbab7d83a743c0", - "reference": "41fc42d276aeff21192465331ebbab7d83a743c0", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/35be0019e2c2c9fba80f9dc033290a5240f7b44f", + "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f", "shasum": "" }, "require": { @@ -10385,7 +10385,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.3" }, "funding": [ { @@ -10405,20 +10405,20 @@ "type": "tidelift" } ], - "time": "2026-06-05T06:23:12+00:00" + "time": "2026-08-04T08:41:16+00:00" }, { "name": "symfony/http-foundation", - "version": "v6.4.44", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "1d5fa99b841ccdbbaa8781b01f07a7b2ede31d09" + "reference": "945bfd2bcccca941f75ce45d5530b36d4cc6dc0b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/1d5fa99b841ccdbbaa8781b01f07a7b2ede31d09", - "reference": "1d5fa99b841ccdbbaa8781b01f07a7b2ede31d09", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/945bfd2bcccca941f75ce45d5530b36d4cc6dc0b", + "reference": "945bfd2bcccca941f75ce45d5530b36d4cc6dc0b", "shasum": "" }, "require": { @@ -10466,7 +10466,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.44" + "source": "https://github.com/symfony/http-foundation/tree/v6.4.45" }, "funding": [ { @@ -10486,20 +10486,20 @@ "type": "tidelift" } ], - "time": "2026-08-20T07:12:57+00:00" + "time": "2026-08-30T20:10:39+00:00" }, { "name": "symfony/http-kernel", - "version": "v6.4.44", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "2223a4e7763fa9284542f78280ebdb72da055d49" + "reference": "e5e8372b0e16ee6d6afa8fba37f23c25822dbfe2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/2223a4e7763fa9284542f78280ebdb72da055d49", - "reference": "2223a4e7763fa9284542f78280ebdb72da055d49", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/e5e8372b0e16ee6d6afa8fba37f23c25822dbfe2", + "reference": "e5e8372b0e16ee6d6afa8fba37f23c25822dbfe2", "shasum": "" }, "require": { @@ -10584,7 +10584,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.44" + "source": "https://github.com/symfony/http-kernel/tree/v6.4.45" }, "funding": [ { @@ -10604,7 +10604,7 @@ "type": "tidelift" } ], - "time": "2026-08-22T13:20:55+00:00" + "time": "2026-08-30T20:52:35+00:00" }, { "name": "symfony/intl", @@ -10695,16 +10695,16 @@ }, { "name": "symfony/lock", - "version": "v6.4.44", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/lock.git", - "reference": "7ecf823a1c86b553c16281e93734a3d677f84fc9" + "reference": "84679f4725d2359f53739ad44e7fa1af9ee9a11d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/lock/zipball/7ecf823a1c86b553c16281e93734a3d677f84fc9", - "reference": "7ecf823a1c86b553c16281e93734a3d677f84fc9", + "url": "https://api.github.com/repos/symfony/lock/zipball/84679f4725d2359f53739ad44e7fa1af9ee9a11d", + "reference": "84679f4725d2359f53739ad44e7fa1af9ee9a11d", "shasum": "" }, "require": { @@ -10754,7 +10754,7 @@ "semaphore" ], "support": { - "source": "https://github.com/symfony/lock/tree/v6.4.44" + "source": "https://github.com/symfony/lock/tree/v6.4.45" }, "funding": [ { @@ -10774,7 +10774,7 @@ "type": "tidelift" } ], - "time": "2026-08-16T17:06:59+00:00" + "time": "2026-08-30T20:10:39+00:00" }, { "name": "symfony/mailer", @@ -10862,16 +10862,16 @@ }, { "name": "symfony/messenger", - "version": "v6.4.44", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/messenger.git", - "reference": "e4e8f57d79eb8df84f4b0172fd128bdd6d412011" + "reference": "f0da814e78084005644e891e100773f2b07a69bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/messenger/zipball/e4e8f57d79eb8df84f4b0172fd128bdd6d412011", - "reference": "e4e8f57d79eb8df84f4b0172fd128bdd6d412011", + "url": "https://api.github.com/repos/symfony/messenger/zipball/f0da814e78084005644e891e100773f2b07a69bb", + "reference": "f0da814e78084005644e891e100773f2b07a69bb", "shasum": "" }, "require": { @@ -10929,7 +10929,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.44" + "source": "https://github.com/symfony/messenger/tree/v6.4.45" }, "funding": [ { @@ -10949,11 +10949,11 @@ "type": "tidelift" } ], - "time": "2026-08-21T16:26:24+00:00" + "time": "2026-08-30T00:27:22+00:00" }, { "name": "symfony/mime", - "version": "v6.4.44", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", @@ -11018,7 +11018,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v6.4.44" + "source": "https://github.com/symfony/mime/tree/v6.4.45" }, "funding": [ { @@ -11042,16 +11042,16 @@ }, { "name": "symfony/monolog-bridge", - "version": "v6.4.44", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/monolog-bridge.git", - "reference": "df7efc5e57d13d393cfc1a51a673e0dad739232d" + "reference": "d3f5dd9133dc5723c7ee77e37b3db07e6934576d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/df7efc5e57d13d393cfc1a51a673e0dad739232d", - "reference": "df7efc5e57d13d393cfc1a51a673e0dad739232d", + "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/d3f5dd9133dc5723c7ee77e37b3db07e6934576d", + "reference": "d3f5dd9133dc5723c7ee77e37b3db07e6934576d", "shasum": "" }, "require": { @@ -11102,7 +11102,7 @@ "description": "Provides integration for Monolog with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/monolog-bridge/tree/v6.4.44" + "source": "https://github.com/symfony/monolog-bridge/tree/v6.4.45" }, "funding": [ { @@ -11122,7 +11122,7 @@ "type": "tidelift" } ], - "time": "2026-08-20T17:31:17+00:00" + "time": "2026-08-30T01:18:12+00:00" }, { "name": "symfony/monolog-bundle", @@ -12274,7 +12274,7 @@ }, { "name": "symfony/process", - "version": "v6.4.44", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/process.git", @@ -12315,7 +12315,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v6.4.44" + "source": "https://github.com/symfony/process/tree/v6.4.45" }, "funding": [ { @@ -12420,16 +12420,16 @@ }, { "name": "symfony/property-info", - "version": "v6.4.44", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/property-info.git", - "reference": "7847669cf171ea57ff6f3b7798ce842255a35fbd" + "reference": "003ec301822cf2cf1b44b217fba1efdf4b4503f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/7847669cf171ea57ff6f3b7798ce842255a35fbd", - "reference": "7847669cf171ea57ff6f3b7798ce842255a35fbd", + "url": "https://api.github.com/repos/symfony/property-info/zipball/003ec301822cf2cf1b44b217fba1efdf4b4503f9", + "reference": "003ec301822cf2cf1b44b217fba1efdf4b4503f9", "shasum": "" }, "require": { @@ -12486,7 +12486,7 @@ "validator" ], "support": { - "source": "https://github.com/symfony/property-info/tree/v6.4.44" + "source": "https://github.com/symfony/property-info/tree/v6.4.45" }, "funding": [ { @@ -12506,7 +12506,7 @@ "type": "tidelift" } ], - "time": "2026-08-21T15:18:56+00:00" + "time": "2026-08-24T07:52:32+00:00" }, { "name": "symfony/proxy-manager-bridge", @@ -12581,7 +12581,7 @@ }, { "name": "symfony/routing", - "version": "v6.4.44", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", @@ -12644,7 +12644,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v6.4.44" + "source": "https://github.com/symfony/routing/tree/v6.4.45" }, "funding": [ { @@ -12668,16 +12668,16 @@ }, { "name": "symfony/security-bundle", - "version": "v6.4.43", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/security-bundle.git", - "reference": "c77d9d6076e6bb8ba706b8616519e3f52f6b2660" + "reference": "de06cd0f756ca8fe4894c7e95bac71d90bb94e64" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-bundle/zipball/c77d9d6076e6bb8ba706b8616519e3f52f6b2660", - "reference": "c77d9d6076e6bb8ba706b8616519e3f52f6b2660", + "url": "https://api.github.com/repos/symfony/security-bundle/zipball/de06cd0f756ca8fe4894c7e95bac71d90bb94e64", + "reference": "de06cd0f756ca8fe4894c7e95bac71d90bb94e64", "shasum": "" }, "require": { @@ -12760,7 +12760,7 @@ "description": "Provides a tight integration of the Security component into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-bundle/tree/v6.4.43" + "source": "https://github.com/symfony/security-bundle/tree/v6.4.45" }, "funding": [ { @@ -12780,20 +12780,20 @@ "type": "tidelift" } ], - "time": "2026-07-29T06:43:01+00:00" + "time": "2026-08-30T06:17:28+00:00" }, { "name": "symfony/security-core", - "version": "v6.4.44", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/security-core.git", - "reference": "9795959163aa73e0f09d7609fa6c9ce5a2c257eb" + "reference": "8f74c37460002b97c1318007629c8be37681498a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-core/zipball/9795959163aa73e0f09d7609fa6c9ce5a2c257eb", - "reference": "9795959163aa73e0f09d7609fa6c9ce5a2c257eb", + "url": "https://api.github.com/repos/symfony/security-core/zipball/8f74c37460002b97c1318007629c8be37681498a", + "reference": "8f74c37460002b97c1318007629c8be37681498a", "shasum": "" }, "require": { @@ -12820,6 +12820,7 @@ "symfony/expression-language": "^5.4|^6.0|^7.0", "symfony/http-foundation": "^5.4|^6.0|^7.0", "symfony/ldap": "^5.4|^6.0|^7.0", + "symfony/property-access": "^5.4|^6.0|^7.0", "symfony/string": "^5.4|^6.0|^7.0", "symfony/translation": "^5.4.35|~6.3.12|^6.4.3|^7.0.3", "symfony/validator": "^6.4|^7.0" @@ -12850,7 +12851,7 @@ "description": "Symfony Security Component - Core Library", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-core/tree/v6.4.44" + "source": "https://github.com/symfony/security-core/tree/v6.4.45" }, "funding": [ { @@ -12870,7 +12871,7 @@ "type": "tidelift" } ], - "time": "2026-08-20T19:22:30+00:00" + "time": "2026-08-30T00:30:12+00:00" }, { "name": "symfony/security-csrf", @@ -12946,16 +12947,16 @@ }, { "name": "symfony/security-http", - "version": "v6.4.44", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/security-http.git", - "reference": "e1f302a96c4c8de56a904b116b7c1506db827141" + "reference": "acc78bd71a614fc15a2931b08373ad6fd0fac9db" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-http/zipball/e1f302a96c4c8de56a904b116b7c1506db827141", - "reference": "e1f302a96c4c8de56a904b116b7c1506db827141", + "url": "https://api.github.com/repos/symfony/security-http/zipball/acc78bd71a614fc15a2931b08373ad6fd0fac9db", + "reference": "acc78bd71a614fc15a2931b08373ad6fd0fac9db", "shasum": "" }, "require": { @@ -13014,7 +13015,7 @@ "description": "Symfony Security Component - HTTP Integration", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-http/tree/v6.4.44" + "source": "https://github.com/symfony/security-http/tree/v6.4.45" }, "funding": [ { @@ -13034,20 +13035,20 @@ "type": "tidelift" } ], - "time": "2026-08-22T07:30:33+00:00" + "time": "2026-08-30T00:30:12+00:00" }, { "name": "symfony/serializer", - "version": "v6.4.44", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "3372b3c367d41542ca99291f8340897544002094" + "reference": "ad3c87d8d47e05e60fb8cd347348f2246c5e3aea" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/3372b3c367d41542ca99291f8340897544002094", - "reference": "3372b3c367d41542ca99291f8340897544002094", + "url": "https://api.github.com/repos/symfony/serializer/zipball/ad3c87d8d47e05e60fb8cd347348f2246c5e3aea", + "reference": "ad3c87d8d47e05e60fb8cd347348f2246c5e3aea", "shasum": "" }, "require": { @@ -13116,7 +13117,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.44" + "source": "https://github.com/symfony/serializer/tree/v6.4.45" }, "funding": [ { @@ -13136,20 +13137,20 @@ "type": "tidelift" } ], - "time": "2026-08-22T09:04:22+00:00" + "time": "2026-08-29T08:24:03+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.7.1", + "version": "v3.7.3", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", "shasum": "" }, "require": { @@ -13203,7 +13204,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" }, "funding": [ { @@ -13223,7 +13224,7 @@ "type": "tidelift" } ], - "time": "2026-06-16T09:55:08+00:00" + "time": "2026-07-27T15:39:01+00:00" }, { "name": "symfony/stimulus-bundle", @@ -14377,16 +14378,16 @@ }, { "name": "symfony/validator", - "version": "v6.4.44", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/validator.git", - "reference": "6e29aaa4662e28312bb84170b990bbfed73f68a8" + "reference": "8acf2afad9c26cd2386d760ef9f0ec94c1c55b90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/validator/zipball/6e29aaa4662e28312bb84170b990bbfed73f68a8", - "reference": "6e29aaa4662e28312bb84170b990bbfed73f68a8", + "url": "https://api.github.com/repos/symfony/validator/zipball/8acf2afad9c26cd2386d760ef9f0ec94c1c55b90", + "reference": "8acf2afad9c26cd2386d760ef9f0ec94c1c55b90", "shasum": "" }, "require": { @@ -14454,7 +14455,7 @@ "description": "Provides tools to validate values", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/validator/tree/v6.4.44" + "source": "https://github.com/symfony/validator/tree/v6.4.45" }, "funding": [ { @@ -14474,20 +14475,20 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:53:43+00:00" + "time": "2026-08-30T00:30:12+00:00" }, { "name": "symfony/var-dumper", - "version": "v6.4.44", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "13909d2c2a9300f6be7b3e75d72279f98b1301b9" + "reference": "86a9b2f1bd81c9780a809632238ab6ba10292166" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/13909d2c2a9300f6be7b3e75d72279f98b1301b9", - "reference": "13909d2c2a9300f6be7b3e75d72279f98b1301b9", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/86a9b2f1bd81c9780a809632238ab6ba10292166", + "reference": "86a9b2f1bd81c9780a809632238ab6ba10292166", "shasum": "" }, "require": { @@ -14542,7 +14543,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v6.4.44" + "source": "https://github.com/symfony/var-dumper/tree/v6.4.45" }, "funding": [ { @@ -14562,20 +14563,20 @@ "type": "tidelift" } ], - "time": "2026-08-21T07:12:07+00:00" + "time": "2026-08-30T20:10:39+00:00" }, { "name": "symfony/var-exporter", - "version": "v6.4.44", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "6e13ff96f54498f86188833832e298ea1dc48ae1" + "reference": "a4e458999ee554a667cd19227f0b97a751bd61e6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/6e13ff96f54498f86188833832e298ea1dc48ae1", - "reference": "6e13ff96f54498f86188833832e298ea1dc48ae1", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/a4e458999ee554a667cd19227f0b97a751bd61e6", + "reference": "a4e458999ee554a667cd19227f0b97a751bd61e6", "shasum": "" }, "require": { @@ -14623,7 +14624,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v6.4.44" + "source": "https://github.com/symfony/var-exporter/tree/v6.4.45" }, "funding": [ { @@ -14643,7 +14644,7 @@ "type": "tidelift" } ], - "time": "2026-07-29T16:40:20+00:00" + "time": "2026-08-23T09:39:49+00:00" }, { "name": "symfony/web-link", @@ -14902,16 +14903,16 @@ }, { "name": "symfony/yaml", - "version": "v6.4.44", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "44b712e89243c358afe0cc227e1fcb6b3ddc957d" + "reference": "a778aba2d7130eba6150cc80cb692988a361ee4b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/44b712e89243c358afe0cc227e1fcb6b3ddc957d", - "reference": "44b712e89243c358afe0cc227e1fcb6b3ddc957d", + "url": "https://api.github.com/repos/symfony/yaml/zipball/a778aba2d7130eba6150cc80cb692988a361ee4b", + "reference": "a778aba2d7130eba6150cc80cb692988a361ee4b", "shasum": "" }, "require": { @@ -14954,7 +14955,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v6.4.44" + "source": "https://github.com/symfony/yaml/tree/v6.4.45" }, "funding": [ { @@ -14974,7 +14975,7 @@ "type": "tidelift" } ], - "time": "2026-08-20T17:31:23+00:00" + "time": "2026-08-30T00:27:22+00:00" }, { "name": "symfonycasts/dynamic-forms", @@ -18717,16 +18718,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.3", + "version": "2.3.5", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" + "reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", - "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/148cefffaf0233e4c08cc13db8a195a56dd6dfe9", + "reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9", "shasum": "" }, "require": { @@ -18758,9 +18759,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.3" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.5" }, - "time": "2026-07-08T07:01:06+00:00" + "time": "2026-08-31T16:05:28+00:00" }, { "name": "phpstan/phpstan", @@ -21179,16 +21180,16 @@ }, { "name": "symfony/css-selector", - "version": "v6.4.44", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "e2f0d361adeeb47feb80fb9102133ae13d376534" + "reference": "5484e316cd8125f5215bbf829151e2211aa6d101" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/e2f0d361adeeb47feb80fb9102133ae13d376534", - "reference": "e2f0d361adeeb47feb80fb9102133ae13d376534", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/5484e316cd8125f5215bbf829151e2211aa6d101", + "reference": "5484e316cd8125f5215bbf829151e2211aa6d101", "shasum": "" }, "require": { @@ -21224,7 +21225,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v6.4.44" + "source": "https://github.com/symfony/css-selector/tree/v6.4.45" }, "funding": [ { @@ -21244,7 +21245,7 @@ "type": "tidelift" } ], - "time": "2026-08-21T15:52:46+00:00" + "time": "2026-08-23T07:35:46+00:00" }, { "name": "symfony/debug-bundle", @@ -21397,7 +21398,7 @@ }, { "name": "symfony/dotenv", - "version": "v6.4.43", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/dotenv.git", @@ -21451,7 +21452,7 @@ "environment" ], "support": { - "source": "https://github.com/symfony/dotenv/tree/v6.4.43" + "source": "https://github.com/symfony/dotenv/tree/v6.4.45" }, "funding": [ { diff --git a/config/twig_hooks/admin.yaml b/config/twig_hooks/admin.yaml index faff9e66..a95b1f1e 100644 --- a/config/twig_hooks/admin.yaml +++ b/config/twig_hooks/admin.yaml @@ -25,9 +25,6 @@ sylius_twig_hooks: hf_identifier: template: '@PayPlugSyliusPayPlugPlugin/admin/payment_method/form/hf_identifier.html.twig' priority: -1 - hf_sub_merchant_id: - template: '@PayPlugSyliusPayPlugPlugin/admin/payment_method/form/hf_sub_merchant_id.html.twig' - priority: -2 deferred_capture: template: '@PayPlugSyliusPayPlugPlugin/admin/payment_method/form/deferred_capture.html.twig' priority: -3 diff --git a/grumphp.yml b/grumphp.yml index e8ea3a8c..ae81bef2 100644 --- a/grumphp.yml +++ b/grumphp.yml @@ -21,7 +21,10 @@ grumphp: configuration: 'ruleset/phpstan.neon' use_grumphp_paths: false memory_limit: '-1' - securitychecker_symfony: ~ + securitychecker_composeraudit: + # Abandoned packages are reported, not fatal: the offenders are transitive + # Sylius/Behat deps this plugin cannot replace. Advisories still fail the task. + abandoned: report yamllint: parse_custom_tags: true ecs: diff --git a/migrations/Version20260907140000.php b/migrations/Version20260907140000.php new file mode 100644 index 00000000..f7e37ce8 --- /dev/null +++ b/migrations/Version20260907140000.php @@ -0,0 +1,47 @@ +addSql(<<<'SQL' + UPDATE sylius_gateway_config + SET config = JSON_REMOVE(config, '$.hfSubMerchantId') + WHERE factory_name = 'payplug' + AND JSON_CONTAINS_PATH(config, 'one', '$.hfSubMerchantId') + SQL); + } + + public function down(Schema $schema): void + { + // Deliberately not reversible: the removed value is a credential this migration does not + // retain, and no code version reads the key any more. Rolling the plugin back past + // PRE-3646 means re-entering it in the admin form, which is also the only way to obtain it. + } +} diff --git a/src/Command/Handler/CaptureAliasPaymentRequestHandler.php b/src/Command/Handler/CaptureAliasPaymentRequestHandler.php index c0b304e5..9d4c772d 100644 --- a/src/Command/Handler/CaptureAliasPaymentRequestHandler.php +++ b/src/Command/Handler/CaptureAliasPaymentRequestHandler.php @@ -55,10 +55,10 @@ public function __invoke(CaptureAliasPaymentRequest $captureAliasPaymentRequest) throw new \LogicException('No saved card alias selected for the payment.'); } [$amount, $currencyCode] = $this->contextBuilder->resolveAmountAndCurrency($payment); - [$accountId, $submerchantExternalId] = $this->contextBuilder->resolveGatewayCredentials($method); + $accountId = $this->contextBuilder->resolveGatewayCredentials($method); $order = $this->assertCardBelongsToOrder($card, $payment->getOrder(), $method); - $common = $this->contextBuilder->buildCommonFields($accountId, $amount, $currencyCode, $submerchantExternalId, $paymentRequest, $order); + $common = $this->contextBuilder->buildCommonFields($accountId, $amount, $currencyCode, $paymentRequest, $order); $dto = $this->buildPaymentDto($common, $card, $order); $output = $this->unifiedApiPaymentCreator->createPayment($dto); diff --git a/src/Command/Handler/CaptureHostedPaymentRequestHandler.php b/src/Command/Handler/CaptureHostedPaymentRequestHandler.php index f99b6899..a5efd925 100644 --- a/src/Command/Handler/CaptureHostedPaymentRequestHandler.php +++ b/src/Command/Handler/CaptureHostedPaymentRequestHandler.php @@ -49,7 +49,7 @@ public function __invoke(CaptureHostedPaymentRequest $captureHostedPaymentReques $method = $this->contextBuilder->resolvePaymentMethod($payment); $hfToken = $this->resolveHostedFieldsToken($details); $amountAndCurrency = $this->contextBuilder->resolveAmountAndCurrency($payment); - $credentials = $this->contextBuilder->resolveGatewayCredentials($method); + $accountId = $this->contextBuilder->resolveGatewayCredentials($method); $dto = $this->buildHostedFieldDto( $paymentRequest, @@ -57,7 +57,7 @@ public function __invoke(CaptureHostedPaymentRequest $captureHostedPaymentReques $details, $hfToken, $amountAndCurrency, - $credentials, + $accountId, ); $this->logger->debug('[PayPlug debug] Unified API hosted payment request payload.', [ @@ -104,7 +104,6 @@ private function resolveHostedFieldsToken(array $details): string /** * @param mixed[] $details * @param array{0: int, 1: string} $amountAndCurrency - * @param array{0: string, 1: string} $credentials */ private function buildHostedFieldDto( PaymentRequestInterface $paymentRequest, @@ -112,14 +111,13 @@ private function buildHostedFieldDto( array $details, string $hfToken, array $amountAndCurrency, - array $credentials, + string $accountId, ): HostedFieldDto { [$amount, $currencyCode] = $amountAndCurrency; - [$accountId, $submerchantExternalId] = $credentials; $order = $payment->getOrder(); - $common = $this->contextBuilder->buildCommonFields($accountId, $amount, $currencyCode, $submerchantExternalId, $paymentRequest, $order); + $common = $this->contextBuilder->buildCommonFields($accountId, $amount, $currencyCode, $paymentRequest, $order); $selectedBrand = $details['hosted_fields_selected_brand'] ?? null; $hasSelectedBrand = \is_string($selectedBrand) && '' !== $selectedBrand; diff --git a/src/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtension.php b/src/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtension.php index 273617ad..0a981b72 100644 --- a/src/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtension.php +++ b/src/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtension.php @@ -10,7 +10,6 @@ use Symfony\Component\Form\AbstractTypeExtension; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; -use Symfony\Component\Form\Extension\Core\Type\PasswordType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormError; @@ -62,33 +61,6 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, 'validation_groups' => AbstractGatewayConfigurationType::VALIDATION_GROUPS, ]) - ->add(PayPlugGatewayFactory::HF_SUB_MERCHANT_ID, PasswordType::class, [ - 'label' => 'payplug_sylius_payplug_plugin.ui.hf_sub_merchant_id_label', - 'required' => false, - 'validation_groups' => AbstractGatewayConfigurationType::VALIDATION_GROUPS, - ]) - ->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event): void { - $rawData = $event->getData(); - if (!is_array($rawData)) { - return; - } - - $submitted = $rawData[PayPlugGatewayFactory::HF_SUB_MERCHANT_ID] ?? ''; - if (!is_scalar($submitted) || '' !== trim((string) $submitted)) { - return; - } - - // PasswordType keeps its default `always_empty` (never echoes the stored secret - // back into the rendered `value` attribute), so a blank submission means "left - // untouched", not "clear it" - same convention as a change-password form. Restore - // the previously persisted value instead of letting a blank field wipe it out. - $previousData = $event->getForm()->getData(); - $previousValue = is_array($previousData) ? ($previousData[PayPlugGatewayFactory::HF_SUB_MERCHANT_ID] ?? null) : null; - if (is_string($previousValue) && '' !== $previousValue) { - $rawData[PayPlugGatewayFactory::HF_SUB_MERCHANT_ID] = $previousValue; - $event->setData($rawData); - } - }) ->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void { $data = $event->getData(); // phpstan check @@ -119,15 +91,12 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $submittedData = [ PayPlugGatewayFactory::DISPLAY_MODE_FIELD => $form->get(PayPlugGatewayFactory::DISPLAY_MODE_FIELD)->getData(), PayPlugGatewayFactory::HF_IDENTIFIER => $form->get(PayPlugGatewayFactory::HF_IDENTIFIER)->getData(), - PayPlugGatewayFactory::HF_SUB_MERCHANT_ID => $form->get(PayPlugGatewayFactory::HF_SUB_MERCHANT_ID)->getData(), ]; foreach (PayPlugGatewayFactory::missingHostedFieldsRequirements($submittedData) as $field) { - $messageKey = PayPlugGatewayFactory::HF_IDENTIFIER === $field - ? 'payplug_sylius_payplug_plugin.form.account_id_required' - : 'payplug_sylius_payplug_plugin.form.submerchant_id_required'; - - $form->get($field)->addError(new FormError($this->translator->trans($messageKey))); + $form->get($field)->addError(new FormError( + $this->translator->trans('payplug_sylius_payplug_plugin.form.account_id_required'), + )); } }) ->addEventListener(FormEvents::SUBMIT, function (FormEvent $event): void { diff --git a/src/Gateway/PayPlugGatewayFactory.php b/src/Gateway/PayPlugGatewayFactory.php index 1b2b5dd3..5321a757 100644 --- a/src/Gateway/PayPlugGatewayFactory.php +++ b/src/Gateway/PayPlugGatewayFactory.php @@ -28,8 +28,6 @@ final class PayPlugGatewayFactory extends AbstractGatewayFactory public const HF_IDENTIFIER = 'hfIdentifier'; - public const HF_SUB_MERCHANT_ID = 'hfSubMerchantId'; - // Unmapped admin form field driving INTEGRATED_PAYMENT/HOSTED_FIELDS below public const DISPLAY_MODE_FIELD = 'hostedFieldsMode'; @@ -40,7 +38,7 @@ final class PayPlugGatewayFactory extends AbstractGatewayFactory /** * Derives the admin form radio's initial selection from persisted config. * Hosted Fields wins if both flags are somehow true, since only it carries the - * mandatory Account ID / SubMerchant ID fields the merchant would otherwise lose sight of. + * mandatory Account ID field the merchant would otherwise lose sight of. */ public static function resolveDisplayMode(array $config): ?string { @@ -70,14 +68,13 @@ public static function resolveDisplayModeFlags(?string $displayMode): array } /** - * @param array $rawFormData Display-mode/HF-identifier/HF-sub-merchant-id - * values as submitted (assembled from the config - * form's already-submitted child forms at - * POST_SUBMIT, not PRE_SUBMIT's raw payload). + * @param array $rawFormData Display-mode/HF-identifier values as submitted + * (assembled from the config form's already-submitted + * child forms at POST_SUBMIT, not PRE_SUBMIT's raw + * payload). * - * @return list Config keys (HF_IDENTIFIER / HF_SUB_MERCHANT_ID) that are blank - * while hosted_fields is selected; empty if hosted_fields isn't selected - * or both fields are filled. + * @return list Config keys (HF_IDENTIFIER) that are blank while hosted_fields is + * selected; empty if hosted_fields isn't selected or the field is filled. */ public static function missingHostedFieldsRequirements(array $rawFormData): array { @@ -89,9 +86,6 @@ public static function missingHostedFieldsRequirements(array $rawFormData): arra if (self::isBlank($rawFormData[self::HF_IDENTIFIER] ?? '')) { $missing[] = self::HF_IDENTIFIER; } - if (self::isBlank($rawFormData[self::HF_SUB_MERCHANT_ID] ?? '')) { - $missing[] = self::HF_SUB_MERCHANT_ID; - } return $missing; } diff --git a/src/PaymentProcessing/RefundPaymentProcessor.php b/src/PaymentProcessing/RefundPaymentProcessor.php index da51fa72..b7fb4e9a 100644 --- a/src/PaymentProcessing/RefundPaymentProcessor.php +++ b/src/PaymentProcessing/RefundPaymentProcessor.php @@ -184,6 +184,7 @@ private function processHostedFields(PaymentInterface $payment): void $details['hosted_fields_payment_id'], PaymentOrderIdResolver::resolve($payment->getOrder(), $payment->getId()), null, + $payment->getCurrencyCode(), ['sylius_payment_id' => $payment->getId()], ); @@ -253,6 +254,11 @@ private function logIfOperationIdMissing(?string $externalId, string $responseBo * genuinely identical between the two callers, $amount aside (null here means a full refund; * a given value means a partial one). * + * $currency is required rather than defaulted, so that a future caller cannot silently omit it + * and fall back to letting the platform infer $amount's minor units from the account — which is + * only unambiguous for a single-currency merchant. Pass $payment->getCurrencyCode(); it is + * nullable at the Sylius contract level, and null legitimately reaches UPC as "not supplied". + * * @param mixed[] $logContext */ private function createRefundOperation( @@ -260,9 +266,10 @@ private function createRefundOperation( string $hostedFieldsPaymentId, string $orderId, ?int $amount, + ?string $currency, array $logContext, ): ?string { - $response = $this->refundCreator->createRefund($method, $hostedFieldsPaymentId, $orderId, $amount); + $response = $this->refundCreator->createRefund($method, $hostedFieldsPaymentId, $orderId, $amount, $currency); $externalId = self::extractFirstOperationId($response['body']); $this->logIfOperationIdMissing($externalId, $response['body'], $logContext); @@ -383,6 +390,7 @@ function () use ($payment, $method, $amount, $refundId): void { $details['hosted_fields_payment_id'], PaymentOrderIdResolver::resolve($payment->getOrder(), $payment->getId()), $amount, + $payment->getCurrencyCode(), ['refund_id' => $refundId], ); diff --git a/src/Provider/SupportedMethodsProvider.php b/src/Provider/SupportedMethodsProvider.php index 352665db..572d27e6 100644 --- a/src/Provider/SupportedMethodsProvider.php +++ b/src/Provider/SupportedMethodsProvider.php @@ -5,6 +5,7 @@ namespace PayPlug\SyliusPayPlugPlugin\Provider; use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface; +use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; use Sylius\Component\Core\Model\PaymentMethodInterface; use Sylius\Component\Currency\Context\CurrencyContextInterface; use Sylius\Component\Payment\Model\GatewayConfigInterface; @@ -18,13 +19,27 @@ public function __construct( ) { } + /** + * $paymentCurrencyCode must be the currency $paymentAmount is denominated in — i.e. the + * payment's own, which Sylius copies from the order (OrderPaymentProcessor sets both amount and + * currency from $order). Passing it in rather than reading CurrencyContextInterface here is + * what keeps the two halves of every comparison below in the same currency: the context holds + * the currency being *displayed* right now, which on a multi-currency channel need not be the + * one the order was placed in — and comparing an amount against another currency's min/max + * silently filters the wrong methods. + * + * The context remains the fallback for a payment with no currency of its own, which the Sylius + * contract permits (PaymentInterface::getCurrencyCode() is nullable) even though the checkout + * flow always sets one. + */ public function provide( array $supportedMethods, string $factoryName, int $paymentAmount, + ?string $paymentCurrencyCode = null, ?string $billingCountryCode = null, ): array { - $activeCurrencyCode = $this->currencyContext->getCurrencyCode(); + $activeCurrencyCode = $paymentCurrencyCode ?? $this->currencyContext->getCurrencyCode(); $authorizedCurrencies = null; $allowedCountries = null; @@ -48,8 +63,23 @@ public function provide( } if (!\array_key_exists($activeCurrencyCode, $authorizedCurrencies)) { - unset($supportedMethods[$key]); - + // Unified Hosted Fields is exempt from the currency gate, pending a source of truth + // that actually knows a UHF account's currencies. The Retail `/account` payload only + // advertises the legacy acquiring setup (`configuration.min_amounts` / + // `max_amounts`), which is demonstrably wrong for a UHF account: account 1461487 + // reports `currencies: ['EUR']` while a USD payment on that same account completed + // on staging (schemeTransactionId 6a9ad8eb905d3). Hiding a method that works is the + // worse failure, so UHF is kept and left to the API to accept or refuse. + // + // Every other gateway keeps the gate: Bancontact, Scalapay, Apple Pay, American + // Express and Wero are EUR-only, and `payplug` in integrated_payment mode is served + // by the very legacy setup this payload does describe correctly. + if (!PayPlugGatewayFactory::isHostedFieldsConfig($gatewayConfig)) { + unset($supportedMethods[$key]); + } + + // An unadvertised currency carries no amount limits either, so a method kept above + // skips the bounds check rather than indexing a missing key. continue; } diff --git a/src/Resolver/AmericanExpressPaymentMethodsResolverDecorator.php b/src/Resolver/AmericanExpressPaymentMethodsResolverDecorator.php index d525bf61..8f421ca8 100644 --- a/src/Resolver/AmericanExpressPaymentMethodsResolverDecorator.php +++ b/src/Resolver/AmericanExpressPaymentMethodsResolverDecorator.php @@ -37,7 +37,8 @@ public function getSupportedMethods(BasePaymentInterface $subject): array $supportedMethods, AmericanExpressGatewayFactory::FACTORY_NAME, $subject->getAmount() ?? 0, - $billingCountryCode, + paymentCurrencyCode: $subject->getCurrencyCode(), + billingCountryCode: $billingCountryCode, ); } diff --git a/src/Resolver/ApplePayPaymentMethodsResolverDecorator.php b/src/Resolver/ApplePayPaymentMethodsResolverDecorator.php index 5205eaf7..99cb874c 100644 --- a/src/Resolver/ApplePayPaymentMethodsResolverDecorator.php +++ b/src/Resolver/ApplePayPaymentMethodsResolverDecorator.php @@ -37,7 +37,8 @@ public function getSupportedMethods(BasePaymentInterface $subject): array $supportedMethods, ApplePayGatewayFactory::FACTORY_NAME, $subject->getAmount() ?? 0, - $billingCountryCode, + paymentCurrencyCode: $subject->getCurrencyCode(), + billingCountryCode: $billingCountryCode, ); } diff --git a/src/Resolver/BancontactPaymentMethodsResolverDecorator.php b/src/Resolver/BancontactPaymentMethodsResolverDecorator.php index c003b444..56ad0753 100644 --- a/src/Resolver/BancontactPaymentMethodsResolverDecorator.php +++ b/src/Resolver/BancontactPaymentMethodsResolverDecorator.php @@ -37,7 +37,8 @@ public function getSupportedMethods(BasePaymentInterface $subject): array $supportedMethods, BancontactGatewayFactory::FACTORY_NAME, $subject->getAmount() ?? 0, - $billingCountryCode, + paymentCurrencyCode: $subject->getCurrencyCode(), + billingCountryCode: $billingCountryCode, ); } diff --git a/src/Resolver/OneyPaymentMethodsResolverDecorator.php b/src/Resolver/OneyPaymentMethodsResolverDecorator.php index 73802220..bb8bde70 100644 --- a/src/Resolver/OneyPaymentMethodsResolverDecorator.php +++ b/src/Resolver/OneyPaymentMethodsResolverDecorator.php @@ -36,7 +36,11 @@ public function getSupportedMethods(BasePaymentInterface $subject): array /** @var OrderInterface $order */ $order = $subject->getOrder(); - $activeCurrencyCode = $this->currencyContext->getCurrencyCode(); + // The payment's own currency, not the one currently displayed: isPriceEligible() below + // compares $subject->getAmount() against Oney's per-currency bounds, and on a + // multi-currency channel the display currency need not be the order's. Falls back to the + // context for a payment carrying no currency, which the Sylius contract allows. + $activeCurrencyCode = $subject->getCurrencyCode() ?? $this->currencyContext->getCurrencyCode(); foreach ($supportedMethods as $key => $paymentMethod) { Assert::isInstanceOf($paymentMethod, PaymentMethodInterface::class); diff --git a/src/Resolver/PayPlugPaymentMethodsResolverDecorator.php b/src/Resolver/PayPlugPaymentMethodsResolverDecorator.php index 39412fa1..67d652da 100644 --- a/src/Resolver/PayPlugPaymentMethodsResolverDecorator.php +++ b/src/Resolver/PayPlugPaymentMethodsResolverDecorator.php @@ -37,7 +37,8 @@ public function getSupportedMethods(BasePaymentInterface $subject): array $supportedMethods, PayPlugGatewayFactory::FACTORY_NAME, $subject->getAmount() ?? 0, - $billingCountryCode, + paymentCurrencyCode: $subject->getCurrencyCode(), + billingCountryCode: $billingCountryCode, ); } diff --git a/src/Resolver/ScalapayPaymentMethodsResolverDecorator.php b/src/Resolver/ScalapayPaymentMethodsResolverDecorator.php index 05664c39..a3856c30 100644 --- a/src/Resolver/ScalapayPaymentMethodsResolverDecorator.php +++ b/src/Resolver/ScalapayPaymentMethodsResolverDecorator.php @@ -37,7 +37,8 @@ public function getSupportedMethods(BasePaymentInterface $subject): array $supportedMethods, ScalapayGatewayFactory::FACTORY_NAME, $subject->getAmount() ?? 0, - $billingCountryCode, + paymentCurrencyCode: $subject->getCurrencyCode(), + billingCountryCode: $billingCountryCode, ); } diff --git a/src/Resolver/WeroPaymentMethodsResolverDecorator.php b/src/Resolver/WeroPaymentMethodsResolverDecorator.php index 0c8b5601..8c342934 100644 --- a/src/Resolver/WeroPaymentMethodsResolverDecorator.php +++ b/src/Resolver/WeroPaymentMethodsResolverDecorator.php @@ -37,7 +37,8 @@ public function getSupportedMethods(BasePaymentInterface $subject): array $supportedMethods, WeroGatewayFactory::FACTORY_NAME, $subject->getAmount() ?? 0, - $billingCountryCode, + paymentCurrencyCode: $subject->getCurrencyCode(), + billingCountryCode: $billingCountryCode, ); } diff --git a/src/Upc/GatewayCredentialsResolver.php b/src/Upc/GatewayCredentialsResolver.php index 392f9624..6650b8d5 100644 --- a/src/Upc/GatewayCredentialsResolver.php +++ b/src/Upc/GatewayCredentialsResolver.php @@ -31,17 +31,20 @@ private function __construct() * Hosted-Fields-configured payment method must have each payment/refund routed to the account * it actually belongs to, never "whichever Hosted Fields config happens to match first." * - * @return array{0: string, 1: string} accountId, submerchantExternalId + * Returns the account id alone. A submerchantExternalId belongs to the UDV/MID configuration + * for the payment's currency — the EUR ones carry one, the other-currency ones do not — and + * this plugin's Hosted Fields flow targets the multi-currency configurations (EUR is served by + * Integrated Payment instead). So there is never one to contribute here, and UPC omits that key + * entirely when none is supplied. */ - public static function resolve(PaymentMethodInterface $method): array + public static function resolve(PaymentMethodInterface $method): string { $gatewayConfig = $method->getGatewayConfig()?->getConfig() ?? []; $accountId = $gatewayConfig[PayPlugGatewayFactory::HF_IDENTIFIER] ?? null; - $submerchantExternalId = $gatewayConfig[PayPlugGatewayFactory::HF_SUB_MERCHANT_ID] ?? null; - if (!\is_string($accountId) || '' === $accountId || !\is_string($submerchantExternalId) || '' === $submerchantExternalId) { - throw new \LogicException('Hosted Fields account id or submerchant id is not configured for this payment method.'); + if (!\is_string($accountId) || '' === $accountId) { + throw new \LogicException('Hosted Fields account id is not configured for this payment method.'); } - return [$accountId, $submerchantExternalId]; + return $accountId; } } diff --git a/src/Upc/PaymentCaptureContextBuilder.php b/src/Upc/PaymentCaptureContextBuilder.php index d7d1da70..908aa127 100644 --- a/src/Upc/PaymentCaptureContextBuilder.php +++ b/src/Upc/PaymentCaptureContextBuilder.php @@ -35,10 +35,7 @@ public function __construct( ) { } - /** - * @return array{0: string, 1: string} accountId, submerchantExternalId - */ - public function resolveGatewayCredentials(PaymentMethodInterface $method): array + public function resolveGatewayCredentials(PaymentMethodInterface $method): string { return GatewayCredentialsResolver::resolve($method); } @@ -47,12 +44,13 @@ public function buildCommonFields( string $accountId, int $amount, string $currencyCode, - string $submerchantExternalId, PaymentRequestInterface $paymentRequest, ?OrderInterface $order, ): CommonFieldsDto { $orderId = PaymentOrderIdResolver::resolve($order, $paymentRequest->getPayment()->getId()); - $common = new CommonFieldsDto($accountId, $amount, \strtoupper($currencyCode), $orderId, $submerchantExternalId); + // No submerchantExternalId: only the EUR MID configurations carry one, and Hosted Fields + // here targets the multi-currency ones. UPC omits the key entirely rather than sending null. + $common = new CommonFieldsDto($accountId, $amount, \strtoupper($currencyCode), $orderId); $common->description = $this->resolveDescription($order); // 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 diff --git a/src/Upc/PaymentCaptureOutcomeApplier.php b/src/Upc/PaymentCaptureOutcomeApplier.php index 7ac52b62..bb24c40b 100644 --- a/src/Upc/PaymentCaptureOutcomeApplier.php +++ b/src/Upc/PaymentCaptureOutcomeApplier.php @@ -13,6 +13,8 @@ use Sylius\Component\Core\Model\PaymentInterface; use Sylius\Component\Payment\Model\PaymentRequestInterface; use Sylius\Component\Payment\PaymentRequestTransitions; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Session\FlashBagAwareSessionInterface; /** * Shared by CaptureHostedPaymentRequestHandler and CaptureAliasPaymentRequestHandler, the two @@ -21,10 +23,21 @@ */ final class PaymentCaptureOutcomeApplier { + // Deliberately generic, and deliberately NOT the caught exception's own message: that text + // comes straight from the Unified API and routinely describes our own infrastructure or the + // merchant's account configuration (a 403 reads "The IP address "10.x.x.x" is not allowed to + // access this account."). The detail stays in the log and in the PaymentRequest's + // response_data; the shopper only needs to know the payment didn't go through and their card + // wasn't charged. Not the legacy error.transaction_failed_1click key, whose wording is + // identical but whose name only fits the one-click flow — this applier serves both capture + // paths. + private const SHOPPER_ERROR_FLASH_KEY = 'payplug_sylius_payplug_plugin.error.transaction_failed'; + public function __construct( private LoggerInterface $logger, private StateMachineInterface $stateMachine, private IOrderStateMutator $orderStateMutator, + private RequestStack $requestStack, ) { } @@ -39,9 +52,36 @@ public function failPaymentRequest( 'error' => $e->getMessage(), ]); $paymentRequest->setResponseData(['error' => $e->getMessage()]); + $this->notifyShopper(); $this->stateMachine->apply($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL); } + /** + * Without this the capture failure is entirely silent to the customer: the Payment stays + * "new" and the order "awaiting_payment" (both intentional — the payment is still + * retryable), so they are simply redirected to the order summary with no indication that + * anything went wrong. + * + * A missing session is not an error to report: this runs from the CLI + * (UpdatePaymentStateCommand) and from worker contexts too, where Request::getSession() + * throws. There is no shopper to tell in those cases, and a failed payment must not turn + * into a 500 because there was nowhere to put the message. + */ + private function notifyShopper(): void + { + $request = $this->requestStack->getMainRequest(); + if (null === $request || !$request->hasSession()) { + return; + } + + $session = $request->getSession(); + if (!$session instanceof FlashBagAwareSessionInterface) { + return; + } + + $session->getFlashBag()->add('error', self::SHOPPER_ERROR_FLASH_KEY); + } + public function applyOutcome( PaymentRequestInterface $paymentRequest, PaymentInterface $payment, diff --git a/src/Upc/RefundCreatorInterface.php b/src/Upc/RefundCreatorInterface.php index e692005d..3e84bff4 100644 --- a/src/Upc/RefundCreatorInterface.php +++ b/src/Upc/RefundCreatorInterface.php @@ -12,9 +12,12 @@ interface RefundCreatorInterface { /** - * $method identifies which Hosted Fields account/submerchant to refund against — the same - * payment method the refunded payment was originally captured with, not merely "any" - * Hosted-Fields-configured payment method, since a merchant may configure more than one. + * $method identifies which Hosted Fields account to refund against — the same payment method + * the refunded payment was originally captured with, not merely "any" Hosted-Fields-configured + * payment method, since a merchant may configure more than one. + * + * $currency states what $amount's minor units are. The Unified API infers them from the account + * when the key is absent, which is only unambiguous for a single-currency merchant. * * @return array{status: int, body: string} * @@ -27,5 +30,6 @@ public function createRefund( string $operationId, string $orderId, ?int $amount = null, + ?string $currency = null, ): array; } diff --git a/src/Upc/UnifiedApiRefundCreator.php b/src/Upc/UnifiedApiRefundCreator.php index ae457303..efbee8c8 100644 --- a/src/Upc/UnifiedApiRefundCreator.php +++ b/src/Upc/UnifiedApiRefundCreator.php @@ -25,8 +25,9 @@ public function createRefund( string $operationId, string $orderId, ?int $amount = null, + ?string $currency = null, ): array { - [$accountId, $subMerchantExternalId] = GatewayCredentialsResolver::resolve($method); + $accountId = GatewayCredentialsResolver::resolve($method); $service = new UnifiedApiPaymentService( $this->httpClient, @@ -41,8 +42,9 @@ public function createRefund( $accountId, $orderId, \sprintf('Refund for order %s', $orderId), - $subMerchantExternalId, + null, $amount, + $currency, ); } } diff --git a/templates/admin/payment_method/form/hf_sub_merchant_id.html.twig b/templates/admin/payment_method/form/hf_sub_merchant_id.html.twig deleted file mode 100644 index 1f2d5696..00000000 --- a/templates/admin/payment_method/form/hf_sub_merchant_id.html.twig +++ /dev/null @@ -1,5 +0,0 @@ -{% set form = hookable_metadata.context.form.gatewayConfig.config.hfSubMerchantId %} - -
- {{ form_row(form) }} -
diff --git a/templates/shop/hosted_fields/index.html.twig b/templates/shop/hosted_fields/index.html.twig index 9adaba0a..8d3a33d2 100644 --- a/templates/shop/hosted_fields/index.html.twig +++ b/templates/shop/hosted_fields/index.html.twig @@ -4,6 +4,10 @@ var payplug_hosted_fields_params = { companyId: '{{ payplug_hosted_fields_company_id(paymentMethod)|e('js') }}', payment_method_code: '{{ paymentMethod.code|e('js') }}', + {# Emitted as a real JS boolean, unlike integrated/index.html.twig's quoted interpolation of + the same flag — that one only happens to work because Twig renders false as an empty + string, and would hand JS a truthy "false" the moment it stopped doing so. #} + has_saved_cards: {{ hasSavedCards|default(false) ? 'true' : 'false' }}, locale: '{{ app.request.locale|split('_')|first }}', error: { tokenization_failed: '{{ 'payplug_sylius_payplug_plugin.ui.hosted_fields.error.tokenization_failed'|trans|e('js') }}', @@ -15,7 +19,12 @@
{{ 'sylius.ui.loading'|trans }} diff --git a/templates/shop/select_payment/_payplug.html.twig b/templates/shop/select_payment/_payplug.html.twig index 5985554f..e7a2630a 100644 --- a/templates/shop/select_payment/_payplug.html.twig +++ b/templates/shop/select_payment/_payplug.html.twig @@ -1,41 +1,33 @@ {% set form = hookable_metadata.context.form %} {% set method = hookable_metadata.context.method %} {% set order = hookable_metadata.context.order %} -{% set factoryName = method.gatewayConfig.factoryName %} -{% set code = method.code %} -{% set payplugFactoryName = constant('PayPlug\\SyliusPayPlugPlugin\\Gateway\\PayplugGatewayFactory::FACTORY_NAME') %} -{% set checkboxClass = 'checkbox-payplug' %} - -{% set hasSavedCards = false %} -{% if is_granted('ROLE_USER') - and form.parent.parent.payplug_card_choice is defined - and is_save_card_enabled(method) - and sylius.customer.cards is not empty -%} - {% set hasSavedCards = true %} +{% set has_saved_cards = false %} +{% if is_granted('ROLE_USER') and form.parent.parent.payplug_card_choice is defined and is_save_card_enabled(method) and sylius.customer.cards is not empty %} + {% set has_saved_cards = true %} {% endif %} -{% set displayMode = payplug_display_mode(method.gatewayConfig.config) %} -{% set hostedFields = displayMode == 'hosted_fields' %} -{% set integratedPayment = displayMode == 'integrated_payment' %} +{% set display_mode = payplug_display_mode(method.gatewayConfig.config) %} +{% set is_hosted_fields = display_mode == 'hosted_fields' %} +{% set is_integrated_payment = display_mode == 'integrated_payment' %} -
- {% if hasSavedCards %} +
+ {% if has_saved_cards %}
{{ form_row(form.parent.parent.payplug_card_choice) }}
{% endif %} - {% if hostedFields %} + {% if is_hosted_fields %} {% include '@PayPlugSyliusPayPlugPlugin/shop/hosted_fields/index.html.twig' with { 'paymentMethod': method, + 'hasSavedCards': has_saved_cards, } %} - {% elseif integratedPayment %} + {% elseif is_integrated_payment %} {% include '@PayPlugSyliusPayPlugPlugin/shop/integrated/index.html.twig' with { 'paymentMethod': method, 'payment': order.getLastPayment('cart'), - 'hasSavedCards': hasSavedCards, + 'hasSavedCards': has_saved_cards, 'paymentInputId': form.vars.id, } %} {% endif %} diff --git a/tests/Behat/Context/Setup/PayPlugContext.php b/tests/Behat/Context/Setup/PayPlugContext.php index ad35eb27..879059da 100644 --- a/tests/Behat/Context/Setup/PayPlugContext.php +++ b/tests/Behat/Context/Setup/PayPlugContext.php @@ -86,7 +86,6 @@ public function theStoreHasAPaymentMethodWithACodeAndPayPlugHostedFieldsPaymentG 'payum.http_client' => '@payplug_sylius_payplug_plugin.api_client.payplug', PayPlugGatewayFactory::HOSTED_FIELDS => true, PayPlugGatewayFactory::HF_IDENTIFIER => 'test-company-id', - PayPlugGatewayFactory::HF_SUB_MERCHANT_ID => 'test-submerchant-id', ]); $this->paymentMethodManager->flush(); diff --git a/tests/PHPUnit/Command/Handler/CaptureAliasPaymentRequestHandlerTest.php b/tests/PHPUnit/Command/Handler/CaptureAliasPaymentRequestHandlerTest.php index 987b7fd7..31281589 100644 --- a/tests/PHPUnit/Command/Handler/CaptureAliasPaymentRequestHandlerTest.php +++ b/tests/PHPUnit/Command/Handler/CaptureAliasPaymentRequestHandlerTest.php @@ -95,7 +95,7 @@ protected function setUp(): void $this->unifiedApiPaymentCreator, new SelectedCardResolver($this->requestStack, $this->payplugCardRepository), new PaymentCaptureContextBuilder($this->urlGenerator, $this->afterPayUrlProvider, new OrderAddressDtoCreator(), $this->requestStack), - new PaymentCaptureOutcomeApplier($this->logger, $this->stateMachine, $this->orderStateMutator), + new PaymentCaptureOutcomeApplier($this->logger, $this->stateMachine, $this->orderStateMutator, $this->requestStack), ); } @@ -113,7 +113,7 @@ protected function setUp(): void */ private function paymentRequestWithSelectedCard( ?Card $card, - ?array $gatewayConfig = ['hfIdentifier' => 'acct_123', 'hfSubMerchantId' => 'sub_ext_1'], + ?array $gatewayConfig = ['hfIdentifier' => 'acct_123'], ?CustomerInterface $cardCustomer = null, ?AddressInterface $billingAddress = null, ?PaymentMethodInterface $cardPaymentMethod = null, diff --git a/tests/PHPUnit/Command/Handler/CaptureHostedPaymentRequestHandlerTest.php b/tests/PHPUnit/Command/Handler/CaptureHostedPaymentRequestHandlerTest.php index c8c4fbef..7444c0ad 100644 --- a/tests/PHPUnit/Command/Handler/CaptureHostedPaymentRequestHandlerTest.php +++ b/tests/PHPUnit/Command/Handler/CaptureHostedPaymentRequestHandlerTest.php @@ -90,7 +90,7 @@ protected function setUp(): void $this->unifiedApiPaymentCreator, $this->operationStatusFetcher, new PaymentCaptureContextBuilder($this->urlGenerator, $this->afterPayUrlProvider, new OrderAddressDtoCreator(), $this->requestStack), - new PaymentCaptureOutcomeApplier($this->logger, $this->stateMachine, $this->orderStateMutator), + new PaymentCaptureOutcomeApplier($this->logger, $this->stateMachine, $this->orderStateMutator, $this->requestStack), new PayplugCardPersister($this->payplugCardFactory, $this->payplugCardRepository, $this->managerRegistry), $this->logger, ); @@ -100,7 +100,7 @@ private function paymentRequestWithPayment( array $details, int $amount = 1000, string $currency = 'EUR', - ?array $gatewayConfig = ['hfIdentifier' => 'acct_123', 'hfSubMerchantId' => 'sub_ext_1'], + ?array $gatewayConfig = ['hfIdentifier' => 'acct_123'], ?AddressInterface $billingAddress = null, ): PaymentRequestInterface&MockObject { @@ -195,7 +195,7 @@ public function testInvoke_whenCustomerEmailIsMissing_failsThePaymentRequestInst { $method = $this->createMock(PaymentMethodInterface::class); $gatewayConfig = $this->createMock(GatewayConfigInterface::class); - $gatewayConfig->method('getConfig')->willReturn(['hfIdentifier' => 'acct_123', 'hfSubMerchantId' => 'sub_ext_1']); + $gatewayConfig->method('getConfig')->willReturn(['hfIdentifier' => 'acct_123']); $method->method('getGatewayConfig')->willReturn($gatewayConfig); $customer = $this->createMock(CustomerInterface::class); $customer->method('getEmail')->willReturn(null); @@ -590,7 +590,7 @@ public function testInvoke_whenSaveCardRequestedButMethodIsNotCorePaymentMethod_ // card entirely rather than flushing one with that mandatory field left unset. $method = $this->createMock(BasePaymentMethodInterface::class); $gatewayConfig = $this->createMock(GatewayConfigInterface::class); - $gatewayConfig->method('getConfig')->willReturn(['hfIdentifier' => 'acct_123', 'hfSubMerchantId' => 'sub_ext_1']); + $gatewayConfig->method('getConfig')->willReturn(['hfIdentifier' => 'acct_123']); $method->method('getGatewayConfig')->willReturn($gatewayConfig); $customer = $this->createMock(CustomerInterface::class); $customer->method('getId')->willReturn(7); diff --git a/tests/PHPUnit/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtensionFormSubmissionTest.php b/tests/PHPUnit/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtensionFormSubmissionTest.php index ec539dee..861aad54 100644 --- a/tests/PHPUnit/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtensionFormSubmissionTest.php +++ b/tests/PHPUnit/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtensionFormSubmissionTest.php @@ -24,7 +24,7 @@ /** * Regression coverage for the "conditional-required errors are silently discarded" bug: the - * old PRE_SUBMIT listener added FormErrors to the hfIdentifier/hfSubMerchantId children, but + * old PRE_SUBMIT listener added FormErrors to the hfIdentifier child, but * Form::submit() resets every form's $errors to [] at the very start of ITS OWN submission, and * children submit immediately after the parent's PRE_SUBMIT fires - wiping out any error added * to a child during the parent's PRE_SUBMIT before submission finishes. @@ -41,8 +41,6 @@ final class PayPlugGatewayConfigurationTypeExtensionFormSubmissionTest extends T private const ACCOUNT_ID_ERROR = 'payplug_sylius_payplug_plugin.form.account_id_required'; - private const SUBMERCHANT_ID_ERROR = 'payplug_sylius_payplug_plugin.form.submerchant_id_required'; - protected function getTypes(): array { $translator = $this->createMock(TranslatorInterface::class); @@ -71,7 +69,7 @@ protected function getTypeExtensions(): array ]; } - public function testSubmit_hostedFieldsModeWithBothFieldsBlank_isInvalidWithBothErrors(): void + public function testSubmit_hostedFieldsModeWithBlankIdentifier_isInvalidWithAccountIdError(): void { $form = $this->createRootForm(); @@ -82,26 +80,25 @@ public function testSubmit_hostedFieldsModeWithBothFieldsBlank_isInvalidWithBoth PayPlugGatewayFactory::DEFERRED_CAPTURE => false, PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_HOSTED_FIELDS, PayPlugGatewayFactory::HF_IDENTIFIER => '', - PayPlugGatewayFactory::HF_SUB_MERCHANT_ID => '', ], ], ]); self::assertTrue($form->isSubmitted()); - self::assertFalse($form->isValid(), 'Form must be invalid when hosted_fields is selected but both fields are blank.'); + self::assertFalse($form->isValid(), 'Form must be invalid when hosted_fields is selected but the account id is blank.'); $configForm = $form->get('gatewayConfig')->get('config'); $identifierErrors = $configForm->get(PayPlugGatewayFactory::HF_IDENTIFIER)->getErrors(); self::assertCount(1, $identifierErrors); self::assertSame(self::ACCOUNT_ID_ERROR, $identifierErrors[0]->getMessage()); - - $subMerchantErrors = $configForm->get(PayPlugGatewayFactory::HF_SUB_MERCHANT_ID)->getErrors(); - self::assertCount(1, $subMerchantErrors); - self::assertSame(self::SUBMERCHANT_ID_ERROR, $subMerchantErrors[0]->getMessage()); } - public function testSubmit_hostedFieldsModeWithBothFieldsFilled_isValid(): void + /** + * The account id is now the only hosted-fields requirement — the SubMerchant ID field it used + * to be paired with is gone, so filling this one alone must be enough to save the form. + */ + public function testSubmit_hostedFieldsModeWithIdentifierFilled_isValid(): void { $form = $this->createRootForm(); @@ -112,7 +109,6 @@ public function testSubmit_hostedFieldsModeWithBothFieldsFilled_isValid(): void PayPlugGatewayFactory::DEFERRED_CAPTURE => false, PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_HOSTED_FIELDS, PayPlugGatewayFactory::HF_IDENTIFIER => 'acct_123', - PayPlugGatewayFactory::HF_SUB_MERCHANT_ID => 'sub_456', ], ], ]); @@ -158,7 +154,6 @@ public function testSetData_existingHostedFieldsConfig_preselectsHostedFieldsRad PayPlugGatewayFactory::INTEGRATED_PAYMENT => false, PayPlugGatewayFactory::HOSTED_FIELDS => true, PayPlugGatewayFactory::HF_IDENTIFIER => 'acct_123', - PayPlugGatewayFactory::HF_SUB_MERCHANT_ID => 'sub_456', ]); self::assertSame( @@ -185,7 +180,7 @@ public function testSetData_neitherFlagSet_leavesRadioUnselected(): void public function testSubmit_integratedPaymentMode_withBlankFields_isValid(): void { // The conditional requirement only applies to hosted_fields; other modes must not be - // affected by blank identifier/sub-merchant fields. + // affected by a blank identifier field. $form = $this->createRootForm(); $form->submit([ @@ -195,7 +190,6 @@ public function testSubmit_integratedPaymentMode_withBlankFields_isValid(): void PayPlugGatewayFactory::DEFERRED_CAPTURE => false, PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_INTEGRATED_PAYMENT, PayPlugGatewayFactory::HF_IDENTIFIER => '', - PayPlugGatewayFactory::HF_SUB_MERCHANT_ID => '', ], ], ]); @@ -204,14 +198,11 @@ public function testSubmit_integratedPaymentMode_withBlankFields_isValid(): void } /** - * `hfSubMerchantId` is a `PasswordType` field kept at Symfony's default `always_empty` (never - * echoes the stored secret back into the rendered `value` attribute, unlike the earlier - * `always_empty => false` this replaces). Leaving it blank on an already-configured payment - * method must be read as "unchanged", not "clear it" - the same convention as a - * change-password form - otherwise every edit that doesn't retype the SubMerchant ID would - * silently wipe it. + * A payment method configured before the SubMerchant ID field was removed still carries + * `hfSubMerchantId` in its stored config. Re-saving it must not fail on the now-unknown key, + * and the leftover value is simply ignored — GatewayCredentialsResolver no longer reads it. */ - public function testSubmit_hostedFieldsModeWithBlankSubMerchantIdAndPreviousValueExists_preservesPreviousValue(): void + public function testSubmit_hostedFieldsModeWithALeftoverSubMerchantIdInStoredConfig_isValid(): void { $form = $this->createRootForm(); $configForm = $form->get('gatewayConfig')->get('config'); @@ -220,7 +211,7 @@ public function testSubmit_hostedFieldsModeWithBlankSubMerchantIdAndPreviousValu PayPlugGatewayFactory::DEFERRED_CAPTURE => false, PayPlugGatewayFactory::HOSTED_FIELDS => true, PayPlugGatewayFactory::HF_IDENTIFIER => 'acct_123', - PayPlugGatewayFactory::HF_SUB_MERCHANT_ID => 'sub_456', + 'hfSubMerchantId' => 'sub_456', ]); $form->submit([ @@ -230,51 +221,11 @@ public function testSubmit_hostedFieldsModeWithBlankSubMerchantIdAndPreviousValu PayPlugGatewayFactory::DEFERRED_CAPTURE => false, PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_HOSTED_FIELDS, PayPlugGatewayFactory::HF_IDENTIFIER => 'acct_123', - PayPlugGatewayFactory::HF_SUB_MERCHANT_ID => '', - ], - ], - ]); - - self::assertTrue($form->isValid(), 'Leaving SubMerchant ID blank on an already-configured payment method must not be treated as missing.'); - self::assertSame( - 'sub_456', - $configForm->getData()[PayPlugGatewayFactory::HF_SUB_MERCHANT_ID] ?? null, - ); - } - - /** - * The blank-preserves-previous-value convention above must not prevent an admin from actually - * changing the SubMerchant ID - only a blank submission falls back to the previous value. - */ - public function testSubmit_hostedFieldsModeWithNewSubMerchantIdValue_overwritesPreviousValue(): void - { - $form = $this->createRootForm(); - $configForm = $form->get('gatewayConfig')->get('config'); - $configForm->setData([ - PayPlugGatewayFactory::ONE_CLICK => false, - PayPlugGatewayFactory::DEFERRED_CAPTURE => false, - PayPlugGatewayFactory::HOSTED_FIELDS => true, - PayPlugGatewayFactory::HF_IDENTIFIER => 'acct_123', - PayPlugGatewayFactory::HF_SUB_MERCHANT_ID => 'sub_456', - ]); - - $form->submit([ - 'gatewayConfig' => [ - 'config' => [ - PayPlugGatewayFactory::ONE_CLICK => false, - PayPlugGatewayFactory::DEFERRED_CAPTURE => false, - PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_HOSTED_FIELDS, - PayPlugGatewayFactory::HF_IDENTIFIER => 'acct_123', - PayPlugGatewayFactory::HF_SUB_MERCHANT_ID => 'sub_789_new', ], ], ]); self::assertTrue($form->isValid()); - self::assertSame( - 'sub_789_new', - $configForm->getData()[PayPlugGatewayFactory::HF_SUB_MERCHANT_ID] ?? null, - ); } /** @@ -298,7 +249,6 @@ public function testSubmit_integratedPaymentModeWithNonEurChannel_isInvalidWithC PayPlugGatewayFactory::DEFERRED_CAPTURE => false, PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_INTEGRATED_PAYMENT, PayPlugGatewayFactory::HF_IDENTIFIER => '', - PayPlugGatewayFactory::HF_SUB_MERCHANT_ID => '', ], ], ], false); @@ -331,7 +281,6 @@ public function testSubmit_hostedFieldsModeWithNonEurChannel_isValid(): void PayPlugGatewayFactory::DEFERRED_CAPTURE => false, PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_HOSTED_FIELDS, PayPlugGatewayFactory::HF_IDENTIFIER => 'acct_123', - PayPlugGatewayFactory::HF_SUB_MERCHANT_ID => 'sub_456', ], ], ], false); diff --git a/tests/PHPUnit/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtensionTest.php b/tests/PHPUnit/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtensionTest.php index 87972fd7..cd5561a2 100644 --- a/tests/PHPUnit/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtensionTest.php +++ b/tests/PHPUnit/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtensionTest.php @@ -10,7 +10,6 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; -use Symfony\Component\Form\Extension\Core\Type\PasswordType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -76,15 +75,17 @@ public function testBuildForm_addsHfIdentifierTextField(): void self::assertFalse($options['required']); } - public function testBuildForm_addsHfSubMerchantIdPasswordField(): void + /** + * The SubMerchant ID field was removed once UPC made `submerchantExternalId` optional: only the + * EUR MID configurations carry a submerchant, and Hosted Fields here targets the multi-currency + * ones, so the key is omitted from every payload rather than sent empty. HF_IDENTIFIER is the + * last field added. + */ + public function testBuildForm_addsNoFieldAfterHfIdentifier(): void { [, $addCalls] = $this->buildFormAndCollectAddCalls(); - [$name, $type, $options] = $addCalls[4]; - self::assertSame(PayPlugGatewayFactory::HF_SUB_MERCHANT_ID, $name); - self::assertSame(PasswordType::class, $type); - self::assertSame('payplug_sylius_payplug_plugin.ui.hf_sub_merchant_id_label', $options['label']); - self::assertFalse($options['required']); + self::assertArrayNotHasKey(4, $addCalls); } public function testGetExtendedTypes_returnsPayPlugGatewayConfigurationType(): void diff --git a/tests/PHPUnit/Gateway/PayPlugGatewayFactoryTest.php b/tests/PHPUnit/Gateway/PayPlugGatewayFactoryTest.php index 14a86673..7a1ae459 100644 --- a/tests/PHPUnit/Gateway/PayPlugGatewayFactoryTest.php +++ b/tests/PHPUnit/Gateway/PayPlugGatewayFactoryTest.php @@ -100,34 +100,32 @@ public function testMissingHostedFieldsRequirements_hostedFieldsNotSelected_retu ])); } - public function testMissingHostedFieldsRequirements_hostedFieldsSelectedBothFieldsMissing_returnsBoth(): void + public function testMissingHostedFieldsRequirements_hostedFieldsSelectedIdentifierMissing_returnsIdentifier(): void { self::assertSame( - [PayPlugGatewayFactory::HF_IDENTIFIER, PayPlugGatewayFactory::HF_SUB_MERCHANT_ID], + [PayPlugGatewayFactory::HF_IDENTIFIER], PayPlugGatewayFactory::missingHostedFieldsRequirements([ PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_HOSTED_FIELDS, ]), ); } - public function testMissingHostedFieldsRequirements_hostedFieldsSelectedOnlyIdentifierBlank_returnsIdentifierOnly(): void + public function testMissingHostedFieldsRequirements_hostedFieldsSelectedIdentifierBlank_returnsIdentifier(): void { self::assertSame( [PayPlugGatewayFactory::HF_IDENTIFIER], PayPlugGatewayFactory::missingHostedFieldsRequirements([ PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_HOSTED_FIELDS, PayPlugGatewayFactory::HF_IDENTIFIER => ' ', - PayPlugGatewayFactory::HF_SUB_MERCHANT_ID => 'sm_123', ]), ); } - public function testMissingHostedFieldsRequirements_hostedFieldsSelectedBothFieldsFilled_returnsEmpty(): void + public function testMissingHostedFieldsRequirements_hostedFieldsSelectedIdentifierFilled_returnsEmpty(): void { self::assertSame([], PayPlugGatewayFactory::missingHostedFieldsRequirements([ PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_HOSTED_FIELDS, PayPlugGatewayFactory::HF_IDENTIFIER => 'acct_123', - PayPlugGatewayFactory::HF_SUB_MERCHANT_ID => 'sm_123', ])); } } diff --git a/tests/PHPUnit/Provider/SupportedMethodsProviderTest.php b/tests/PHPUnit/Provider/SupportedMethodsProviderTest.php index 6e015ea5..80277592 100644 --- a/tests/PHPUnit/Provider/SupportedMethodsProviderTest.php +++ b/tests/PHPUnit/Provider/SupportedMethodsProviderTest.php @@ -67,6 +67,9 @@ public function testProvide_withDifferentFactory_doesNotFilter(): void /** * The current currency is USD but the method only authorizes EUR. * Verifies the method is removed from the result list. + * + * No $paymentCurrencyCode is passed, so this also covers the documented fallback to + * CurrencyContextInterface for a payment carrying no currency of its own. */ public function testProvide_withUnauthorizedCurrency_removesMethod(): void { @@ -80,6 +83,91 @@ public function testProvide_withUnauthorizedCurrency_removesMethod(): void self::assertEmpty($result); } + /** + * The payment's own currency decides, not the one being displayed. Here the shopper browses in + * EUR (authorized) while the order was placed in USD (not authorized) — the amount is USD, so + * the method must go. Reading the display currency instead would keep a method whose limits + * were never checked against the amount's actual currency. + */ + public function testProvide_withPaymentCurrencyUnauthorized_ignoresDisplayCurrency(): void + { + $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); + $this->apiClient->method('getAccount')->willReturn($this->buildAccount(99, 2000000)); + + $method = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME); + + $result = $this->provider->provide( + [$method], + PayPlugGatewayFactory::FACTORY_NAME, + 1000, + paymentCurrencyCode: 'USD', + ); + + self::assertEmpty($result); + } + + /** + * The mirror case, and the one that was mis-filtering before: the order is in EUR (authorized, + * amount within bounds) while the shopper has switched the display to USD. The method must be + * kept — previously the USD display currency was compared against a EUR-only account and hid a + * payment method that would have been charged in EUR. + */ + public function testProvide_withPaymentCurrencyAuthorized_keepsMethodDespiteDisplayCurrency(): void + { + $this->currencyContext->method('getCurrencyCode')->willReturn('USD'); + $this->apiClient->method('getAccount')->willReturn($this->buildAccount(99, 2000000)); + + $method = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME); + + $result = $this->provider->provide( + [$method], + PayPlugGatewayFactory::FACTORY_NAME, + 1000, + paymentCurrencyCode: 'EUR', + ); + + self::assertCount(1, $result); + } + + /** + * Same unauthorized-currency setup, but the `payplug` method has Hosted Fields selected. + * UHF is exempt from the currency gate because the Retail `/account` payload does not know a + * UHF account's currencies (see the comment in SupportedMethodsProvider), so the method must + * survive — and, having no advertised limits for USD, must not be amount-filtered either + * despite 1000 sitting outside the EUR 99..2000000 range that the payload does advertise. + */ + public function testProvide_withUnauthorizedCurrencyAndHostedFields_keepsMethod(): void + { + $this->currencyContext->method('getCurrencyCode')->willReturn('USD'); + $this->apiClient->method('getAccount')->willReturn($this->buildAccount(99, 2000000)); + + $method = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME, [ + PayPlugGatewayFactory::HOSTED_FIELDS => true, + ]); + + $result = $this->provider->provide([$method], PayPlugGatewayFactory::FACTORY_NAME, 1000); + + self::assertCount(1, $result); + } + + /** + * Hosted Fields is exempt from the currency gate, not from the amount limits: when the active + * currency *is* advertised, its min/max still apply. + */ + public function testProvide_withAuthorizedCurrencyAndHostedFields_stillAppliesAmountLimits(): void + { + $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); + $this->apiClient->method('getAccount')->willReturn($this->buildAccount(99, 2000000)); + + $method = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME, [ + PayPlugGatewayFactory::HOSTED_FIELDS => true, + ]); + + $result = $this->provider->provide([$method], PayPlugGatewayFactory::FACTORY_NAME, 50); + + self::assertEmpty($result); + } + // ------------------------------------------------------------------------- // provide() — amount below min_amount → method removed // ------------------------------------------------------------------------- @@ -225,7 +313,7 @@ public function testProvide_withAllowedCountry_keepsMethod(): void $this->apiClient->method('getAccount')->willReturn($account); $method = $this->buildPaymentMethod('payplug_scalapay'); - $result = $this->provider->provide([$method], 'payplug_scalapay', 1000, 'FR'); + $result = $this->provider->provide([$method], 'payplug_scalapay', 1000, billingCountryCode: 'FR'); self::assertCount(1, $result); } @@ -243,7 +331,7 @@ public function testProvide_withDisallowedCountry_removesMethod(): void $this->apiClient->method('getAccount')->willReturn($account); $method = $this->buildPaymentMethod('payplug_scalapay'); - $result = $this->provider->provide([$method], 'payplug_scalapay', 1000, 'US'); + $result = $this->provider->provide([$method], 'payplug_scalapay', 1000, billingCountryCode: 'US'); self::assertEmpty($result); } @@ -261,7 +349,7 @@ public function testProvide_withAllowedCountriesAll_keepsMethod(): void $this->apiClient->method('getAccount')->willReturn($account); $method = $this->buildPaymentMethod('payplug_bancontact'); - $result = $this->provider->provide([$method], 'payplug_bancontact', 1000, 'US'); + $result = $this->provider->provide([$method], 'payplug_bancontact', 1000, billingCountryCode: 'US'); self::assertCount(1, $result); } @@ -279,7 +367,7 @@ public function testProvide_withNullBillingCountry_keepsMethod(): void $this->apiClient->method('getAccount')->willReturn($account); $method = $this->buildPaymentMethod('payplug_scalapay'); - $result = $this->provider->provide([$method], 'payplug_scalapay', 1000, null); + $result = $this->provider->provide([$method], 'payplug_scalapay', 1000, billingCountryCode: null); self::assertCount(1, $result); } @@ -374,10 +462,15 @@ private function buildAccount(int $minAmount, int $maxAmount): array ]; } - private function buildPaymentMethod(string $factoryName): PaymentMethodInterface + /** + * @param array $config Persisted gateway config; defaults to empty, which is + * neither integrated_payment nor hosted_fields. + */ + private function buildPaymentMethod(string $factoryName, array $config = []): PaymentMethodInterface { $gatewayConfig = $this->createMock(GatewayConfigInterface::class); $gatewayConfig->method('getFactoryName')->willReturn($factoryName); + $gatewayConfig->method('getConfig')->willReturn($config); $method = $this->createMock(PaymentMethodInterface::class); $method->method('getGatewayConfig')->willReturn($gatewayConfig); diff --git a/tests/PHPUnit/Upc/GatewayCredentialsResolverTest.php b/tests/PHPUnit/Upc/GatewayCredentialsResolverTest.php index d2ac4e61..03488faa 100644 --- a/tests/PHPUnit/Upc/GatewayCredentialsResolverTest.php +++ b/tests/PHPUnit/Upc/GatewayCredentialsResolverTest.php @@ -12,18 +12,36 @@ final class GatewayCredentialsResolverTest extends TestCase { - public function testResolve_withBothConfigured_returnsThem(): void + public function testResolve_withAccountIdConfigured_returnsIt(): void { $gatewayConfig = $this->createMock(GatewayConfigInterface::class); $gatewayConfig->method('getConfig')->willReturn([ PayPlugGatewayFactory::HF_IDENTIFIER => 'acct_123', - PayPlugGatewayFactory::HF_SUB_MERCHANT_ID => 'submerchant_123', ]); $method = $this->createMock(PaymentMethodInterface::class); $method->method('getGatewayConfig')->willReturn($gatewayConfig); - self::assertSame(['acct_123', 'submerchant_123'], GatewayCredentialsResolver::resolve($method)); + self::assertSame('acct_123', GatewayCredentialsResolver::resolve($method)); + } + + /** + * The submerchant is no longer configurable here — it belongs to the EUR MID configurations, + * not to the multi-currency ones this flow targets — so a config left over from before the + * field was removed must resolve exactly like one without it rather than throwing. + */ + public function testResolve_withALeftoverSubmerchantIdInConfig_ignoresIt(): void + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getConfig')->willReturn([ + PayPlugGatewayFactory::HF_IDENTIFIER => 'acct_123', + 'hfSubMerchantId' => 'submerchant_123', + ]); + + $method = $this->createMock(PaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn($gatewayConfig); + + self::assertSame('acct_123', GatewayCredentialsResolver::resolve($method)); } public function testResolve_withNoGatewayConfig_throws(): void @@ -39,9 +57,7 @@ public function testResolve_withNoGatewayConfig_throws(): void public function testResolve_withMissingAccountId_throws(): void { $gatewayConfig = $this->createMock(GatewayConfigInterface::class); - $gatewayConfig->method('getConfig')->willReturn([ - PayPlugGatewayFactory::HF_SUB_MERCHANT_ID => 'submerchant_123', - ]); + $gatewayConfig->method('getConfig')->willReturn([]); $method = $this->createMock(PaymentMethodInterface::class); $method->method('getGatewayConfig')->willReturn($gatewayConfig); @@ -51,11 +67,11 @@ public function testResolve_withMissingAccountId_throws(): void GatewayCredentialsResolver::resolve($method); } - public function testResolve_withMissingSubmerchantId_throws(): void + public function testResolve_withBlankAccountId_throws(): void { $gatewayConfig = $this->createMock(GatewayConfigInterface::class); $gatewayConfig->method('getConfig')->willReturn([ - PayPlugGatewayFactory::HF_IDENTIFIER => 'acct_123', + PayPlugGatewayFactory::HF_IDENTIFIER => '', ]); $method = $this->createMock(PaymentMethodInterface::class); diff --git a/tests/PHPUnit/Upc/PaymentCaptureContextBuilderTest.php b/tests/PHPUnit/Upc/PaymentCaptureContextBuilderTest.php index 98a97a90..533dc551 100644 --- a/tests/PHPUnit/Upc/PaymentCaptureContextBuilderTest.php +++ b/tests/PHPUnit/Upc/PaymentCaptureContextBuilderTest.php @@ -60,11 +60,11 @@ private function methodWithGatewayConfig(?array $config): PaymentMethodInterface return $method; } - public function testResolveGatewayCredentials_withCompleteConfig_returnsAccountIdAndSubmerchantId(): void + public function testResolveGatewayCredentials_withCompleteConfig_returnsAccountId(): void { - $method = $this->methodWithGatewayConfig(['hfIdentifier' => 'acct_123', 'hfSubMerchantId' => 'sub_ext_1']); + $method = $this->methodWithGatewayConfig(['hfIdentifier' => 'acct_123']); - self::assertSame(['acct_123', 'sub_ext_1'], $this->builder->resolveGatewayCredentials($method)); + self::assertSame('acct_123', $this->builder->resolveGatewayCredentials($method)); } public function testResolveGatewayCredentials_withNoGatewayConfig_throws(): void @@ -74,11 +74,11 @@ public function testResolveGatewayCredentials_withNoGatewayConfig_throws(): void $this->builder->resolveGatewayCredentials($this->methodWithGatewayConfig(null)); } - public function testResolveGatewayCredentials_withBlankSubmerchantId_throws(): void + public function testResolveGatewayCredentials_withBlankAccountId_throws(): void { $this->expectException(\LogicException::class); - $this->builder->resolveGatewayCredentials($this->methodWithGatewayConfig(['hfIdentifier' => 'acct_123', 'hfSubMerchantId' => ''])); + $this->builder->resolveGatewayCredentials($this->methodWithGatewayConfig(['hfIdentifier' => ''])); } public function testResolvePaymentMethod_withNoMethodOnThePayment_throws(): void @@ -190,13 +190,13 @@ public function testBuildCommonFields_setsSuccessCancelAndNotificationUrls(): vo $paymentRequest->method('getPayment')->willReturn($payment); $paymentRequest->method('getHash')->willReturn(Uuid::v4()); - $common = $this->builder->buildCommonFields('acct_123', 1000, 'eur', 'sub_ext_1', $paymentRequest, $order); + $common = $this->builder->buildCommonFields('acct_123', 1000, 'eur', $paymentRequest, $order); self::assertSame('acct_123', $common->accountId); self::assertSame(1000, $common->amount); self::assertSame('EUR', $common->currency); self::assertSame('00000042', $common->orderId); - self::assertSame('sub_ext_1', $common->submerchantExternalId); + self::assertNull($common->submerchantExternalId); self::assertSame('https://shop.test/payplug/notify/abc', $common->notificationUrl); self::assertSame('https://shop.test/order/00000042/pay', $common->successUrl); self::assertSame('https://shop.test/order/00000042/pay?status=canceled', $common->cancelUrl); @@ -213,7 +213,7 @@ public function testBuildCommonFields_withNoOrder_fallsBackToPaymentIdAsOrderId( $paymentRequest->method('getPayment')->willReturn($payment); $paymentRequest->method('getHash')->willReturn(Uuid::v4()); - $common = $this->builder->buildCommonFields('acct_123', 1000, 'eur', 'sub_ext_1', $paymentRequest, null); + $common = $this->builder->buildCommonFields('acct_123', 1000, 'eur', $paymentRequest, null); self::assertSame('42', $common->orderId); self::assertNull($common->billing); @@ -238,7 +238,7 @@ public function testBuildCommonFields_withAnOrderItem_usesItsProductNameAsDescri $paymentRequest->method('getPayment')->willReturn($payment); $paymentRequest->method('getHash')->willReturn(Uuid::v4()); - $common = $this->builder->buildCommonFields('acct_123', 1000, 'eur', 'sub_ext_1', $paymentRequest, $order); + $common = $this->builder->buildCommonFields('acct_123', 1000, 'eur', $paymentRequest, $order); self::assertSame('Blue T-Shirt', $common->description); } @@ -258,7 +258,7 @@ public function testBuildCommonFields_withNoOrderItem_fallsBackToTheIntegrationD $paymentRequest->method('getPayment')->willReturn($payment); $paymentRequest->method('getHash')->willReturn(Uuid::v4()); - $common = $this->builder->buildCommonFields('acct_123', 1000, 'eur', 'sub_ext_1', $paymentRequest, $order); + $common = $this->builder->buildCommonFields('acct_123', 1000, 'eur', $paymentRequest, $order); self::assertNotNull($common->description); self::assertNotSame('Blue T-Shirt', $common->description); diff --git a/tests/PHPUnit/Upc/PaymentCaptureOutcomeApplierTest.php b/tests/PHPUnit/Upc/PaymentCaptureOutcomeApplierTest.php index 2caf2440..801d685b 100644 --- a/tests/PHPUnit/Upc/PaymentCaptureOutcomeApplierTest.php +++ b/tests/PHPUnit/Upc/PaymentCaptureOutcomeApplierTest.php @@ -16,15 +16,25 @@ use Sylius\Component\Core\Model\PaymentInterface; use Sylius\Component\Payment\Model\PaymentRequestInterface; use Sylius\Component\Payment\PaymentRequestTransitions; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Session\Session; +use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; final class PaymentCaptureOutcomeApplierTest extends TestCase { + private const SHOPPER_ERROR_FLASH_KEY = 'payplug_sylius_payplug_plugin.error.transaction_failed'; + private LoggerInterface&MockObject $logger; private StateMachineInterface&MockObject $stateMachine; private IOrderStateMutator&MockObject $orderStateMutator; + private RequestStack $requestStack; + + private Session $session; + private PaymentCaptureOutcomeApplier $applier; protected function setUp(): void @@ -33,7 +43,91 @@ protected function setUp(): void $this->stateMachine = $this->createMock(StateMachineInterface::class); $this->orderStateMutator = $this->createMock(IOrderStateMutator::class); - $this->applier = new PaymentCaptureOutcomeApplier($this->logger, $this->stateMachine, $this->orderStateMutator); + // A real Session/FlashBag rather than a mock: the assertions below are about what the + // shopper actually ends up seeing, which is the flash bag's contents. + $this->session = new Session(new MockArraySessionStorage()); + $request = new Request(); + $request->setSession($this->session); + $this->requestStack = new RequestStack(); + $this->requestStack->push($request); + + $this->applier = $this->createApplier($this->requestStack); + } + + private function createApplier(RequestStack $requestStack): PaymentCaptureOutcomeApplier + { + return new PaymentCaptureOutcomeApplier( + $this->logger, + $this->stateMachine, + $this->orderStateMutator, + $requestStack, + ); + } + + public function testFailPaymentRequest_tellsTheShopperTheTransactionDidNotGoThrough(): void + { + $this->applier->failPaymentRequest( + $this->createMock(PaymentRequestInterface::class), + $this->createMock(PaymentInterface::class), + new \LogicException('boom'), + PaymentCaptureFlow::Alias, + ); + + self::assertSame( + [self::SHOPPER_ERROR_FLASH_KEY], + $this->session->getFlashBag()->get('error'), + ); + } + + public function testFailPaymentRequest_neverLeaksTheExceptionMessageToTheShopper(): void + { + // Real example from a UPC 403: the raw message names internal infrastructure and the + // account's configuration, so it must stay in the log and out of the flash bag. + $leaky = 'The IP address "10.204.92.13" is not allowed to access this account.'; + + $this->applier->failPaymentRequest( + $this->createMock(PaymentRequestInterface::class), + $this->createMock(PaymentInterface::class), + new \RuntimeException($leaky), + PaymentCaptureFlow::Alias, + ); + + self::assertSame([self::SHOPPER_ERROR_FLASH_KEY], $this->session->getFlashBag()->get('error')); + } + + public function testFailPaymentRequest_withoutASession_stillFailsThePaymentRequest(): void + { + // Reachable from the CLI (UpdatePaymentStateCommand) and from worker contexts, where + // Request::getSession() would throw — a failed payment must not become a 500 because + // there was nowhere to put a flash message. + $requestStack = new RequestStack(); + $requestStack->push(new Request()); + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL); + + $this->createApplier($requestStack)->failPaymentRequest( + $paymentRequest, + $this->createMock(PaymentInterface::class), + new \LogicException('boom'), + PaymentCaptureFlow::Alias, + ); + } + + public function testFailPaymentRequest_withNoRequestAtAll_stillFailsThePaymentRequest(): void + { + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL); + + $this->createApplier(new RequestStack())->failPaymentRequest( + $paymentRequest, + $this->createMock(PaymentInterface::class), + new \LogicException('boom'), + PaymentCaptureFlow::Alias, + ); } public function testFailPaymentRequest_logsSetsResponseDataAndAppliesFailTransition(): void diff --git a/tests/PHPUnit/Upc/UnifiedApiPaymentCreatorTest.php b/tests/PHPUnit/Upc/UnifiedApiPaymentCreatorTest.php index 0805d8b3..4636d692 100644 --- a/tests/PHPUnit/Upc/UnifiedApiPaymentCreatorTest.php +++ b/tests/PHPUnit/Upc/UnifiedApiPaymentCreatorTest.php @@ -57,7 +57,7 @@ protected function setUp(): void private function dto(): HostedFieldDto { - return new HostedFieldDto(new CommonFieldsDto('acct_123', 1000, 'eur', '42', 'sub_ext_1'), 'hf_token_abc'); + return new HostedFieldDto(new CommonFieldsDto('acct_123', 1000, 'eur', '42'), 'hf_token_abc'); } public function testCreateHostedPayment_withValidCredentials_returnsTheOutput(): void diff --git a/tests/PHPUnit/Upc/UnifiedApiRefundCreatorTest.php b/tests/PHPUnit/Upc/UnifiedApiRefundCreatorTest.php index bda89899..2c9754d0 100644 --- a/tests/PHPUnit/Upc/UnifiedApiRefundCreatorTest.php +++ b/tests/PHPUnit/Upc/UnifiedApiRefundCreatorTest.php @@ -61,7 +61,7 @@ protected function setUp(): void public function testCreateRefund_withoutAmount_sendsAFullRefundUsingTheMethodsOwnAccountId(): void { - $method = $this->buildHostedFieldsPaymentMethod('acct_123', 'submerchant_123'); + $method = $this->buildHostedFieldsPaymentMethod('acct_123'); $this->unifiedApiHttpClient->expects(self::once()) ->method('postJson') @@ -71,7 +71,6 @@ public function testCreateRefund_withoutAmount_sendsAFullRefundUsingTheMethodsOw 'account' => ['id' => 'acct_123'], 'orderId' => 'order_1', 'description' => 'Refund for order order_1', - 'submerchantExternalId' => 'submerchant_123', ], ['Authorization' => 'Bearer cached-jwt', 'Content-Type' => 'application/json'], ) @@ -84,7 +83,7 @@ public function testCreateRefund_withoutAmount_sendsAFullRefundUsingTheMethodsOw public function testCreateRefund_withAmount_sendsAPartialRefund(): void { - $method = $this->buildHostedFieldsPaymentMethod('acct_123', 'submerchant_123'); + $method = $this->buildHostedFieldsPaymentMethod('acct_123'); $this->unifiedApiHttpClient->expects(self::once()) ->method('postJson') @@ -94,7 +93,6 @@ public function testCreateRefund_withAmount_sendsAPartialRefund(): void 'account' => ['id' => 'acct_123'], 'orderId' => 'order_1', 'description' => 'Refund for order order_1', - 'submerchantExternalId' => 'submerchant_123', 'amount' => 500, ], ['Authorization' => 'Bearer cached-jwt', 'Content-Type' => 'application/json'], @@ -106,7 +104,7 @@ public function testCreateRefund_withAmount_sendsAPartialRefund(): void public function testCreateRefund_onA404Response_throwsPaymentNotFoundException(): void { - $method = $this->buildHostedFieldsPaymentMethod('acct_123', 'submerchant_123'); + $method = $this->buildHostedFieldsPaymentMethod('acct_123'); $this->unifiedApiHttpClient->method('postJson')->willReturn(['status' => 404, 'body' => '{}']); $this->expectException(PaymentNotFoundException::class); @@ -116,7 +114,7 @@ public function testCreateRefund_onA404Response_throwsPaymentNotFoundException() public function testCreateRefund_onNon2xxResponse_throwsApiException(): void { - $method = $this->buildHostedFieldsPaymentMethod('acct_123', 'submerchant_123'); + $method = $this->buildHostedFieldsPaymentMethod('acct_123'); $this->unifiedApiHttpClient->method('postJson')->willReturn(['status' => 500, 'body' => '{}']); $this->expectException(ApiException::class); @@ -126,7 +124,7 @@ public function testCreateRefund_onNon2xxResponse_throwsApiException(): void public function testCreateRefund_withANonPositiveAmount_throwsRefundAmountExceptionBeforeAnyNetworkCall(): void { - $method = $this->buildHostedFieldsPaymentMethod('acct_123', 'submerchant_123'); + $method = $this->buildHostedFieldsPaymentMethod('acct_123'); $this->unifiedApiHttpClient->expects(self::never())->method('postJson'); $this->expectException(RefundAmountException::class); @@ -135,51 +133,58 @@ public function testCreateRefund_withANonPositiveAmount_throwsRefundAmountExcept } /** - * Same guard CaptureHostedPaymentRequestHandler::resolveGatewayCredentials() already applies - * at payment-creation time — a blank submerchant id must fail fast and locally rather than - * round-tripping to the Unified API for a 400 ("subMerchantExternalId is missing") that's - * indistinguishable from any other malformed-request cause once wrapped in ApiException. + * The refund body must state what $amount's minor units are: without it the Unified API infers + * the currency from the account, which silently means the wrong thing for a multi-currency + * merchant. */ - public function testCreateRefund_withNoConfiguredSubmerchantId_throwsLogicExceptionBeforeAnyNetworkCall(): void + public function testCreateRefund_withCurrency_sendsItAlongsideTheAmount(): void { - $method = $this->buildHostedFieldsPaymentMethod('acct_123', ''); + $method = $this->buildHostedFieldsPaymentMethod('acct_123'); - $this->unifiedApiHttpClient->expects(self::never())->method('postJson'); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Hosted Fields account id or submerchant id is not configured for this payment method.'); + $this->unifiedApiHttpClient->expects(self::once()) + ->method('postJson') + ->with( + 'https://api.payplug.com/api/payment-gateway/payments/pay_123/refund', + [ + 'account' => ['id' => 'acct_123'], + 'orderId' => 'order_1', + 'description' => 'Refund for order order_1', + 'amount' => 6800, + 'currency' => 'USD', + ], + ['Authorization' => 'Bearer cached-jwt', 'Content-Type' => 'application/json'], + ) + ->willReturn(['status' => 200, 'body' => '{}']); - $this->creator->createRefund($method, 'pay_123', 'order_1'); + $this->creator->createRefund($method, 'pay_123', 'order_1', 6800, 'USD'); } /** * Credentials must come from $method's own gateway config, not from whichever * Hosted-Fields-configured payment method IConfigurationRepository's backing store happens to * resolve first — otherwise a merchant with more than one such payment method could have a - * refund routed to the wrong account/submerchant. + * refund routed to the wrong account. */ public function testCreateRefund_withNoConfiguredAccountId_throwsLogicExceptionBeforeAnyNetworkCall(): void { - $method = $this->buildHostedFieldsPaymentMethod('', 'submerchant_123'); + $method = $this->buildHostedFieldsPaymentMethod(''); $this->unifiedApiHttpClient->expects(self::never())->method('postJson'); $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Hosted Fields account id or submerchant id is not configured for this payment method.'); + $this->expectExceptionMessage('Hosted Fields account id is not configured for this payment method.'); $this->creator->createRefund($method, 'pay_123', 'order_1'); } private function buildHostedFieldsPaymentMethod( string $accountId, - string $submerchantExternalId, ): PaymentMethodInterface&MockObject { $gatewayConfig = $this->createMock(GatewayConfigInterface::class); $gatewayConfig->method('getConfig')->willReturn([ PayPlugGatewayFactory::HOSTED_FIELDS => true, PayPlugGatewayFactory::HF_IDENTIFIER => $accountId, - PayPlugGatewayFactory::HF_SUB_MERCHANT_ID => $submerchantExternalId, ]); $method = $this->createMock(PaymentMethodInterface::class); diff --git a/translations/flashes.en.yml b/translations/flashes.en.yml index 1faaa11a..7d4de015 100644 --- a/translations/flashes.en.yml +++ b/translations/flashes.en.yml @@ -3,6 +3,7 @@ payplug_sylius_payplug_plugin: oney_not_enabled: Oney is not (or is no longer) activated on your account. api_unknow_error: An error occurred. Please retry in few seconds. transaction_failed_1click: The transaction was not completed and your card was not charged. + transaction_failed: The transaction was not completed and your card was not charged. warning: payment_success_no_card_saved: The payment was successful but we were unable to save your credit card details. admin: diff --git a/translations/flashes.fr.yml b/translations/flashes.fr.yml index b05b5110..cca3eac2 100644 --- a/translations/flashes.fr.yml +++ b/translations/flashes.fr.yml @@ -3,6 +3,7 @@ payplug_sylius_payplug_plugin: oney_not_enabled: Oney n'est pas ou plus activé sur votre compte. api_unknow_error: Une erreur s'est produite. Veuillez réessayer dans quelques secondes. transaction_failed_1click: La transaction a échoué, votre carte de paiement ne sera pas débitée. + transaction_failed: La transaction a échoué, votre carte de paiement ne sera pas débitée. warning: payment_success_no_card_saved: Le paiement a été effectué avec succès, mais nous n'avons pas pu enregistrer votre carte bancaire. admin: diff --git a/translations/flashes.it.yml b/translations/flashes.it.yml index 10cd5113..49355425 100644 --- a/translations/flashes.it.yml +++ b/translations/flashes.it.yml @@ -3,6 +3,7 @@ payplug_sylius_payplug_plugin: oney_not_enabled: Oney non è o non è più attivato per il tuo account. api_unknow_error: C'è stato un errore. Per favore riprova tra qualche secondo. transaction_failed_1click: La transazione non è stata conclusa e non è stato effettuato alcun addebito sulla tua carta. + transaction_failed: La transazione non è stata conclusa e non è stato effettuato alcun addebito sulla tua carta. warning: payment_success_no_card_saved: Il pagamento è andato a buon fine ma non è stato possibile salvare la tua carta di credito. admin: diff --git a/translations/messages.en.yml b/translations/messages.en.yml index 16d55b13..aa7d7102 100644 --- a/translations/messages.en.yml +++ b/translations/messages.en.yml @@ -123,7 +123,6 @@ payplug_sylius_payplug_plugin: renew_oauth_help: | If this option is checked, a new authentication flow will be started when clicking the "Update" button. hf_identifier_label: 'Account ID' - hf_sub_merchant_id_label: 'SubMerchant ID' hosted_fields_option: 'Advanced (beta) Hosted Fields' form: oney_error: Some missing information is required to pay using Oney by Payplug @@ -147,7 +146,6 @@ payplug_sylius_payplug_plugin: integrated_payment_enable: Enable payment integrated redirected_payment_enable: Enable redirected payment account_id_required: 'Advanced features require an Account ID, please contact your Account Manager.' - submerchant_id_required: 'Advanced features require a SubMerchant ID, please contact your Account Manager.' integrated_payment_currency_incompatible: 'The selected channel is not compatible with Integrated Payment.' deferred_capture_enable: Enable deferred capture diff --git a/translations/messages.fr.yml b/translations/messages.fr.yml index b642463b..fa51f2ec 100644 --- a/translations/messages.fr.yml +++ b/translations/messages.fr.yml @@ -143,7 +143,6 @@ payplug_sylius_payplug_plugin: renew_oauth_help: | Si cette option est cochée, un nouveau flux d’authentification sera lancé lors du clic sur le bouton "Mise à jour". hf_identifier_label: 'Identifiant de compte' - hf_sub_merchant_id_label: 'Identifiant SubMerchant' hosted_fields_option: 'Hosted Fields avancé (beta)' form: oney_error: Il y a des informations manquantes pour pouvoir payer en utilisant Oney by Payplug @@ -168,7 +167,6 @@ payplug_sylius_payplug_plugin: integrated_payment_enable: Activer le Paiement Integré redirected_payment_enable: Activer le Paiement redirigé account_id_required: 'Les fonctionnalités avancées nécessitent un Account ID, veuillez contacter votre Account Manager.' - submerchant_id_required: 'Les fonctionnalités avancées nécessitent un SubMerchant ID, veuillez contacter votre Account Manager.' integrated_payment_currency_incompatible: 'Le canal sélectionné n''est pas compatible avec le Paiement intégré' deferred_capture_enable: Activer la capture différée deferred_capture_help: | diff --git a/translations/messages.it.yml b/translations/messages.it.yml index f607f93e..1bfe7a6f 100644 --- a/translations/messages.it.yml +++ b/translations/messages.it.yml @@ -123,7 +123,6 @@ payplug_sylius_payplug_plugin: renew_oauth_help: | Se questa opzione è selezionata, un nuovo flusso di autenticazione verrà avviato quando si fa clic sul pulsante "Aggiorna". hf_identifier_label: 'ID Account' - hf_sub_merchant_id_label: 'ID SubMerchant' hosted_fields_option: 'Hosted Fields avanzato (beta)' form: oney_error: Mancano alcune informazioni per poter pagare con “Oney by Payplug” @@ -147,7 +146,6 @@ payplug_sylius_payplug_plugin: integrated_payment_enable: Abilita il pagamento integrato redirected_payment_enable: Abilita il pagamento reindirizzato account_id_required: 'Le funzionalità avanzate richiedono un Account ID, si prega di contattare il vostro Account Manager.' - submerchant_id_required: 'Le funzionalità avanzate richiedono un SubMerchant ID, si prega di contattare il vostro Account Manager.' integrated_payment_currency_incompatible: 'Il canale selezionato non è compatibile con il Pagamento integrato.' deferred_capture_enable: Abilita la cattura differita deferred_capture_help: |