diff --git a/assets/shop/controllers/hosted-fields_controller.js b/assets/shop/controllers/hosted-fields_controller.js index 720cbc5a..f78b1f9a 100644 --- a/assets/shop/controllers/hosted-fields_controller.js +++ b/assets/shop/controllers/hosted-fields_controller.js @@ -149,6 +149,18 @@ export default class extends Controller { this.form.querySelector('#hostedfields_token').value = result.hfToken; this.form.querySelector('#hostedfields_selected_brand').value = selectedBrand; this.form.querySelector('#hostedfields_save_card').value = saveCard ? 'true' : 'false'; + // last4/expirationMonth/expirationYear/country field names are unverified against a real + // createToken() response (no vendored SDK docs/types exist in this repo to confirm them) — + // if wrong, these silently fall back to '' rather than error. This data is fully + // client-controlled and only ever used as a display-only fallback for a saved card's + // metadata when PayPlug's own operation-fetch is unavailable — PayplugCardPersister + // validates the format of each field (4-digit last4, 1-12 month, a plausible year, a + // 2-letter country) before trusting any of it, and discards anything that doesn't match + // rather than persisting it as-is. + this.form.querySelector('#hostedfields_last4').value = result.last4 || ''; + this.form.querySelector('#hostedfields_exp_month').value = result.expirationMonth || ''; + this.form.querySelector('#hostedfields_exp_year').value = result.expirationYear || ''; + this.form.querySelector('#hostedfields_country').value = result.country || ''; this.form.submit(); }); } diff --git a/composer.json b/composer.json index c4daaef2..c3e7a991 100755 --- a/composer.json +++ b/composer.json @@ -14,7 +14,7 @@ "ext-json": "*", "giggsey/libphonenumber-for-php": "^8.12", "payplug/payplug-php": "^4.0", - "payplug/unified-plugin-core": "^1.0.1", + "payplug/unified-plugin-core": "^1.1.0", "php-http/message-factory": "^1.1", "sylius/refund-plugin": "^2.0", "sylius/sylius": "^2.0", diff --git a/composer.lock b/composer.lock index 11191e5c..50db7607 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": "ce80f54cb76369b724595a99335edad3", + "content-hash": "a4230575f0ea5ede3f5d0e5d484bcd2a", "packages": [ { "name": "alcohol/iso4217", @@ -2111,24 +2111,32 @@ }, { "name": "doctrine/doctrine-migrations-bundle", - "version": "3.7.0", + "version": "3.7.1", "source": { "type": "git", "url": "https://github.com/doctrine/DoctrineMigrationsBundle.git", - "reference": "1e380c6dd8ac8488217f39cff6b77e367f1a644b" + "reference": "00056695242a3e88369fe7a6e10669d8f6d7496a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/DoctrineMigrationsBundle/zipball/1e380c6dd8ac8488217f39cff6b77e367f1a644b", - "reference": "1e380c6dd8ac8488217f39cff6b77e367f1a644b", + "url": "https://api.github.com/repos/doctrine/DoctrineMigrationsBundle/zipball/00056695242a3e88369fe7a6e10669d8f6d7496a", + "reference": "00056695242a3e88369fe7a6e10669d8f6d7496a", "shasum": "" }, "require": { + "doctrine/dbal": "^2 || ^3 || ^4", "doctrine/doctrine-bundle": "^2.4 || ^3.0", "doctrine/migrations": "^3.2", "php": "^7.2 || ^8.0", + "psr/log": "^1 || ^2 || ^3", + "symfony/config": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/console": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0 || ^8.0", "symfony/deprecation-contracts": "^2.1 || ^3", - "symfony/framework-bundle": "^5.4 || ^6.0 || ^7.0 || ^8.0" + "symfony/framework-bundle": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/http-foundation": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/http-kernel": "^5.4 || ^6.0 || ^7.0", + "symfony/service-contracts": "^1.0 || ^2.0 || ^3.0" }, "require-dev": { "composer/semver": "^3.0", @@ -2176,7 +2184,7 @@ ], "support": { "issues": "https://github.com/doctrine/DoctrineMigrationsBundle/issues", - "source": "https://github.com/doctrine/DoctrineMigrationsBundle/tree/3.7.0" + "source": "https://github.com/doctrine/DoctrineMigrationsBundle/tree/3.7.1" }, "funding": [ { @@ -2192,7 +2200,7 @@ "type": "tidelift" } ], - "time": "2025-11-15T19:02:59+00:00" + "time": "2026-08-26T05:40:19+00:00" }, { "name": "doctrine/event-manager", @@ -3339,22 +3347,22 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.15.3", + "version": "7.15.5", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc" + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc", - "reference": "ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ee80339fd9177ba44c49cdb653ff02a4d1106b9a", + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.5.2", - "guzzlehttp/psr7": "^2.13", + "guzzlehttp/promises": "^2.5.3", + "guzzlehttp/psr7": "^2.13.1", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.5 || ^3.0", @@ -3447,7 +3455,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.15.3" + "source": "https://github.com/guzzle/guzzle/tree/7.15.5" }, "funding": [ { @@ -3463,20 +3471,20 @@ "type": "tidelift" } ], - "time": "2026-08-05T19:48:21+00:00" + "time": "2026-08-24T09:21:06+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.5.2", + "version": "2.5.3", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "2823687acff28b2dbe67b2508a6b300e2c3fa4ce" + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/2823687acff28b2dbe67b2508a6b300e2c3fa4ce", - "reference": "2823687acff28b2dbe67b2508a6b300e2c3fa4ce", + "url": "https://api.github.com/repos/guzzle/promises/zipball/cde49999552d185d64715fe9c1f77a2aadd2f9f1", + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1", "shasum": "" }, "require": { @@ -3531,7 +3539,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.5.2" + "source": "https://github.com/guzzle/promises/tree/2.5.3" }, "funding": [ { @@ -3547,20 +3555,20 @@ "type": "tidelift" } ], - "time": "2026-08-05T19:30:54+00:00" + "time": "2026-08-24T09:11:28+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.13.0", + "version": "2.13.1", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4" + "reference": "95e7828100de18b4e269fb1703be530082d5166d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/dad89620b7a6edb60c15858442eb2e408b45d8f4", - "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/95e7828100de18b4e269fb1703be530082d5166d", + "reference": "95e7828100de18b4e269fb1703be530082d5166d", "shasum": "" }, "require": { @@ -3650,7 +3658,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.13.0" + "source": "https://github.com/guzzle/psr7/tree/2.13.1" }, "funding": [ { @@ -3666,7 +3674,7 @@ "type": "tidelift" } ], - "time": "2026-07-16T22:23:49+00:00" + "time": "2026-08-24T09:13:11+00:00" }, { "name": "imagine/imagine", @@ -4329,16 +4337,16 @@ }, { "name": "league/flysystem", - "version": "3.35.2", + "version": "3.35.3", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "b277b5dc3d56650b68904117124e79c851e12376" + "reference": "5fc8404762179ae514678487b23494fd69b2309c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376", - "reference": "b277b5dc3d56650b68904117124e79c851e12376", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/5fc8404762179ae514678487b23494fd69b2309c", + "reference": "5fc8404762179ae514678487b23494fd69b2309c", "shasum": "" }, "require": { @@ -4406,9 +4414,9 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.35.2" + "source": "https://github.com/thephpleague/flysystem/tree/3.35.3" }, - "time": "2026-07-06T14:42:07+00:00" + "time": "2026-08-22T12:55:54+00:00" }, { "name": "league/flysystem-bundle", @@ -4482,16 +4490,16 @@ }, { "name": "league/flysystem-local", - "version": "3.31.0", + "version": "3.35.3", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-local.git", - "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079" + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/2f669db18a4c20c755c2bb7d3a7b0b2340488079", - "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/a099b24dce160f3b2239043d13d47c4a1a214ea4", + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4", "shasum": "" }, "require": { @@ -4525,9 +4533,9 @@ "local" ], "support": { - "source": "https://github.com/thephpleague/flysystem-local/tree/3.31.0" + "source": "https://github.com/thephpleague/flysystem-local/tree/3.35.3" }, - "time": "2026-01-23T15:30:45+00:00" + "time": "2026-08-12T13:29:21+00:00" }, { "name": "league/mime-type-detection", @@ -5588,16 +5596,16 @@ }, { "name": "paragonie/sodium_compat", - "version": "v2.5.0", + "version": "v2.5.2", "source": { "type": "git", "url": "https://github.com/paragonie/sodium_compat.git", - "reference": "4714da6efdc782c06690bc72ce34fae7941c2d9f" + "reference": "3246b36803c177847e677862fedeb7f937869adb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paragonie/sodium_compat/zipball/4714da6efdc782c06690bc72ce34fae7941c2d9f", - "reference": "4714da6efdc782c06690bc72ce34fae7941c2d9f", + "url": "https://api.github.com/repos/paragonie/sodium_compat/zipball/3246b36803c177847e677862fedeb7f937869adb", + "reference": "3246b36803c177847e677862fedeb7f937869adb", "shasum": "" }, "require": { @@ -5678,9 +5686,9 @@ ], "support": { "issues": "https://github.com/paragonie/sodium_compat/issues", - "source": "https://github.com/paragonie/sodium_compat/tree/v2.5.0" + "source": "https://github.com/paragonie/sodium_compat/tree/v2.5.2" }, - "time": "2025-12-30T16:12:18+00:00" + "time": "2026-08-18T23:46:48+00:00" }, { "name": "payplug/payplug-php", @@ -5739,16 +5747,16 @@ }, { "name": "payplug/unified-plugin-core", - "version": "1.0.1", + "version": "1.1.0", "source": { "type": "git", "url": "https://github.com/payplug/unified-plugin-core.git", - "reference": "789dc07c031fdbe492f626ec37fb6adc57fc288a" + "reference": "f86247f8b52fba52c709cad206c50974927fa8dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/payplug/unified-plugin-core/zipball/789dc07c031fdbe492f626ec37fb6adc57fc288a", - "reference": "789dc07c031fdbe492f626ec37fb6adc57fc288a", + "url": "https://api.github.com/repos/payplug/unified-plugin-core/zipball/f86247f8b52fba52c709cad206c50974927fa8dc", + "reference": "f86247f8b52fba52c709cad206c50974927fa8dc", "shasum": "" }, "require": { @@ -5785,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.0.1" + "source": "https://github.com/payplug/unified-plugin-core/tree/1.1.0" }, - "time": "2026-08-21T13:13:21+00:00" + "time": "2026-08-28T14:15:47+00:00" }, { "name": "payum/core", @@ -8739,16 +8747,16 @@ }, { "name": "symfony/cache", - "version": "v6.4.43", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "8f0b5de5d9f5131aa99fdab78d7802efef9322dc" + "reference": "66c07f6399aaff49d6aff3ff62197f745741af6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/8f0b5de5d9f5131aa99fdab78d7802efef9322dc", - "reference": "8f0b5de5d9f5131aa99fdab78d7802efef9322dc", + "url": "https://api.github.com/repos/symfony/cache/zipball/66c07f6399aaff49d6aff3ff62197f745741af6a", + "reference": "66c07f6399aaff49d6aff3ff62197f745741af6a", "shasum": "" }, "require": { @@ -8771,7 +8779,7 @@ "symfony/cache-implementation": "1.1|2.0|3.0" }, "require-dev": { - "cache/integration-tests": "dev-master", + "cache/integration-tests": "^0.18@stable", "doctrine/dbal": "^2.13.1|^3|^4", "predis/predis": "^1.1|^2.0", "psr/simple-cache": "^1.0|^2.0|^3.0", @@ -8815,7 +8823,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v6.4.43" + "source": "https://github.com/symfony/cache/tree/v6.4.44" }, "funding": [ { @@ -8835,7 +8843,7 @@ "type": "tidelift" } ], - "time": "2026-07-28T21:33:22+00:00" + "time": "2026-08-19T08:22:32+00:00" }, { "name": "symfony/cache-contracts", @@ -8997,16 +9005,16 @@ }, { "name": "symfony/config", - "version": "v6.4.43", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "e39cdd91b2adf67a117bea29660b73d896937d86" + "reference": "6a5abf67fd5b138df96984fe6f8fd736fb7d6b29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/e39cdd91b2adf67a117bea29660b73d896937d86", - "reference": "e39cdd91b2adf67a117bea29660b73d896937d86", + "url": "https://api.github.com/repos/symfony/config/zipball/6a5abf67fd5b138df96984fe6f8fd736fb7d6b29", + "reference": "6a5abf67fd5b138df96984fe6f8fd736fb7d6b29", "shasum": "" }, "require": { @@ -9052,7 +9060,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v6.4.43" + "source": "https://github.com/symfony/config/tree/v6.4.44" }, "funding": [ { @@ -9072,20 +9080,20 @@ "type": "tidelift" } ], - "time": "2026-07-21T09:46:53+00:00" + "time": "2026-08-20T09:38:57+00:00" }, { "name": "symfony/console", - "version": "v6.4.43", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "3b643aa587acbc42f967a429af088a56ed8f046d" + "reference": "60e3944c4859c487aa6ea2f0f7754917f70f7524" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/3b643aa587acbc42f967a429af088a56ed8f046d", - "reference": "3b643aa587acbc42f967a429af088a56ed8f046d", + "url": "https://api.github.com/repos/symfony/console/zipball/60e3944c4859c487aa6ea2f0f7754917f70f7524", + "reference": "60e3944c4859c487aa6ea2f0f7754917f70f7524", "shasum": "" }, "require": { @@ -9150,7 +9158,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.43" + "source": "https://github.com/symfony/console/tree/v6.4.44" }, "funding": [ { @@ -9170,20 +9178,20 @@ "type": "tidelift" } ], - "time": "2026-07-26T14:44:19+00:00" + "time": "2026-08-21T07:42:12+00:00" }, { "name": "symfony/dependency-injection", - "version": "v6.4.43", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "f11815fd2f5f80ce94bf527b5ecfd4bd0b1cc905" + "reference": "675b2dfaf70bdca37d13816582023e3bac5fc2d1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/f11815fd2f5f80ce94bf527b5ecfd4bd0b1cc905", - "reference": "f11815fd2f5f80ce94bf527b5ecfd4bd0b1cc905", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/675b2dfaf70bdca37d13816582023e3bac5fc2d1", + "reference": "675b2dfaf70bdca37d13816582023e3bac5fc2d1", "shasum": "" }, "require": { @@ -9235,7 +9243,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v6.4.43" + "source": "https://github.com/symfony/dependency-injection/tree/v6.4.44" }, "funding": [ { @@ -9255,7 +9263,7 @@ "type": "tidelift" } ], - "time": "2026-07-20T15:13:12+00:00" + "time": "2026-08-21T15:20:39+00:00" }, { "name": "symfony/deprecation-contracts", @@ -9330,16 +9338,16 @@ }, { "name": "symfony/doctrine-bridge", - "version": "v6.4.43", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-bridge.git", - "reference": "653001640a2ba86bb5fcec7f58832b74e6b3aa76" + "reference": "f80901d137e312a21b53f719016be0584968df57" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/653001640a2ba86bb5fcec7f58832b74e6b3aa76", - "reference": "653001640a2ba86bb5fcec7f58832b74e6b3aa76", + "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/f80901d137e312a21b53f719016be0584968df57", + "reference": "f80901d137e312a21b53f719016be0584968df57", "shasum": "" }, "require": { @@ -9365,7 +9373,7 @@ "symfony/property-info": "<5.4|>=8", "symfony/security-bundle": "<5.4", "symfony/security-core": "<6.4", - "symfony/validator": "<6.4" + "symfony/validator": "<6.4.44|>=7.0,<7.4.17|>=8.0,<8.1.5" }, "require-dev": { "doctrine/collections": "^1.0|^2.0", @@ -9418,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.43" + "source": "https://github.com/symfony/doctrine-bridge/tree/v6.4.44" }, "funding": [ { @@ -9438,7 +9446,7 @@ "type": "tidelift" } ], - "time": "2026-07-21T10:19:36+00:00" + "time": "2026-08-21T19:37:17+00:00" }, { "name": "symfony/doctrine-messenger", @@ -9518,16 +9526,16 @@ }, { "name": "symfony/error-handler", - "version": "v6.4.43", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "ecec4c500623ee0ac0c925c3426bcb3e7f429b14" + "reference": "8fa59eb915a14b2881993565a122c9dd1b0f8a19" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/ecec4c500623ee0ac0c925c3426bcb3e7f429b14", - "reference": "ecec4c500623ee0ac0c925c3426bcb3e7f429b14", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8fa59eb915a14b2881993565a122c9dd1b0f8a19", + "reference": "8fa59eb915a14b2881993565a122c9dd1b0f8a19", "shasum": "" }, "require": { @@ -9573,7 +9581,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v6.4.43" + "source": "https://github.com/symfony/error-handler/tree/v6.4.44" }, "funding": [ { @@ -9593,20 +9601,20 @@ "type": "tidelift" } ], - "time": "2026-07-21T10:11:50+00:00" + "time": "2026-08-20T17:31:15+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v6.4.43", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "ac405d324c10ebbbde6a6e58379bf81db10f1dbf" + "reference": "d7110a878e7e7cbd8aaebe9f55da8af885b8c0af" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/ac405d324c10ebbbde6a6e58379bf81db10f1dbf", - "reference": "ac405d324c10ebbbde6a6e58379bf81db10f1dbf", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d7110a878e7e7cbd8aaebe9f55da8af885b8c0af", + "reference": "d7110a878e7e7cbd8aaebe9f55da8af885b8c0af", "shasum": "" }, "require": { @@ -9657,7 +9665,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.43" + "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.44" }, "funding": [ { @@ -9677,7 +9685,7 @@ "type": "tidelift" } ], - "time": "2026-07-21T14:00:19+00:00" + "time": "2026-08-21T14:01:40+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -9829,16 +9837,16 @@ }, { "name": "symfony/filesystem", - "version": "v6.4.43", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "9ff03da12d67649fbd1f34ca95951554624d0a16" + "reference": "e84ffb3e77e3a7144727a0d0735d0d9cc0ca77a7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/9ff03da12d67649fbd1f34ca95951554624d0a16", - "reference": "9ff03da12d67649fbd1f34ca95951554624d0a16", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/e84ffb3e77e3a7144727a0d0735d0d9cc0ca77a7", + "reference": "e84ffb3e77e3a7144727a0d0735d0d9cc0ca77a7", "shasum": "" }, "require": { @@ -9875,7 +9883,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v6.4.43" + "source": "https://github.com/symfony/filesystem/tree/v6.4.44" }, "funding": [ { @@ -9895,20 +9903,20 @@ "type": "tidelift" } ], - "time": "2026-06-27T10:13:35+00:00" + "time": "2026-08-20T20:21:47+00:00" }, { "name": "symfony/finder", - "version": "v6.4.42", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "0b73dac42493acbadbba644207a715b254e9b029" + "reference": "211b28d13d044dacdc00c1629a3bbcff27dc793a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/0b73dac42493acbadbba644207a715b254e9b029", - "reference": "0b73dac42493acbadbba644207a715b254e9b029", + "url": "https://api.github.com/repos/symfony/finder/zipball/211b28d13d044dacdc00c1629a3bbcff27dc793a", + "reference": "211b28d13d044dacdc00c1629a3bbcff27dc793a", "shasum": "" }, "require": { @@ -9943,7 +9951,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v6.4.42" + "source": "https://github.com/symfony/finder/tree/v6.4.44" }, "funding": [ { @@ -9963,20 +9971,20 @@ "type": "tidelift" } ], - "time": "2026-06-26T15:18:24+00:00" + "time": "2026-08-21T10:00:03+00:00" }, { "name": "symfony/form", - "version": "v6.4.43", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/form.git", - "reference": "7d05d6a4a936433247b6ae32c2074923684ec1c7" + "reference": "8e28c46ad733ee40c42b9cf242cb52b2d6b1ffef" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/form/zipball/7d05d6a4a936433247b6ae32c2074923684ec1c7", - "reference": "7d05d6a4a936433247b6ae32c2074923684ec1c7", + "url": "https://api.github.com/repos/symfony/form/zipball/8e28c46ad733ee40c42b9cf242cb52b2d6b1ffef", + "reference": "8e28c46ad733ee40c42b9cf242cb52b2d6b1ffef", "shasum": "" }, "require": { @@ -10044,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.43" + "source": "https://github.com/symfony/form/tree/v6.4.44" }, "funding": [ { @@ -10064,20 +10072,20 @@ "type": "tidelift" } ], - "time": "2026-07-28T13:57:03+00:00" + "time": "2026-08-21T16:18:55+00:00" }, { "name": "symfony/framework-bundle", - "version": "v6.4.43", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "dcec940410874561f58edb75e2f3574c8a1340d3" + "reference": "a1d129830823409ff9c4b0cd88b8c75ee8601a5d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/dcec940410874561f58edb75e2f3574c8a1340d3", - "reference": "dcec940410874561f58edb75e2f3574c8a1340d3", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/a1d129830823409ff9c4b0cd88b8c75ee8601a5d", + "reference": "a1d129830823409ff9c4b0cd88b8c75ee8601a5d", "shasum": "" }, "require": { @@ -10111,7 +10119,7 @@ "symfony/form": "<5.4", "symfony/http-client": "<6.3", "symfony/lock": "<5.4", - "symfony/mailer": "<5.4", + "symfony/mailer": "<6.4.44|>=7.0,<7.4.17|>=8.0,<8.1.5", "symfony/messenger": "<6.3", "symfony/mime": "<6.4.37|>=7.0,<7.4.9", "symfony/property-access": "<5.4", @@ -10197,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.43" + "source": "https://github.com/symfony/framework-bundle/tree/v6.4.44" }, "funding": [ { @@ -10217,20 +10225,20 @@ "type": "tidelift" } ], - "time": "2026-07-26T09:00:38+00:00" + "time": "2026-08-21T19:37:17+00:00" }, { "name": "symfony/http-client", - "version": "v6.4.43", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "38540911e9e3c3ca12e562dcce758d9bfcfa7cdd" + "reference": "ba6237fa906710149b6b7881cff59edde2b4abfc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/38540911e9e3c3ca12e562dcce758d9bfcfa7cdd", - "reference": "38540911e9e3c3ca12e562dcce758d9bfcfa7cdd", + "url": "https://api.github.com/repos/symfony/http-client/zipball/ba6237fa906710149b6b7881cff59edde2b4abfc", + "reference": "ba6237fa906710149b6b7881cff59edde2b4abfc", "shasum": "" }, "require": { @@ -10295,7 +10303,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v6.4.43" + "source": "https://github.com/symfony/http-client/tree/v6.4.44" }, "funding": [ { @@ -10315,7 +10323,7 @@ "type": "tidelift" } ], - "time": "2026-07-29T06:36:08+00:00" + "time": "2026-08-20T17:31:15+00:00" }, { "name": "symfony/http-client-contracts", @@ -10401,16 +10409,16 @@ }, { "name": "symfony/http-foundation", - "version": "v6.4.43", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "ea0c801ec34e9017a8c9363e55c8ef5f1717216e" + "reference": "1d5fa99b841ccdbbaa8781b01f07a7b2ede31d09" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/ea0c801ec34e9017a8c9363e55c8ef5f1717216e", - "reference": "ea0c801ec34e9017a8c9363e55c8ef5f1717216e", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/1d5fa99b841ccdbbaa8781b01f07a7b2ede31d09", + "reference": "1d5fa99b841ccdbbaa8781b01f07a7b2ede31d09", "shasum": "" }, "require": { @@ -10458,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.43" + "source": "https://github.com/symfony/http-foundation/tree/v6.4.44" }, "funding": [ { @@ -10478,20 +10486,20 @@ "type": "tidelift" } ], - "time": "2026-07-29T06:55:26+00:00" + "time": "2026-08-20T07:12:57+00:00" }, { "name": "symfony/http-kernel", - "version": "v6.4.43", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "cc35556a9bc9e9a6a35fb1114609d1e3c9a63738" + "reference": "2223a4e7763fa9284542f78280ebdb72da055d49" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/cc35556a9bc9e9a6a35fb1114609d1e3c9a63738", - "reference": "cc35556a9bc9e9a6a35fb1114609d1e3c9a63738", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/2223a4e7763fa9284542f78280ebdb72da055d49", + "reference": "2223a4e7763fa9284542f78280ebdb72da055d49", "shasum": "" }, "require": { @@ -10576,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.43" + "source": "https://github.com/symfony/http-kernel/tree/v6.4.44" }, "funding": [ { @@ -10596,20 +10604,20 @@ "type": "tidelift" } ], - "time": "2026-07-29T11:30:30+00:00" + "time": "2026-08-22T13:20:55+00:00" }, { "name": "symfony/intl", - "version": "v6.4.42", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/intl.git", - "reference": "65e85c6665bb4b4b1c2214a8e89eb30acc20468a" + "reference": "d79905d256652f1353b26e1a59d89f835b2d31cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/intl/zipball/65e85c6665bb4b4b1c2214a8e89eb30acc20468a", - "reference": "65e85c6665bb4b4b1c2214a8e89eb30acc20468a", + "url": "https://api.github.com/repos/symfony/intl/zipball/d79905d256652f1353b26e1a59d89f835b2d31cf", + "reference": "d79905d256652f1353b26e1a59d89f835b2d31cf", "shasum": "" }, "require": { @@ -10663,7 +10671,7 @@ "localization" ], "support": { - "source": "https://github.com/symfony/intl/tree/v6.4.42" + "source": "https://github.com/symfony/intl/tree/v6.4.44" }, "funding": [ { @@ -10683,20 +10691,20 @@ "type": "tidelift" } ], - "time": "2026-06-08T07:06:12+00:00" + "time": "2026-08-20T17:31:16+00:00" }, { "name": "symfony/lock", - "version": "v6.4.42", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/lock.git", - "reference": "b9e0464c0085b12fb50924f87883421e67917b33" + "reference": "7ecf823a1c86b553c16281e93734a3d677f84fc9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/lock/zipball/b9e0464c0085b12fb50924f87883421e67917b33", - "reference": "b9e0464c0085b12fb50924f87883421e67917b33", + "url": "https://api.github.com/repos/symfony/lock/zipball/7ecf823a1c86b553c16281e93734a3d677f84fc9", + "reference": "7ecf823a1c86b553c16281e93734a3d677f84fc9", "shasum": "" }, "require": { @@ -10746,7 +10754,7 @@ "semaphore" ], "support": { - "source": "https://github.com/symfony/lock/tree/v6.4.42" + "source": "https://github.com/symfony/lock/tree/v6.4.44" }, "funding": [ { @@ -10766,20 +10774,20 @@ "type": "tidelift" } ], - "time": "2026-06-16T10:12:24+00:00" + "time": "2026-08-16T17:06:59+00:00" }, { "name": "symfony/mailer", - "version": "v6.4.43", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "2a380924b56e034076c4a658d40e140ba150e44c" + "reference": "cea6ee2fb64d486f709d80bd3158cf28346e1ba4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/2a380924b56e034076c4a658d40e140ba150e44c", - "reference": "2a380924b56e034076c4a658d40e140ba150e44c", + "url": "https://api.github.com/repos/symfony/mailer/zipball/cea6ee2fb64d486f709d80bd3158cf28346e1ba4", + "reference": "cea6ee2fb64d486f709d80bd3158cf28346e1ba4", "shasum": "" }, "require": { @@ -10830,7 +10838,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v6.4.43" + "source": "https://github.com/symfony/mailer/tree/v6.4.44" }, "funding": [ { @@ -10850,20 +10858,20 @@ "type": "tidelift" } ], - "time": "2026-07-27T19:43:14+00:00" + "time": "2026-08-21T13:01:07+00:00" }, { "name": "symfony/messenger", - "version": "v6.4.43", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/messenger.git", - "reference": "a99c7d0787a95329c49eeec6a5f60968042f4e7d" + "reference": "e4e8f57d79eb8df84f4b0172fd128bdd6d412011" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/messenger/zipball/a99c7d0787a95329c49eeec6a5f60968042f4e7d", - "reference": "a99c7d0787a95329c49eeec6a5f60968042f4e7d", + "url": "https://api.github.com/repos/symfony/messenger/zipball/e4e8f57d79eb8df84f4b0172fd128bdd6d412011", + "reference": "e4e8f57d79eb8df84f4b0172fd128bdd6d412011", "shasum": "" }, "require": { @@ -10921,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.43" + "source": "https://github.com/symfony/messenger/tree/v6.4.44" }, "funding": [ { @@ -10941,20 +10949,20 @@ "type": "tidelift" } ], - "time": "2026-07-21T14:40:31+00:00" + "time": "2026-08-21T16:26:24+00:00" }, { "name": "symfony/mime", - "version": "v6.4.43", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "38421e10911725aaa5e44e1b4606d7a37f652b37" + "reference": "3f850171f3bb396a84117ca97d3d474fc4b83deb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/38421e10911725aaa5e44e1b4606d7a37f652b37", - "reference": "38421e10911725aaa5e44e1b4606d7a37f652b37", + "url": "https://api.github.com/repos/symfony/mime/zipball/3f850171f3bb396a84117ca97d3d474fc4b83deb", + "reference": "3f850171f3bb396a84117ca97d3d474fc4b83deb", "shasum": "" }, "require": { @@ -10978,7 +10986,7 @@ "symfony/process": "^5.4|^6.4|^7.0", "symfony/property-access": "^5.4|^6.0|^7.0", "symfony/property-info": "^5.4|^6.0|^7.0", - "symfony/serializer": "^6.4.3|^7.0.3" + "symfony/serializer": "^6.4.44|^7.4.17" }, "type": "library", "autoload": { @@ -11010,7 +11018,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v6.4.43" + "source": "https://github.com/symfony/mime/tree/v6.4.44" }, "funding": [ { @@ -11030,20 +11038,20 @@ "type": "tidelift" } ], - "time": "2026-07-29T07:59:11+00:00" + "time": "2026-08-22T07:48:48+00:00" }, { "name": "symfony/monolog-bridge", - "version": "v6.4.43", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/monolog-bridge.git", - "reference": "68e9e40663e8af73901653f49ca149d13b6b581e" + "reference": "df7efc5e57d13d393cfc1a51a673e0dad739232d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/68e9e40663e8af73901653f49ca149d13b6b581e", - "reference": "68e9e40663e8af73901653f49ca149d13b6b581e", + "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/df7efc5e57d13d393cfc1a51a673e0dad739232d", + "reference": "df7efc5e57d13d393cfc1a51a673e0dad739232d", "shasum": "" }, "require": { @@ -11064,6 +11072,7 @@ "symfony/mailer": "^5.4|^6.0|^7.0", "symfony/messenger": "^5.4|^6.0|^7.0", "symfony/mime": "^5.4|^6.0|^7.0", + "symfony/notifier": "^5.4|^6.0|^7.0", "symfony/security-core": "^5.4|^6.0|^7.0", "symfony/var-dumper": "^5.4|^6.0|^7.0" }, @@ -11093,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.43" + "source": "https://github.com/symfony/monolog-bridge/tree/v6.4.44" }, "funding": [ { @@ -11113,7 +11122,7 @@ "type": "tidelift" } ], - "time": "2026-07-26T13:45:43+00:00" + "time": "2026-08-20T17:31:17+00:00" }, { "name": "symfony/monolog-bundle", @@ -11681,16 +11690,16 @@ }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.38.1", + "version": "v1.42.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "dc21118016c039a66235cf93d96b435ffb282412" + "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", - "reference": "dc21118016c039a66235cf93d96b435ffb282412", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/51b5ff5ba85452b31ec6f55490b08148612339d9", + "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9", "shasum": "" }, "require": { @@ -11744,7 +11753,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.42.0" }, "funding": [ { @@ -11764,20 +11773,20 @@ "type": "tidelift" } ], - "time": "2026-05-25T15:22:23+00:00" + "time": "2026-08-24T10:51:20+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.38.0", + "version": "v1.42.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", - "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", "shasum": "" }, "require": { @@ -11829,7 +11838,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0" }, "funding": [ { @@ -11849,7 +11858,7 @@ "type": "tidelift" } ], - "time": "2026-05-25T13:48:31+00:00" + "time": "2026-08-07T06:33:24+00:00" }, { "name": "symfony/polyfill-mbstring", @@ -12265,16 +12274,16 @@ }, { "name": "symfony/process", - "version": "v6.4.41", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "c8fc09bdfe9fde9aaa89b415a4477feaccec16a7" + "reference": "0b0c5b7d895211b82021469d1cb8ef2448caed96" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/c8fc09bdfe9fde9aaa89b415a4477feaccec16a7", - "reference": "c8fc09bdfe9fde9aaa89b415a4477feaccec16a7", + "url": "https://api.github.com/repos/symfony/process/zipball/0b0c5b7d895211b82021469d1cb8ef2448caed96", + "reference": "0b0c5b7d895211b82021469d1cb8ef2448caed96", "shasum": "" }, "require": { @@ -12306,7 +12315,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v6.4.41" + "source": "https://github.com/symfony/process/tree/v6.4.44" }, "funding": [ { @@ -12326,20 +12335,20 @@ "type": "tidelift" } ], - "time": "2026-05-23T13:47:21+00:00" + "time": "2026-08-20T17:31:17+00:00" }, { "name": "symfony/property-access", - "version": "v6.4.32", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/property-access.git", - "reference": "6dfa655ac9e9860c05cabb287f34da86b18c237e" + "reference": "57793e8d1e3ede2d4a65604e8f213a3cfa8d1721" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-access/zipball/6dfa655ac9e9860c05cabb287f34da86b18c237e", - "reference": "6dfa655ac9e9860c05cabb287f34da86b18c237e", + "url": "https://api.github.com/repos/symfony/property-access/zipball/57793e8d1e3ede2d4a65604e8f213a3cfa8d1721", + "reference": "57793e8d1e3ede2d4a65604e8f213a3cfa8d1721", "shasum": "" }, "require": { @@ -12387,7 +12396,7 @@ "reflection" ], "support": { - "source": "https://github.com/symfony/property-access/tree/v6.4.32" + "source": "https://github.com/symfony/property-access/tree/v6.4.44" }, "funding": [ { @@ -12407,20 +12416,20 @@ "type": "tidelift" } ], - "time": "2026-01-05T08:25:17+00:00" + "time": "2026-07-30T11:04:25+00:00" }, { "name": "symfony/property-info", - "version": "v6.4.43", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/property-info.git", - "reference": "ff32a0d7885f6f8997ee0aca499fd008852ad5bd" + "reference": "7847669cf171ea57ff6f3b7798ce842255a35fbd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/ff32a0d7885f6f8997ee0aca499fd008852ad5bd", - "reference": "ff32a0d7885f6f8997ee0aca499fd008852ad5bd", + "url": "https://api.github.com/repos/symfony/property-info/zipball/7847669cf171ea57ff6f3b7798ce842255a35fbd", + "reference": "7847669cf171ea57ff6f3b7798ce842255a35fbd", "shasum": "" }, "require": { @@ -12477,7 +12486,7 @@ "validator" ], "support": { - "source": "https://github.com/symfony/property-info/tree/v6.4.43" + "source": "https://github.com/symfony/property-info/tree/v6.4.44" }, "funding": [ { @@ -12497,7 +12506,7 @@ "type": "tidelift" } ], - "time": "2026-07-22T12:35:29+00:00" + "time": "2026-08-21T15:18:56+00:00" }, { "name": "symfony/proxy-manager-bridge", @@ -12572,16 +12581,16 @@ }, { "name": "symfony/routing", - "version": "v6.4.43", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "f8dedd42e7a511906aa54be6cebffbbe826ff20e" + "reference": "6fe2ed5b356beec41d5720dd73db72887c3f9d6d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/f8dedd42e7a511906aa54be6cebffbbe826ff20e", - "reference": "f8dedd42e7a511906aa54be6cebffbbe826ff20e", + "url": "https://api.github.com/repos/symfony/routing/zipball/6fe2ed5b356beec41d5720dd73db72887c3f9d6d", + "reference": "6fe2ed5b356beec41d5720dd73db72887c3f9d6d", "shasum": "" }, "require": { @@ -12635,7 +12644,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v6.4.43" + "source": "https://github.com/symfony/routing/tree/v6.4.44" }, "funding": [ { @@ -12655,7 +12664,7 @@ "type": "tidelift" } ], - "time": "2026-07-21T13:51:56+00:00" + "time": "2026-08-16T17:07:07+00:00" }, { "name": "symfony/security-bundle", @@ -12775,16 +12784,16 @@ }, { "name": "symfony/security-core", - "version": "v6.4.43", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/security-core.git", - "reference": "9d2741948b0eb1fc08fd5431909dac8316ba49c2" + "reference": "9795959163aa73e0f09d7609fa6c9ce5a2c257eb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-core/zipball/9d2741948b0eb1fc08fd5431909dac8316ba49c2", - "reference": "9d2741948b0eb1fc08fd5431909dac8316ba49c2", + "url": "https://api.github.com/repos/symfony/security-core/zipball/9795959163aa73e0f09d7609fa6c9ce5a2c257eb", + "reference": "9795959163aa73e0f09d7609fa6c9ce5a2c257eb", "shasum": "" }, "require": { @@ -12841,7 +12850,7 @@ "description": "Symfony Security Component - Core Library", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-core/tree/v6.4.43" + "source": "https://github.com/symfony/security-core/tree/v6.4.44" }, "funding": [ { @@ -12861,7 +12870,7 @@ "type": "tidelift" } ], - "time": "2026-07-13T05:00:30+00:00" + "time": "2026-08-20T19:22:30+00:00" }, { "name": "symfony/security-csrf", @@ -12937,16 +12946,16 @@ }, { "name": "symfony/security-http", - "version": "v6.4.42", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/security-http.git", - "reference": "c6a3836e6c331503a841f439912939c4e1be480f" + "reference": "e1f302a96c4c8de56a904b116b7c1506db827141" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-http/zipball/c6a3836e6c331503a841f439912939c4e1be480f", - "reference": "c6a3836e6c331503a841f439912939c4e1be480f", + "url": "https://api.github.com/repos/symfony/security-http/zipball/e1f302a96c4c8de56a904b116b7c1506db827141", + "reference": "e1f302a96c4c8de56a904b116b7c1506db827141", "shasum": "" }, "require": { @@ -13005,7 +13014,7 @@ "description": "Symfony Security Component - HTTP Integration", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-http/tree/v6.4.42" + "source": "https://github.com/symfony/security-http/tree/v6.4.44" }, "funding": [ { @@ -13025,20 +13034,20 @@ "type": "tidelift" } ], - "time": "2026-06-16T12:40:39+00:00" + "time": "2026-08-22T07:30:33+00:00" }, { "name": "symfony/serializer", - "version": "v6.4.43", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "9a74371980b9eea34a39804fcfd7d0e4c81ccff1" + "reference": "3372b3c367d41542ca99291f8340897544002094" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/9a74371980b9eea34a39804fcfd7d0e4c81ccff1", - "reference": "9a74371980b9eea34a39804fcfd7d0e4c81ccff1", + "url": "https://api.github.com/repos/symfony/serializer/zipball/3372b3c367d41542ca99291f8340897544002094", + "reference": "3372b3c367d41542ca99291f8340897544002094", "shasum": "" }, "require": { @@ -13107,7 +13116,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.43" + "source": "https://github.com/symfony/serializer/tree/v6.4.44" }, "funding": [ { @@ -13127,7 +13136,7 @@ "type": "tidelift" } ], - "time": "2026-07-29T07:59:11+00:00" + "time": "2026-08-22T09:04:22+00:00" }, { "name": "symfony/service-contracts", @@ -13446,16 +13455,16 @@ }, { "name": "symfony/translation", - "version": "v6.4.42", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "fef99cef37890b350976f5f492854faefadd4e15" + "reference": "fa2235501e2cf6b1d38ab42954b248a5e93c8e89" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/fef99cef37890b350976f5f492854faefadd4e15", - "reference": "fef99cef37890b350976f5f492854faefadd4e15", + "url": "https://api.github.com/repos/symfony/translation/zipball/fa2235501e2cf6b1d38ab42954b248a5e93c8e89", + "reference": "fa2235501e2cf6b1d38ab42954b248a5e93c8e89", "shasum": "" }, "require": { @@ -13521,7 +13530,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v6.4.42" + "source": "https://github.com/symfony/translation/tree/v6.4.44" }, "funding": [ { @@ -13541,7 +13550,7 @@ "type": "tidelift" } ], - "time": "2026-06-05T16:46:18+00:00" + "time": "2026-08-20T19:22:13+00:00" }, { "name": "symfony/translation-contracts", @@ -13627,16 +13636,16 @@ }, { "name": "symfony/twig-bridge", - "version": "v6.4.43", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/twig-bridge.git", - "reference": "a94c869d1dd4df385a30cf8a9a86d1f7ba70466b" + "reference": "3036bcbdd27da00ded851b5049e1f73589c8d506" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/a94c869d1dd4df385a30cf8a9a86d1f7ba70466b", - "reference": "a94c869d1dd4df385a30cf8a9a86d1f7ba70466b", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/3036bcbdd27da00ded851b5049e1f73589c8d506", + "reference": "3036bcbdd27da00ded851b5049e1f73589c8d506", "shasum": "" }, "require": { @@ -13680,7 +13689,7 @@ "symfony/security-core": "^5.4|^6.0|^7.0", "symfony/security-csrf": "^5.4|^6.0|^7.0", "symfony/security-http": "^5.4|^6.0|^7.0", - "symfony/serializer": "^6.4.3|^7.0.3", + "symfony/serializer": "^6.4.44|^7.4.17", "symfony/stopwatch": "^5.4|^6.0|^7.0", "symfony/translation": "^6.1|^7.0", "symfony/web-link": "^5.4|^6.0|^7.0", @@ -13716,7 +13725,7 @@ "description": "Provides integration for Twig with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bridge/tree/v6.4.43" + "source": "https://github.com/symfony/twig-bridge/tree/v6.4.44" }, "funding": [ { @@ -13736,7 +13745,7 @@ "type": "tidelift" } ], - "time": "2026-07-21T10:37:57+00:00" + "time": "2026-08-22T07:48:48+00:00" }, { "name": "symfony/twig-bundle", @@ -13828,16 +13837,16 @@ }, { "name": "symfony/type-info", - "version": "v7.4.9", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/type-info.git", - "reference": "cafeedbf157b890e94ac5b83eaed85595106d5d6" + "reference": "62d0ad6995630a4f7ea8fd270aadb3e2545c8e31" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/type-info/zipball/cafeedbf157b890e94ac5b83eaed85595106d5d6", - "reference": "cafeedbf157b890e94ac5b83eaed85595106d5d6", + "url": "https://api.github.com/repos/symfony/type-info/zipball/62d0ad6995630a4f7ea8fd270aadb3e2545c8e31", + "reference": "62d0ad6995630a4f7ea8fd270aadb3e2545c8e31", "shasum": "" }, "require": { @@ -13887,7 +13896,7 @@ "type" ], "support": { - "source": "https://github.com/symfony/type-info/tree/v7.4.9" + "source": "https://github.com/symfony/type-info/tree/v7.4.17" }, "funding": [ { @@ -13907,7 +13916,7 @@ "type": "tidelift" } ], - "time": "2026-04-22T15:21:55+00:00" + "time": "2026-08-21T15:52:48+00:00" }, { "name": "symfony/uid", @@ -14368,16 +14377,16 @@ }, { "name": "symfony/validator", - "version": "v6.4.43", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/validator.git", - "reference": "ddde49bfa032b6c7f7b70fd3c82310d3d8cf952e" + "reference": "6e29aaa4662e28312bb84170b990bbfed73f68a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/validator/zipball/ddde49bfa032b6c7f7b70fd3c82310d3d8cf952e", - "reference": "ddde49bfa032b6c7f7b70fd3c82310d3d8cf952e", + "url": "https://api.github.com/repos/symfony/validator/zipball/6e29aaa4662e28312bb84170b990bbfed73f68a8", + "reference": "6e29aaa4662e28312bb84170b990bbfed73f68a8", "shasum": "" }, "require": { @@ -14445,7 +14454,7 @@ "description": "Provides tools to validate values", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/validator/tree/v6.4.43" + "source": "https://github.com/symfony/validator/tree/v6.4.44" }, "funding": [ { @@ -14465,20 +14474,20 @@ "type": "tidelift" } ], - "time": "2026-07-28T13:57:03+00:00" + "time": "2026-08-21T17:53:43+00:00" }, { "name": "symfony/var-dumper", - "version": "v6.4.43", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "6638cfe00907b2b980b749fcf2cd4cd6b2b1396e" + "reference": "13909d2c2a9300f6be7b3e75d72279f98b1301b9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/6638cfe00907b2b980b749fcf2cd4cd6b2b1396e", - "reference": "6638cfe00907b2b980b749fcf2cd4cd6b2b1396e", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/13909d2c2a9300f6be7b3e75d72279f98b1301b9", + "reference": "13909d2c2a9300f6be7b3e75d72279f98b1301b9", "shasum": "" }, "require": { @@ -14533,7 +14542,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v6.4.43" + "source": "https://github.com/symfony/var-dumper/tree/v6.4.44" }, "funding": [ { @@ -14553,20 +14562,20 @@ "type": "tidelift" } ], - "time": "2026-07-06T08:03:42+00:00" + "time": "2026-08-21T07:12:07+00:00" }, { "name": "symfony/var-exporter", - "version": "v6.4.42", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "15e670e0218bfe4efbc0556b8a12ad1304f4c7f4" + "reference": "6e13ff96f54498f86188833832e298ea1dc48ae1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/15e670e0218bfe4efbc0556b8a12ad1304f4c7f4", - "reference": "15e670e0218bfe4efbc0556b8a12ad1304f4c7f4", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/6e13ff96f54498f86188833832e298ea1dc48ae1", + "reference": "6e13ff96f54498f86188833832e298ea1dc48ae1", "shasum": "" }, "require": { @@ -14614,7 +14623,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v6.4.42" + "source": "https://github.com/symfony/var-exporter/tree/v6.4.44" }, "funding": [ { @@ -14634,7 +14643,7 @@ "type": "tidelift" } ], - "time": "2026-06-27T08:12:01+00:00" + "time": "2026-07-29T16:40:20+00:00" }, { "name": "symfony/web-link", @@ -14893,16 +14902,16 @@ }, { "name": "symfony/yaml", - "version": "v6.4.43", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "d2ee2daa59be8cd0864835ea918ea209149796a3" + "reference": "44b712e89243c358afe0cc227e1fcb6b3ddc957d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/d2ee2daa59be8cd0864835ea918ea209149796a3", - "reference": "d2ee2daa59be8cd0864835ea918ea209149796a3", + "url": "https://api.github.com/repos/symfony/yaml/zipball/44b712e89243c358afe0cc227e1fcb6b3ddc957d", + "reference": "44b712e89243c358afe0cc227e1fcb6b3ddc957d", "shasum": "" }, "require": { @@ -14945,7 +14954,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v6.4.43" + "source": "https://github.com/symfony/yaml/tree/v6.4.44" }, "funding": [ { @@ -14965,7 +14974,7 @@ "type": "tidelift" } ], - "time": "2026-07-20T15:18:49+00:00" + "time": "2026-08-20T17:31:23+00:00" }, { "name": "symfonycasts/dynamic-forms", @@ -15645,16 +15654,16 @@ }, { "name": "amphp/dns", - "version": "v2.4.0", + "version": "v2.4.1", "source": { "type": "git", "url": "https://github.com/amphp/dns.git", - "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71" + "reference": "ec5948bfafac808f410406e18bc7b52a2d4629b7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/dns/zipball/78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", - "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", + "url": "https://api.github.com/repos/amphp/dns/zipball/ec5948bfafac808f410406e18bc7b52a2d4629b7", + "reference": "ec5948bfafac808f410406e18bc7b52a2d4629b7", "shasum": "" }, "require": { @@ -15673,7 +15682,7 @@ "amphp/php-cs-fixer-config": "^2", "amphp/phpunit-util": "^3", "phpunit/phpunit": "^9", - "psalm/phar": "5.20" + "psalm/phar": "6.16.1" }, "type": "library", "autoload": { @@ -15722,7 +15731,7 @@ ], "support": { "issues": "https://github.com/amphp/dns/issues", - "source": "https://github.com/amphp/dns/tree/v2.4.0" + "source": "https://github.com/amphp/dns/tree/v2.4.1" }, "funding": [ { @@ -15730,7 +15739,7 @@ "type": "github" } ], - "time": "2025-01-19T15:43:40+00:00" + "time": "2026-07-26T17:53:54+00:00" }, { "name": "amphp/parallel", @@ -16082,16 +16091,16 @@ }, { "name": "amphp/socket", - "version": "v2.4.0", + "version": "v2.4.1", "source": { "type": "git", "url": "https://github.com/amphp/socket.git", - "reference": "dadb63c5d3179fd83803e29dfeac27350e619314" + "reference": "b347be5aff6b2cc025208bb4d896607eb470c018" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/socket/zipball/dadb63c5d3179fd83803e29dfeac27350e619314", - "reference": "dadb63c5d3179fd83803e29dfeac27350e619314", + "url": "https://api.github.com/repos/amphp/socket/zipball/b347be5aff6b2cc025208bb4d896607eb470c018", + "reference": "b347be5aff6b2cc025208bb4d896607eb470c018", "shasum": "" }, "require": { @@ -16154,7 +16163,7 @@ ], "support": { "issues": "https://github.com/amphp/socket/issues", - "source": "https://github.com/amphp/socket/tree/v2.4.0" + "source": "https://github.com/amphp/socket/tree/v2.4.1" }, "funding": [ { @@ -16162,7 +16171,7 @@ "type": "github" } ], - "time": "2026-04-19T15:09:56+00:00" + "time": "2026-08-22T18:13:20+00:00" }, { "name": "amphp/sync", @@ -17713,16 +17722,16 @@ }, { "name": "laravel/serializable-closure", - "version": "v2.0.15", + "version": "v2.0.16", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "dccd8bcb851bb03fcc005df650b708b57cc52661" + "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/dccd8bcb851bb03fcc005df650b708b57cc52661", - "reference": "dccd8bcb851bb03fcc005df650b708b57cc52661", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", + "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", "shasum": "" }, "require": { @@ -17770,7 +17779,7 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-07-21T16:49:22+00:00" + "time": "2026-08-18T20:28:54+00:00" }, { "name": "masterminds/html5", @@ -21098,16 +21107,16 @@ }, { "name": "symfony/browser-kit", - "version": "v6.4.42", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/browser-kit.git", - "reference": "94d0d5413126d81bffcd0de509fb9daf0363babd" + "reference": "4f631476bb549d882a173c47bf693f3ef84c722d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/browser-kit/zipball/94d0d5413126d81bffcd0de509fb9daf0363babd", - "reference": "94d0d5413126d81bffcd0de509fb9daf0363babd", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/4f631476bb549d882a173c47bf693f3ef84c722d", + "reference": "4f631476bb549d882a173c47bf693f3ef84c722d", "shasum": "" }, "require": { @@ -21146,7 +21155,7 @@ "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/browser-kit/tree/v6.4.42" + "source": "https://github.com/symfony/browser-kit/tree/v6.4.44" }, "funding": [ { @@ -21166,20 +21175,20 @@ "type": "tidelift" } ], - "time": "2026-06-08T07:06:12+00:00" + "time": "2026-08-21T15:19:05+00:00" }, { "name": "symfony/css-selector", - "version": "v6.4.34", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "b0314c186f1464de048cce58979ff1625ca88bbb" + "reference": "e2f0d361adeeb47feb80fb9102133ae13d376534" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/b0314c186f1464de048cce58979ff1625ca88bbb", - "reference": "b0314c186f1464de048cce58979ff1625ca88bbb", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/e2f0d361adeeb47feb80fb9102133ae13d376534", + "reference": "e2f0d361adeeb47feb80fb9102133ae13d376534", "shasum": "" }, "require": { @@ -21215,7 +21224,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v6.4.34" + "source": "https://github.com/symfony/css-selector/tree/v6.4.44" }, "funding": [ { @@ -21235,7 +21244,7 @@ "type": "tidelift" } ], - "time": "2026-02-16T08:37:21+00:00" + "time": "2026-08-21T15:52:46+00:00" }, { "name": "symfony/debug-bundle", @@ -21317,16 +21326,16 @@ }, { "name": "symfony/dom-crawler", - "version": "v6.4.40", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "7e65f76c28f5ed8d933f2c86698a3e2bf0de1b10" + "reference": "998855f6c3699f148b54096d1d7d2b8b0b0fc298" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/7e65f76c28f5ed8d933f2c86698a3e2bf0de1b10", - "reference": "7e65f76c28f5ed8d933f2c86698a3e2bf0de1b10", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/998855f6c3699f148b54096d1d7d2b8b0b0fc298", + "reference": "998855f6c3699f148b54096d1d7d2b8b0b0fc298", "shasum": "" }, "require": { @@ -21364,7 +21373,7 @@ "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v6.4.40" + "source": "https://github.com/symfony/dom-crawler/tree/v6.4.44" }, "funding": [ { @@ -21384,7 +21393,7 @@ "type": "tidelift" } ], - "time": "2026-05-19T20:33:22+00:00" + "time": "2026-08-20T17:31:07+00:00" }, { "name": "symfony/dotenv", @@ -21622,16 +21631,16 @@ }, { "name": "symfony/web-profiler-bundle", - "version": "v6.4.43", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/web-profiler-bundle.git", - "reference": "eb916752c0b1d0e167c3c54709a3a7092a975ed3" + "reference": "a1a4e508411254392dbf5a2fb2390b11d790481d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/eb916752c0b1d0e167c3c54709a3a7092a975ed3", - "reference": "eb916752c0b1d0e167c3c54709a3a7092a975ed3", + "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/a1a4e508411254392dbf5a2fb2390b11d790481d", + "reference": "a1a4e508411254392dbf5a2fb2390b11d790481d", "shasum": "" }, "require": { @@ -21684,7 +21693,7 @@ "dev" ], "support": { - "source": "https://github.com/symfony/web-profiler-bundle/tree/v6.4.43" + "source": "https://github.com/symfony/web-profiler-bundle/tree/v6.4.44" }, "funding": [ { @@ -21704,7 +21713,7 @@ "type": "tidelift" } ], - "time": "2026-07-21T10:37:57+00:00" + "time": "2026-08-22T06:28:57+00:00" }, { "name": "symplify/easy-coding-standard", diff --git a/config/services.yaml b/config/services.yaml index c9b3afd7..8d8b846d 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -68,8 +68,8 @@ services: PayplugUnifiedCore\Contracts\IOrderStateMutator: alias: PayPlug\SyliusPayPlugPlugin\Upc\SyliusOrderStateMutator - PayPlug\SyliusPayPlugPlugin\Upc\HostedPaymentCreatorInterface: - alias: PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiHostedPaymentCreator + PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiPaymentCreatorInterface: + alias: PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiPaymentCreator PayPlug\SyliusPayPlugPlugin\Upc\OperationStatusFetcherInterface: alias: PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiOperationStatusFetcher diff --git a/migrations/Version20260901120000.php b/migrations/Version20260901120000.php new file mode 100644 index 00000000..65336e3c --- /dev/null +++ b/migrations/Version20260901120000.php @@ -0,0 +1,37 @@ +addSql('DELETE t1 FROM payplug_cards t1 INNER JOIN payplug_cards t2 ON t1.external_id = t2.external_id AND t1.is_live = t2.is_live AND t1.id > t2.id'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_payplug_cards_external_id_is_live ON payplug_cards (external_id, is_live)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX UNIQ_payplug_cards_external_id_is_live ON payplug_cards'); + } +} diff --git a/src/Command/CaptureAliasPaymentRequest.php b/src/Command/CaptureAliasPaymentRequest.php new file mode 100644 index 00000000..665bbabe --- /dev/null +++ b/src/Command/CaptureAliasPaymentRequest.php @@ -0,0 +1,9 @@ +paymentRequestProvider->provide($captureAliasPaymentRequest); + /** @var PaymentInterface $payment */ + $payment = $paymentRequest->getPayment(); + + try { + $method = $this->contextBuilder->resolvePaymentMethod($payment); + + $card = $this->selectedCardResolver->resolve(); + if (null === $card) { + throw new \LogicException('No saved card alias selected for the payment.'); + } + [$amount, $currencyCode] = $this->contextBuilder->resolveAmountAndCurrency($payment); + [$accountId, $submerchantExternalId] = $this->contextBuilder->resolveGatewayCredentials($method); + + $order = $this->assertCardBelongsToOrder($card, $payment->getOrder(), $method); + $common = $this->contextBuilder->buildCommonFields($accountId, $amount, $currencyCode, $submerchantExternalId, $paymentRequest, $order); + $dto = $this->buildPaymentDto($common, $card, $order); + + $output = $this->unifiedApiPaymentCreator->createPayment($dto); + } catch (ApiException | InvalidPaymentException | \LogicException $e) { + $this->outcomeApplier->failPaymentRequest($paymentRequest, $payment, $e, PaymentCaptureFlow::Alias); + + return; + } + + $payment->setDetails([ + ...$payment->getDetails(), + 'alias_id' => $card->getExternalId(), + 'alias_payment_created_at' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM), + ...$this->contextBuilder->resolveHostedFieldsIds($output->body), + ]); + + $this->outcomeApplier->applyOutcome($paymentRequest, $payment, $output); + + $this->stateMachine->apply($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE); + } + + private function assertCardBelongsToOrder( + Card $card, + ?OrderInterface $order, + PaymentMethodInterface $method, + ): OrderInterface { + if (null === $order || $card->getCustomer() !== $order->getCustomer() || $card->getPaymentMethod() !== $method) { + throw new \LogicException('Selected card does not belong to the paying customer or payment method.'); + } + + return $order; + } + + private function buildPaymentDto(CommonFieldsDto $common, Card $card, OrderInterface $order): PaymentDto + { + $customerDto = $this->contextBuilder->buildCustomerDto($order); + $browserDto = $this->contextBuilder->buildBrowserDto(); + + $fullName = $this->contextBuilder->resolveFullNameForCardDetails($order); + $paymentMethod = null !== $fullName + ? ['details' => ['fullName' => $fullName]] + : null; + + return new PaymentDto($common, $card->getExternalId(), 'ONE_CLICK', $browserDto, $customerDto, $paymentMethod); + } +} diff --git a/src/Command/Handler/CaptureHostedPaymentRequestHandler.php b/src/Command/Handler/CaptureHostedPaymentRequestHandler.php index dec435d2..f99b6899 100644 --- a/src/Command/Handler/CaptureHostedPaymentRequestHandler.php +++ b/src/Command/Handler/CaptureHostedPaymentRequestHandler.php @@ -5,30 +5,23 @@ namespace PayPlug\SyliusPayPlugPlugin\Command\Handler; use PayPlug\SyliusPayPlugPlugin\Command\CaptureHostedPaymentRequest; -use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; -use PayPlug\SyliusPayPlugPlugin\Upc\HostedPaymentCreatorInterface; -use PayplugUnifiedCore\Contracts\IOrderStateMutator; -use PayplugUnifiedCore\Dto\BrowserDto; -use PayplugUnifiedCore\Dto\CommonFieldsDto; -use PayplugUnifiedCore\Dto\CustomerDto; +use PayPlug\SyliusPayPlugPlugin\Command\PaymentCaptureFlow; +use PayPlug\SyliusPayPlugPlugin\Upc\CardDataFromPaymentMethodExtractor; +use PayPlug\SyliusPayPlugPlugin\Upc\OperationStatusFetcherInterface; +use PayPlug\SyliusPayPlugPlugin\Upc\PaymentCaptureContextBuilder; +use PayPlug\SyliusPayPlugPlugin\Upc\PaymentCaptureOutcomeApplier; +use PayPlug\SyliusPayPlugPlugin\Upc\PayplugCardPersister; +use PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiPaymentCreatorInterface; use PayplugUnifiedCore\Dto\HostedFieldDto; use PayplugUnifiedCore\Exceptions\ApiException; use PayplugUnifiedCore\Exceptions\InvalidHostedFieldException; -use PayplugUnifiedCore\Output\HostedPaymentOutput; -use PayplugUnifiedCore\Utilities\Helpers\ExecCodeMapper; use Psr\Log\LoggerInterface; use Sylius\Abstraction\StateMachine\StateMachineInterface; -use Sylius\Bundle\CoreBundle\OrderPay\Provider\UrlProviderInterface; use Sylius\Bundle\PaymentBundle\Provider\PaymentRequestProviderInterface; -use Sylius\Component\Core\Model\OrderInterface; use Sylius\Component\Core\Model\PaymentInterface; -use Sylius\Component\Payment\Model\PaymentMethodInterface; use Sylius\Component\Payment\Model\PaymentRequestInterface; use Sylius\Component\Payment\PaymentRequestTransitions; -use Symfony\Component\DependencyInjection\Attribute\Autowire; -use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Messenger\Attribute\AsMessageHandler; -use Symfony\Component\Routing\Generator\UrlGeneratorInterface; #[AsMessageHandler] final class CaptureHostedPaymentRequestHandler @@ -36,13 +29,12 @@ final class CaptureHostedPaymentRequestHandler public function __construct( private PaymentRequestProviderInterface $paymentRequestProvider, private StateMachineInterface $stateMachine, - private HostedPaymentCreatorInterface $hostedPaymentCreator, - #[Autowire(service: 'sylius_shop.provider.order_pay.after_pay_url')] // @phpstan-ignore-line - private UrlProviderInterface $afterPayUrlProvider, - private UrlGeneratorInterface $urlGenerator, + private UnifiedApiPaymentCreatorInterface $unifiedApiPaymentCreator, + private OperationStatusFetcherInterface $operationStatusFetcher, + private PaymentCaptureContextBuilder $contextBuilder, + private PaymentCaptureOutcomeApplier $outcomeApplier, + private PayplugCardPersister $cardPersister, private LoggerInterface $logger, - private RequestStack $requestStack, - private IOrderStateMutator $orderStateMutator, ) { } @@ -53,12 +45,12 @@ public function __invoke(CaptureHostedPaymentRequest $captureHostedPaymentReques $payment = $paymentRequest->getPayment(); $details = $payment->getDetails(); - $method = $this->resolvePaymentMethod($payment); - $hfToken = $this->resolveHostedFieldsToken($details); - $amountAndCurrency = $this->resolveAmountAndCurrency($payment); - $credentials = $this->resolveGatewayCredentials($method); - try { + $method = $this->contextBuilder->resolvePaymentMethod($payment); + $hfToken = $this->resolveHostedFieldsToken($details); + $amountAndCurrency = $this->contextBuilder->resolveAmountAndCurrency($payment); + $credentials = $this->contextBuilder->resolveGatewayCredentials($method); + $dto = $this->buildHostedFieldDto( $paymentRequest, $payment, @@ -72,32 +64,30 @@ public function __invoke(CaptureHostedPaymentRequest $captureHostedPaymentReques 'payload' => $dto->createPayloadBody(), ]); - $output = $this->hostedPaymentCreator->createHostedPayment($dto); + $output = $this->unifiedApiPaymentCreator->createPayment($dto); } catch (ApiException | InvalidHostedFieldException | \LogicException $e) { - $this->failPaymentRequest($paymentRequest, $payment, $e); + $this->outcomeApplier->failPaymentRequest($paymentRequest, $payment, $e, PaymentCaptureFlow::Hosted); return; } + $hostedFieldsIds = $this->contextBuilder->resolveHostedFieldsIds($output->body); $payment->setDetails([ ...$details, 'hosted_fields_created_at' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM), - ...$this->resolveHostedFieldsIds($output->body), + ...$hostedFieldsIds, ]); - $this->applyOutcome($paymentRequest, $payment, $output); - - $this->stateMachine->apply($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE); - } + $this->outcomeApplier->applyOutcome($paymentRequest, $payment, $output); - private function resolvePaymentMethod(PaymentInterface $payment): PaymentMethodInterface - { - $method = $payment->getMethod(); - if (null === $method) { - throw new \LogicException('Payment method is not set for the payment.'); + $saveCard = true === ($details['hosted_fields_save_card'] ?? false); + if ($saveCard && null !== $output->aliasId) { + $unifiedApiOperationId = $hostedFieldsIds['hosted_fields_operation_id'] ?? null; + $fetchedCardData = null !== $unifiedApiOperationId ? $this->fetchCardDataFromUnifiedApi($unifiedApiOperationId) : []; + $this->cardPersister->persist($output->aliasId, $payment, $method, $details, $fetchedCardData); } - return $method; + $this->stateMachine->apply($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE); } /** @param mixed[] $details */ @@ -111,55 +101,6 @@ private function resolveHostedFieldsToken(array $details): string return $hfToken; } - /** - * Extracted from the creation response body rather than HostedPaymentOutput itself (which - * carries no such fields) — needed by StatusHostedPaymentRequestHandler's 3DS polling - * fallback and consistent with the ids the webhook path resolves against. - * - * @return array{hosted_fields_payment_id?: string, hosted_fields_operation_id?: string} - */ - private function resolveHostedFieldsIds(string $body): array - { - $decoded = \json_decode($body, true); - $paymentId = \is_array($decoded) ? ($decoded['id'] ?? null) : null; - $operationIds = \is_array($decoded) ? ($decoded['operationIds'] ?? null) : null; - $operationId = \is_array($operationIds) ? ($operationIds[0] ?? null) : null; - - if (!\is_string($paymentId) || '' === $paymentId || !\is_string($operationId) || '' === $operationId) { - return []; - } - - return [ - 'hosted_fields_payment_id' => $paymentId, - 'hosted_fields_operation_id' => $operationId, - ]; - } - - /** @return array{0: int, 1: string} */ - private function resolveAmountAndCurrency(PaymentInterface $payment): array - { - $amount = $payment->getAmount(); - $currencyCode = $payment->getCurrencyCode(); - if (null === $amount || null === $currencyCode) { - throw new \LogicException('Payment amount or currency is not set.'); - } - - return [$amount, $currencyCode]; - } - - /** @return array{0: string, 1: string} */ - private function resolveGatewayCredentials(PaymentMethodInterface $method): array - { - $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.'); - } - - return [$accountId, $submerchantExternalId]; - } - /** * @param mixed[] $details * @param array{0: int, 1: string} $amountAndCurrency @@ -177,118 +118,74 @@ private function buildHostedFieldDto( [$accountId, $submerchantExternalId] = $credentials; $order = $payment->getOrder(); - $orderId = $order?->getNumber() ?? self::idToString($payment->getId()); - $firstItemOrFalse = $order?->getItems()->first(); - $firstItem = false !== $firstItemOrFalse ? $firstItemOrFalse : null; - $common = new CommonFieldsDto($accountId, $amount, \strtoupper($currencyCode), $orderId, $submerchantExternalId); - $common->description = null !== $firstItem ? $firstItem->getProductName() : null; - // Fixed, parameter-less URL rather than the per-payment-request hashed route: - // UnifiedApiIpnAction already resolves the target Payment generically from the webhook - // body's own "id" (via PaymentRepositoryInterface::findOneByPayPlugPaymentId(), matching - // hosted_fields_payment_id), so a stable URL is enough — and it's the only shape PayPlug's - // Unified API notifier Receiver (configured once in Cockpit) can target at all. - $common->notificationUrl = $this->urlGenerator->generate( - 'payplug_sylius_unified_api_ipn', - [], - UrlGeneratorInterface::ABSOLUTE_URL, - ); - $successUrl = $this->afterPayUrlProvider->getUrl($paymentRequest, UrlGeneratorInterface::ABSOLUTE_URL); - $common->successUrl = $successUrl; - $common->cancelUrl = $successUrl . '?' . http_build_query(['status' => 'canceled']); + $common = $this->contextBuilder->buildCommonFields($accountId, $amount, $currencyCode, $submerchantExternalId, $paymentRequest, $order); $selectedBrand = $details['hosted_fields_selected_brand'] ?? null; - $paymentMethodDetails = \is_string($selectedBrand) && '' !== $selectedBrand - ? ['details' => ['selectedBrand' => $selectedBrand]] - : null; + $hasSelectedBrand = \is_string($selectedBrand) && '' !== $selectedBrand; + $fullName = $this->contextBuilder->resolveFullNameForCardDetails($order); + $hasFullName = null !== $fullName; - return new HostedFieldDto( - $common, - $hfToken, - $this->buildBrowserDto(), - $this->buildCustomerDto($order), - $paymentMethodDetails, - ); - } + $saveCard = true === ($details['hosted_fields_save_card'] ?? false); + $recurringMode = $saveCard ? 'ONE_CLICK' : null; - private function buildCustomerDto(?OrderInterface $order): CustomerDto - { - $customer = $order?->getCustomer(); - if (null === $customer || null === $customer->getEmail()) { - throw new \LogicException('Customer email is not set for the payment.'); + $cardDetails = []; + if ($hasFullName) { + $cardDetails['fullName'] = $fullName; } - - return new CustomerDto(self::idToString($customer->getId()), $customer->getEmail()); - } - - private function buildBrowserDto(): ?BrowserDto - { - $request = $this->requestStack->getCurrentRequest(); - if (null === $request) { - return null; + if ($hasSelectedBrand) { + $cardDetails['selectedBrand'] = $selectedBrand; } - return new BrowserDto( - $request->getClientIp() ?? '', - $request->headers->get('referer', '') ?? '', - $request->headers->get('User-Agent', '') ?? '', + // saveFutureUsage is only requested alongside a fullName: the Unified API rejects an + // alias-creation request (paymentMethod.saveFutureUsage: true) missing + // paymentMethod.details.fullName, so requesting it without one would fail the entire + // payment rather than just skip saving the card. + /** @var array{details?: array{fullName?: string, selectedBrand?: string}, saveFutureUsage?: bool}|null $paymentMethodDetails */ + $paymentMethodDetails = match (true) { + [] !== $cardDetails && $saveCard && $hasFullName => ['details' => $cardDetails, 'saveFutureUsage' => true], + [] !== $cardDetails => ['details' => $cardDetails], + default => null, + }; + + // Named arguments here (rather than positional) because HostedFieldDto's real + // constructor interposes $recurringMode between $hfToken and $browser/$customer — + // positional args would silently misalign. + return new HostedFieldDto( + $common, + $hfToken, + recurringMode: $recurringMode, + browser: $this->contextBuilder->buildBrowserDto(), + customer: $this->contextBuilder->buildCustomerDto($order), + paymentMethod: $paymentMethodDetails, ); } - private function failPaymentRequest( - PaymentRequestInterface $paymentRequest, - PaymentInterface $payment, - \Throwable $e, - ): void { - $this->logger->error('[PayPlug][UPC] Hosted payment creation failed.', [ - 'sylius_payment_id' => $payment->getId(), - 'error' => $e->getMessage(), - ]); - $paymentRequest->setResponseData(['error' => $e->getMessage()]); - $this->stateMachine->apply($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL); - } - - private function applyOutcome( - PaymentRequestInterface $paymentRequest, - PaymentInterface $payment, - HostedPaymentOutput $output, - ): void { - if (null !== $output->redirectUrl) { - $paymentRequest->setResponseData(['redirect_url' => $output->redirectUrl]); - - return; - } - - if (null !== $output->redirectHtml) { - // The Unified API's "recommended for web" 3DS shape: a self-submitting HTML form - // (rendered as-is by CaptureHttpResponseProvider) rather than a redirect target. Like - // the redirect_url branch above, the outcome is still pending the customer completing - // the challenge — never apply it synchronously. - $paymentRequest->setResponseData(['redirect_html' => $output->redirectHtml]); - - return; - } - - $paymentRequest->setResponseData(['status' => $output->status]); - - // No 3DS redirect means the outcome is already known synchronously — apply it to the - // actual Sylius Payment right away instead of waiting on the async webhook, which may - // be delayed or, in this test environment, never arrive at all. SyliusOrderStateMutator - // is idempotent (checks the state machine before transitioning), so it's safe to also - // run again if/when the webhook (NotifyHostedPaymentRequestHandler) eventually shows up. - $responseBody = \json_decode($output->body, true); - $execCode = \is_array($responseBody) ? ($responseBody['execCode'] ?? null) : null; - if (\is_string($execCode)) { - $this->orderStateMutator->apply(self::idToString($payment->getId()), ExecCodeMapper::toPaymentOutcome($execCode)); - } - } - - private static function idToString(mixed $id): string + /** + * The Unified API's alias-creation response carries only the alias id, no card metadata — the + * card's real brand/last4/expiration only show up on the operation resource, fetched here via + * the existing OperationStatusFetcherInterface (already used by StatusHostedPaymentRequestHandler + * for its 3DS polling fallback) rather than a second, duplicate client. Best-effort: any + * failure (API error, unexpected shape) is logged and swallowed rather than propagated, since + * this only enriches the card being persisted — PayplugCardPersister::persist()'s own + * $details-based fallback values already cover the case where this fetch fails or a field + * turns out to be absent. + * + * @return array{aliasId?: string, brand?: string, last4?: string, expirationMonth?: int, expirationYear?: int} + */ + private function fetchCardDataFromUnifiedApi(string $operationId): array { - if (!\is_int($id) && !\is_string($id)) { - throw new \LogicException('Unexpected non-scalar resource identifier.'); + try { + $response = $this->operationStatusFetcher->getOperation($operationId); + } catch (ApiException $e) { + $this->logger->error('[PayPlug][UPC] Failed to fetch operation for card metadata.', [ + 'unified_api_operation_id' => $operationId, + 'error' => $e->getMessage(), + ]); + + return []; } - return (string) $id; + return CardDataFromPaymentMethodExtractor::extract($response['body']); } } diff --git a/src/Command/PaymentCaptureFlow.php b/src/Command/PaymentCaptureFlow.php new file mode 100644 index 00000000..074790fd --- /dev/null +++ b/src/Command/PaymentCaptureFlow.php @@ -0,0 +1,16 @@ +getAction() === PaymentRequestInterface::ACTION_CAPTURE; @@ -24,12 +39,52 @@ public function supports(PaymentRequestInterface $paymentRequest): bool public function provide(PaymentRequestInterface $paymentRequest): object { $details = $paymentRequest->getPayment()->getDetails(); - if (\is_string($details['hosted_fields_created_at'] ?? null)) { - // A payment already created via createHostedPayment() (e.g. the shopper returned to - // /pay after a 3DS redirect) — do not create it again. + $selectedCard = $this->selectedCardResolver->resolve(); + + if ($this->isAlreadyInFlight($details, $selectedCard)) { return new OfflineCapturePaymentRequest($paymentRequest->getId()); } - return new CaptureHostedPaymentRequest($paymentRequest->getId()); + return null !== $selectedCard + ? new CaptureAliasPaymentRequest($paymentRequest->getId()) + : new CaptureHostedPaymentRequest($paymentRequest->getId()); + } + + /** + * True when a capture attempt for this exact payment/card pair was already created — either + * via createPayment() (e.g. the shopper returned to /pay after a 3DS redirect) or as an + * already-in-flight alias attempt for the same card. + * + * @param mixed[] $details + */ + private function isAlreadyInFlight(array $details, ?Card $selectedCard): bool + { + if (\is_string($details['hosted_fields_created_at'] ?? null)) { + return true; + } + + if (\is_string($details['alias_payment_created_at'] ?? null)) { + return $this->isAliasAttemptStillInFlight($details, $selectedCard); + } + + return false; + } + + // Split out of isAlreadyInFlight() to keep its own return count within SonarCloud's limit + // (php:S1142). + private function isAliasAttemptStillInFlight(array $details, ?Card $selectedCard): bool + { + // The customer switching to a *different, currently selected* saved card after an + // earlier alias attempt was created for another one is NOT "in flight" — the caller + // must fall through and re-evaluate as a fresh attempt, rather than silently + // re-polling the stale one. But switching to "other"/no selection at all gives no new + // card to justify a fresh attempt, so the still-unresolved earlier alias attempt stays + // in flight rather than dispatching a second, independent capture for the same + // payment (double-charge risk if the abandoned attempt later resolves too). + if (null === $selectedCard) { + return true; + } + + return $selectedCard->getExternalId() === ($details['alias_id'] ?? null); } } diff --git a/src/Controller/UnifiedApiIpnAction.php b/src/Controller/UnifiedApiIpnAction.php index 1ad2114d..f87b9d8e 100644 --- a/src/Controller/UnifiedApiIpnAction.php +++ b/src/Controller/UnifiedApiIpnAction.php @@ -37,6 +37,18 @@ #[AsController] class UnifiedApiIpnAction { + // PayPlug's Unified API webhook can be delivered within tens of milliseconds of the payment- + // creation response returning — often before CaptureHostedPaymentRequestHandler's own + // hosted_fields_payment_id/hosted_fields_operation_id write has actually committed (Sylius's + // doctrine_transaction messenger middleware only commits once that whole handler returns, and + // that handler can still be mid-flight fetching card metadata for PayplugCardPersister at that + // point). A brief bounded retry closes this window without touching that transaction's + // boundaries — the legacy IpnAction/NotifyAction has the same class of problem, mitigated + // there with a blocking sleep(10) before processing. + private const PAYMENT_RESOLUTION_MAX_ATTEMPTS = 4; + + private const PAYMENT_RESOLUTION_RETRY_DELAY_MICROSECONDS = 200_000; + public function __construct( private LoggerInterface $logger, private HostedFieldsWebhookNotificationHandler $hostedFieldsWebhookNotificationHandler, @@ -70,9 +82,12 @@ private function resolveHostedFieldsPayment(string $input): ?PaymentInterface { $content = json_decode($input, true); $id = \is_array($content) ? ($content['id'] ?? null) : null; + if (!\is_string($id) || '' === $id) { + // if we are too fast canceling a payment before we got an answer from PayPlug gateway + return null; + } - // if we are too fast canceling a payment before we got an answer from PayPlug gateway - $payment = \is_string($id) && '' !== $id ? $this->paymentRepository->findOneByPayPlugPaymentId($id) : null; + $payment = $this->findPaymentWithRetry($id); if (null === $payment) { return null; } @@ -89,6 +104,22 @@ private function resolveHostedFieldsPayment(string $input): ?PaymentInterface return PayPlugGatewayFactory::isHostedFieldsConfig($gateway) ? $payment : null; } + private function findPaymentWithRetry(string $id): ?PaymentInterface + { + for ($attempt = 1; $attempt <= self::PAYMENT_RESOLUTION_MAX_ATTEMPTS; ++$attempt) { + $payment = $this->paymentRepository->findOneByPayPlugPaymentId($id); + if (null !== $payment) { + return $payment; + } + + if ($attempt < self::PAYMENT_RESOLUTION_MAX_ATTEMPTS) { + usleep(self::PAYMENT_RESOLUTION_RETRY_DELAY_MICROSECONDS); + } + } + + return null; + } + /** * @param array> $rawHeaders * diff --git a/src/Creator/PayPlugPaymentDataCreator.php b/src/Creator/PayPlugPaymentDataCreator.php index 9b437e68..7b7000df 100644 --- a/src/Creator/PayPlugPaymentDataCreator.php +++ b/src/Creator/PayPlugPaymentDataCreator.php @@ -21,6 +21,7 @@ use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\ScalapayGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\WeroGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\Upc\CustomerTitleResolver; use Sylius\Component\Core\Model\AddressInterface; use Sylius\Component\Core\Model\CustomerInterface; use Sylius\Component\Core\Model\OrderInterface; @@ -37,8 +38,6 @@ class PayPlugPaymentDataCreator private const DELIVERY_TYPE_NEW = 'NEW'; - private const PAYPLUG_CARD_ID_OTHER = 'other'; - public function __construct( private CanSaveCardCheckerInterface $canSaveCardChecker, private RepositoryInterface $payplugCardRepository, @@ -142,9 +141,9 @@ public function formatNumber(string $phoneNumber, ?string $isoCode): array private function formatTitle(CustomerInterface $customer): ?string { - $gender = $customer->getGender(); + $title = CustomerTitleResolver::resolve($customer->getGender()); - return 'm' === $gender ? 'mr' : ('f' === $gender ? 'mrs' : null); + return null !== $title ? strtolower($title) : null; } private function formatLanguageCode(?string $languageCode): ?string @@ -252,7 +251,7 @@ private function alterPayPlugDetailsForOneClick( $cardId = $this->requestStack->getSession()->get('payplug_payment_method'); if ( - (null === $cardId || self::PAYPLUG_CARD_ID_OTHER === $cardId) && $this->canSaveCardChecker->isAllowed( + (null === $cardId || PayPlugGatewayFactory::CARD_CHOICE_OTHER === $cardId) && $this->canSaveCardChecker->isAllowed( $paymentMethod, ) ) { diff --git a/src/Entity/Card.php b/src/Entity/Card.php index 147bdaeb..2f749b3c 100644 --- a/src/Entity/Card.php +++ b/src/Entity/Card.php @@ -17,6 +17,7 @@ */ #[ORM\Entity] #[ORM\Table(name: 'payplug_cards')] +#[ORM\UniqueConstraint(name: 'UNIQ_payplug_cards_external_id_is_live', columns: ['external_id', 'is_live'])] class Card implements ResourceInterface { /** diff --git a/src/EventSubscriber/PostPaymentSelectEventSubscriber.php b/src/EventSubscriber/PostPaymentSelectEventSubscriber.php index ec4ede10..d633b4b1 100644 --- a/src/EventSubscriber/PostPaymentSelectEventSubscriber.php +++ b/src/EventSubscriber/PostPaymentSelectEventSubscriber.php @@ -6,6 +6,7 @@ use Doctrine\ORM\EntityManagerInterface; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\PaymentProcessing\HostedFieldsCaptureData; use PayPlug\SyliusPayPlugPlugin\PaymentProcessing\HostedFieldsPaymentProcessorInterface; use Sylius\Abstraction\StateMachine\StateMachineInterface; use Sylius\Bundle\ResourceBundle\Event\ResourceControllerEvent; @@ -31,6 +32,14 @@ final class PostPaymentSelectEventSubscriber implements EventSubscriberInterface private const HOSTED_FIELDS_SAVE_CARD_FIELD = 'hostedfields_save_card'; + private const HOSTED_FIELDS_LAST4_FIELD = 'hostedfields_last4'; + + private const HOSTED_FIELDS_EXP_MONTH_FIELD = 'hostedfields_exp_month'; + + private const HOSTED_FIELDS_EXP_YEAR_FIELD = 'hostedfields_exp_year'; + + private const HOSTED_FIELDS_COUNTRY_FIELD = 'hostedfields_country'; + public function __construct( private RequestStack $requestStack, private EntityManagerInterface $entityManager, @@ -178,8 +187,15 @@ private function handleHostedFieldsToken(Request $request, PaymentInterface $las $hfToken = $this->getRequestField($request, self::HOSTED_FIELDS_TOKEN_FIELD); $selectedBrand = $this->getRequestField($request, self::HOSTED_FIELDS_SELECTED_BRAND_FIELD); $saveCard = 'true' === $request->request->get(self::HOSTED_FIELDS_SAVE_CARD_FIELD, 'false'); + $last4 = $this->getRequestField($request, self::HOSTED_FIELDS_LAST4_FIELD); + $expirationMonth = $this->getOptionalIntRequestField($request, self::HOSTED_FIELDS_EXP_MONTH_FIELD); + $expirationYear = $this->getOptionalIntRequestField($request, self::HOSTED_FIELDS_EXP_YEAR_FIELD); + $countryCode = $this->getRequestField($request, self::HOSTED_FIELDS_COUNTRY_FIELD); - $this->hostedFieldsPaymentProcessor->process($lastPayment, $hfToken, $selectedBrand, $saveCard); + $this->hostedFieldsPaymentProcessor->process( + $lastPayment, + new HostedFieldsCaptureData($hfToken, $selectedBrand, $saveCard, $last4, $expirationMonth, $expirationYear, $countryCode), + ); $this->applyToComplete($lastPayment->getOrder() ?? throw new \LogicException('Order not found for payment')); } @@ -197,6 +213,17 @@ private function getRequestField(Request $request, string $field): string return $value; } + /** + * Distinguishes a genuinely absent field from a legitimately-fetched 0, unlike a plain + * (int) cast on the empty-string default, which would collapse both to the same value. + */ + private function getOptionalIntRequestField(Request $request, string $field): ?int + { + $value = $this->getRequestField($request, $field); + + return '' === $value ? null : (int) $value; + } + private function applyToComplete(OrderInterface $order): void { if ($this->stateMachine->can($order, OrderCheckoutTransitions::GRAPH, OrderCheckoutTransitions::TRANSITION_COMPLETE)) { diff --git a/src/Gateway/PayPlugGatewayFactory.php b/src/Gateway/PayPlugGatewayFactory.php index 042c0e2e..1b2b5dd3 100644 --- a/src/Gateway/PayPlugGatewayFactory.php +++ b/src/Gateway/PayPlugGatewayFactory.php @@ -15,6 +15,11 @@ final class PayPlugGatewayFactory extends AbstractGatewayFactory // Custom gateway configuration keys public const ONE_CLICK = 'oneClick'; + // Session-protocol sentinel meaning "the customer chose to pay with a different/new card + // rather than one of their saved aliases" — shared by PayPlugPaymentDataCreator (legacy SDK + // flow) and CaptureAliasPaymentRequestHandler (Hosted Fields flow). + public const CARD_CHOICE_OTHER = 'other'; + public const INTEGRATED_PAYMENT = 'integratedPayment'; public const DEFERRED_CAPTURE = 'deferredCapture'; diff --git a/src/Handler/HostedFieldsWebhookNotificationHandler.php b/src/Handler/HostedFieldsWebhookNotificationHandler.php index 360f988e..1605a23b 100644 --- a/src/Handler/HostedFieldsWebhookNotificationHandler.php +++ b/src/Handler/HostedFieldsWebhookNotificationHandler.php @@ -4,6 +4,10 @@ namespace PayPlug\SyliusPayPlugPlugin\Handler; +use PayPlug\SyliusPayPlugPlugin\Upc\CardDataFromPaymentMethodExtractor; +use PayPlug\SyliusPayPlugPlugin\Upc\PaymentOrderIdResolver; +use PayPlug\SyliusPayPlugPlugin\Upc\PayplugCardPersister; +use PayPlug\SyliusPayPlugPlugin\Upc\ResourceIdentifier; use PayplugUnifiedCore\Contracts\IConfigurationRepository; use PayplugUnifiedCore\Contracts\ILock; use PayplugUnifiedCore\Contracts\IOrderStateMutator; @@ -54,6 +58,7 @@ public function __construct( private IConfigurationRepository $configurationRepository, private ILock $lock, private LoggerInterface $logger, + private PayplugCardPersister $cardPersister, ) { } @@ -86,13 +91,48 @@ public function treat(PaymentInterface $payment, string $rawBody, array $headers } $this->paymentRepository->save($operationData); - $this->orderStateMutator->apply(self::idToString($payment->getId()), $operationData->outcome); + $this->orderStateMutator->apply(ResourceIdentifier::toString($payment->getId()), $operationData->outcome); $this->paymentRepository->markTreated($operationData->operationId); + + if (PaymentOutcome::PAID === $operationData->outcome) { + $this->maybeSaveCard($payment, $rawBody); + } } finally { $this->lock->release($lockKey); } } + // A 3DS-challenge capture never gets an alias back synchronously (CaptureHostedPaymentRequestHandler + // only sees one on a direct, frictionless success) — this webhook, fired once the challenge is + // validated, is the only place a 3DS payment's card ever gets saved. The alias/card metadata + // itself is already in $rawBody: confirmed the same paymentMethod.{id, card, details} shape as + // the operation resource CaptureHostedPaymentRequestHandler fetches separately, so no extra API + // call is needed here. + private function maybeSaveCard(PaymentInterface $payment, string $rawBody): void + { + $details = $payment->getDetails(); + if (true !== ($details['hosted_fields_save_card'] ?? false)) { + return; + } + + $method = $payment->getMethod(); + if (null === $method) { + return; + } + + $cardData = CardDataFromPaymentMethodExtractor::extractFromDecoded(\json_decode($rawBody, true)); + $aliasId = $cardData['aliasId'] ?? null; + if (null === $aliasId) { + $this->logger->error('[PayPlug][UPC] Save-card was requested but the webhook notification carried no alias id.', [ + 'sylius_payment_id' => $payment->getId(), + ]); + + return; + } + + $this->cardPersister->persist($aliasId, $payment, $method, $details, $cardData); + } + // Split out of treat() to keep its own return count within SonarCloud's limit (php:S1142) — // both branches here mean "nothing to apply," they just differ in whether that's expected // (still-pending) or a problem worth logging over (mismatch). @@ -107,7 +147,7 @@ private function matchesPayment(PaymentInterface $payment, OperationData $operat return false; } - $expectedOrderId = self::resolveExpectedOrderId($payment); + $expectedOrderId = PaymentOrderIdResolver::resolve($payment->getOrder(), $payment->getId()); if ($operationData->orderId !== $expectedOrderId || $operationData->amount !== $payment->getAmount()) { $this->logger->error('[PayPlug][UPC] Hosted Fields webhook notification does not match the payment it was resolved against.', [ 'sylius_payment_id' => $payment->getId(), @@ -122,21 +162,4 @@ private function matchesPayment(PaymentInterface $payment, OperationData $operat return true; } - - // Mirrors the fallback CaptureHostedPaymentRequestHandler used when it built the orderId sent - // to PayPlug at creation time (order number if the order exists yet, else the payment id) — - // this must resolve to the same value the platform was originally given back. - private static function resolveExpectedOrderId(PaymentInterface $payment): string - { - return $payment->getOrder()?->getNumber() ?? self::idToString($payment->getId()); - } - - private static function idToString(mixed $id): string - { - if (!\is_int($id) && !\is_string($id)) { - throw new \LogicException('Unexpected non-scalar resource identifier.'); - } - - return (string) $id; - } } diff --git a/src/Handler/PaymentNotificationHandler.php b/src/Handler/PaymentNotificationHandler.php index 7e1c8851..c6671c1b 100644 --- a/src/Handler/PaymentNotificationHandler.php +++ b/src/Handler/PaymentNotificationHandler.php @@ -5,7 +5,9 @@ namespace PayPlug\SyliusPayPlugPlugin\Handler; use DateTimeImmutable; +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\Persistence\ManagerRegistry; use Payplug\Resource\IVerifiableAPIResource; use Payplug\Resource\Payment; use Payplug\Resource\PaymentAuthorization; @@ -31,6 +33,7 @@ public function __construct( private EntityManagerInterface $entityManager, private LockFactory $lockFactory, private RequestStack $requestStack, + private ManagerRegistry $managerRegistry, ) { } @@ -137,6 +140,9 @@ private function saveCard(PaymentInterface $payment, IVerifiableAPIResource $pay return; } + // This check-then-act is not by itself race-proof — two payments notified concurrently + // for the same card alias can both pass this guard — so a DB-level unique constraint on + // (external_id, is_live) backs it up; see the catch below. $card = $this->payplugCardRepository->findOneBy([ 'externalId' => $paymentResource->__get('card')->id, 'isLive' => $paymentResource->is_live, @@ -163,7 +169,16 @@ private function saveCard(PaymentInterface $payment, IVerifiableAPIResource $pay ->setIsLive($paymentResource->is_live) ; - $this->payplugCardRepository->add($card); + try { + $this->payplugCardRepository->add($card); + } catch (UniqueConstraintViolationException) { + // The findOneBy() guard above lost a race against a concurrent save for the same + // alias — that other call already stored the canonical Card row, so there is nothing + // left to do here. Doctrine's UnitOfWork closes the EntityManager on ANY flush + // failure, catch included — reset the registry so Doctrine work resolved fresh after + // this point doesn't inherit the now-closed instance. + $this->managerRegistry->resetManager(); + } } private function isResourceIsAuthorized(IVerifiableAPIResource $paymentResource): bool diff --git a/src/OrderPay/Provider/CaptureHttpResponseProvider.php b/src/OrderPay/Provider/CaptureHttpResponseProvider.php index 4c0de531..5d8b6806 100644 --- a/src/OrderPay/Provider/CaptureHttpResponseProvider.php +++ b/src/OrderPay/Provider/CaptureHttpResponseProvider.php @@ -59,9 +59,9 @@ public function getResponse( // This is called after the capture payment request has been handled $data = $paymentRequest->getResponseData(); - // The Unified API's "recommended for web" 3DS shape (Hosted Fields only, see - // CaptureHostedPaymentRequestHandler): a self-submitting HTML form to render as-is, rather - // than a plain redirect target. + // The Unified API's "recommended for web" 3DS-pending shape (see PaymentOutput, returned by + // both CaptureHostedPaymentRequestHandler and CaptureAliasPaymentRequestHandler): a + // self-submitting HTML form to render as-is, rather than a plain redirect target. if (\is_string($data['redirect_html'] ?? null)) { return new Response($data['redirect_html']); } diff --git a/src/PaymentProcessing/HostedFieldsCaptureData.php b/src/PaymentProcessing/HostedFieldsCaptureData.php new file mode 100644 index 00000000..201a12b6 --- /dev/null +++ b/src/PaymentProcessing/HostedFieldsCaptureData.php @@ -0,0 +1,24 @@ +logger->info('Hosted Fields token received, awaiting UPC payment processing (PRE-3551).', [ + $this->logger->info('Hosted Fields token received, awaiting UPC payment processing.', [ 'payment_id' => $payment->getId(), - 'selected_brand' => $selectedBrand, - 'save_card' => $saveCard, + 'selected_brand' => $captureData->selectedBrand, + 'save_card' => $captureData->saveCard, ]); $payment->setDetails(\array_merge( $payment->getDetails(), [ - 'hosted_fields_token' => $hfToken, - 'hosted_fields_selected_brand' => $selectedBrand, - 'hosted_fields_save_card' => $saveCard, + 'hosted_fields_token' => $captureData->hfToken, + 'hosted_fields_selected_brand' => $captureData->selectedBrand, + 'hosted_fields_save_card' => $captureData->saveCard, + 'hosted_fields_last4' => $captureData->last4, + 'hosted_fields_expiration_month' => $captureData->expirationMonth, + 'hosted_fields_expiration_year' => $captureData->expirationYear, + 'hosted_fields_country' => $captureData->countryCode, 'status' => PaymentInterface::STATE_PROCESSING, ], )); diff --git a/src/Resolver/SelectedCardResolver.php b/src/Resolver/SelectedCardResolver.php new file mode 100644 index 00000000..0c43d88b --- /dev/null +++ b/src/Resolver/SelectedCardResolver.php @@ -0,0 +1,36 @@ +requestStack->getSession()->get('payplug_payment_method'); + if (null === $cardId || PayPlugGatewayFactory::CARD_CHOICE_OTHER === $cardId) { + return null; + } + + $card = $this->payplugCardRepository->find($cardId); + + return $card instanceof Card ? $card : null; + } +} diff --git a/src/Upc/CardDataFromPaymentMethodExtractor.php b/src/Upc/CardDataFromPaymentMethodExtractor.php new file mode 100644 index 00000000..99e11af5 --- /dev/null +++ b/src/Upc/CardDataFromPaymentMethodExtractor.php @@ -0,0 +1,116 @@ + $aliasId] : []; + } + + /** @return array{brand?: string, last4?: string} */ + private static function extractFromCard(mixed $card): array + { + if (!\is_array($card)) { + return []; + } + + $result = []; + + $network = $card['network'] ?? null; + if (\is_string($network) && '' !== $network) { + $result['brand'] = $network; + } + + $code6x4 = $card['code6x4'] ?? null; + if (\is_string($code6x4) && \strlen($code6x4) >= 4) { + $result['last4'] = \substr($code6x4, -4); + } + + return $result; + } + + /** + * @param bool $hasBrand true when a brand was already resolved from the card object, taking + * precedence over the details' own selectedBrand + * + * @return array{brand?: string, expirationMonth?: int, expirationYear?: int} + */ + private static function extractFromDetails(mixed $cardDetails, bool $hasBrand): array + { + if (!\is_array($cardDetails)) { + return []; + } + + $result = []; + + $selectedBrand = $cardDetails['selectedBrand'] ?? null; + if (!$hasBrand && \is_string($selectedBrand) && '' !== $selectedBrand) { + $result['brand'] = $selectedBrand; + } + + $validityDate = $cardDetails['validityDate'] ?? null; + if (\is_string($validityDate) && 1 === \preg_match('/^(\d{4})-(\d{2})$/', $validityDate, $matches)) { + $month = (int) $matches[2]; + if ($month >= 1 && $month <= 12) { + $result['expirationYear'] = (int) $matches[1]; + $result['expirationMonth'] = $month; + } + } + + return $result; + } +} diff --git a/src/Upc/CustomerTitleResolver.php b/src/Upc/CustomerTitleResolver.php new file mode 100644 index 00000000..61d9e27b --- /dev/null +++ b/src/Upc/CustomerTitleResolver.php @@ -0,0 +1,30 @@ + 'MR', + 'f' => 'MRS', + default => null, + }; + } +} diff --git a/src/Upc/HostedPaymentCreatorInterface.php b/src/Upc/HostedPaymentCreatorInterface.php deleted file mode 100644 index 5646bd07..00000000 --- a/src/Upc/HostedPaymentCreatorInterface.php +++ /dev/null @@ -1,19 +0,0 @@ -getBillingAddress(); + if (null === $address) { + return null; + } + + return new BillingDto( + $this->buildAddress($address), + $this->buildContact($address), + $this->title($order), + ); + } + + public function createShipping(OrderInterface $order): ?ShippingDto + { + $address = $order->getShippingAddress(); + if (null === $address) { + return null; + } + + return new ShippingDto( + $this->buildAddress($address), + $this->buildContact($address), + $order->getCustomer()?->getEmail(), + $address->getCompany(), + ); + } + + private function buildContact(AddressInterface $address): ContactDto + { + [$phone, $mobilePhone] = $this->splitPhone($address); + + return new ContactDto($address->getFirstName(), $address->getLastName(), $phone, $mobilePhone); + } + + private function buildAddress(AddressInterface $address): AddressDto + { + return new AddressDto( + $address->getStreet(), + $address->getCity(), + $address->getCountryCode(), + // AddressDto::$state is documented as 0-3 chars, so a short province CODE (e.g. "75") + // fits where the full province NAME (used elsewhere in this plugin's legacy + // PayPlugPaymentDataCreator) would not — but Sylius province codes aren't always that + // short (e.g. ISO-3166-2-style "US-CA"), so only pass one through when it actually + // fits, rather than risk sending a malformed state to the Unified API. + $this->shortProvinceCode($address), + $address->getPostcode(), + ); + } + + private function shortProvinceCode(AddressInterface $address): ?string + { + $provinceCode = $address->getProvinceCode(); + + return null !== $provinceCode && \strlen($provinceCode) <= 3 ? $provinceCode : null; + } + + /** + * @return array{0: string|null, 1: string|null} [phone, mobilePhone] — Sylius stores only one + * phone number per address, so only one of the two ever comes back non-null here, matching + * whichever type PhoneHelper::isMobile() detects it as; an unparseable/invalid number is + * treated as absent rather than failing the whole payment over supplementary contact data. + */ + private function splitPhone(AddressInterface $address): array + { + $rawPhone = $address->getPhoneNumber(); + $countryCode = $address->getCountryCode(); + if (null === $rawPhone || '' === $rawPhone || null === $countryCode) { + return [null, null]; + } + + try { + $e164Phone = PhoneHelper::toE164($rawPhone, $countryCode); + $isMobile = PhoneHelper::isMobile($rawPhone, $countryCode); + } catch (InvalidPhoneNumberException) { + return [null, null]; + } + + return $isMobile ? [null, $e164Phone] : [$e164Phone, null]; + } + + private function title(OrderInterface $order): ?string + { + $gender = $order->getCustomer()?->getGender(); + + return null !== $gender ? CustomerTitleResolver::resolve($gender) : null; + } +} diff --git a/src/Upc/PaymentCaptureContextBuilder.php b/src/Upc/PaymentCaptureContextBuilder.php new file mode 100644 index 00000000..1716ae41 --- /dev/null +++ b/src/Upc/PaymentCaptureContextBuilder.php @@ -0,0 +1,187 @@ +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.'); + } + + return [$accountId, $submerchantExternalId]; + } + + 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); + $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 + // Receiver at /payplug/v2/ipn (see UnifiedApiIpnAction's docblock). Set anyway to keep the + // DTO's contract intact rather than leaving the field unset. + $common->notificationUrl = $this->urlGenerator->generate( + 'sylius_payment_request_notify', + ['hash' => (string) $paymentRequest->getHash()], + UrlGeneratorInterface::ABSOLUTE_URL, + ); + $successUrl = $this->afterPayUrlProvider->getUrl($paymentRequest, UrlGeneratorInterface::ABSOLUTE_URL); + $common->successUrl = $successUrl; + $common->cancelUrl = $successUrl . '?' . http_build_query(['status' => 'canceled']); + if (null !== $order) { + $common->billing = $this->orderAddressDtoCreator->createBilling($order); + $common->shipping = $this->orderAddressDtoCreator->createShipping($order); + } + + return $common; + } + + public function buildBrowserDto(): ?BrowserDto + { + $request = $this->requestStack->getCurrentRequest(); + + return null !== $request + ? new BrowserDto( + $request->getClientIp() ?? '', + $request->headers->get('referer', '') ?? '', + $request->headers->get('User-Agent', '') ?? '', + ) + : null; + } + + public function resolvePaymentMethod(PaymentInterface $payment): PaymentMethodInterface + { + $method = $payment->getMethod(); + if (null === $method) { + throw new \LogicException('Payment method is not set for the payment.'); + } + + return $method; + } + + /** @return array{0: int, 1: string} */ + public function resolveAmountAndCurrency(PaymentInterface $payment): array + { + $amount = $payment->getAmount(); + $currencyCode = $payment->getCurrencyCode(); + if (null === $amount || null === $currencyCode) { + throw new \LogicException('Payment amount or currency is not set.'); + } + + return [$amount, $currencyCode]; + } + + /** + * Extracted from a payment-creation response body rather than PaymentOutput itself (which + * carries no such fields) — needed by StatusHostedPaymentRequestHandler's 3DS polling + * fallback and by UnifiedApiIpnAction::resolveHostedFieldsPayment(), which looks these ids up + * on Payment::details. Shared by both capture handlers (token and alias) since either flow's + * response can carry a pending 3DS challenge that only the webhook/polling path resolves. + * + * @return array{hosted_fields_payment_id?: string, hosted_fields_operation_id?: string} + */ + public function resolveHostedFieldsIds(string $body): array + { + $decoded = \json_decode($body, true); + $paymentId = \is_array($decoded) ? ($decoded['id'] ?? null) : null; + $operationIds = \is_array($decoded) ? ($decoded['operationIds'] ?? null) : null; + $operationId = \is_array($operationIds) ? ($operationIds[0] ?? null) : null; + + // Each id is stored independently — a response carrying only one of the two (e.g. no + // operationIds yet) must not also drop the other, since dropping hosted_fields_operation_id + // silently disables the card-metadata enrichment in PayplugCardPersister::persist(). + $result = []; + if (\is_string($paymentId) && '' !== $paymentId) { + $result['hosted_fields_payment_id'] = $paymentId; + } + if (\is_string($operationId) && '' !== $operationId) { + $result['hosted_fields_operation_id'] = $operationId; + } + + return $result; + } + + public function buildCustomerDto(?OrderInterface $order): CustomerDto + { + $customer = $order?->getCustomer(); + if (null === $customer || null === $customer->getEmail()) { + throw new \LogicException('Customer email is not set for the payment.'); + } + + return new CustomerDto(ResourceIdentifier::toString($customer->getId()), $customer->getEmail()); + } + + /** + * Falls back to the billing address's own full name, then the customer's, so a + * paymentMethod.details.fullName the Unified API requires whenever saveFutureUsage is + * requested isn't left unset just because the billing address itself has none set. + */ + public function resolveFullNameForCardDetails(?OrderInterface $order): ?string + { + $fullName = $order?->getBillingAddress()?->getFullName(); + if (null !== $fullName && '' !== $fullName) { + return $fullName; + } + + $fullName = $order?->getCustomer()?->getFullName(); + + return null !== $fullName && '' !== $fullName ? $fullName : null; + } + + // Identifies the order's own product for PayPlug's back office; falls back to a generic + // integration/version string only when there's no order or no line item to name (e.g. this + // is also used by the pay-with-an-existing-alias flow, which shares the same order shape). + private function resolveDescription(?OrderInterface $order): ?string + { + $firstItemOrFalse = $order?->getItems()->first(); + $firstItem = false !== $firstItemOrFalse ? $firstItemOrFalse : null; + + return null !== $firstItem ? $firstItem->getProductName() : IntegrationDescriptionProvider::build(); + } +} diff --git a/src/Upc/PaymentCaptureOutcomeApplier.php b/src/Upc/PaymentCaptureOutcomeApplier.php new file mode 100644 index 00000000..7ac52b62 --- /dev/null +++ b/src/Upc/PaymentCaptureOutcomeApplier.php @@ -0,0 +1,79 @@ +logger->error(\sprintf('[PayPlug][UPC] %s payment creation failed.', $flow->value), [ + 'sylius_payment_id' => $payment->getId(), + 'error' => $e->getMessage(), + ]); + $paymentRequest->setResponseData(['error' => $e->getMessage()]); + $this->stateMachine->apply($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL); + } + + public function applyOutcome( + PaymentRequestInterface $paymentRequest, + PaymentInterface $payment, + PaymentOutput $output, + ): void { + if (null !== $output->redirectHtml) { + // The "recommended for web" 3DS-pending shape — an auto-submitting HTML form the + // browser must render directly (see CaptureHttpResponseProvider). This is what the + // Unified API actually returns by default; redirectUrl only appears when the request + // explicitly set card.threeDSecure.displayMode=raw, which this plugin never does. + $paymentRequest->setResponseData(['redirect_html' => $output->redirectHtml]); + + return; + } + + if (null !== $output->redirectUrl) { + $paymentRequest->setResponseData(['redirect_url' => $output->redirectUrl]); + + return; + } + + $paymentRequest->setResponseData(['status' => $output->status]); + + // No 3DS redirect means the outcome is already known synchronously — apply it to the + // actual Sylius Payment right away instead of waiting on the async webhook, which may + // be delayed or, in this test environment, never arrive at all. SyliusOrderStateMutator + // is idempotent (checks the state machine before transitioning), so it's safe to also + // run again if/when the webhook (e.g. NotifyHostedPaymentRequestHandler) eventually shows up. + $responseBody = \json_decode($output->body, true); + $execCode = \is_array($responseBody) ? ($responseBody['execCode'] ?? null) : null; + if (\is_string($execCode)) { + $this->orderStateMutator->apply(ResourceIdentifier::toString($payment->getId()), ExecCodeMapper::toPaymentOutcome($execCode)); + } + } +} diff --git a/src/Upc/PaymentOrderIdResolver.php b/src/Upc/PaymentOrderIdResolver.php new file mode 100644 index 00000000..12252001 --- /dev/null +++ b/src/Upc/PaymentOrderIdResolver.php @@ -0,0 +1,29 @@ +getNumber() ?? ResourceIdentifier::toString($paymentId); + } +} diff --git a/src/Upc/PayplugCardPersister.php b/src/Upc/PayplugCardPersister.php new file mode 100644 index 00000000..58fb90bb --- /dev/null +++ b/src/Upc/PayplugCardPersister.php @@ -0,0 +1,149 @@ +getOrder(); + + $customer = $order?->getCustomer(); + if (null === $customer) { + return; + } + + // Payment::getMethod() is only typed to the base Payment component's PaymentMethodInterface, + // but Card::$paymentMethod is a mandatory (non-nullable) association requiring Sylius + // Core's narrower PaymentMethodInterface (the only kind Sylius actually wires up at + // runtime) — bail out before building a Card at all rather than persisting one with that + // required field left unset, which would only fail later at flush time. + if (!$method instanceof CorePaymentMethodInterface) { + return; + } + + $gatewayConfig = $method->getGatewayConfig()?->getConfig() ?? []; + $isLive = true === ($gatewayConfig['live'] ?? false); + + // Guards against double-saving the same alias — e.g. a 3DS payment whose webhook fires + // more than once for reasons outside isTreated()'s own operation-id dedupe (a different + // operation id notifying the same alias), or any future path that also calls persist() + // for an alias already stored. This check-then-act is not by itself race-proof — the + // synchronous frictionless capture and the async webhook can both reach persist() for the + // same alias — so a DB-level unique constraint on (external_id, is_live) backs it up; see + // the catch below. + if (null !== $this->payplugCardRepository->findOneBy(['externalId' => $aliasId, 'isLive' => $isLive])) { + return; + } + + // $details' own hosted_fields_* values are fully client-controlled (see + // hosted-fields_controller.js) — used only as a display-only fallback for whatever + // $fetchedCardData (PayPlug's own API/webhook data) doesn't carry, and validated here + // rather than trusted as-is: anything not matching the expected shape is discarded as if + // it were absent. + $currentYear = (int) (new \DateTimeImmutable())->format('Y'); + $brand = $fetchedCardData['brand'] ?? $this->sanitizeBrand($details['hosted_fields_selected_brand'] ?? null); + $last4 = $fetchedCardData['last4'] + ?? $this->sanitizeString($details['hosted_fields_last4'] ?? null, self::LAST4_PATTERN); + $expirationMonth = $fetchedCardData['expirationMonth'] + ?? $this->sanitizeIntInRange($details['hosted_fields_expiration_month'] ?? null, 1, 12); + $expirationYear = $fetchedCardData['expirationYear'] + ?? $this->sanitizeIntInRange( + $details['hosted_fields_expiration_year'] ?? null, + $currentYear, + $currentYear + self::MAX_EXPIRATION_YEARS_AHEAD, + ); + // No card country field exists on the operation resource (confirmed against a real + // staging response) — $countryCode keeps relying entirely on the client-submitted + // $details value. + $countryCodeCandidate = $this->sanitizeString($details['hosted_fields_country'] ?? null, self::COUNTRY_CODE_PATTERN); + $countryCode = null !== $countryCodeCandidate ? \strtoupper($countryCodeCandidate) : null; + + /** @var Card $card */ + $card = $this->payplugCardFactory->createNew(); + $card + ->setCustomer($customer) + ->setExternalId($aliasId) + ->setBrand(\is_string($brand) ? $brand : '') + ->setLast4(\is_string($last4) ? $last4 : '') + ->setExpirationMonth(\is_int($expirationMonth) ? $expirationMonth : 0) + ->setExpirationYear(\is_int($expirationYear) ? $expirationYear : 0) + ->setCountryCode(\is_string($countryCode) ? $countryCode : '') + ->setIsLive($isLive) + ->setPaymentMethod($method) + ; + + try { + $this->payplugCardRepository->add($card); + } catch (UniqueConstraintViolationException) { + // The findOneBy() guard above lost a race against a concurrent persist() call for the + // same alias — that other call already stored the canonical Card row, so there is + // nothing left to do here. Doctrine's UnitOfWork closes the EntityManager on ANY + // flush failure, catch included — reset the registry so Doctrine work resolved fresh + // after this point doesn't inherit the now-closed instance. + $this->managerRegistry->resetManager(); + } + } + + private function sanitizeBrand(mixed $value): ?string + { + return \is_string($value) && \in_array($value, self::ALLOWED_BRANDS, true) ? $value : null; + } + + private function sanitizeString(mixed $value, string $pattern): ?string + { + return \is_string($value) && 1 === \preg_match($pattern, $value) ? $value : null; + } + + private function sanitizeIntInRange(mixed $value, int $min, int $max): ?int + { + return \is_int($value) && $value >= $min && $value <= $max ? $value : null; + } +} diff --git a/src/Upc/ResourceIdentifier.php b/src/Upc/ResourceIdentifier.php new file mode 100644 index 00000000..fd8909b0 --- /dev/null +++ b/src/Upc/ResourceIdentifier.php @@ -0,0 +1,29 @@ +logger->debug('[PayPlug debug] Unified API raw request.', [ 'method' => $method, 'url' => $url, diff --git a/src/Upc/UnifiedApiHostedPaymentCreator.php b/src/Upc/UnifiedApiPaymentCreator.php similarity index 63% rename from src/Upc/UnifiedApiHostedPaymentCreator.php rename to src/Upc/UnifiedApiPaymentCreator.php index e01e7bd6..eac6108b 100644 --- a/src/Upc/UnifiedApiHostedPaymentCreator.php +++ b/src/Upc/UnifiedApiPaymentCreator.php @@ -7,11 +7,11 @@ use PayplugUnifiedCore\Auth\TokenManager; use PayplugUnifiedCore\Contracts\IConfigurationRepository; use PayplugUnifiedCore\Contracts\IUnifiedApiHttpClient; -use PayplugUnifiedCore\Dto\HostedFieldDto; -use PayplugUnifiedCore\Output\HostedPaymentOutput; -use PayplugUnifiedCore\Services\UnifiedApiHostedPaymentService; +use PayplugUnifiedCore\Contracts\PaymentRequestPayload; +use PayplugUnifiedCore\Output\PaymentOutput; +use PayplugUnifiedCore\Services\UnifiedApiPaymentService; -final class UnifiedApiHostedPaymentCreator implements HostedPaymentCreatorInterface +final class UnifiedApiPaymentCreator implements UnifiedApiPaymentCreatorInterface { public function __construct( private IUnifiedApiHttpClient $httpClient, @@ -21,9 +21,9 @@ public function __construct( ) { } - public function createHostedPayment(HostedFieldDto $dto): HostedPaymentOutput + public function createPayment(PaymentRequestPayload $dto): PaymentOutput { - $service = new UnifiedApiHostedPaymentService( + $service = new UnifiedApiPaymentService( $this->httpClient, $this->tokenManager, $this->unifiedApiBaseUrl, @@ -31,6 +31,6 @@ public function createHostedPayment(HostedFieldDto $dto): HostedPaymentOutput $this->configurationRepository->getClientSecret(), ); - return $service->createHostedPayment($dto); + return $service->createPayment($dto); } } diff --git a/src/Upc/UnifiedApiPaymentCreatorInterface.php b/src/Upc/UnifiedApiPaymentCreatorInterface.php new file mode 100644 index 00000000..1dc79629 --- /dev/null +++ b/src/Upc/UnifiedApiPaymentCreatorInterface.php @@ -0,0 +1,21 @@ + + + + + diff --git a/tests/PHPUnit/Command/Handler/CaptureAliasPaymentRequestHandlerTest.php b/tests/PHPUnit/Command/Handler/CaptureAliasPaymentRequestHandlerTest.php new file mode 100644 index 00000000..987b7fd7 --- /dev/null +++ b/tests/PHPUnit/Command/Handler/CaptureAliasPaymentRequestHandlerTest.php @@ -0,0 +1,371 @@ +paymentRequestProvider = $this->createMock(PaymentRequestProviderInterface::class); + $this->stateMachine = $this->createMock(StateMachineInterface::class); + $this->unifiedApiPaymentCreator = $this->createMock(UnifiedApiPaymentCreatorInterface::class); + $this->session = $this->createMock(SessionInterface::class); + $sessionData = []; + $this->session->method('set')->willReturnCallback(static function (string $key, mixed $value) use (&$sessionData): void { + $sessionData[$key] = $value; + }); + $this->session->method('get')->willReturnCallback(static function (string $key, mixed $default = null) use (&$sessionData): mixed { + return $sessionData[$key] ?? $default; + }); + + $request = new \Symfony\Component\HttpFoundation\Request(); + $request->setSession($this->session); + + $this->requestStack = new RequestStack(); + $this->requestStack->push($request); + $this->payplugCardRepository = $this->createMock(RepositoryInterface::class); + $this->orderStateMutator = $this->createMock(IOrderStateMutator::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $this->afterPayUrlProvider = $this->createMock(UrlProviderInterface::class); + $this->afterPayUrlProvider->method('getUrl')->willReturn('https://shop.test/order/00000042/pay'); + + $this->handler = new CaptureAliasPaymentRequestHandler( + $this->paymentRequestProvider, + $this->stateMachine, + $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), + ); + } + + /** + * @param CustomerInterface&MockObject|null $cardCustomer customer the selected Card belongs + * to; defaults to the paying order's + * own customer (the happy path) — + * pass a different mock to exercise + * the ownership-mismatch guard + * @param PaymentMethodInterface&MockObject|null $cardPaymentMethod payment method the selected + * Card was saved under; defaults to + * the payment's own method (the happy + * path) — pass a different mock to + * exercise the account-mismatch guard + */ + private function paymentRequestWithSelectedCard( + ?Card $card, + ?array $gatewayConfig = ['hfIdentifier' => 'acct_123', 'hfSubMerchantId' => 'sub_ext_1'], + ?CustomerInterface $cardCustomer = null, + ?AddressInterface $billingAddress = null, + ?PaymentMethodInterface $cardPaymentMethod = null, + ?string $customerEmail = 'customer@example.com', + ): PaymentRequestInterface&MockObject + { + $this->requestStack->getSession()->set('payplug_payment_method', self::SELECTED_CARD_ID); + + $method = $this->createMock(PaymentMethodInterface::class); + if (null !== $gatewayConfig) { + $config = $this->createMock(GatewayConfigInterface::class); + $config->method('getConfig')->willReturn($gatewayConfig); + $method->method('getGatewayConfig')->willReturn($config); + } + + $customer = $this->createMock(CustomerInterface::class); + $customer->method('getId')->willReturn(7); + $customer->method('getEmail')->willReturn($customerEmail); + + $card?->setCustomer($cardCustomer ?? $customer); + $card?->setPaymentMethod($cardPaymentMethod ?? $method); + $this->payplugCardRepository->method('find')->with(self::SELECTED_CARD_ID)->willReturn($card); + + $order = $this->createMock(OrderInterface::class); + $order->method('getCustomer')->willReturn($customer); + $order->method('getNumber')->willReturn('00000042'); + $order->method('getBillingAddress')->willReturn($billingAddress); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getId')->willReturn(42); + $payment->method('getDetails')->willReturn([]); + $payment->method('getMethod')->willReturn($method); + $payment->method('getOrder')->willReturn($order); + $payment->method('getAmount')->willReturn(1000); + $payment->method('getCurrencyCode')->willReturn('EUR'); + + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $paymentRequest->method('getPayment')->willReturn($payment); + $paymentRequest->method('getHash')->willReturn(Uuid::v4()); + + $this->paymentRequestProvider->method('provide')->willReturn($paymentRequest); + + return $paymentRequest; + } + + private function savedCard(): Card + { + return (new Card())->setExternalId('alias_existing_1')->setBrand('VISA')->setLast4('4242') + ->setExpirationMonth(12)->setExpirationYear(2030)->setCountryCode('FR')->setIsLive(false); + } + + public function testInvoke_withNoCardSelected_failsThePaymentRequestInsteadOfCrashing(): void + { + $paymentRequest = $this->paymentRequestWithSelectedCard(null); + + $this->unifiedApiPaymentCreator->expects(self::never())->method('createPayment'); + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL); + + $this->handler->__invoke(new CaptureAliasPaymentRequest(null)); + } + + public function testInvoke_withGatewayConfigMissingAccountId_failsThePaymentRequestInsteadOfCrashing(): void + { + $paymentRequest = $this->paymentRequestWithSelectedCard($this->savedCard(), gatewayConfig: null); + + $this->unifiedApiPaymentCreator->expects(self::never())->method('createPayment'); + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL); + + $this->handler->__invoke(new CaptureAliasPaymentRequest(null)); + } + + public function testInvoke_onDirectSuccess_completesThePaymentRequestWithoutARedirect(): void + { + $paymentRequest = $this->paymentRequestWithSelectedCard($this->savedCard()); + + $this->unifiedApiPaymentCreator->method('createPayment')->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', null, null, 'alias_existing_1')); + + $paymentRequest->expects(self::once())->method('setResponseData') + ->with(self::callback(static fn (array $data): bool => !isset($data['redirect_url']))); + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE); + + $this->handler->__invoke(new CaptureAliasPaymentRequest(null)); + } + + public function testInvoke_onApiException_failsThePaymentRequest(): void + { + $paymentRequest = $this->paymentRequestWithSelectedCard($this->savedCard()); + + $this->unifiedApiPaymentCreator->method('createPayment')->willThrowException(new ApiException('boom')); + + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL); + + $this->handler->__invoke(new CaptureAliasPaymentRequest(null)); + } + + public function testInvoke_withCardBelongingToAnotherCustomer_failsThePaymentRequest(): void + { + $anotherCustomer = $this->createMock(CustomerInterface::class); + $paymentRequest = $this->paymentRequestWithSelectedCard($this->savedCard(), cardCustomer: $anotherCustomer); + + $this->unifiedApiPaymentCreator->expects(self::never())->method('createPayment'); + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL); + + $this->handler->__invoke(new CaptureAliasPaymentRequest(null)); + } + + public function testInvoke_withCardBelongingToAnotherPaymentMethod_failsThePaymentRequest(): void + { + $anotherMethod = $this->createMock(PaymentMethodInterface::class); + $paymentRequest = $this->paymentRequestWithSelectedCard($this->savedCard(), cardPaymentMethod: $anotherMethod); + + $this->unifiedApiPaymentCreator->expects(self::never())->method('createPayment'); + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL); + + $this->handler->__invoke(new CaptureAliasPaymentRequest(null)); + } + + public function testInvoke_withNoCustomerEmail_failsThePaymentRequest(): void + { + $paymentRequest = $this->paymentRequestWithSelectedCard($this->savedCard(), customerEmail: null); + + $this->unifiedApiPaymentCreator->expects(self::never())->method('createPayment'); + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL); + + $this->handler->__invoke(new CaptureAliasPaymentRequest(null)); + } + + public function testInvoke_onDirectSuccessWithSuccessExecCode_appliesPaidOutcomeToOrderStateMutator(): void + { + $this->paymentRequestWithSelectedCard($this->savedCard()); + + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0000"}', null, null, 'alias_existing_1')); + + $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::PAID); + + $this->handler->__invoke(new CaptureAliasPaymentRequest(null)); + } + + public function testInvoke_onRedirectOutcome_neverAppliesOrderStateMutator(): void + { + $paymentRequest = $this->paymentRequestWithSelectedCard($this->savedCard()); + + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', 'https://example.com/3ds', null, 'alias_existing_1')); + + $this->orderStateMutator->expects(self::never())->method('apply'); + $paymentRequest->expects(self::once())->method('setResponseData') + ->with(['redirect_url' => 'https://example.com/3ds']); + + $this->handler->__invoke(new CaptureAliasPaymentRequest(null)); + } + + public function testInvoke_onRedirectHtmlOutcome_storesRedirectHtmlAndNeverAppliesOrderStateMutator(): void + { + $paymentRequest = $this->paymentRequestWithSelectedCard($this->savedCard()); + + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0001"}', null, '
3ds
', 'alias_existing_1')); + + $this->orderStateMutator->expects(self::never())->method('apply'); + $paymentRequest->expects(self::once())->method('setResponseData') + ->with(['redirect_html' => '
3ds
']); + + $this->handler->__invoke(new CaptureAliasPaymentRequest(null)); + } + + public function testInvoke_setsSuccessAndCancelUrlOnTheUnifiedApiRequest(): void + { + $this->paymentRequestWithSelectedCard($this->savedCard()); + + $this->unifiedApiPaymentCreator->expects(self::once())->method('createPayment') + ->with(self::callback(function (PaymentDto $dto): bool { + self::assertSame('https://shop.test/order/00000042/pay', $dto->common->successUrl); + self::assertSame('https://shop.test/order/00000042/pay?status=canceled', $dto->common->cancelUrl); + + return true; + })) + ->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', null, null, 'alias_existing_1')); + + $this->handler->__invoke(new CaptureAliasPaymentRequest(null)); + } + + public function testInvoke_withABillingAddress_sendsItsFullNameAsThePaymentMethodDetails(): void + { + $billingAddress = $this->createMock(AddressInterface::class); + $billingAddress->method('getFullName')->willReturn('John Doe'); + + $this->paymentRequestWithSelectedCard($this->savedCard(), billingAddress: $billingAddress); + + $this->unifiedApiPaymentCreator->expects(self::once())->method('createPayment') + ->with(self::callback(function (PaymentDto $dto): bool { + self::assertIsArray($dto->paymentMethod); + self::assertSame('John Doe', $dto->paymentMethod['details']['fullName'] ?? null); + + return true; + })) + ->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', null, null, 'alias_existing_1')); + + $this->handler->__invoke(new CaptureAliasPaymentRequest(null)); + } + + public function testInvoke_withNoBillingAddress_leavesPaymentMethodNull(): void + { + $this->paymentRequestWithSelectedCard($this->savedCard()); + + $this->unifiedApiPaymentCreator->expects(self::once())->method('createPayment') + ->with(self::callback(function (PaymentDto $dto): bool { + self::assertNull($dto->paymentMethod); + + return true; + })) + ->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', null, null, 'alias_existing_1')); + + $this->handler->__invoke(new CaptureAliasPaymentRequest(null)); + } + + public function testInvoke_onSuccess_storesTheUnifiedApiPaymentAndOperationIdsOnThePaymentDetails(): void + { + $paymentRequest = $this->paymentRequestWithSelectedCard($this->savedCard()); + $payment = $paymentRequest->getPayment(); + + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0000","operationIds":["op_1"]}', null, null, 'alias_existing_1')); + + $payment->expects(self::once())->method('setDetails') + ->with(self::callback(static fn (array $details): bool => 'pay_1' === ($details['hosted_fields_payment_id'] ?? null) && + 'op_1' === ($details['hosted_fields_operation_id'] ?? null) && + 'alias_existing_1' === ($details['alias_id'] ?? null))); + + $this->handler->__invoke(new CaptureAliasPaymentRequest(null)); + } + + public function testInvoke_onPending3ds_storesTheUnifiedApiPaymentAndOperationIdsOnThePaymentDetails(): void + { + $paymentRequest = $this->paymentRequestWithSelectedCard($this->savedCard()); + $payment = $paymentRequest->getPayment(); + + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(200, '{"id":"pay_1","execCode":"0001","operationIds":["op_1"]}', 'https://example.com/3ds', null, 'alias_existing_1')); + + $payment->expects(self::once())->method('setDetails') + ->with(self::callback(static fn (array $details): bool => 'pay_1' === ($details['hosted_fields_payment_id'] ?? null) && + 'op_1' === ($details['hosted_fields_operation_id'] ?? null))); + + $this->handler->__invoke(new CaptureAliasPaymentRequest(null)); + } +} diff --git a/tests/PHPUnit/Command/Handler/CaptureHostedPaymentRequestHandlerTest.php b/tests/PHPUnit/Command/Handler/CaptureHostedPaymentRequestHandlerTest.php index c966947f..c8c4fbef 100644 --- a/tests/PHPUnit/Command/Handler/CaptureHostedPaymentRequestHandlerTest.php +++ b/tests/PHPUnit/Command/Handler/CaptureHostedPaymentRequestHandlerTest.php @@ -4,14 +4,21 @@ namespace Tests\PayPlug\SyliusPayPlugPlugin\PHPUnit\Command\Handler; +use Doctrine\Persistence\ManagerRegistry; use PayPlug\SyliusPayPlugPlugin\Command\CaptureHostedPaymentRequest; use PayPlug\SyliusPayPlugPlugin\Command\Handler\CaptureHostedPaymentRequestHandler; -use PayPlug\SyliusPayPlugPlugin\Upc\HostedPaymentCreatorInterface; +use PayPlug\SyliusPayPlugPlugin\Entity\Card; +use PayPlug\SyliusPayPlugPlugin\Upc\OperationStatusFetcherInterface; +use PayPlug\SyliusPayPlugPlugin\Upc\OrderAddressDtoCreator; +use PayPlug\SyliusPayPlugPlugin\Upc\PaymentCaptureContextBuilder; +use PayPlug\SyliusPayPlugPlugin\Upc\PaymentCaptureOutcomeApplier; +use PayPlug\SyliusPayPlugPlugin\Upc\PayplugCardPersister; +use PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiPaymentCreatorInterface; use PayplugUnifiedCore\Contracts\IOrderStateMutator; use PayplugUnifiedCore\DataValues\PaymentOutcome; use PayplugUnifiedCore\Dto\HostedFieldDto; use PayplugUnifiedCore\Exceptions\ApiException; -use PayplugUnifiedCore\Output\HostedPaymentOutput; +use PayplugUnifiedCore\Output\PaymentOutput; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; @@ -19,12 +26,16 @@ use Sylius\Bundle\CoreBundle\OrderPay\Provider\UrlProviderInterface; use Sylius\Bundle\PaymentBundle\Provider\PaymentRequestProviderInterface; use Sylius\Bundle\PayumBundle\Model\GatewayConfigInterface; +use Sylius\Component\Core\Model\AddressInterface; use Sylius\Component\Core\Model\CustomerInterface; use Sylius\Component\Core\Model\OrderInterface; use Sylius\Component\Core\Model\PaymentInterface; -use Sylius\Component\Payment\Model\PaymentMethodInterface; +use Sylius\Component\Core\Model\PaymentMethodInterface; +use Sylius\Component\Payment\Model\PaymentMethodInterface as BasePaymentMethodInterface; use Sylius\Component\Payment\Model\PaymentRequestInterface; use Sylius\Component\Payment\PaymentRequestTransitions; +use Sylius\Component\Resource\Factory\FactoryInterface; +use Sylius\Component\Resource\Repository\RepositoryInterface; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Uid\Uuid; @@ -35,7 +46,9 @@ final class CaptureHostedPaymentRequestHandlerTest extends TestCase private StateMachineInterface&MockObject $stateMachine; - private HostedPaymentCreatorInterface&MockObject $hostedPaymentCreator; + private UnifiedApiPaymentCreatorInterface&MockObject $unifiedApiPaymentCreator; + + private OperationStatusFetcherInterface&MockObject $operationStatusFetcher; private UrlProviderInterface&MockObject $afterPayUrlProvider; @@ -47,28 +60,39 @@ final class CaptureHostedPaymentRequestHandlerTest extends TestCase private IOrderStateMutator&MockObject $orderStateMutator; + private FactoryInterface&MockObject $payplugCardFactory; + + private RepositoryInterface&MockObject $payplugCardRepository; + + private ManagerRegistry&MockObject $managerRegistry; + private CaptureHostedPaymentRequestHandler $handler; protected function setUp(): void { $this->paymentRequestProvider = $this->createMock(PaymentRequestProviderInterface::class); $this->stateMachine = $this->createMock(StateMachineInterface::class); - $this->hostedPaymentCreator = $this->createMock(HostedPaymentCreatorInterface::class); + $this->unifiedApiPaymentCreator = $this->createMock(UnifiedApiPaymentCreatorInterface::class); + $this->operationStatusFetcher = $this->createMock(OperationStatusFetcherInterface::class); $this->afterPayUrlProvider = $this->createMock(UrlProviderInterface::class); + $this->afterPayUrlProvider->method('getUrl')->willReturn('https://shop.test/order/00000042/pay'); $this->urlGenerator = $this->createMock(UrlGeneratorInterface::class); $this->logger = $this->createMock(LoggerInterface::class); $this->requestStack = $this->createMock(RequestStack::class); $this->orderStateMutator = $this->createMock(IOrderStateMutator::class); + $this->payplugCardFactory = $this->createMock(FactoryInterface::class); + $this->payplugCardRepository = $this->createMock(RepositoryInterface::class); + $this->managerRegistry = $this->createMock(ManagerRegistry::class); $this->handler = new CaptureHostedPaymentRequestHandler( $this->paymentRequestProvider, $this->stateMachine, - $this->hostedPaymentCreator, - $this->afterPayUrlProvider, - $this->urlGenerator, + $this->unifiedApiPaymentCreator, + $this->operationStatusFetcher, + new PaymentCaptureContextBuilder($this->urlGenerator, $this->afterPayUrlProvider, new OrderAddressDtoCreator(), $this->requestStack), + new PaymentCaptureOutcomeApplier($this->logger, $this->stateMachine, $this->orderStateMutator), + new PayplugCardPersister($this->payplugCardFactory, $this->payplugCardRepository, $this->managerRegistry), $this->logger, - $this->requestStack, - $this->orderStateMutator, ); } @@ -77,6 +101,7 @@ private function paymentRequestWithPayment( int $amount = 1000, string $currency = 'EUR', ?array $gatewayConfig = ['hfIdentifier' => 'acct_123', 'hfSubMerchantId' => 'sub_ext_1'], + ?AddressInterface $billingAddress = null, ): PaymentRequestInterface&MockObject { $method = $this->createMock(PaymentMethodInterface::class); @@ -92,6 +117,7 @@ private function paymentRequestWithPayment( $order = $this->createMock(OrderInterface::class); $order->method('getCustomer')->willReturn($customer); + $order->method('getBillingAddress')->willReturn($billingAddress); $payment = $this->createMock(PaymentInterface::class); $payment->method('getId')->willReturn(42); @@ -117,7 +143,7 @@ public function testInvoke_onDirectSuccess_completesThePaymentRequestWithoutARed 'hosted_fields_selected_brand' => 'VISA', ]); - $this->hostedPaymentCreator->method('createHostedPayment')->willReturn(new HostedPaymentOutput(201, '{"id":"pay_1"}', null)); + $this->unifiedApiPaymentCreator->method('createPayment')->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', null, null, null)); $paymentRequest->expects(self::once())->method('setResponseData') ->with(self::callback(static fn (array $data): bool => !isset($data['redirect_url']))); @@ -127,20 +153,28 @@ public function testInvoke_onDirectSuccess_completesThePaymentRequestWithoutARed $this->handler->__invoke(new CaptureHostedPaymentRequest($paymentRequest->getId())); } - public function testInvoke_whenNoHostedFieldsTokenStored_throws(): void + public function testInvoke_whenNoHostedFieldsTokenStored_failsThePaymentRequestInsteadOfCrashing(): void { - $this->paymentRequestWithPayment([]); + $paymentRequest = $this->paymentRequestWithPayment([]); - $this->expectException(\LogicException::class); + $this->unifiedApiPaymentCreator->expects(self::never())->method('createPayment'); + $paymentRequest->expects(self::once())->method('setResponseData') + ->with(self::callback(static fn (array $data): bool => isset($data['error']))); + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL); $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); } - public function testInvoke_whenGatewayConfigIsMissingAccountOrSubmerchantId_throws(): void + public function testInvoke_whenGatewayConfigIsMissingAccountOrSubmerchantId_failsThePaymentRequestInsteadOfCrashing(): void { - $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc'], gatewayConfig: null); + $paymentRequest = $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc'], gatewayConfig: null); - $this->expectException(\LogicException::class); + $this->unifiedApiPaymentCreator->expects(self::never())->method('createPayment'); + $paymentRequest->expects(self::once())->method('setResponseData') + ->with(self::callback(static fn (array $data): bool => isset($data['error']))); + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL); $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); } @@ -149,7 +183,7 @@ public function testInvoke_onApiException_failsThePaymentRequest(): void { $paymentRequest = $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']); - $this->hostedPaymentCreator->method('createHostedPayment')->willThrowException(new ApiException('boom')); + $this->unifiedApiPaymentCreator->method('createPayment')->willThrowException(new ApiException('boom')); $this->stateMachine->expects(self::once())->method('apply') ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL); @@ -157,7 +191,7 @@ public function testInvoke_onApiException_failsThePaymentRequest(): void $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); } - public function testInvoke_whenCustomerEmailIsMissing_failsThePaymentRequestInsteadOfCallingHostedPaymentCreator(): void + public function testInvoke_whenCustomerEmailIsMissing_failsThePaymentRequestInsteadOfCallingUnifiedApiPaymentCreator(): void { $method = $this->createMock(PaymentMethodInterface::class); $gatewayConfig = $this->createMock(GatewayConfigInterface::class); @@ -179,7 +213,7 @@ public function testInvoke_whenCustomerEmailIsMissing_failsThePaymentRequestInst $paymentRequest->method('getHash')->willReturn(Uuid::v4()); $this->paymentRequestProvider->method('provide')->willReturn($paymentRequest); - $this->hostedPaymentCreator->expects(self::never())->method('createHostedPayment'); + $this->unifiedApiPaymentCreator->expects(self::never())->method('createPayment'); $paymentRequest->expects(self::once())->method('setResponseData') ->with(self::callback(static fn (array $data): bool => isset($data['error']))); $this->stateMachine->expects(self::once())->method('apply') @@ -192,7 +226,7 @@ public function testInvoke_onDirectSuccessWithoutExecCode_neverAppliesOrderState { $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']); - $this->hostedPaymentCreator->method('createHostedPayment')->willReturn(new HostedPaymentOutput(201, '{"id":"pay_1"}', null)); + $this->unifiedApiPaymentCreator->method('createPayment')->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', null, null, null)); $this->orderStateMutator->expects(self::never())->method('apply'); @@ -203,8 +237,8 @@ public function testInvoke_onDirectSuccessWithSuccessExecCode_appliesPaidOutcome { $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']); - $this->hostedPaymentCreator->method('createHostedPayment') - ->willReturn(new HostedPaymentOutput(201, '{"id":"pay_1","execCode":"0000"}', null)); + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0000"}', null, null, null)); $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::PAID); @@ -215,40 +249,20 @@ public function testInvoke_onDirectSuccessWithFailureExecCode_appliesFailedOutco { $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']); - $this->hostedPaymentCreator->method('createHostedPayment') - ->willReturn(new HostedPaymentOutput(201, '{"id":"pay_1","execCode":"9999"}', null)); + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"9999"}', null, null, null)); $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::FAILED); $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); } - public function testInvoke_setsSuccessAndCancelUrlsFromTheAfterPayUrlProvider(): void - { - $paymentRequest = $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']); - - $this->afterPayUrlProvider->method('getUrl') - ->with($paymentRequest, UrlGeneratorInterface::ABSOLUTE_URL) - ->willReturn('https://shop.example.com/pay/abc'); - - $this->hostedPaymentCreator->expects(self::once())->method('createHostedPayment') - ->with(self::callback(static function (HostedFieldDto $dto): bool { - self::assertSame('https://shop.example.com/pay/abc', $dto->common->successUrl); - self::assertSame('https://shop.example.com/pay/abc?status=canceled', $dto->common->cancelUrl); - - return true; - })) - ->willReturn(new HostedPaymentOutput(201, '{"id":"pay_1"}', null)); - - $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); - } - public function testInvoke_onRedirectOutcome_neverAppliesOrderStateMutator(): void { $paymentRequest = $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']); - $this->hostedPaymentCreator->method('createHostedPayment') - ->willReturn(new HostedPaymentOutput(201, '{"id":"pay_1"}', 'https://example.com/3ds')); + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', 'https://example.com/3ds', null, null)); $this->orderStateMutator->expects(self::never())->method('apply'); $paymentRequest->expects(self::once())->method('setResponseData') @@ -262,8 +276,8 @@ public function testInvoke_onPending3ds_storesTheUnifiedApiPaymentAndOperationId $paymentRequest = $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']); $payment = $paymentRequest->getPayment(); - $this->hostedPaymentCreator->method('createHostedPayment') - ->willReturn(new HostedPaymentOutput(200, '{"id":"pay_1","execCode":"0001","operationIds":["op_1"]}', 'https://example.com/3ds')); + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(200, '{"id":"pay_1","execCode":"0001","operationIds":["op_1"]}', 'https://example.com/3ds', null, null)); $payment->expects(self::once())->method('setDetails') ->with(self::callback(static fn (array $details): bool => 'pay_1' === $details['hosted_fields_payment_id'] && @@ -277,7 +291,7 @@ public function testInvoke_whenResponseBodyHasNoId_neverStoresAHostedFieldsPayme $paymentRequest = $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']); $payment = $paymentRequest->getPayment(); - $this->hostedPaymentCreator->method('createHostedPayment')->willReturn(new HostedPaymentOutput(201, '{}', null)); + $this->unifiedApiPaymentCreator->method('createPayment')->willReturn(new PaymentOutput(201, '{}', null, null, null)); $payment->expects(self::once())->method('setDetails') ->with(self::callback(static fn (array $details): bool => !isset($details['hosted_fields_payment_id']) && @@ -286,17 +300,319 @@ public function testInvoke_whenResponseBodyHasNoId_neverStoresAHostedFieldsPayme $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); } - public function testInvoke_onRedirectHtmlOutcome_setsResponseDataAndNeverAppliesOrderStateMutator(): void + public function testInvoke_onRedirectHtmlOutcome_storesRedirectHtmlAndNeverAppliesOrderStateMutator(): void { $paymentRequest = $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']); - $html = '3DS challenge form'; - $this->hostedPaymentCreator->method('createHostedPayment') - ->willReturn(new HostedPaymentOutput(200, '{"id":"pay_1","execCode":"0001"}', null, $html)); + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0001"}', null, '
3ds
', null)); $this->orderStateMutator->expects(self::never())->method('apply'); $paymentRequest->expects(self::once())->method('setResponseData') - ->with(['redirect_html' => $html]); + ->with(['redirect_html' => '
3ds
']); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + } + + public function testInvoke_setsSuccessAndCancelUrlOnTheUnifiedApiRequest(): void + { + $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']); + + $this->unifiedApiPaymentCreator->expects(self::once())->method('createPayment') + ->with(self::callback(function (HostedFieldDto $dto): bool { + self::assertSame('https://shop.test/order/00000042/pay', $dto->common->successUrl); + self::assertSame('https://shop.test/order/00000042/pay?status=canceled', $dto->common->cancelUrl); + + return true; + })) + ->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', null, null, null)); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + } + + public function testInvoke_withABillingAddress_sendsItsFullNameAlongsideSelectedBrand(): void + { + $billingAddress = $this->createMock(AddressInterface::class); + $billingAddress->method('getFullName')->willReturn('John Doe'); + + $this->paymentRequestWithPayment( + ['hosted_fields_token' => 'hf_token_abc', 'hosted_fields_selected_brand' => 'VISA'], + billingAddress: $billingAddress, + ); + + $this->unifiedApiPaymentCreator->expects(self::once())->method('createPayment') + ->with(self::callback(function (HostedFieldDto $dto): bool { + self::assertIsArray($dto->paymentMethod); + self::assertSame('John Doe', $dto->paymentMethod['details']['fullName'] ?? null); + self::assertSame('VISA', $dto->paymentMethod['details']['selectedBrand'] ?? null); + + return true; + })) + ->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', null, null, null)); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + } + + public function testInvoke_withNoBillingAddress_omitsFullNameButStillSendsSelectedBrand(): void + { + $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc', 'hosted_fields_selected_brand' => 'VISA']); + + $this->unifiedApiPaymentCreator->expects(self::once())->method('createPayment') + ->with(self::callback(function (HostedFieldDto $dto): bool { + self::assertIsArray($dto->paymentMethod); + self::assertArrayNotHasKey('fullName', $dto->paymentMethod['details'] ?? []); + self::assertSame('VISA', $dto->paymentMethod['details']['selectedBrand'] ?? null); + + return true; + })) + ->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', null, null, null)); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + } + + public function testInvoke_whenSaveCardRequestedWithNoFullNameOrBrandAvailable_omitsSaveFutureUsageInsteadOfFailingThePayment(): void + { + $paymentRequest = $this->paymentRequestWithPayment([ + 'hosted_fields_token' => 'hf_token_abc', + 'hosted_fields_save_card' => true, + ]); + + $this->unifiedApiPaymentCreator->expects(self::once())->method('createPayment') + ->with(self::callback(function (HostedFieldDto $dto): bool { + self::assertNull($dto->paymentMethod); + + return true; + })) + ->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', null, null, null)); + + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + } + + public function testInvoke_whenSaveCardRequestedAndAliasReturned_persistsANewCard(): void + { + $billingAddress = $this->createMock(AddressInterface::class); + $billingAddress->method('getFullName')->willReturn('John Doe'); + + $paymentRequest = $this->paymentRequestWithPayment([ + 'hosted_fields_token' => 'hf_token_abc', + 'hosted_fields_selected_brand' => 'VISA', + 'hosted_fields_save_card' => true, + 'hosted_fields_last4' => '4242', + 'hosted_fields_expiration_month' => 12, + 'hosted_fields_expiration_year' => 2030, + 'hosted_fields_country' => 'FR', + ], billingAddress: $billingAddress); + + $this->unifiedApiPaymentCreator->expects(self::once())->method('createPayment') + ->with(self::callback(function (HostedFieldDto $dto): bool { + self::assertSame('ONE_CLICK', $dto->recurringMode); + self::assertIsArray($dto->paymentMethod); + self::assertTrue($dto->paymentMethod['saveFutureUsage'] ?? false); + self::assertSame('VISA', $dto->paymentMethod['details']['selectedBrand'] ?? null); + + return true; + })) + ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0000"}', null, null, 'alias_new_1')); + + $card = new Card(); + $this->payplugCardFactory->method('createNew')->willReturn($card); + $this->payplugCardRepository->expects(self::once())->method('add')->with($card); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + + self::assertSame('alias_new_1', $card->getExternalId()); + self::assertSame('VISA', $card->getBrand()); + self::assertSame('4242', $card->getLast4()); + self::assertSame(12, $card->getExpirationMonth()); + self::assertSame(2030, $card->getExpirationYear()); + self::assertSame('FR', $card->getCountryCode()); + } + + public function testInvoke_whenSaveCardRequestedAndUnifiedApiOperationIdAvailable_enrichesTheCardWithExpirationFetchedFromTheUnifiedApi(): void + { + $this->paymentRequestWithPayment([ + 'hosted_fields_token' => 'hf_token_abc', + 'hosted_fields_selected_brand' => 'VISA', + 'hosted_fields_save_card' => true, + 'hosted_fields_country' => 'BE', + ]); + + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0000","operationIds":["op_1"]}', null, null, 'alias_new_1')); + + // Real shape confirmed against a staging operation response: card metadata lives under + // paymentMethod.card (network, a masked code6x4 PAN standing in for a dedicated last4 + // field) and paymentMethod.details (selectedBrand, validityDate in "YYYY-MM" form). No + // country field exists anywhere on that response. + $this->operationStatusFetcher->expects(self::once())->method('getOperation') + ->with('op_1') + ->willReturn(['status' => 200, 'body' => json_encode([ + 'paymentMethod' => [ + 'id' => 'alias_new_1', + 'card' => [ + 'code6x4' => '424242XXXXXX4242', + 'network' => 'VISA', + ], + 'details' => [ + 'fullName' => 'John Doe', + 'validityDate' => '2027-12', + 'selectedBrand' => 'VISA', + ], + ], + ])]); + + $card = new Card(); + $this->payplugCardFactory->method('createNew')->willReturn($card); + $this->payplugCardRepository->expects(self::once())->method('add')->with($card); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + + self::assertSame('4242', $card->getLast4()); + self::assertSame('BE', $card->getCountryCode()); + self::assertSame(12, $card->getExpirationMonth()); + self::assertSame(2027, $card->getExpirationYear()); + } + + public function testInvoke_whenSaveCardRequestedAndFetchingTheOperationFails_stillPersistsTheCardUsingDetailsFallback(): void + { + $this->paymentRequestWithPayment([ + 'hosted_fields_token' => 'hf_token_abc', + 'hosted_fields_selected_brand' => 'VISA', + 'hosted_fields_save_card' => true, + 'hosted_fields_last4' => '4242', + 'hosted_fields_expiration_month' => 12, + 'hosted_fields_expiration_year' => 2030, + 'hosted_fields_country' => 'FR', + ]); + + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0000","operationIds":["op_1"]}', null, null, 'alias_new_1')); + + $this->operationStatusFetcher->method('getOperation')->willThrowException(new ApiException('boom')); + + $card = new Card(); + $this->payplugCardFactory->method('createNew')->willReturn($card); + $this->payplugCardRepository->expects(self::once())->method('add')->with($card); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + + self::assertSame('4242', $card->getLast4()); + self::assertSame(12, $card->getExpirationMonth()); + self::assertSame(2030, $card->getExpirationYear()); + self::assertSame('FR', $card->getCountryCode()); + } + + /** + * @dataProvider malformedOperationResponseBodyProvider + */ + public function testInvoke_whenSaveCardRequestedAndOperationResponseShapeIsMalformed_stillPersistsTheCardUsingDetailsFallback( + string $body, + ): void { + $this->paymentRequestWithPayment([ + 'hosted_fields_token' => 'hf_token_abc', + 'hosted_fields_selected_brand' => 'VISA', + 'hosted_fields_save_card' => true, + 'hosted_fields_last4' => '4242', + 'hosted_fields_expiration_month' => 12, + 'hosted_fields_expiration_year' => 2030, + 'hosted_fields_country' => 'FR', + ]); + + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0000","operationIds":["op_1"]}', null, null, 'alias_new_1')); + + $this->operationStatusFetcher->method('getOperation')->willReturn(['status' => 200, 'body' => $body]); + + $card = new Card(); + $this->payplugCardFactory->method('createNew')->willReturn($card); + $this->payplugCardRepository->expects(self::once())->method('add')->with($card); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + + self::assertSame('4242', $card->getLast4()); + self::assertSame(12, $card->getExpirationMonth()); + self::assertSame(2030, $card->getExpirationYear()); + self::assertSame('FR', $card->getCountryCode()); + } + + /** @return array */ + public static function malformedOperationResponseBodyProvider(): array + { + return [ + 'non-array body' => ['"just a string"'], + 'paymentMethod key missing' => [json_encode(['id' => 'op_1'])], + 'card key missing' => [json_encode(['paymentMethod' => ['details' => ['selectedBrand' => 'VISA']]])], + 'details key missing' => [json_encode(['paymentMethod' => ['card' => ['network' => 'VISA']]])], + 'validityDate does not match the expected YYYY-MM format' => [json_encode(['paymentMethod' => ['details' => ['validityDate' => '1225']]])], + 'validityDate has an out-of-range month' => [json_encode(['paymentMethod' => ['details' => ['validityDate' => '2027-13']]])], + 'code6x4 shorter than 4 characters' => [json_encode(['paymentMethod' => ['card' => ['code6x4' => '42']]])], + ]; + } + + public function testInvoke_whenSaveCardNotRequested_neverPersistsACard(): void + { + $this->paymentRequestWithPayment([ + 'hosted_fields_token' => 'hf_token_abc', + 'hosted_fields_save_card' => false, + ]); + + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0000"}', null, null, null)); + + $this->payplugCardRepository->expects(self::never())->method('add'); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + } + + public function testInvoke_whenSaveCardRequestedButNoAliasReturned_neverPersistsACard(): void + { + $this->paymentRequestWithPayment([ + 'hosted_fields_token' => 'hf_token_abc', + 'hosted_fields_save_card' => true, + ]); + + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0000"}', null, null, null)); + + $this->payplugCardRepository->expects(self::never())->method('add'); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + } + + public function testInvoke_whenSaveCardRequestedButMethodIsNotCorePaymentMethod_neverPersistsACard(): void + { + // Card::$paymentMethod requires Sylius Core's PaymentMethodInterface, which every real + // Sylius-wired payment method satisfies — a test double built against only the base + // Payment component's PaymentMethodInterface exercises the guard that skips persisting a + // 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']); + $method->method('getGatewayConfig')->willReturn($gatewayConfig); + $customer = $this->createMock(CustomerInterface::class); + $customer->method('getId')->willReturn(7); + $customer->method('getEmail')->willReturn('customer@example.com'); + $order = $this->createMock(OrderInterface::class); + $order->method('getCustomer')->willReturn($customer); + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getId')->willReturn(42); + $payment->method('getDetails')->willReturn(['hosted_fields_token' => 'hf_token_abc', 'hosted_fields_save_card' => true]); + $payment->method('getMethod')->willReturn($method); + $payment->method('getOrder')->willReturn($order); + $payment->method('getAmount')->willReturn(1000); + $payment->method('getCurrencyCode')->willReturn('EUR'); + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $paymentRequest->method('getPayment')->willReturn($payment); + $paymentRequest->method('getHash')->willReturn(Uuid::v4()); + $this->paymentRequestProvider->method('provide')->willReturn($paymentRequest); + + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0000"}', null, null, 'alias_new_1')); + + $this->payplugCardRepository->expects(self::never())->method('add'); $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); } diff --git a/tests/PHPUnit/Command/Provider/CaptureHostedPaymentRequestCommandProviderTest.php b/tests/PHPUnit/Command/Provider/CaptureHostedPaymentRequestCommandProviderTest.php new file mode 100644 index 00000000..c05fbe86 --- /dev/null +++ b/tests/PHPUnit/Command/Provider/CaptureHostedPaymentRequestCommandProviderTest.php @@ -0,0 +1,136 @@ +setSession(new Session(new MockArraySessionStorage())); + + $this->requestStack = new RequestStack(); + $this->requestStack->push($request); + $this->payplugCardRepository = $this->createMock(RepositoryInterface::class); + + $this->provider = new CaptureHostedPaymentRequestCommandProvider(new SelectedCardResolver($this->requestStack, $this->payplugCardRepository)); + } + + private function paymentRequestWithDetails(array $details): PaymentRequestInterface&MockObject + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getDetails')->willReturn($details); + + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $paymentRequest->method('getPayment')->willReturn($payment); + $paymentRequest->method('getId')->willReturn('1'); + + return $paymentRequest; + } + + public function testProvide_withNoCardSelected_returnsCaptureHostedPaymentRequest(): void + { + $paymentRequest = $this->paymentRequestWithDetails([]); + + self::assertInstanceOf(CaptureHostedPaymentRequest::class, $this->provider->provide($paymentRequest)); + } + + public function testProvide_withOtherCardSentinelSelected_returnsCaptureHostedPaymentRequest(): void + { + $this->requestStack->getSession()->set('payplug_payment_method', 'other'); + $paymentRequest = $this->paymentRequestWithDetails([]); + + self::assertInstanceOf(CaptureHostedPaymentRequest::class, $this->provider->provide($paymentRequest)); + } + + public function testProvide_withExistingCardSelectedAndNotYetCaptured_returnsCaptureAliasPaymentRequest(): void + { + $this->requestStack->getSession()->set('payplug_payment_method', self::SELECTED_CARD_ID); + $this->payplugCardRepository->method('find')->with(self::SELECTED_CARD_ID)->willReturn(new Card()); + $paymentRequest = $this->paymentRequestWithDetails([]); + + self::assertInstanceOf(CaptureAliasPaymentRequest::class, $this->provider->provide($paymentRequest)); + } + + public function testProvide_withExistingCardSelectedButAlreadyCaptured_returnsOfflineCaptureRequest(): void + { + $card = new Card(); + $card->setExternalId('alias_existing_1'); + + $this->requestStack->getSession()->set('payplug_payment_method', self::SELECTED_CARD_ID); + $this->payplugCardRepository->method('find')->with(self::SELECTED_CARD_ID)->willReturn($card); + $paymentRequest = $this->paymentRequestWithDetails([ + 'alias_payment_created_at' => '2026-08-17T10:00:00+00:00', + 'alias_id' => 'alias_existing_1', + ]); + + self::assertInstanceOf(OfflineCapturePaymentRequest::class, $this->provider->provide($paymentRequest)); + } + + public function testProvide_withDifferentCardSelectedAfterEarlierAliasAttempt_returnsFreshCaptureAliasPaymentRequest(): void + { + $card = new Card(); + $card->setExternalId('alias_new'); + + $this->requestStack->getSession()->set('payplug_payment_method', self::SELECTED_CARD_ID); + $this->payplugCardRepository->method('find')->with(self::SELECTED_CARD_ID)->willReturn($card); + $paymentRequest = $this->paymentRequestWithDetails([ + 'alias_payment_created_at' => '2026-08-17T10:00:00+00:00', + 'alias_id' => 'alias_old', + ]); + + self::assertInstanceOf(CaptureAliasPaymentRequest::class, $this->provider->provide($paymentRequest)); + } + + public function testProvide_withNoCardSelectedAfterEarlierAliasAttempt_returnsOfflineCaptureRequestInsteadOfADuplicateCapture(): void + { + $paymentRequest = $this->paymentRequestWithDetails([ + 'alias_payment_created_at' => '2026-08-17T10:00:00+00:00', + 'alias_id' => 'alias_old', + ]); + + self::assertInstanceOf(OfflineCapturePaymentRequest::class, $this->provider->provide($paymentRequest)); + } + + public function testProvide_withHostedFieldsTokenAlreadyCaptured_returnsOfflineCaptureRequest(): void + { + $paymentRequest = $this->paymentRequestWithDetails(['hosted_fields_created_at' => '2026-08-17T10:00:00+00:00']); + + self::assertInstanceOf(OfflineCapturePaymentRequest::class, $this->provider->provide($paymentRequest)); + } + + public function testProvide_withSelectedCardIdNoLongerFound_fallsBackToCaptureHostedPaymentRequest(): void + { + $this->requestStack->getSession()->set('payplug_payment_method', self::SELECTED_CARD_ID); + $this->payplugCardRepository->method('find')->with(self::SELECTED_CARD_ID)->willReturn(null); + $paymentRequest = $this->paymentRequestWithDetails([]); + + self::assertInstanceOf(CaptureHostedPaymentRequest::class, $this->provider->provide($paymentRequest)); + } +} diff --git a/tests/PHPUnit/Controller/UnifiedApiIpnActionTest.php b/tests/PHPUnit/Controller/UnifiedApiIpnActionTest.php index 487ca49d..d95658dd 100644 --- a/tests/PHPUnit/Controller/UnifiedApiIpnActionTest.php +++ b/tests/PHPUnit/Controller/UnifiedApiIpnActionTest.php @@ -121,4 +121,40 @@ public function testInvoke_whenBodyHasNoId_returnsUnauthorized(): void self::assertSame(401, $response->getStatusCode()); } + + /** + * PayPlug's webhook can be delivered before CaptureHostedPaymentRequestHandler's own + * hosted_fields_payment_id/hosted_fields_operation_id write has committed (Sylius's + * doctrine_transaction messenger middleware only commits once that whole handler returns) — + * findOneByPayPlugPaymentId() briefly returns null for a payment that does exist. + */ + public function testInvoke_whenPaymentNotYetVisibleOnFirstLookup_retriesAndStillDelegatesToTheWebhookNotificationHandler(): void + { + $payment = $this->paymentWithGatewayConfig(hostedFields: true); + $this->paymentRepository->expects(self::exactly(3))->method('findOneByPayPlugPaymentId') + ->with('pay_1') + ->willReturnOnConsecutiveCalls(null, null, $payment); + + $request = Request::create('/payplug/v2/ipn', 'POST', content: \json_encode(['id' => 'pay_1', 'execCode' => '0000'])); + + $this->hostedFieldsWebhookNotificationHandler->expects(self::once())->method('treat') + ->with($payment, $request->getContent(), self::isType('array')); + + $response = $this->action->__invoke($request); + + self::assertSame(200, $response->getStatusCode()); + } + + public function testInvoke_whenPaymentNeverBecomesVisible_stopsRetryingAndReturnsUnauthorized(): void + { + $this->paymentRepository->expects(self::exactly(4))->method('findOneByPayPlugPaymentId')->with('pay_1')->willReturn(null); + + $request = Request::create('/payplug/v2/ipn', 'POST', content: \json_encode(['id' => 'pay_1'])); + + $this->hostedFieldsWebhookNotificationHandler->expects(self::never())->method('treat'); + + $response = $this->action->__invoke($request); + + self::assertSame(401, $response->getStatusCode()); + } } diff --git a/tests/PHPUnit/EventSubscriber/PostPaymentSelectEventSubscriberTest.php b/tests/PHPUnit/EventSubscriber/PostPaymentSelectEventSubscriberTest.php index cdcd8a55..f4212c4c 100644 --- a/tests/PHPUnit/EventSubscriber/PostPaymentSelectEventSubscriberTest.php +++ b/tests/PHPUnit/EventSubscriber/PostPaymentSelectEventSubscriberTest.php @@ -7,6 +7,7 @@ use Doctrine\ORM\EntityManagerInterface; use PayPlug\SyliusPayPlugPlugin\EventSubscriber\PostPaymentSelectEventSubscriber; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\PaymentProcessing\HostedFieldsCaptureData; use PayPlug\SyliusPayPlugPlugin\PaymentProcessing\HostedFieldsPaymentProcessorInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -55,6 +56,10 @@ public function testHandle_withHostedFieldsToken_delegatesToProcessorAndComplete 'hostedfields_token' => 'hf_token_abc', 'hostedfields_selected_brand' => 'VISA', 'hostedfields_save_card' => 'true', + 'hostedfields_last4' => '4242', + 'hostedfields_exp_month' => '12', + 'hostedfields_exp_year' => '2030', + 'hostedfields_country' => 'FR', ]); $request->attributes->set('_route', 'sylius_shop_checkout_select_payment'); $this->requestStack->method('getCurrentRequest')->willReturn($request); @@ -72,7 +77,7 @@ public function testHandle_withHostedFieldsToken_delegatesToProcessorAndComplete $this->hostedFieldsPaymentProcessor->expects(self::once()) ->method('process') - ->with($payment, 'hf_token_abc', 'VISA', true) + ->with($payment, new HostedFieldsCaptureData('hf_token_abc', 'VISA', true, '4242', 12, 2030, 'FR')) ; $this->stateMachine->method('can') @@ -95,6 +100,10 @@ public function testHandle_withHostedFieldsTokenButHostedFieldsNotEnabled_doesNo 'hostedfields_token' => 'hf_token_abc', 'hostedfields_selected_brand' => 'VISA', 'hostedfields_save_card' => 'true', + 'hostedfields_last4' => '4242', + 'hostedfields_exp_month' => '12', + 'hostedfields_exp_year' => '2030', + 'hostedfields_country' => 'FR', ]); $request->attributes->set('_route', 'sylius_shop_checkout_select_payment'); $this->requestStack->method('getCurrentRequest')->willReturn($request); @@ -129,6 +138,10 @@ public function testHandle_withHostedFieldsTokenButDifferentFactory_doesNotProce 'hostedfields_token' => 'hf_token_abc', 'hostedfields_selected_brand' => 'VISA', 'hostedfields_save_card' => 'true', + 'hostedfields_last4' => '4242', + 'hostedfields_exp_month' => '12', + 'hostedfields_exp_year' => '2030', + 'hostedfields_country' => 'FR', ]); $request->attributes->set('_route', 'sylius_shop_checkout_select_payment'); $this->requestStack->method('getCurrentRequest')->willReturn($request); @@ -162,6 +175,10 @@ public function testHandle_withHostedFieldsTokenButConfigKeyAbsent_doesNotProces 'hostedfields_token' => 'hf_token_abc', 'hostedfields_selected_brand' => 'VISA', 'hostedfields_save_card' => 'true', + 'hostedfields_last4' => '4242', + 'hostedfields_exp_month' => '12', + 'hostedfields_exp_year' => '2030', + 'hostedfields_country' => 'FR', ]); $request->attributes->set('_route', 'sylius_shop_checkout_select_payment'); $this->requestStack->method('getCurrentRequest')->willReturn($request); @@ -196,6 +213,10 @@ public function testHandle_withBothTokens_isProcessedAsHostedFields(): void 'payplug_integrated_payment_token' => 'pay_123', 'hostedfields_token' => 'hf_token_abc', 'hostedfields_selected_brand' => 'CB', + 'hostedfields_last4' => '4242', + 'hostedfields_exp_month' => '12', + 'hostedfields_exp_year' => '2030', + 'hostedfields_country' => 'FR', ]); $request->attributes->set('_route', 'sylius_shop_checkout_select_payment'); $this->requestStack->method('getCurrentRequest')->willReturn($request); @@ -214,7 +235,7 @@ public function testHandle_withBothTokens_isProcessedAsHostedFields(): void // Hosted Fields path: the processor is used and no payment_id is written to the details. $this->hostedFieldsPaymentProcessor->expects(self::once()) ->method('process') - ->with($payment, 'hf_token_abc', 'CB', false) + ->with($payment, new HostedFieldsCaptureData('hf_token_abc', 'CB', false, '4242', 12, 2030, 'FR')) ; $payment->expects(self::never())->method('setDetails'); @@ -225,6 +246,10 @@ public function testHandle_withHostedFieldsTokenButNoPaymentMethod_doesNotProces { $request = Request::create('/checkout/select-payment', 'POST', [ 'hostedfields_token' => 'hf_token_abc', + 'hostedfields_last4' => '4242', + 'hostedfields_exp_month' => '12', + 'hostedfields_exp_year' => '2030', + 'hostedfields_country' => 'FR', ]); $request->attributes->set('_route', 'sylius_shop_checkout_select_payment'); $this->requestStack->method('getCurrentRequest')->willReturn($request); diff --git a/tests/PHPUnit/Handler/HostedFieldsWebhookNotificationHandlerTest.php b/tests/PHPUnit/Handler/HostedFieldsWebhookNotificationHandlerTest.php index 6a94ffba..f1c0338d 100644 --- a/tests/PHPUnit/Handler/HostedFieldsWebhookNotificationHandlerTest.php +++ b/tests/PHPUnit/Handler/HostedFieldsWebhookNotificationHandlerTest.php @@ -4,7 +4,10 @@ namespace Tests\PayPlug\SyliusPayPlugPlugin\PHPUnit\Handler; +use Doctrine\Persistence\ManagerRegistry; +use PayPlug\SyliusPayPlugPlugin\Entity\Card; use PayPlug\SyliusPayPlugPlugin\Handler\HostedFieldsWebhookNotificationHandler; +use PayPlug\SyliusPayPlugPlugin\Upc\PayplugCardPersister; use PayplugUnifiedCore\Contracts\IConfigurationRepository; use PayplugUnifiedCore\Contracts\ILock; use PayplugUnifiedCore\Contracts\IOrderStateMutator; @@ -14,8 +17,12 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; +use Sylius\Component\Core\Model\CustomerInterface; use Sylius\Component\Core\Model\OrderInterface; use Sylius\Component\Core\Model\PaymentInterface; +use Sylius\Component\Core\Model\PaymentMethodInterface; +use Sylius\Component\Resource\Factory\FactoryInterface; +use Sylius\Component\Resource\Repository\RepositoryInterface; final class HostedFieldsWebhookNotificationHandlerTest extends TestCase { @@ -29,6 +36,12 @@ final class HostedFieldsWebhookNotificationHandlerTest extends TestCase private LoggerInterface&MockObject $logger; + private FactoryInterface&MockObject $payplugCardFactory; + + private RepositoryInterface&MockObject $payplugCardRepository; + + private ManagerRegistry&MockObject $managerRegistry; + private HostedFieldsWebhookNotificationHandler $handler; protected function setUp(): void @@ -39,6 +52,9 @@ protected function setUp(): void $this->lock = $this->createMock(ILock::class); $this->lock->method('acquire')->willReturn(true); $this->logger = $this->createMock(LoggerInterface::class); + $this->payplugCardFactory = $this->createMock(FactoryInterface::class); + $this->payplugCardRepository = $this->createMock(RepositoryInterface::class); + $this->managerRegistry = $this->createMock(ManagerRegistry::class); $this->handler = new HostedFieldsWebhookNotificationHandler( $this->paymentRepository, @@ -46,18 +62,31 @@ protected function setUp(): void $this->configurationRepository, $this->lock, $this->logger, + new PayplugCardPersister($this->payplugCardFactory, $this->payplugCardRepository, $this->managerRegistry), ); } - private function payment(int $id = 42, int $amount = 1000, ?string $orderNumber = null): PaymentInterface&MockObject - { + /** + * @param mixed[] $details + */ + private function payment( + int $id = 42, + int $amount = 1000, + ?string $orderNumber = null, + array $details = [], + ?PaymentMethodInterface $method = null, + ?CustomerInterface $customer = null, + ): PaymentInterface&MockObject { $payment = $this->createMock(PaymentInterface::class); $payment->method('getId')->willReturn($id); $payment->method('getAmount')->willReturn($amount); + $payment->method('getDetails')->willReturn($details); + $payment->method('getMethod')->willReturn($method); - if (null !== $orderNumber) { + if (null !== $orderNumber || null !== $customer) { $order = $this->createMock(OrderInterface::class); $order->method('getNumber')->willReturn($orderNumber); + $order->method('getCustomer')->willReturn($customer); $payment->method('getOrder')->willReturn($order); } else { $payment->method('getOrder')->willReturn(null); @@ -105,6 +134,7 @@ public function testTreat_whenLockCannotBeAcquired_doesNothingAndReturnsSilently $this->configurationRepository, $this->lock, $this->logger, + new PayplugCardPersister($this->payplugCardFactory, $this->payplugCardRepository, $this->managerRegistry), ); $this->paymentRepository->expects(self::never())->method('isTreated'); @@ -248,4 +278,135 @@ public function testTreat_onAmountMismatch_logsAndSkipsWithoutApplyingTheOutcome $this->handler->treat($this->payment(42, 1000), $body, ['Authorization' => 'Bearer shared-secret']); } + + /** + * A 3DS-challenge capture never gets an alias back synchronously — this webhook, fired once + * the challenge is validated, is the only place a 3DS payment's card ever gets saved. The + * alias/card metadata is already in the webhook body itself: same paymentMethod.{id, card, + * details} shape as the operation resource CaptureHostedPaymentRequestHandler fetches + * separately for a frictionless payment. + */ + public function testTreat_onPaidOutcomeWithSaveCardRequested_persistsANewCard(): void + { + $body = \json_encode([ + 'id' => 'op_123', + 'execCode' => '0000', + 'orderId' => '42', + 'amount' => 1000, + 'paymentMethod' => [ + 'id' => 'card_new_1', + 'card' => ['network' => 'VISA', 'code6x4' => '424242XXXXXX4242'], + 'details' => ['selectedBrand' => 'VISA', 'validityDate' => '2030-12'], + ], + ]); + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + $this->paymentRepository->method('isTreated')->willReturn(false); + + $method = $this->createMock(PaymentMethodInterface::class); + $customer = $this->createMock(CustomerInterface::class); + $payment = $this->payment(42, 1000, details: ['hosted_fields_save_card' => true], method: $method, customer: $customer); + + $card = new Card(); + $this->payplugCardFactory->method('createNew')->willReturn($card); + $this->payplugCardRepository->expects(self::once())->method('add')->with($card); + + $this->handler->treat($payment, $body, ['Authorization' => 'Bearer shared-secret']); + + self::assertSame('card_new_1', $card->getExternalId()); + self::assertSame('VISA', $card->getBrand()); + self::assertSame('4242', $card->getLast4()); + self::assertSame(12, $card->getExpirationMonth()); + self::assertSame(2030, $card->getExpirationYear()); + } + + public function testTreat_onPaidOutcomeWithoutSaveCardRequested_doesNotPersistACard(): void + { + $body = \json_encode([ + 'id' => 'op_123', + 'execCode' => '0000', + 'orderId' => '42', + 'amount' => 1000, + 'paymentMethod' => ['id' => 'card_new_1', 'card' => ['network' => 'VISA', 'code6x4' => '424242XXXXXX4242']], + ]); + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + $this->paymentRepository->method('isTreated')->willReturn(false); + + $payment = $this->payment(42, 1000, details: ['hosted_fields_save_card' => false]); + + $this->payplugCardRepository->expects(self::never())->method('add'); + + $this->handler->treat($payment, $body, ['Authorization' => 'Bearer shared-secret']); + } + + public function testTreat_onPaidOutcomeWithSaveCardRequestedButNoAliasInPayload_logsAndDoesNotPersistACard(): void + { + $body = \json_encode([ + 'id' => 'op_123', + 'execCode' => '0000', + 'orderId' => '42', + 'amount' => 1000, + // No paymentMethod.id — e.g. this operation never involved an alias at all. + 'paymentMethod' => ['card' => ['network' => 'VISA', 'code6x4' => '424242XXXXXX4242']], + ]); + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + $this->paymentRepository->method('isTreated')->willReturn(false); + + $method = $this->createMock(PaymentMethodInterface::class); + $customer = $this->createMock(CustomerInterface::class); + $payment = $this->payment(42, 1000, details: ['hosted_fields_save_card' => true], method: $method, customer: $customer); + + $this->logger->expects(self::once())->method('error') + ->with(self::stringContains('no alias id'), self::anything()); + $this->payplugCardRepository->expects(self::never())->method('add'); + + $this->handler->treat($payment, $body, ['Authorization' => 'Bearer shared-secret']); + } + + public function testTreat_onNonPaidOutcome_doesNotAttemptToPersistACard(): void + { + // execCode "9999" maps to PaymentOutcome::FAILED (not PAID, not the 0001 pending case + // already covered elsewhere) — the card-save branch must not even be attempted. + $body = \json_encode([ + 'id' => 'op_123', + 'execCode' => '9999', + 'orderId' => '42', + 'amount' => 1000, + 'paymentMethod' => ['id' => 'card_new_1', 'card' => ['network' => 'VISA', 'code6x4' => '424242XXXXXX4242']], + ]); + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + $this->paymentRepository->method('isTreated')->willReturn(false); + + $payment = $this->payment(42, 1000, details: ['hosted_fields_save_card' => true]); + + $this->payplugCardRepository->expects(self::never())->method('add'); + + $this->handler->treat($payment, $body, ['Authorization' => 'Bearer shared-secret']); + } + + public function testTreat_onPaidOutcomeWithSaveCardRequestedAndCardAlreadySaved_doesNotPersistADuplicate(): void + { + $body = \json_encode([ + 'id' => 'op_123', + 'execCode' => '0000', + 'orderId' => '42', + 'amount' => 1000, + 'paymentMethod' => ['id' => 'card_existing_1', 'card' => ['network' => 'VISA', 'code6x4' => '424242XXXXXX4242']], + ]); + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + $this->paymentRepository->method('isTreated')->willReturn(false); + + $method = $this->createMock(PaymentMethodInterface::class); + $customer = $this->createMock(CustomerInterface::class); + $payment = $this->payment(42, 1000, details: ['hosted_fields_save_card' => true], method: $method, customer: $customer); + + $this->payplugCardRepository->method('findOneBy')->with(['externalId' => 'card_existing_1', 'isLive' => false])->willReturn(new Card()); + $this->payplugCardRepository->expects(self::never())->method('add'); + + $this->handler->treat($payment, $body, ['Authorization' => 'Bearer shared-secret']); + } } diff --git a/tests/PHPUnit/Handler/PaymentNotificationHandlerTest.php b/tests/PHPUnit/Handler/PaymentNotificationHandlerTest.php index 62d92df0..a1bd28e6 100644 --- a/tests/PHPUnit/Handler/PaymentNotificationHandlerTest.php +++ b/tests/PHPUnit/Handler/PaymentNotificationHandlerTest.php @@ -4,7 +4,9 @@ namespace Tests\PayPlug\SyliusPayPlugPlugin\PHPUnit\Handler; +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\Persistence\ManagerRegistry; use Payplug\Resource\Payment as PayplugPayment; use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface; use PayPlug\SyliusPayPlugPlugin\Entity\Card; @@ -39,6 +41,8 @@ final class PaymentNotificationHandlerTest extends TestCase private RequestStack&MockObject $requestStack; + private ManagerRegistry&MockObject $managerRegistry; + private PaymentNotificationHandler $handler; protected function setUp(): void @@ -50,6 +54,7 @@ protected function setUp(): void $this->entityManager = $this->createMock(EntityManagerInterface::class); $this->lockFactory = $this->createMock(LockFactory::class); $this->requestStack = $this->createMock(RequestStack::class); + $this->managerRegistry = $this->createMock(ManagerRegistry::class); $this->handler = new PaymentNotificationHandler( $this->logger, @@ -59,6 +64,7 @@ protected function setUp(): void $this->entityManager, $this->lockFactory, $this->requestStack, + $this->managerRegistry, ); } @@ -328,6 +334,61 @@ public function testTreat_withIsPaidAndMissingCustomerId_doesNotSaveCard(): void self::assertSame(PayPlugApiClientInterface::STATUS_CAPTURED, $details['status']); } + // ------------------------------------------------------------------------- + // treat() — card saving: concurrent save for the same alias does not throw + // ------------------------------------------------------------------------- + + /** + * Two payments notified concurrently for the same card alias can both pass the findOneBy() + * guard before either commits; the DB-level unique constraint then rejects the second add(). + * Verifies that race is swallowed rather than propagated as an uncaught exception. + */ + public function testTreat_whenAddLosesARaceAgainstAConcurrentSaveForTheSameAlias_doesNotThrow(): void + { + $lock = $this->buildLock(); + $this->lockFactory->method('createLock')->willReturn($lock); + + $customer = $this->createMock(CustomerInterface::class); + $this->customerRepository->method('find')->with(9)->willReturn($customer); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn($this->createMock(PaymentMethodInterface::class)); + $this->entityManager->method('refresh'); + + $paymentResource = $this->buildPayment([ + 'id' => 'pay_008', + 'is_paid' => true, + 'is_live' => false, + 'created_at' => time(), + 'metadata' => ['customer_id' => 9], + 'card' => ['id' => 'card_external_race', 'brand' => 'Visa', 'country' => 'FR', 'last4' => '4242', 'exp_month' => 12, 'exp_year' => 2030], + ]); + + $this->payplugCardRepository->method('findOneBy')->willReturn(null); + + $card = $this->createMock(Card::class); + $card->method('setCustomer')->willReturnSelf(); + $card->method('setPaymentMethod')->willReturnSelf(); + $card->method('setExternalId')->willReturnSelf(); + $card->method('setBrand')->willReturnSelf(); + $card->method('setCountryCode')->willReturnSelf(); + $card->method('setLast4')->willReturnSelf(); + $card->method('setExpirationMonth')->willReturnSelf(); + $card->method('setExpirationYear')->willReturnSelf(); + $card->method('setIsLive')->willReturnSelf(); + + $this->payplugCardFactory->method('createNew')->willReturn($card); + $this->payplugCardRepository->method('add')->with($card) + ->willThrowException($this->createMock(UniqueConstraintViolationException::class)); + $this->managerRegistry->expects(self::once())->method('resetManager'); + + $details = new \ArrayObject(['status' => PayPlugApiClientInterface::STATUS_CREATED]); + + $this->handler->treat($payment, $paymentResource, $details); + + self::assertSame(PayPlugApiClientInterface::STATUS_CAPTURED, $details['status']); + } + // ------------------------------------------------------------------------- // treat() — card NOT saved when card already exists in repo // ------------------------------------------------------------------------- diff --git a/tests/PHPUnit/PaymentProcessing/NullHostedFieldsPaymentProcessorTest.php b/tests/PHPUnit/PaymentProcessing/NullHostedFieldsPaymentProcessorTest.php index c6ac578a..a4a24faa 100644 --- a/tests/PHPUnit/PaymentProcessing/NullHostedFieldsPaymentProcessorTest.php +++ b/tests/PHPUnit/PaymentProcessing/NullHostedFieldsPaymentProcessorTest.php @@ -4,6 +4,7 @@ namespace Tests\PayPlug\SyliusPayPlugPlugin\PHPUnit\PaymentProcessing; +use PayPlug\SyliusPayPlugPlugin\PaymentProcessing\HostedFieldsCaptureData; use PayPlug\SyliusPayPlugPlugin\PaymentProcessing\NullHostedFieldsPaymentProcessor; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -22,25 +23,24 @@ protected function setUp(): void $this->processor = new NullHostedFieldsPaymentProcessor($this->logger); } - public function testProcess_logsAndStoresDetailsWithoutCallingAnyApi(): void + public function testProcess_storesCardMetadataAlongsideTokenAndBrand(): void { - $payment = $this->createMock(PaymentInterface::class); - $payment->method('getId')->willReturn(42); - $payment->method('getDetails')->willReturn(['existing' => 'value']); - - $payment->expects(self::once()) - ->method('setDetails') - ->with([ - 'existing' => 'value', - 'hosted_fields_token' => 'hf_token_123', - 'hosted_fields_selected_brand' => 'CB', - 'hosted_fields_save_card' => true, - 'status' => PaymentInterface::STATE_PROCESSING, - ]) - ; - $this->logger->expects(self::once())->method('info'); - $this->processor->process($payment, 'hf_token_123', 'CB', true); + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getDetails')->willReturn(['existing_key' => 'kept']); + $payment->expects(self::once())->method('setDetails')->with([ + 'existing_key' => 'kept', + 'hosted_fields_token' => 'hf_token_abc', + 'hosted_fields_selected_brand' => 'VISA', + 'hosted_fields_save_card' => true, + 'hosted_fields_last4' => '4242', + 'hosted_fields_expiration_month' => 12, + 'hosted_fields_expiration_year' => 2030, + 'hosted_fields_country' => 'FR', + 'status' => PaymentInterface::STATE_PROCESSING, + ]); + + $this->processor->process($payment, new HostedFieldsCaptureData('hf_token_abc', 'VISA', true, '4242', 12, 2030, 'FR')); } } diff --git a/tests/PHPUnit/Resolver/SelectedCardResolverTest.php b/tests/PHPUnit/Resolver/SelectedCardResolverTest.php new file mode 100644 index 00000000..b5e49250 --- /dev/null +++ b/tests/PHPUnit/Resolver/SelectedCardResolverTest.php @@ -0,0 +1,67 @@ +setSession(new Session(new MockArraySessionStorage())); + + $this->requestStack = new RequestStack(); + $this->requestStack->push($request); + $this->payplugCardRepository = $this->createMock(RepositoryInterface::class); + + $this->resolver = new SelectedCardResolver($this->requestStack, $this->payplugCardRepository); + } + + public function testResolve_withNoCardIdInSession_returnsNull(): void + { + self::assertNull($this->resolver->resolve()); + } + + public function testResolve_withOtherCardSentinelSelected_returnsNull(): void + { + $this->requestStack->getSession()->set('payplug_payment_method', 'other'); + + self::assertNull($this->resolver->resolve()); + } + + public function testResolve_withSelectedCardIdFound_returnsTheCard(): void + { + $card = new Card(); + $this->requestStack->getSession()->set('payplug_payment_method', self::SELECTED_CARD_ID); + $this->payplugCardRepository->method('find')->with(self::SELECTED_CARD_ID)->willReturn($card); + + self::assertSame($card, $this->resolver->resolve()); + } + + public function testResolve_withSelectedCardIdNoLongerFound_returnsNull(): void + { + $this->requestStack->getSession()->set('payplug_payment_method', self::SELECTED_CARD_ID); + $this->payplugCardRepository->method('find')->with(self::SELECTED_CARD_ID)->willReturn(null); + + self::assertNull($this->resolver->resolve()); + } +} diff --git a/tests/PHPUnit/Upc/CardDataFromPaymentMethodExtractorTest.php b/tests/PHPUnit/Upc/CardDataFromPaymentMethodExtractorTest.php new file mode 100644 index 00000000..52568e78 --- /dev/null +++ b/tests/PHPUnit/Upc/CardDataFromPaymentMethodExtractorTest.php @@ -0,0 +1,124 @@ + [ + 'id' => 'card_xxx', + 'card' => [ + 'network' => 'VISA', + 'code6x4' => '424242XXXXXX4242', + ], + 'details' => [ + 'selectedBrand' => 'VISA', + 'validityDate' => '2027-12', + ], + ], + ]); + + self::assertSame([ + 'aliasId' => 'card_xxx', + 'brand' => 'VISA', + 'last4' => '4242', + 'expirationYear' => 2027, + 'expirationMonth' => 12, + ], CardDataFromPaymentMethodExtractor::extract($body)); + } + + public function testExtract_whenCardNetworkAndDetailsSelectedBrandDisagree_cardNetworkWins(): void + { + $body = json_encode([ + 'paymentMethod' => [ + 'card' => ['network' => 'VISA'], + 'details' => ['selectedBrand' => 'MASTERCARD'], + ], + ]); + + self::assertSame(['brand' => 'VISA'], CardDataFromPaymentMethodExtractor::extract($body)); + } + + public function testExtract_withNoCardNetwork_fallsBackToDetailsSelectedBrand(): void + { + $body = json_encode([ + 'paymentMethod' => [ + 'card' => ['code6x4' => '424242XXXXXX4242'], + 'details' => ['selectedBrand' => 'MASTERCARD'], + ], + ]); + + $result = CardDataFromPaymentMethodExtractor::extract($body); + + self::assertSame('MASTERCARD', $result['brand']); + } + + public function testExtract_withNonArrayBody_returnsEmptyArray(): void + { + self::assertSame([], CardDataFromPaymentMethodExtractor::extract('"just a string"')); + } + + public function testExtract_withPaymentMethodKeyMissing_returnsEmptyArray(): void + { + self::assertSame([], CardDataFromPaymentMethodExtractor::extract(json_encode(['id' => 'op_1']))); + } + + public function testExtract_withCardKeyMissing_returnsOnlyAliasId(): void + { + $body = json_encode(['paymentMethod' => ['id' => 'card_xxx', 'details' => ['selectedBrand' => 'VISA']]]); + + self::assertSame(['aliasId' => 'card_xxx', 'brand' => 'VISA'], CardDataFromPaymentMethodExtractor::extract($body)); + } + + public function testExtract_withDetailsKeyMissing_returnsOnlyCardFields(): void + { + $body = json_encode(['paymentMethod' => ['card' => ['network' => 'VISA']]]); + + self::assertSame(['brand' => 'VISA'], CardDataFromPaymentMethodExtractor::extract($body)); + } + + public function testExtract_withEmptyAliasId_omitsAliasId(): void + { + $body = json_encode(['paymentMethod' => ['id' => '']]); + + self::assertSame([], CardDataFromPaymentMethodExtractor::extract($body)); + } + + public function testExtract_withCode6x4ShorterThanFourCharacters_omitsLast4(): void + { + $body = json_encode(['paymentMethod' => ['card' => ['code6x4' => '42']]]); + + self::assertSame([], CardDataFromPaymentMethodExtractor::extract($body)); + } + + public function testExtract_withValidityDateNotMatchingTheExpectedFormat_omitsExpiration(): void + { + $body = json_encode(['paymentMethod' => ['details' => ['validityDate' => '1225']]]); + + self::assertSame([], CardDataFromPaymentMethodExtractor::extract($body)); + } + + public function testExtract_withValidityDateOutOfRangeMonth_omitsExpiration(): void + { + $body = json_encode(['paymentMethod' => ['details' => ['validityDate' => '2027-13']]]); + + self::assertSame([], CardDataFromPaymentMethodExtractor::extract($body)); + } + + public function testExtractFromDecoded_withAnAlreadyDecodedBody_behavesLikeExtract(): void + { + $decoded = ['paymentMethod' => ['id' => 'card_xxx', 'card' => ['network' => 'VISA']]]; + + self::assertSame( + ['aliasId' => 'card_xxx', 'brand' => 'VISA'], + CardDataFromPaymentMethodExtractor::extractFromDecoded($decoded), + ); + } +} diff --git a/tests/PHPUnit/Upc/IntegrationDescriptionProviderTest.php b/tests/PHPUnit/Upc/IntegrationDescriptionProviderTest.php new file mode 100644 index 00000000..2e3831b0 --- /dev/null +++ b/tests/PHPUnit/Upc/IntegrationDescriptionProviderTest.php @@ -0,0 +1,35 @@ +factory = new OrderAddressDtoCreator(); + } + + private function address( + ?string $phoneNumber = null, + string $countryCode = 'FR', + string $provinceCode = '75', + ): AddressInterface&MockObject { + $address = $this->createMock(AddressInterface::class); + $address->method('getFirstName')->willReturn('Jane'); + $address->method('getLastName')->willReturn('Doe'); + $address->method('getStreet')->willReturn('10 Rue de Rivoli'); + $address->method('getCity')->willReturn('Paris'); + $address->method('getCountryCode')->willReturn($countryCode); + $address->method('getProvinceCode')->willReturn($provinceCode); + $address->method('getPostcode')->willReturn('75001'); + $address->method('getCompany')->willReturn('Acme Corp'); + $address->method('getPhoneNumber')->willReturn($phoneNumber); + + return $address; + } + + private function orderWithAddresses( + ?AddressInterface $billing, + ?AddressInterface $shipping, + ?string $customerEmail = 'jane@example.com', + string $customerGender = '', + ): OrderInterface&MockObject { + $customer = $this->createMock(CustomerInterface::class); + $customer->method('getEmail')->willReturn($customerEmail); + $customer->method('getGender')->willReturn($customerGender); + + $order = $this->createMock(OrderInterface::class); + $order->method('getCustomer')->willReturn($customer); + $order->method('getBillingAddress')->willReturn($billing); + $order->method('getShippingAddress')->willReturn($shipping); + + return $order; + } + + public function testCreateBilling_withNoBillingAddress_returnsNull(): void + { + $order = $this->orderWithAddresses(null, null); + + self::assertNull($this->factory->createBilling($order)); + } + + public function testCreateShipping_withNoShippingAddress_returnsNull(): void + { + $order = $this->orderWithAddresses(null, null); + + self::assertNull($this->factory->createShipping($order)); + } + + public function testCreateBilling_withAFullAddressAndMobilePhone_mapsEveryField(): void + { + $order = $this->orderWithAddresses($this->address('+33612345678'), null, customerGender: 'f'); + + $billing = $this->factory->createBilling($order); + + self::assertNotNull($billing); + self::assertNotNull($billing->contact); + self::assertSame('Jane', $billing->contact->firstName); + self::assertSame('Doe', $billing->contact->lastName); + self::assertSame('MRS', $billing->title); + self::assertNull($billing->contact->phone); + self::assertSame('+33612345678', $billing->contact->mobilePhone); + self::assertNotNull($billing->address); + self::assertSame('10 Rue de Rivoli', $billing->address->line); + self::assertSame('Paris', $billing->address->city); + self::assertSame('FR', $billing->address->country); + self::assertSame('75', $billing->address->state); + self::assertSame('75001', $billing->address->zipCode); + } + + public function testCreateBilling_withAProvinceCodeLongerThanThreeChars_omitsState(): void + { + $address = $this->address(provinceCode: 'US-CA'); + $order = $this->orderWithAddresses($address, null); + + $billing = $this->factory->createBilling($order); + + self::assertNotNull($billing); + self::assertNotNull($billing->address); + self::assertNull($billing->address->state); + } + + public function testCreateBilling_withALandlinePhone_setsPhoneNotMobilePhone(): void + { + $order = $this->orderWithAddresses($this->address('+33142345678'), null); + + $billing = $this->factory->createBilling($order); + + self::assertNotNull($billing); + self::assertNotNull($billing->contact); + self::assertSame('+33142345678', $billing->contact->phone); + self::assertNull($billing->contact->mobilePhone); + } + + public function testCreateBilling_withNoPhoneNumber_leavesBothPhoneFieldsNull(): void + { + $order = $this->orderWithAddresses($this->address(null), null); + + $billing = $this->factory->createBilling($order); + + self::assertNotNull($billing); + self::assertNotNull($billing->contact); + self::assertNull($billing->contact->phone); + self::assertNull($billing->contact->mobilePhone); + } + + public function testCreateBilling_withAnUnparseablePhoneNumber_leavesBothPhoneFieldsNullInsteadOfThrowing(): void + { + $order = $this->orderWithAddresses($this->address('not-a-phone-number'), null); + + $billing = $this->factory->createBilling($order); + + self::assertNotNull($billing); + self::assertNotNull($billing->contact); + self::assertNull($billing->contact->phone); + self::assertNull($billing->contact->mobilePhone); + } + + public function testCreateBilling_withMaleGender_mapsTitleToMr(): void + { + $order = $this->orderWithAddresses($this->address(), null, customerGender: 'm'); + + $billing = $this->factory->createBilling($order); + + self::assertNotNull($billing); + self::assertSame('MR', $billing->title); + } + + public function testCreateBilling_withNoGender_leavesTitleNull(): void + { + $order = $this->orderWithAddresses($this->address(), null, customerGender: ''); + + $billing = $this->factory->createBilling($order); + + self::assertNotNull($billing); + self::assertNull($billing->title); + } + + public function testCreateShipping_withAFullAddress_mapsEveryFieldIncludingCustomerEmailAndCompany(): void + { + $order = $this->orderWithAddresses(null, $this->address('+33612345678'), customerEmail: 'jane@example.com'); + + $shipping = $this->factory->createShipping($order); + + self::assertNotNull($shipping); + self::assertNotNull($shipping->contact); + self::assertSame('Jane', $shipping->contact->firstName); + self::assertSame('Doe', $shipping->contact->lastName); + self::assertSame('jane@example.com', $shipping->email); + self::assertSame('Acme Corp', $shipping->companyName); + self::assertNull($shipping->contact->phone); + self::assertSame('+33612345678', $shipping->contact->mobilePhone); + self::assertNotNull($shipping->address); + self::assertSame('75001', $shipping->address->zipCode); + } + + public function testCreateBillingAndCreateShipping_areIndependentOfEachOther(): void + { + $order = $this->orderWithAddresses($this->address('+33612345678'), null); + + self::assertNotNull($this->factory->createBilling($order)); + self::assertNull($this->factory->createShipping($order)); + } +} diff --git a/tests/PHPUnit/Upc/PaymentCaptureContextBuilderTest.php b/tests/PHPUnit/Upc/PaymentCaptureContextBuilderTest.php new file mode 100644 index 00000000..98a97a90 --- /dev/null +++ b/tests/PHPUnit/Upc/PaymentCaptureContextBuilderTest.php @@ -0,0 +1,296 @@ +urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $this->afterPayUrlProvider = $this->createMock(UrlProviderInterface::class); + $this->afterPayUrlProvider->method('getUrl')->willReturn('https://shop.test/order/00000042/pay'); + $this->requestStack = new RequestStack(); + + $this->builder = new PaymentCaptureContextBuilder( + $this->urlGenerator, + $this->afterPayUrlProvider, + new OrderAddressDtoCreator(), + $this->requestStack, + ); + } + + private function methodWithGatewayConfig(?array $config): PaymentMethodInterface&MockObject + { + $method = $this->createMock(PaymentMethodInterface::class); + if (null !== $config) { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getConfig')->willReturn($config); + $method->method('getGatewayConfig')->willReturn($gatewayConfig); + } + + return $method; + } + + public function testResolveGatewayCredentials_withCompleteConfig_returnsAccountIdAndSubmerchantId(): void + { + $method = $this->methodWithGatewayConfig(['hfIdentifier' => 'acct_123', 'hfSubMerchantId' => 'sub_ext_1']); + + self::assertSame(['acct_123', 'sub_ext_1'], $this->builder->resolveGatewayCredentials($method)); + } + + public function testResolveGatewayCredentials_withNoGatewayConfig_throws(): void + { + $this->expectException(\LogicException::class); + + $this->builder->resolveGatewayCredentials($this->methodWithGatewayConfig(null)); + } + + public function testResolveGatewayCredentials_withBlankSubmerchantId_throws(): void + { + $this->expectException(\LogicException::class); + + $this->builder->resolveGatewayCredentials($this->methodWithGatewayConfig(['hfIdentifier' => 'acct_123', 'hfSubMerchantId' => ''])); + } + + public function testResolvePaymentMethod_withNoMethodOnThePayment_throws(): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn(null); + + $this->expectException(\LogicException::class); + + $this->builder->resolvePaymentMethod($payment); + } + + public function testResolvePaymentMethod_withAMethodOnThePayment_returnsIt(): void + { + $method = $this->createMock(PaymentMethodInterface::class); + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn($method); + + self::assertSame($method, $this->builder->resolvePaymentMethod($payment)); + } + + public function testResolveAmountAndCurrency_withAmountOrCurrencyMissing_throws(): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getAmount')->willReturn(null); + $payment->method('getCurrencyCode')->willReturn('EUR'); + + $this->expectException(\LogicException::class); + + $this->builder->resolveAmountAndCurrency($payment); + } + + public function testResolveAmountAndCurrency_withBothSet_returnsThem(): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getAmount')->willReturn(1000); + $payment->method('getCurrencyCode')->willReturn('EUR'); + + self::assertSame([1000, 'EUR'], $this->builder->resolveAmountAndCurrency($payment)); + } + + public function testBuildCustomerDto_withNoCustomerOnTheOrder_throws(): void + { + $order = $this->createMock(OrderInterface::class); + $order->method('getCustomer')->willReturn(null); + + $this->expectException(\LogicException::class); + + $this->builder->buildCustomerDto($order); + } + + public function testBuildCustomerDto_withNoCustomerEmail_throws(): void + { + $customer = $this->createMock(CustomerInterface::class); + $customer->method('getEmail')->willReturn(null); + $order = $this->createMock(OrderInterface::class); + $order->method('getCustomer')->willReturn($customer); + + $this->expectException(\LogicException::class); + + $this->builder->buildCustomerDto($order); + } + + public function testBuildCustomerDto_withCustomerAndEmail_returnsCustomerDto(): void + { + $customer = $this->createMock(CustomerInterface::class); + $customer->method('getId')->willReturn(7); + $customer->method('getEmail')->willReturn('customer@example.com'); + $order = $this->createMock(OrderInterface::class); + $order->method('getCustomer')->willReturn($customer); + + $dto = $this->builder->buildCustomerDto($order); + + self::assertSame('7', $dto->id); + self::assertSame('customer@example.com', $dto->email); + } + + public function testBuildBrowserDto_withNoCurrentRequest_returnsNull(): void + { + self::assertNull($this->builder->buildBrowserDto()); + } + + public function testBuildBrowserDto_withACurrentRequest_returnsItsClientDetails(): void + { + $request = new Request(server: ['REMOTE_ADDR' => '203.0.113.5']); + $request->headers->set('referer', 'https://shop.test/checkout'); + $request->headers->set('User-Agent', 'TestAgent/1.0'); + $this->requestStack->push($request); + + $dto = $this->builder->buildBrowserDto(); + + self::assertNotNull($dto); + self::assertSame('203.0.113.5', $dto->ip); + self::assertSame('https://shop.test/checkout', $dto->referrer); + self::assertSame('TestAgent/1.0', $dto->userAgent); + } + + public function testBuildCommonFields_setsSuccessCancelAndNotificationUrls(): void + { + $this->urlGenerator->method('generate')->willReturn('https://shop.test/payplug/notify/abc'); + + $order = $this->createMock(OrderInterface::class); + $order->method('getNumber')->willReturn('00000042'); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getId')->willReturn(42); + + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $paymentRequest->method('getPayment')->willReturn($payment); + $paymentRequest->method('getHash')->willReturn(Uuid::v4()); + + $common = $this->builder->buildCommonFields('acct_123', 1000, 'eur', 'sub_ext_1', $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::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); + } + + public function testBuildCommonFields_withNoOrder_fallsBackToPaymentIdAsOrderId(): void + { + $this->urlGenerator->method('generate')->willReturn('https://shop.test/payplug/notify/abc'); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getId')->willReturn(42); + + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $paymentRequest->method('getPayment')->willReturn($payment); + $paymentRequest->method('getHash')->willReturn(Uuid::v4()); + + $common = $this->builder->buildCommonFields('acct_123', 1000, 'eur', 'sub_ext_1', $paymentRequest, null); + + self::assertSame('42', $common->orderId); + self::assertNull($common->billing); + self::assertNull($common->shipping); + } + + public function testBuildCommonFields_withAnOrderItem_usesItsProductNameAsDescription(): void + { + $this->urlGenerator->method('generate')->willReturn('https://shop.test/payplug/notify/abc'); + + $item = $this->createMock(OrderItemInterface::class); + $item->method('getProductName')->willReturn('Blue T-Shirt'); + + $order = $this->createMock(OrderInterface::class); + $order->method('getNumber')->willReturn('00000042'); + $order->method('getItems')->willReturn(new ArrayCollection([$item])); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getId')->willReturn(42); + + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $paymentRequest->method('getPayment')->willReturn($payment); + $paymentRequest->method('getHash')->willReturn(Uuid::v4()); + + $common = $this->builder->buildCommonFields('acct_123', 1000, 'eur', 'sub_ext_1', $paymentRequest, $order); + + self::assertSame('Blue T-Shirt', $common->description); + } + + public function testBuildCommonFields_withNoOrderItem_fallsBackToTheIntegrationDescription(): void + { + $this->urlGenerator->method('generate')->willReturn('https://shop.test/payplug/notify/abc'); + + $order = $this->createMock(OrderInterface::class); + $order->method('getNumber')->willReturn('00000042'); + $order->method('getItems')->willReturn(new ArrayCollection()); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getId')->willReturn(42); + + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $paymentRequest->method('getPayment')->willReturn($payment); + $paymentRequest->method('getHash')->willReturn(Uuid::v4()); + + $common = $this->builder->buildCommonFields('acct_123', 1000, 'eur', 'sub_ext_1', $paymentRequest, $order); + + self::assertNotNull($common->description); + self::assertNotSame('Blue T-Shirt', $common->description); + } + + public function testResolveFullNameForCardDetails_withABillingAddressFullName_returnsIt(): void + { + $billingAddress = $this->createMock(AddressInterface::class); + $billingAddress->method('getFullName')->willReturn('Jane Doe'); + $order = $this->createMock(OrderInterface::class); + $order->method('getBillingAddress')->willReturn($billingAddress); + + self::assertSame('Jane Doe', $this->builder->resolveFullNameForCardDetails($order)); + } + + public function testResolveFullNameForCardDetails_withNoBillingAddressFullName_fallsBackToTheCustomerFullName(): void + { + $billingAddress = $this->createMock(AddressInterface::class); + $billingAddress->method('getFullName')->willReturn(''); + $customer = $this->createMock(CustomerInterface::class); + $customer->method('getFullName')->willReturn('Jane Customer'); + $order = $this->createMock(OrderInterface::class); + $order->method('getBillingAddress')->willReturn($billingAddress); + $order->method('getCustomer')->willReturn($customer); + + self::assertSame('Jane Customer', $this->builder->resolveFullNameForCardDetails($order)); + } + + public function testResolveFullNameForCardDetails_withNoNameAvailableAnywhere_returnsNull(): void + { + $order = $this->createMock(OrderInterface::class); + + self::assertNull($this->builder->resolveFullNameForCardDetails($order)); + } +} diff --git a/tests/PHPUnit/Upc/PaymentCaptureOutcomeApplierTest.php b/tests/PHPUnit/Upc/PaymentCaptureOutcomeApplierTest.php new file mode 100644 index 00000000..2caf2440 --- /dev/null +++ b/tests/PHPUnit/Upc/PaymentCaptureOutcomeApplierTest.php @@ -0,0 +1,100 @@ +logger = $this->createMock(LoggerInterface::class); + $this->stateMachine = $this->createMock(StateMachineInterface::class); + $this->orderStateMutator = $this->createMock(IOrderStateMutator::class); + + $this->applier = new PaymentCaptureOutcomeApplier($this->logger, $this->stateMachine, $this->orderStateMutator); + } + + public function testFailPaymentRequest_logsSetsResponseDataAndAppliesFailTransition(): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getId')->willReturn(42); + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + + $this->logger->expects(self::once())->method('error') + ->with(self::stringContains('Hosted payment creation failed.'), self::anything()); + $paymentRequest->expects(self::once())->method('setResponseData')->with(['error' => 'boom']); + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL); + + $this->applier->failPaymentRequest($paymentRequest, $payment, new \LogicException('boom'), PaymentCaptureFlow::Hosted); + } + + public function testApplyOutcome_withRedirectHtml_storesItAndNeverAppliesOrderStateMutator(): void + { + $payment = $this->createMock(PaymentInterface::class); + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $output = new PaymentOutput(201, '{"id":"pay_1","execCode":"0001"}', null, '
3ds
', null); + + $paymentRequest->expects(self::once())->method('setResponseData')->with(['redirect_html' => '
3ds
']); + $this->orderStateMutator->expects(self::never())->method('apply'); + + $this->applier->applyOutcome($paymentRequest, $payment, $output); + } + + public function testApplyOutcome_withRedirectUrl_storesItAndNeverAppliesOrderStateMutator(): void + { + $payment = $this->createMock(PaymentInterface::class); + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $output = new PaymentOutput(201, '{"id":"pay_1"}', 'https://example.com/3ds', null, null); + + $paymentRequest->expects(self::once())->method('setResponseData')->with(['redirect_url' => 'https://example.com/3ds']); + $this->orderStateMutator->expects(self::never())->method('apply'); + + $this->applier->applyOutcome($paymentRequest, $payment, $output); + } + + public function testApplyOutcome_withDirectSuccessExecCode_appliesPaidOutcomeToOrderStateMutator(): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getId')->willReturn(42); + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $output = new PaymentOutput(201, '{"id":"pay_1","execCode":"0000"}', null, null, null); + + $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::PAID); + + $this->applier->applyOutcome($paymentRequest, $payment, $output); + } + + public function testApplyOutcome_withNoExecCodeInResponseBody_neverAppliesOrderStateMutator(): void + { + $payment = $this->createMock(PaymentInterface::class); + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $output = new PaymentOutput(201, '{"id":"pay_1"}', null, null, null); + + $this->orderStateMutator->expects(self::never())->method('apply'); + + $this->applier->applyOutcome($paymentRequest, $payment, $output); + } +} diff --git a/tests/PHPUnit/Upc/PayplugCardPersisterTest.php b/tests/PHPUnit/Upc/PayplugCardPersisterTest.php new file mode 100644 index 00000000..29699845 --- /dev/null +++ b/tests/PHPUnit/Upc/PayplugCardPersisterTest.php @@ -0,0 +1,235 @@ +payplugCardFactory = $this->createMock(FactoryInterface::class); + $this->payplugCardRepository = $this->createMock(RepositoryInterface::class); + $this->managerRegistry = $this->createMock(ManagerRegistry::class); + + $this->persister = new PayplugCardPersister($this->payplugCardFactory, $this->payplugCardRepository, $this->managerRegistry); + } + + private function paymentWithOrder(?CustomerInterface $customer): PaymentInterface&MockObject + { + $order = $this->createMock(OrderInterface::class); + $order->method('getCustomer')->willReturn($customer); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getOrder')->willReturn($order); + + return $payment; + } + + private function corePaymentMethod(bool $live = false): CorePaymentMethodInterface&MockObject + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getConfig')->willReturn(['live' => $live]); + + $method = $this->createMock(CorePaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn($gatewayConfig); + + return $method; + } + + public function testPersist_withNoCustomerOnTheOrder_doesNotPersistACard(): void + { + $payment = $this->paymentWithOrder(null); + + $this->payplugCardRepository->expects(self::never())->method('add'); + + $this->persister->persist('alias_1', $payment, $this->corePaymentMethod(), [], []); + } + + public function testPersist_withMethodNotACorePaymentMethod_doesNotPersistACard(): void + { + $payment = $this->paymentWithOrder($this->createMock(CustomerInterface::class)); + $method = $this->createMock(PaymentMethodInterface::class); + + $this->payplugCardRepository->expects(self::never())->method('add'); + + $this->persister->persist('alias_1', $payment, $method, [], []); + } + + public function testPersist_withAliasAlreadyStored_doesNotPersistADuplicate(): void + { + $payment = $this->paymentWithOrder($this->createMock(CustomerInterface::class)); + + $this->payplugCardRepository->method('findOneBy') + ->with(['externalId' => 'alias_1', 'isLive' => false]) + ->willReturn(new Card()); + $this->payplugCardRepository->expects(self::never())->method('add'); + + $this->persister->persist('alias_1', $payment, $this->corePaymentMethod(), [], []); + } + + public function testPersist_whenAddLosesARaceAgainstAConcurrentPersistCallForTheSameAlias_doesNotThrow(): void + { + $payment = $this->paymentWithOrder($this->createMock(CustomerInterface::class)); + + $card = new Card(); + $this->payplugCardFactory->method('createNew')->willReturn($card); + $this->payplugCardRepository->method('add')->with($card) + ->willThrowException($this->createMock(UniqueConstraintViolationException::class)); + $this->managerRegistry->expects(self::once())->method('resetManager'); + + $this->persister->persist('alias_1', $payment, $this->corePaymentMethod(), [], []); + + $this->addToAssertionCount(1); + } + + public function testPersist_withFetchedCardDataAvailable_takesPrecedenceOverDetailsFallback(): void + { + $customer = $this->createMock(CustomerInterface::class); + $payment = $this->paymentWithOrder($customer); + $method = $this->corePaymentMethod(live: true); + + $card = new Card(); + $this->payplugCardFactory->method('createNew')->willReturn($card); + $this->payplugCardRepository->expects(self::once())->method('add')->with($card); + + $this->persister->persist( + 'alias_1', + $payment, + $method, + [ + 'hosted_fields_selected_brand' => 'CB', + 'hosted_fields_last4' => '0000', + 'hosted_fields_expiration_month' => 1, + 'hosted_fields_expiration_year' => 2020, + 'hosted_fields_country' => 'DE', + ], + [ + 'brand' => 'VISA', + 'last4' => '4242', + 'expirationMonth' => 12, + 'expirationYear' => 2030, + ], + ); + + self::assertSame($customer, $card->getCustomer()); + self::assertSame('alias_1', $card->getExternalId()); + self::assertSame('VISA', $card->getBrand()); + self::assertSame('4242', $card->getLast4()); + self::assertSame(12, $card->getExpirationMonth()); + self::assertSame(2030, $card->getExpirationYear()); + // No card country field exists on the operation resource, so it always comes from $details. + self::assertSame('DE', $card->getCountryCode()); + self::assertTrue($card->isLive()); + self::assertSame($method, $card->getPaymentMethod()); + } + + public function testPersist_withNoFetchedCardDataAndNoDetails_usesEmptyDefaults(): void + { + $payment = $this->paymentWithOrder($this->createMock(CustomerInterface::class)); + + $card = new Card(); + $this->payplugCardFactory->method('createNew')->willReturn($card); + + $this->persister->persist('alias_1', $payment, $this->corePaymentMethod(), [], []); + + self::assertSame('', $card->getBrand()); + self::assertSame('', $card->getLast4()); + self::assertSame(0, $card->getExpirationMonth()); + self::assertSame(0, $card->getExpirationYear()); + self::assertSame('', $card->getCountryCode()); + } + + public function testPersist_withValidDetailsFallbackOnly_usesTheSanitizedValues(): void + { + $payment = $this->paymentWithOrder($this->createMock(CustomerInterface::class)); + + $card = new Card(); + $this->payplugCardFactory->method('createNew')->willReturn($card); + + // Computed relative to today rather than hardcoded, since sanitizeExpirationYear() rejects + // anything before the wall-clock current year — a fixed literal would eventually become a + // past year and start failing this test for no code-regression reason. + $futureYear = (int) (new \DateTimeImmutable())->format('Y') + 1; + + $this->persister->persist( + 'alias_1', + $payment, + $this->corePaymentMethod(), + [ + 'hosted_fields_last4' => '4242', + 'hosted_fields_expiration_month' => 12, + 'hosted_fields_expiration_year' => $futureYear, + 'hosted_fields_country' => 'fr', + ], + [], + ); + + self::assertSame('4242', $card->getLast4()); + self::assertSame(12, $card->getExpirationMonth()); + self::assertSame($futureYear, $card->getExpirationYear()); + // Uppercased regardless of the case the client submitted it in. + self::assertSame('FR', $card->getCountryCode()); + } + + /** + * @dataProvider malformedDetailsFallbackProvider + * + * @param mixed[] $details + */ + public function testPersist_withMalformedDetailsFallbackValues_discardsThemAsIfAbsent( + array $details, + string $getter, + string|int $defaultValue, + ): void { + $payment = $this->paymentWithOrder($this->createMock(CustomerInterface::class)); + + $card = new Card(); + $this->payplugCardFactory->method('createNew')->willReturn($card); + + $this->persister->persist('alias_1', $payment, $this->corePaymentMethod(), $details, []); + + self::assertSame($defaultValue, $card->$getter()); + } + + /** @return array */ + public static function malformedDetailsFallbackProvider(): array + { + return [ + 'brand not in the allowed list' => [['hosted_fields_selected_brand' => 'AMEX'], 'getBrand', ''], + 'last4 not 4 digits' => [['hosted_fields_last4' => '42'], 'getLast4', ''], + 'last4 not numeric' => [['hosted_fields_last4' => 'abcd'], 'getLast4', ''], + 'last4 with trailing newline' => [['hosted_fields_last4' => "4242\n"], 'getLast4', ''], + 'expiration month out of range' => [['hosted_fields_expiration_month' => 13], 'getExpirationMonth', 0], + 'expiration month zero' => [['hosted_fields_expiration_month' => 0], 'getExpirationMonth', 0], + 'expiration year in the past' => [['hosted_fields_expiration_year' => 2000], 'getExpirationYear', 0], + 'expiration year implausibly far ahead' => [['hosted_fields_expiration_year' => 9999], 'getExpirationYear', 0], + 'country not two letters' => [['hosted_fields_country' => 'FRA'], 'getCountryCode', ''], + 'country not alphabetic' => [['hosted_fields_country' => '12'], 'getCountryCode', ''], + 'country with trailing newline' => [['hosted_fields_country' => "fr\n"], 'getCountryCode', ''], + ]; + } +} diff --git a/tests/PHPUnit/Upc/SyliusUnifiedApiHttpClientTest.php b/tests/PHPUnit/Upc/SyliusUnifiedApiHttpClientTest.php index d91e4f19..d26d6393 100644 --- a/tests/PHPUnit/Upc/SyliusUnifiedApiHttpClientTest.php +++ b/tests/PHPUnit/Upc/SyliusUnifiedApiHttpClientTest.php @@ -34,7 +34,10 @@ public function testGet_sendsGetRequestWithHeaders(): void $response->method('getContent')->with(false)->willReturn('{"id":"pay_123"}'); $this->httpClient->expects(self::once())->method('request') - ->with('GET', 'https://api.payplug.com/payments/pay_123', ['headers' => ['Authorization' => 'Bearer jwt']]) + ->with('GET', 'https://api.payplug.com/payments/pay_123', [ + 'headers' => ['Authorization' => 'Bearer jwt'], + 'timeout' => 10, + ]) ->willReturn($response); $result = $this->adapter->get('https://api.payplug.com/payments/pay_123', ['Authorization' => 'Bearer jwt']); @@ -52,6 +55,7 @@ public function testPostJson_sendsJsonEncodedBody(): void ->with('POST', 'https://api.payplug.com/payments', [ 'json' => ['amount' => 1000], 'headers' => ['Authorization' => 'Bearer jwt'], + 'timeout' => 10, ]) ->willReturn($response); @@ -97,6 +101,7 @@ public function testGet_whenVerifyTlsDisabled_passesVerifyPeerAndVerifyHostFalse 'headers' => [], 'verify_peer' => false, 'verify_host' => false, + 'timeout' => 10, ]) ->willReturn($response); @@ -110,7 +115,7 @@ public function testGet_whenVerifyTlsEnabled_neverPassesVerifyPeerOrVerifyHost() $response->method('getContent')->with(false)->willReturn('{}'); $this->httpClient->expects(self::once())->method('request') - ->with('GET', 'https://api.payplug.com/payments/pay_123', ['headers' => []]) + ->with('GET', 'https://api.payplug.com/payments/pay_123', ['headers' => [], 'timeout' => 10]) ->willReturn($response); $this->adapter->get('https://api.payplug.com/payments/pay_123'); diff --git a/tests/PHPUnit/Upc/UnifiedApiHostedPaymentCreatorTest.php b/tests/PHPUnit/Upc/UnifiedApiPaymentCreatorTest.php similarity index 89% rename from tests/PHPUnit/Upc/UnifiedApiHostedPaymentCreatorTest.php rename to tests/PHPUnit/Upc/UnifiedApiPaymentCreatorTest.php index d8bd1e87..0805d8b3 100644 --- a/tests/PHPUnit/Upc/UnifiedApiHostedPaymentCreatorTest.php +++ b/tests/PHPUnit/Upc/UnifiedApiPaymentCreatorTest.php @@ -4,7 +4,7 @@ namespace Tests\PayPlug\SyliusPayPlugPlugin\PHPUnit\Upc; -use PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiHostedPaymentCreator; +use PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiPaymentCreator; use PayplugUnifiedCore\Auth\OAuth2Client; use PayplugUnifiedCore\Auth\TokenManager; use PayplugUnifiedCore\Contracts\IConfigurationRepository; @@ -23,7 +23,7 @@ * the injected IOAuthHttpClient (OAuth2 token endpoint) and ITokenCache (caching) — same pattern * as tests/PHPUnit/ApiClient/PayPlugApiClientFactoryTest.php. */ -final class UnifiedApiHostedPaymentCreatorTest extends TestCase +final class UnifiedApiPaymentCreatorTest extends TestCase { private IUnifiedApiHttpClient&MockObject $unifiedApiHttpClient; @@ -33,7 +33,7 @@ final class UnifiedApiHostedPaymentCreatorTest extends TestCase private IConfigurationRepository&MockObject $configurationRepository; - private UnifiedApiHostedPaymentCreator $creator; + private UnifiedApiPaymentCreator $creator; protected function setUp(): void { @@ -47,7 +47,7 @@ protected function setUp(): void $oauth2Client = new OAuth2Client($this->oauthHttpClient, 'https://api.payplug.com', '', '', 'https://www.payplug.com'); $tokenManager = new TokenManager($this->tokenCache, $oauth2Client); - $this->creator = new UnifiedApiHostedPaymentCreator( + $this->creator = new UnifiedApiPaymentCreator( $this->unifiedApiHttpClient, $tokenManager, $this->configurationRepository, @@ -69,7 +69,7 @@ public function testCreateHostedPayment_withValidCredentials_returnsTheOutput(): ]); $this->unifiedApiHttpClient->method('postJson')->willReturn(['status' => 201, 'body' => '{"id":"pay_1"}']); - $output = $this->creator->createHostedPayment($this->dto()); + $output = $this->creator->createPayment($this->dto()); self::assertSame(201, $output->status); self::assertNull($output->redirectUrl); @@ -83,7 +83,7 @@ public function testCreateHostedPayment_withPending3ds_extractsTheRedirectUrl(): 'body' => json_encode(['id' => 'pay_1', 'redirect' => ['url' => 'https://3ds.payplug.com/challenge']]), ]); - $output = $this->creator->createHostedPayment($this->dto()); + $output = $this->creator->createPayment($this->dto()); self::assertSame('https://3ds.payplug.com/challenge', $output->redirectUrl); } @@ -95,6 +95,6 @@ public function testCreateHostedPayment_onNon2xxResponse_throwsApiException(): v $this->expectException(ApiException::class); - $this->creator->createHostedPayment($this->dto()); + $this->creator->createPayment($this->dto()); } }