diff --git a/composer.json b/composer.json
index 2d93bb4d..1942f156 100755
--- a/composer.json
+++ b/composer.json
@@ -14,6 +14,7 @@
"ext-json": "*",
"giggsey/libphonenumber-for-php": "^8.12",
"payplug/payplug-php": "^4.0",
+ "payplug/unified-plugin-core": "dev-develop",
"php-http/message-factory": "^1.1",
"sylius/refund-plugin": "^2.0",
"sylius/sylius": "^2.0",
@@ -57,6 +58,12 @@
"webmozart/assert": "^1.8"
},
"prefer-stable": true,
+ "repositories": [
+ {
+ "type": "vcs",
+ "url": "https://github.com/payplug/unified-plugin-core.git"
+ }
+ ],
"autoload": {
"psr-4": {
"PayPlug\\SyliusPayPlugPlugin\\": "src/"
diff --git a/config/services.yaml b/config/services.yaml
index 7db6a5fe..28fce12c 100644
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -10,6 +10,12 @@ services:
bind:
Psr\Log\LoggerInterface: '@monolog.logger.payplug'
+ # PRE-3469: explicit alias, matching this file's convention for other interfaces below —
+ # relying on Symfony to auto-resolve a singly-implemented interface does not work for plain
+ # autowiring (confirmed: omitting this alias fails container compilation outright, since
+ # NotifyPaymentRequestHandler's #[AsMessageHandler] service is always instantiated).
+ PayplugUnifiedCore\Contracts\IOrderStateMutator: '@PayPlug\SyliusPayPlugPlugin\PaymentProcessing\PayplugOrderStateMutator'
+
PayPlug\SyliusPayPlugPlugin\Repository\PaymentRepositoryInterface:
class: PayPlug\SyliusPayPlugPlugin\Repository\PaymentRepository
parent: sylius.repository.payment
diff --git a/migrations/Version20260720100000.php b/migrations/Version20260720100000.php
new file mode 100644
index 00000000..bbe438c3
--- /dev/null
+++ b/migrations/Version20260720100000.php
@@ -0,0 +1,62 @@
+skipIf(
+ 'test' !== ($_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? null),
+ 'PRE-3469 spike-only schema, not applied outside the test environment.',
+ );
+
+ $this->addSql('CREATE TABLE payplug_operation (
+ id INT AUTO_INCREMENT NOT NULL,
+ operation_id VARCHAR(255) NOT NULL,
+ exec_code VARCHAR(255) NOT NULL,
+ outcome VARCHAR(255) NOT NULL,
+ amount INT NOT NULL,
+ order_id VARCHAR(255) NOT NULL,
+ treated TINYINT(1) NOT NULL,
+ treated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\',
+ created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\',
+ UNIQUE INDEX payplug_operation_id_unique (operation_id),
+ INDEX payplug_operation_order_id_idx (order_id),
+ PRIMARY KEY(id)
+ ) DEFAULT CHARACTER SET UTF8 COLLATE `UTF8_unicode_ci` ENGINE = InnoDB');
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->skipIf(
+ 'test' !== ($_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? null),
+ 'PRE-3469 spike-only schema, not applied outside the test environment.',
+ );
+
+ $this->addSql('DROP TABLE payplug_operation');
+ }
+}
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index b01264ac..7dc60db7 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -7,12 +7,16 @@
tests/PHPUnit
+
+ tests/PHPUnit/Spike/SpikeIntegrationTest.php
-
+
diff --git a/src/Command/Handler/NotifyPaymentRequestHandler.php b/src/Command/Handler/NotifyPaymentRequestHandler.php
index 9ea5147f..55fe5add 100644
--- a/src/Command/Handler/NotifyPaymentRequestHandler.php
+++ b/src/Command/Handler/NotifyPaymentRequestHandler.php
@@ -6,10 +6,14 @@
use Payplug\Resource\Payment;
use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface;
+use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface;
use PayPlug\SyliusPayPlugPlugin\Command\NotifyPaymentRequest;
use PayPlug\SyliusPayPlugPlugin\Handler\PaymentNotificationHandler;
use PayPlug\SyliusPayPlugPlugin\Handler\RefundNotificationHandler;
use PayPlug\SyliusPayPlugPlugin\PaymentProcessing\PaymentTransitionApplier;
+use PayplugUnifiedCore\Contracts\IOrderStateMutator;
+use PayplugUnifiedCore\Models\PaymentOutcome;
+use Psr\Log\LoggerInterface;
use Sylius\Abstraction\StateMachine\StateMachineInterface;
use Sylius\Bundle\PaymentBundle\Provider\PaymentRequestProviderInterface;
use Sylius\Component\Core\Model\PaymentInterface;
@@ -19,6 +23,21 @@
#[AsMessageHandler]
class NotifyPaymentRequestHandler
{
+ /**
+ * PRE-3469: additive translation from the PayPlug status vocabulary (the same one
+ * PaymentTransitionApplier already maps from) to UPC's PaymentOutcome vocabulary, for the
+ * real IOrderStateMutator call below. Statuses with no PaymentOutcome equivalent
+ * (e.g. STATUS_ABORTED/STATUS_CANCELED*, which PaymentTransitionApplier maps to
+ * TRANSITION_CANCEL) are intentionally absent here — they're skipped, never force-mapped.
+ *
+ * @var array
+ */
+ private const STATUS_TO_OUTCOME = [
+ PayPlugApiClientInterface::STATUS_CAPTURED => PaymentOutcome::PAID,
+ PayPlugApiClientInterface::STATUS_AUTHORIZED => PaymentOutcome::AUTHORIZED,
+ PayPlugApiClientInterface::FAILED => PaymentOutcome::FAILED,
+ ];
+
public function __construct(
private PaymentRequestProviderInterface $paymentRequestProvider,
private StateMachineInterface $stateMachine,
@@ -26,6 +45,8 @@ public function __construct(
private PaymentNotificationHandler $paymentNotificationHandler,
private RefundNotificationHandler $refundNotificationHandler,
private PaymentTransitionApplier $paymentTransitionApplier,
+ private IOrderStateMutator $orderStateMutator,
+ private LoggerInterface $logger,
) {}
public function __invoke(NotifyPaymentRequest $notifyPaymentRequest): void
@@ -67,6 +88,7 @@ public function __invoke(NotifyPaymentRequest $notifyPaymentRequest): void
$payment->setDetails($details->getArrayCopy());
if ($resource instanceof Payment) {
$this->paymentTransitionApplier->apply($payment);
+ $this->applyOrderStateMutator($payment);
}
$this->stateMachine->apply(
@@ -85,4 +107,44 @@ public function __invoke(NotifyPaymentRequest $notifyPaymentRequest): void
);
}
}
+
+ /**
+ * PRE-3469: additive real call site for PayplugOrderStateMutator. PaymentTransitionApplier
+ * has already applied the real transition above by the time this runs, so the mutator's own
+ * can()-guard makes this a no-op in the normal case — this exists to prove the contract works
+ * against a live webhook event, not to change behavior. Any failure here is caught and
+ * logged, never allowed to affect the primary notification flow above.
+ */
+ private function applyOrderStateMutator(PaymentInterface $payment): void
+ {
+ $details = $payment->getDetails(); // @phpstan-ignore-line - getDetails() return mixed
+ $status = $details['status'] ?? '';
+ $outcome = self::STATUS_TO_OUTCOME[$status] ?? null;
+ if (null === $outcome) {
+ return;
+ }
+
+ $order = $payment->getOrder();
+ if (null === $order) {
+ return;
+ }
+
+ // PayplugOrderStateMutator resolves the order's *last* payment internally (its contract
+ // is keyed by order ID, not payment ID). On a multi-payment order — e.g. a failed attempt
+ // followed by a retry — that could be a different payment than the one this webhook is
+ // actually about. Skip rather than risk transitioning the wrong payment.
+ if ($order->getLastPayment()?->getId() !== $payment->getId()) {
+ return;
+ }
+
+ try {
+ $this->orderStateMutator->apply((string) $order->getId(), $outcome); // @phpstan-ignore-line - ResourceInterface::getId() return mixed
+ } catch (\Throwable $e) {
+ $this->logger->warning('[PayPlug] PayplugOrderStateMutator additive call failed.', [
+ 'sylius_payment_id' => $payment->getId(),
+ 'outcome' => $outcome,
+ 'exception' => $e->getMessage(),
+ ]);
+ }
+ }
}
diff --git a/src/ConfigurationRepository/PayplugConfigurationRepository.php b/src/ConfigurationRepository/PayplugConfigurationRepository.php
new file mode 100644
index 00000000..5a207004
--- /dev/null
+++ b/src/ConfigurationRepository/PayplugConfigurationRepository.php
@@ -0,0 +1,123 @@
+activeClientConfig();
+ $value = $client[$key] ?? null;
+
+ return \is_string($value) ? $value : null;
+ }
+
+ public function set(string $key, string $value): void
+ {
+ $config = $this->gatewayConfig->getConfig();
+ $scope = $this->activeScope($config);
+ $client = $config[$scope] ?? [];
+ if (!\is_array($client)) {
+ $client = [];
+ }
+ $client[$key] = $value;
+ $config[$scope] = $client;
+
+ // Persisting $config is the caller's responsibility (Doctrine flush), same as every
+ // other GatewayConfigInterface mutation in this plugin (see UnifiedAuthenticationController).
+ $this->gatewayConfig->setConfig($config);
+ }
+
+ public function getClientId(): string
+ {
+ return $this->requireString('client_id');
+ }
+
+ public function getClientSecret(): string
+ {
+ return $this->requireString('client_secret');
+ }
+
+ public function getPublicKeyId(): string
+ {
+ return $this->get('public_key_id') ?? '';
+ }
+
+ public function getPublicKeyValue(): string
+ {
+ return $this->get('public_key_value') ?? '';
+ }
+
+ private function requireString(string $key): string
+ {
+ $value = $this->get($key);
+ if (null === $value || '' === $value) {
+ // Never interpolate the resolved *value* here, only the key name and factory name.
+ throw new ApiException(\sprintf(
+ 'Missing "%s" in gateway configuration "%s".',
+ $key,
+ $this->gatewayConfig->getFactoryName() ?? 'unknown',
+ ));
+ }
+
+ return $value;
+ }
+
+ /**
+ * @return array
+ */
+ private function activeClientConfig(): array
+ {
+ $config = $this->gatewayConfig->getConfig();
+ $client = $config[$this->activeScope($config)] ?? [];
+ if (!\is_array($client)) {
+ return [];
+ }
+
+ /** @var array $typedClient */
+ $typedClient = $client;
+
+ return $typedClient;
+ }
+
+ /**
+ * @param array $config
+ */
+ private function activeScope(array $config): string
+ {
+ return true === ($config['live'] ?? false) ? 'live_client' : 'test_client';
+ }
+}
diff --git a/src/DependencyInjection/PayPlugSyliusPayPlugExtension.php b/src/DependencyInjection/PayPlugSyliusPayPlugExtension.php
index d22ef359..e3aea36d 100644
--- a/src/DependencyInjection/PayPlugSyliusPayPlugExtension.php
+++ b/src/DependencyInjection/PayPlugSyliusPayPlugExtension.php
@@ -33,6 +33,35 @@ public function prepend(ContainerBuilder $container): void
$this->prependTwigExtension($container);
$this->prependDoctrineMigrations($container);
$this->prependMonologExtension($container);
+ $this->prependSpikeDoctrineMapping($container);
+ }
+
+ /**
+ * PRE-3469 spike only — registers src/Spike/Entity as a plain (non-Sylius-resource)
+ * Doctrine mapping so the spike's integration test can persist PayplugOperation for real.
+ * Not a `sylius_resource` on purpose: that would pull in grids/forms/routes this throwaway
+ * entity has no use for. Restricted to the `test` environment on purpose too — this is
+ * test-only scaffolding, it must never register Doctrine metadata for a throwaway entity in
+ * prod. Remove this method along with src/Spike/ once the spike is closed.
+ */
+ private function prependSpikeDoctrineMapping(ContainerBuilder $container): void
+ {
+ if (!$container->hasExtension('doctrine') || 'test' !== $container->getParameter('kernel.environment')) {
+ return;
+ }
+
+ $container->prependExtensionConfig('doctrine', [
+ 'orm' => [
+ 'mappings' => [
+ 'PayPlugSyliusPayPlugPluginSpike' => [
+ 'type' => 'attribute',
+ 'dir' => dirname(__DIR__) . '/Spike/Entity',
+ 'prefix' => 'PayPlug\SyliusPayPlugPlugin\Spike\Entity',
+ 'is_bundle' => false,
+ ],
+ ],
+ ],
+ ]);
}
private function prependTwigExtension(ContainerBuilder $container): void
diff --git a/src/PaymentProcessing/PayplugOrderStateMutator.php b/src/PaymentProcessing/PayplugOrderStateMutator.php
new file mode 100644
index 00000000..ebf0a688
--- /dev/null
+++ b/src/PaymentProcessing/PayplugOrderStateMutator.php
@@ -0,0 +1,78 @@
+
+ */
+ private const OUTCOME_TO_TRANSITION = [
+ PaymentOutcome::PAID => PaymentTransitions::TRANSITION_COMPLETE,
+ PaymentOutcome::AUTHORIZED => PaymentTransitions::TRANSITION_AUTHORIZE,
+ PaymentOutcome::CAPTURE_REQUIRED => PaymentTransitions::TRANSITION_PROCESS,
+ PaymentOutcome::REFUNDED => PaymentTransitions::TRANSITION_REFUND,
+ PaymentOutcome::FAILED => PaymentTransitions::TRANSITION_FAIL,
+ ];
+
+ public function __construct(
+ private readonly OrderRepositoryInterface $orderRepository,
+ private readonly StateMachineInterface $stateMachine,
+ private readonly EntityManagerInterface $entityManager,
+ ) {
+ }
+
+ public function apply(string $orderId, string $outcome): void
+ {
+ if (PaymentOutcome::THREE_DS_PENDING === $outcome) {
+ return;
+ }
+
+ $transition = self::OUTCOME_TO_TRANSITION[$outcome] ?? null;
+ if (null === $transition) {
+ throw new \InvalidArgumentException(\sprintf('No Symfony Workflow transition mapped for PaymentOutcome "%s".', $outcome));
+ }
+
+ $order = $this->orderRepository->findOrderById($orderId);
+ if (null === $order) {
+ throw new PaymentNotFoundException(\sprintf('No order found for id "%s".', $orderId));
+ }
+
+ $payment = $order->getLastPayment();
+ if (null === $payment) {
+ throw new PaymentNotFoundException(\sprintf('Order "%s" has no payment to mutate.', $orderId));
+ }
+
+ // Guarded exactly like the plugin's existing PaymentStateResolver::applyTransition():
+ // an out-of-order webhook retry landing on a payment already past this transition is a
+ // silent no-op rather than a thrown workflow exception.
+ if ($this->stateMachine->can($payment, PaymentTransitions::GRAPH, $transition)) {
+ $this->stateMachine->apply($payment, PaymentTransitions::GRAPH, $transition);
+ $this->entityManager->flush();
+ }
+ }
+}
diff --git a/src/Spike/Entity/PayplugOperation.php b/src/Spike/Entity/PayplugOperation.php
new file mode 100644
index 00000000..3f1f5c84
--- /dev/null
+++ b/src/Spike/Entity/PayplugOperation.php
@@ -0,0 +1,116 @@
+operationId = $operationId;
+ $this->execCode = $execCode;
+ $this->outcome = $outcome;
+ $this->amount = $amount;
+ $this->orderId = $orderId;
+ $this->createdAt = new \DateTimeImmutable();
+ }
+
+ public static function fromOperationData(OperationData $operationData): self
+ {
+ return new self(
+ $operationData->operationId,
+ $operationData->execCode,
+ $operationData->outcome,
+ $operationData->amount,
+ $operationData->orderId,
+ );
+ }
+
+ public function updateFromOperationData(OperationData $operationData): void
+ {
+ $this->execCode = $operationData->execCode;
+ $this->outcome = $operationData->outcome;
+ $this->amount = $operationData->amount;
+ }
+
+ public function toOperationData(): OperationData
+ {
+ return new OperationData($this->operationId, $this->execCode, $this->outcome, $this->amount, $this->orderId);
+ }
+
+ public function getOperationId(): string
+ {
+ return $this->operationId;
+ }
+
+ public function getOrderId(): string
+ {
+ return $this->orderId;
+ }
+
+ public function isTreated(): bool
+ {
+ return $this->treated;
+ }
+
+ public function markTreated(): void
+ {
+ $this->treated = true;
+ $this->treatedAt = new \DateTimeImmutable();
+ }
+}
diff --git a/src/Spike/SyliusPaymentRepository.php b/src/Spike/SyliusPaymentRepository.php
new file mode 100644
index 00000000..42f1b0ca
--- /dev/null
+++ b/src/Spike/SyliusPaymentRepository.php
@@ -0,0 +1,80 @@
+requireOperation(['orderId' => $orderId], \sprintf('No operation for order "%s".', $orderId))->toOperationData();
+ }
+
+ public function getByOperationId(string $operationId): OperationData
+ {
+ return $this->requireOperation(['operationId' => $operationId], \sprintf('No operation "%s".', $operationId))->toOperationData();
+ }
+
+ public function save(OperationData $operationData): void
+ {
+ $existing = $this->findOperation(['operationId' => $operationData->operationId]);
+
+ if (null === $existing) {
+ $this->entityManager->persist(PayplugOperation::fromOperationData($operationData));
+ } else {
+ $existing->updateFromOperationData($operationData);
+ }
+
+ $this->entityManager->flush();
+ }
+
+ public function markTreated(string $operationId): void
+ {
+ $operation = $this->requireOperation(['operationId' => $operationId], \sprintf('No operation "%s".', $operationId));
+ $operation->markTreated();
+ $this->entityManager->flush();
+ }
+
+ public function isTreated(string $operationId): bool
+ {
+ $operation = $this->findOperation(['operationId' => $operationId]);
+
+ return null !== $operation && $operation->isTreated();
+ }
+
+ /**
+ * @param array $criteria
+ */
+ private function requireOperation(array $criteria, string $notFoundMessage): PayplugOperation
+ {
+ $operation = $this->findOperation($criteria);
+ if (null === $operation) {
+ throw new PaymentNotFoundException($notFoundMessage);
+ }
+
+ return $operation;
+ }
+
+ /**
+ * @param array $criteria
+ */
+ private function findOperation(array $criteria): ?PayplugOperation
+ {
+ return $this->entityManager->getRepository(PayplugOperation::class)->findOneBy($criteria);
+ }
+}
diff --git a/src/Spike/VALIDATION.md b/src/Spike/VALIDATION.md
new file mode 100644
index 00000000..1d952226
--- /dev/null
+++ b/src/Spike/VALIDATION.md
@@ -0,0 +1,188 @@
+# PRE-3469 — Validation des contrats UPC contre Sylius
+
+`IOrderStateMutator`, `ITokenCache` et `IConfigurationRepository` ont été promus en implémentations
+réelles, branchées dans le code de production existant (voir `src/PaymentProcessing/PayplugOrderStateMutator.php`,
+`src/TokenCache/PayplugTokenCache.php`, `src/ConfigurationRepository/PayplugConfigurationRepository.php`).
+`IPaymentRepository` reste hors scope de ce ticket (lié à un Value Object fortement couplé à
+l'API Unifiée, non encore branché sur le plugin) — son squelette de preuve reste dans ce dossier,
+voir `SyliusPaymentRepository.php`.
+
+Couverture de tests, 3 niveaux :
+
+- **Unitaire** (`tests/PHPUnit/{PaymentProcessing,TokenCache,ConfigurationRepository}/*Test.php`
+ + `tests/PHPUnit/Command/Handler/NotifyPaymentRequestHandlerTest.php`) : mocks natifs PHPUnit
+ sur chaque collaborateur.
+- **Intégration réelle** (`SpikeIntegrationTest.php`, toujours dans ce dossier car c'est le seul
+ endroit du plugin qui boote un vrai kernel Sylius pour les tests) : kernel Sylius réellement
+ booté, vraie base MariaDB jetable, vrai state machine Symfony Workflow, vrai pool de cache
+ PSR-6, vraie persistance Doctrine — aucun mock. Couvre les 4 contrats, y compris
+ `IPaymentRepository`.
+- Les deux suites tournent avec `composer install`, `vendor/bin/phpunit` une fois la base de
+ test créée — voir section Câblage.
+
+**Verdict global : aucune friction bloquante restante.** Une friction bloquante a été trouvée et
+corrigée pendant ce rework (voir « Friction bloquante corrigée » ci-dessous), plus deux notes non
+bloquantes (aucune ne remet en cause la forme des interfaces).
+
+## Friction bloquante corrigée : `payplug/unified-plugin-core` en `require-dev`
+
+`SyliusOrderStateMutator`/`SyliusTokenCache`/`SyliusConfigurationRepository` (les squelettes
+d'origine) implémentaient directement les interfaces UPC depuis `src/Spike/`, avec
+`payplug/unified-plugin-core` en `require-dev` uniquement. Tant que ce code restait isolé et
+jamais référencé par une classe de production, ça ne posait pas de problème. Mais brancher
+`PayplugOrderStateMutator` dans `NotifyPaymentRequestHandler` — une classe systématiquement
+chargée, y compris en production — change la donne : un vrai `composer install --no-dev` chez un
+marchand n'installerait pas `unified-plugin-core`, et charger une classe qui `implements` une
+interface inexistante est une erreur PHP fatale, pas un échec silencieux.
+
+**Fix** : `payplug/unified-plugin-core` est passé en dépendance `require` (toujours pinné sur
+`dev-develop`, même repository VCS). Documenté ici plutôt que traité comme un simple détail de
+`composer.json`, parce que ça anticipe une partie de ce que PRE-3563 (la vraie dépendance de
+production pour l'OAuth) devait poser — voir section Câblage plus bas pour ce qui reste à faire
+pour PRE-3563.
+
+## IOrderStateMutator — réel : `PayplugOrderStateMutator.php`
+
+Mapping validé : `PaymentOutcome::PAID/AUTHORIZED/CAPTURE_REQUIRED/REFUNDED/FAILED` →
+`PaymentTransitions::TRANSITION_COMPLETE/AUTHORIZE/PROCESS/REFUND/FAIL` sur le graphe
+`sylius_payment`, via `StateMachineInterface::can()/apply()` — exactement le pattern déjà utilisé
+par `PaymentStateResolver::applyTransition()` dans le plugin actuel.
+
+`THREE_DS_PENDING` confirmé sans transition : l'implémentation retourne immédiatement sans
+toucher au state machine, la commande reste `new` jusqu'au webhook — conforme au résultat attendu
+du ticket.
+
+**Branché en production** : appel additif dans `NotifyPaymentRequestHandler`, juste après le
+`PaymentTransitionApplier::apply($payment)` existant, une fois la transition déjà appliquée par
+ce dernier. Le statut PayPlug déjà connu à cet endroit (`$payment->getDetails()['status']`) est
+traduit en `PaymentOutcome` (seuls `STATUS_CAPTURED`/`STATUS_AUTHORIZED`/`FAILED` ont un
+équivalent — `STATUS_ABORTED`/`STATUS_CANCELED*`, mappés par `PaymentTransitionApplier` sur
+`TRANSITION_CANCEL`, n'ont pas d'équivalent `PaymentOutcome` et sont donc volontairement ignorés,
+pas forcés). L'appel est protégé par un `try/catch` qui journalise sans jamais propager : la
+transition ayant déjà été appliquée par `PaymentTransitionApplier`, le garde-fou `can()` du
+mutateur en fait un no-op silencieux dans le cas normal ; ce câblage prouve que le contrat
+fonctionne contre un webhook réel sans remplacer ni risquer le flux existant.
+
+**Note (non bloquante)** : le contrat prend un `orderId`, mais la transition Symfony Workflow vit
+sur le sous-objet `Payment` de la commande (`Order::getLastPayment()`), pas sur l'`Order`
+lui-même. Un hop supplémentaire Order → Payment est donc nécessaire côté adaptateur Sylius — ce
+que WooCommerce n'aurait pas besoin de faire. C'est le bon endroit pour cette différence (dans
+l'adaptateur, pas dans le contrat CMS-agnostique).
+
+**Note annexe (mécanique, pas conceptuelle)** : le plugin a aujourd'hui deux chemins de
+production distincts qui appliquent des transitions Symfony Workflow sur un paiement —
+`PaymentTransitionApplier` (webhooks/status, celui utilisé ci-dessus) et `PaymentStateResolver`
+(réconciliation CLI, `payplug:update-payment-state`). Ce ticket ne les unifie pas ; ça reste deux
+implémentations parallèles du même idiome `can()`/`apply()`.
+
+## ITokenCache — réel : `PayplugTokenCache.php`
+
+Aucune friction. `get`/`set`/`delete` se posent 1:1 sur `Psr\Cache\CacheItemPoolInterface::getItem/
+save/deleteItem`, exactement comme le docblock de l'interface le prévoyait déjà. Le pool par
+défaut de Sylius (`cache.app`, `Symfony\Component\Cache\Adapter\AdapterInterface`) satisfait déjà
+`CacheItemPoolInterface`, aliasé nativement par FrameworkBundle — filesystem, APCu ou Redis selon
+le déploiement, sans changement de code côté `PayplugTokenCache`.
+
+**Cible confirmée par le ticket** : `ITokenCache` cible le cache du token JWT OAuth
+(authentification via le SDK PayPlug), pas le stockage carte/one-click — la sauvegarde de carte
+est une entité Doctrine permanente (`Card`), sans aucun cache impliqué.
+
+**Validé par un test d'intégration réel, pas par un appel de production** : le seul analogue de
+production existant, `PayPlugApiClientFactory::getTokenForGatewayConfig()`, met en cache le token
+OAuth qui conditionne l'authentification de *toutes* les gateways du plugin. Remplacer sa logique
+de cache inline aurait un risque de régression disproportionné par rapport à l'objectif de
+validation de ce ticket ; `PayplugTokenCache` reste donc validé par un test d'intégration contre
+un vrai pool PSR-6 (`SpikeIntegrationTest::testTokenCache_realCachePool_roundTripsThroughRealAdapter`),
+sans point d'entrée de production.
+
+## IConfigurationRepository — réel : `PayplugConfigurationRepository.php`
+
+`GatewayConfigInterface::getConfig()` scope les credentials par `PaymentMethod` *et* par mode
+live/test (`config['live_client']` vs `config['test_client']`, sélectionné par `config['live']` —
+même pattern que `PayPlugApiClientFactory::getTokenForGatewayConfig()` existant). Une instance de
+`PayplugConfigurationRepository` doit donc être construite par `GatewayConfigInterface` (donc par
+`PaymentMethod`), pas partagée comme service unique — c'est une factory, pas un singleton. Ne
+remet pas en cause le contrat, mais à garder en tête si le futur client Unified API suppose "un
+repository = un marchand".
+
+**Point positif** : Sylius expose déjà un `GatewayConfigEncrypter` (expérimental) qui chiffre au
+repos l'intégralité du tableau `getConfig()` — si branché, `CLIENT_SECRET` en bénéficie
+gratuitement. Ce que `PayplugConfigurationRepository` doit garantir lui-même, c'est qu'un secret
+déchiffré ne fuite jamais dans un message de log ou d'exception : `requireString()` n'interpole
+jamais que le nom de la clé, jamais sa valeur.
+
+**Friction non bloquante, résolue par un choix explicite** : `getPublicKeyId()`/
+`getPublicKeyValue()` n'ont aucun équivalent de production — le plugin n'a aucune notion de clé
+publique Hosted Fields aujourd'hui (`grep` sur `public_key`/`PUBLIC_KEY` dans `src/` ne retourne
+rien en dehors de ce fichier). Plutôt que de lever une exception comme `getClientId()`/
+`getClientSecret()`, ces deux méthodes renvoient une chaîne vide tant qu'aucun code de production
+n'écrit ces clés — le contrat reste implémentable de bout en bout, prêt pour quand Hosted Fields
+sera construit.
+
+## IPaymentRepository — hors scope, squelette inchangé : `SyliusPaymentRepository.php` + `Entity/PayplugOperation.php`
+
+Hors scope de ce ticket (lié à un Value Object fortement couplé à l'API Unifiée, non encore
+branché sur le plugin) — reste un squelette de preuve, inchangé depuis la version précédente de
+ce document.
+
+**Constat principal du ticket, confirmé** : le plugin actuel ne stocke pas `OperationData` de
+façon normalisée. L'id Payplug est aujourd'hui écrit dans le JSON de `Payment::details` et
+retrouvé via `LIKE '%id%'` (`PaymentRepository::findOneByPayPlugPaymentId`). Ça fonctionne pour ce
+seul lookup, mais ne peut pas porter `markTreated()`/`isTreated()` (il faut un flag d'idempotence
+indexé) ni `getByOperationId()` proprement (il faut une colonne indexée, pas une recherche de
+sous-chaîne dans un blob sérialisé).
+
+Le squelette introduit donc une nouvelle table `payplug_operation`, sans aucune dépendance au SDK
+`payplug/payplug-php` — juste `OperationData` en entrée/sortie. C'est un vrai changement de schéma
+(nouvelle table), pas une extension de l'existant — à traiter comme tel dans le chiffrage du futur
+ticket de production.
+
+**Note annexe (mécanique, pas conceptuelle)** : `PayplugOperation` vit hors de `src/Entity/` et
+n'est pas un `sylius_resource` dans `config/resources.yaml` (contrairement à `Card` et
+`RefundHistory`) — enregistrer un `sylius_resource` a été essayé pour le test d'intégration mais
+échoue tant que la classe n'implémente pas `Sylius\Resource\Model\ResourceInterface` (grilles/
+formulaires/routes Sylius, inutiles pour cette entité de pure persistance). Le mapping Doctrine
+réel utilisé pour le test d'intégration passe donc par un `doctrine.orm.mappings` prepend dans
+`PayPlugSyliusPayPlugExtension::prependSpikeDoctrineMapping()` — méthode explicitement marquée
+PRE-3469-only, restreinte à `kernel.environment === 'test'` (jamais en prod), à retirer avec
+`src/Spike/` si le spike est abandonné.
+
+## Câblage (pour rejouer les tests)
+
+- `composer.json` : `repositories` avec un repository `vcs` vers
+ `https://github.com/payplug/unified-plugin-core.git` + `payplug/unified-plugin-core:
+ "dev-develop"` désormais en dépendance `require` (voir « Friction bloquante corrigée »
+ ci-dessus) — fonctionne pour n'importe qui ayant accès au repo GitHub (même accès que pour ce
+ repo), et en CI via l'étape `Composer - Github Auth` déjà configurée dans
+ `payplug/template-ci`. Une première version utilisait un repository `path` local
+ (`../../unified-plugin-core`) : ça ne marche que sur une machine avec les deux repos clonés
+ côte à côte, cassait `composer install` pour tout le monde d'autre — corrigé. Nécessite que le
+ pin exact `symfony/polyfill-mbstring: 1.28.0` d'UPC ait été relâché en `^1.28` (corrigé dans
+ unified-plugin-core suite à ce spike — il bloquait tout `composer install` aux côtés de
+ `sylius/sylius ^2.0`, qui exige `^1.31`). Contrepartie connue : ça ajoute une résolution réseau
+ vers GitHub à chaque install (léger coût CI/dev) — pas d'alternative disponible ici (pas de
+ Packagist privé configuré dans cet org pour `payplug/unified-plugin-core`, et un repository
+ `path` est exclu pour la raison ci-dessus). Ce compromis disparaît quand PRE-3563 posera la
+ vraie dépendance de production définitive (OAuth réel contre UPC).
+- `phpunit.xml.dist` : `KERNEL_CLASS_PATH` renommé en `KERNEL_CLASS` — la variable que Symfony lit
+ réellement pour `KernelTestCase::bootKernel()`. Sans ce fix, aucun test à base de kernel
+ (fonctionnel/intégration) n'a jamais pu tourner dans ce repo — un bug de config préexistant,
+ invisible tant qu'aucun test de ce type n'existait.
+- `tests/TestApplication/.env.test.local` (gitignored) : `DATABASE_URL` pointée vers un conteneur
+ MariaDB jetable (`docker run --name payplug-pre3469-mysql -e MYSQL_ALLOW_EMPTY_PASSWORD=yes -p
+ 3309:3306 mariadb:latest`) — le MySQL système de la machine refuse `root@127.0.0.1` sans mot de
+ passe. `.env.local` seul ne suffit pas : Symfony l'ignore délibérément quand `APP_ENV=test`
+ (reproductibilité des tests) — il faut `.env.test.local` spécifiquement.
+- Base créée/migrée avec les commandes standard du Makefile (`doctrine:database:create`,
+ `doctrine:migration:migrate`, `sylius:payment:generate-key`, `sylius:fixtures:load`) — la table
+ `payplug_operation` est créée automatiquement par `migrations/Version20260720100000.php`, plus
+ besoin d'aucune étape manuelle.
+
+**Isolation des tests** : `SpikeIntegrationTest` ne tourne dans aucune transaction annulée en fin
+de test (pas de `DAMADoctrineTestBundle`). Une première version cherchait un paiement de fixture
+déjà à l'état `new` — ça fonctionne une fois, mais une commande réelle ne repasse jamais à `new`
+une fois transitionnée, donc ce pool fini s'épuise au fil des ré-exécutions contre la même base
+jetable. Corrigé en créant un `Payment` frais sur une commande de fixture existante à chaque test
+(`createOrderWithFreshPayment()`) plutôt que d'en chercher un dans un état donné — vérifié stable
+sur 3 exécutions consécutives. Une vraie suite (non-spike) voudrait un rollback transactionnel
+entre tests plutôt que ce contournement.
diff --git a/src/TokenCache/PayplugTokenCache.php b/src/TokenCache/PayplugTokenCache.php
new file mode 100644
index 00000000..815cb970
--- /dev/null
+++ b/src/TokenCache/PayplugTokenCache.php
@@ -0,0 +1,55 @@
+ cache.app alias) already satisfies
+ * CacheItemPoolInterface, regardless of whether that pool is backed by the filesystem, APCu, or
+ * Redis in a given deployment. This class is validated by a real-infrastructure integration
+ * test (see SpikeIntegrationTest) rather than wired into a live request path: the one existing
+ * production caller of an OAuth token cache, PayPlugApiClientFactory::getTokenForGatewayConfig(),
+ * gates authentication for every PayPlug gateway — replacing its inline caching logic here would
+ * carry more regression risk than this ticket's validation goal justifies.
+ */
+final class PayplugTokenCache implements ITokenCache
+{
+ public function __construct(private readonly CacheItemPoolInterface $cache)
+ {
+ }
+
+ public function get(string $key): ?string
+ {
+ $item = $this->cache->getItem($key);
+ if (!$item->isHit()) {
+ return null;
+ }
+
+ $value = $item->get();
+
+ return \is_string($value) ? $value : null;
+ }
+
+ public function set(string $key, string $value, int $ttlSeconds): void
+ {
+ $item = $this->cache->getItem($key);
+ $item->set($value);
+ $item->expiresAfter($ttlSeconds);
+ $this->cache->save($item);
+ }
+
+ public function delete(string $key): void
+ {
+ $this->cache->deleteItem($key);
+ }
+}
diff --git a/tests/PHPUnit/Command/Handler/NotifyPaymentRequestHandlerTest.php b/tests/PHPUnit/Command/Handler/NotifyPaymentRequestHandlerTest.php
new file mode 100644
index 00000000..257a9157
--- /dev/null
+++ b/tests/PHPUnit/Command/Handler/NotifyPaymentRequestHandlerTest.php
@@ -0,0 +1,215 @@
+paymentRequestProvider = $this->createMock(PaymentRequestProviderInterface::class);
+ $this->stateMachine = $this->createMock(StateMachineInterface::class);
+ $this->apiClientFactory = $this->createMock(PayPlugApiClientFactoryInterface::class);
+ $this->paymentNotificationHandler = $this->createMock(PaymentNotificationHandler::class);
+ $this->refundNotificationHandler = $this->createMock(RefundNotificationHandler::class);
+ $this->paymentTransitionApplier = $this->createMock(PaymentTransitionApplier::class);
+ $this->orderStateMutator = $this->createMock(IOrderStateMutator::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+
+ $this->handler = new NotifyPaymentRequestHandler(
+ $this->paymentRequestProvider,
+ $this->stateMachine,
+ $this->apiClientFactory,
+ $this->paymentNotificationHandler,
+ $this->refundNotificationHandler,
+ $this->paymentTransitionApplier,
+ $this->orderStateMutator,
+ $this->logger,
+ );
+ }
+
+ public function testInvoke_knownStatus_callsOrderStateMutatorWithMappedOutcome(): void
+ {
+ $this->prepareNormalFlow(PayPlugApiClientInterface::STATUS_CAPTURED, 42);
+
+ $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::PAID);
+
+ $this->handler->__invoke(new NotifyPaymentRequest('hash'));
+ }
+
+ public function testInvoke_unknownStatus_doesNotCallOrderStateMutator(): void
+ {
+ $this->prepareNormalFlow(PayPlugApiClientInterface::STATUS_CANCELED, 42);
+
+ $this->orderStateMutator->expects(self::never())->method('apply');
+
+ $this->handler->__invoke(new NotifyPaymentRequest('hash'));
+ }
+
+ public function testInvoke_orderStateMutatorThrows_isCaughtAndDoesNotPreventCompletion(): void
+ {
+ $this->prepareNormalFlow(PayPlugApiClientInterface::STATUS_CAPTURED, 42);
+
+ $this->orderStateMutator->method('apply')->willThrowException(new \RuntimeException('boom'));
+ $this->logger->expects(self::once())->method('warning');
+
+ // The outer flow must still complete normally: TRANSITION_COMPLETE, not TRANSITION_FAIL.
+ $this->stateMachine->expects(self::once())->method('apply')->with(
+ self::isInstanceOf(PaymentRequestInterface::class),
+ PaymentRequestTransitions::GRAPH,
+ PaymentRequestTransitions::TRANSITION_COMPLETE,
+ );
+
+ $this->handler->__invoke(new NotifyPaymentRequest('hash'));
+ }
+
+ public function testInvoke_paymentAlreadyCompleted_shortCircuitsAndSkipsOrderStateMutator(): void
+ {
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getState')->willReturn(PaymentInterface::STATE_COMPLETED);
+ $method = $this->createMock(PaymentMethodInterface::class);
+ $payment->method('getMethod')->willReturn($method);
+
+ $paymentRequest = $this->createMock(PaymentRequestInterface::class);
+ $paymentRequest->method('getPayment')->willReturn($payment);
+ $paymentRequest->method('getPayload')->willReturn(['http_request' => ['content' => '{}']]);
+ $this->paymentRequestProvider->method('provide')->willReturn($paymentRequest);
+
+ $client = $this->createMock(PayPlugApiClientInterface::class);
+ $resource = $this->createMock(PayplugResourcePayment::class);
+ $client->method('treat')->willReturn($resource);
+ $this->apiClientFactory->method('createForPaymentMethod')->willReturn($client);
+
+ $this->paymentTransitionApplier->expects(self::never())->method('apply');
+ $this->orderStateMutator->expects(self::never())->method('apply');
+ $this->stateMachine->expects(self::once())->method('apply')->with(
+ $paymentRequest,
+ PaymentRequestTransitions::GRAPH,
+ PaymentRequestTransitions::TRANSITION_COMPLETE,
+ );
+
+ $this->handler->__invoke(new NotifyPaymentRequest('hash'));
+ }
+
+ public function testInvoke_invalidPayload_failsWithoutCallingOrderStateMutator(): void
+ {
+ $payment = $this->createMock(PaymentInterface::class);
+ $paymentRequest = $this->createMock(PaymentRequestInterface::class);
+ $paymentRequest->method('getPayment')->willReturn($payment);
+ $paymentRequest->method('getPayload')->willReturn(['http_request' => ['content' => '']]);
+ $this->paymentRequestProvider->method('provide')->willReturn($paymentRequest);
+
+ $paymentRequest->expects(self::once())->method('setResponseData');
+ $this->orderStateMutator->expects(self::never())->method('apply');
+ $this->stateMachine->expects(self::once())->method('apply')->with(
+ $paymentRequest,
+ PaymentRequestTransitions::GRAPH,
+ PaymentRequestTransitions::TRANSITION_FAIL,
+ );
+
+ $this->handler->__invoke(new NotifyPaymentRequest('hash'));
+ }
+
+ /**
+ * Regression test for a multi-payment order: PayplugOrderStateMutator resolves the order's
+ * *last* payment internally (its contract is keyed by order ID, not payment ID). If a
+ * different payment (e.g. an earlier failed attempt) is the order's last payment, the
+ * additive call must be skipped rather than risk transitioning the wrong payment.
+ */
+ public function testInvoke_paymentIsNotOrdersLastPayment_doesNotCallOrderStateMutator(): void
+ {
+ $otherPayment = $this->createMock(PaymentInterface::class);
+ $otherPayment->method('getId')->willReturn(999);
+
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getId')->willReturn(42);
+ $order->method('getLastPayment')->willReturn($otherPayment);
+
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getState')->willReturn(PaymentInterface::STATE_NEW);
+ $payment->method('getDetails')->willReturn(['status' => PayPlugApiClientInterface::STATUS_CAPTURED]);
+ $payment->method('getOrder')->willReturn($order);
+ $payment->method('getId')->willReturn(1);
+ $method = $this->createMock(PaymentMethodInterface::class);
+ $payment->method('getMethod')->willReturn($method);
+
+ $paymentRequest = $this->createMock(PaymentRequestInterface::class);
+ $paymentRequest->method('getPayment')->willReturn($payment);
+ $paymentRequest->method('getPayload')->willReturn(['http_request' => ['content' => '{}']]);
+ $this->paymentRequestProvider->method('provide')->willReturn($paymentRequest);
+
+ $client = $this->createMock(PayPlugApiClientInterface::class);
+ $resource = $this->createMock(PayplugResourcePayment::class);
+ $client->method('treat')->willReturn($resource);
+ $this->apiClientFactory->method('createForPaymentMethod')->willReturn($client);
+
+ $this->orderStateMutator->expects(self::never())->method('apply');
+
+ $this->handler->__invoke(new NotifyPaymentRequest('hash'));
+ }
+
+ private function prepareNormalFlow(string $status, int $orderId): void
+ {
+ $payment = $this->createMock(PaymentInterface::class);
+ $payment->method('getState')->willReturn(PaymentInterface::STATE_NEW);
+ $payment->method('getDetails')->willReturn(['status' => $status]);
+ $payment->method('getId')->willReturn(1);
+ $method = $this->createMock(PaymentMethodInterface::class);
+ $payment->method('getMethod')->willReturn($method);
+
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getId')->willReturn($orderId);
+ $order->method('getLastPayment')->willReturn($payment);
+ $payment->method('getOrder')->willReturn($order);
+
+ $paymentRequest = $this->createMock(PaymentRequestInterface::class);
+ $paymentRequest->method('getPayment')->willReturn($payment);
+ $paymentRequest->method('getPayload')->willReturn(['http_request' => ['content' => '{}']]);
+ $this->paymentRequestProvider->method('provide')->willReturn($paymentRequest);
+
+ $client = $this->createMock(PayPlugApiClientInterface::class);
+ $resource = $this->createMock(PayplugResourcePayment::class);
+ $client->method('treat')->willReturn($resource);
+ $this->apiClientFactory->method('createForPaymentMethod')->willReturn($client);
+ }
+}
diff --git a/tests/PHPUnit/ConfigurationRepository/PayplugConfigurationRepositoryTest.php b/tests/PHPUnit/ConfigurationRepository/PayplugConfigurationRepositoryTest.php
new file mode 100644
index 00000000..34c20686
--- /dev/null
+++ b/tests/PHPUnit/ConfigurationRepository/PayplugConfigurationRepositoryTest.php
@@ -0,0 +1,157 @@
+gatewayConfig = $this->createMock(GatewayConfigInterface::class);
+ }
+
+ public function testGet_liveMode_readsFromLiveClientScope(): void
+ {
+ $this->gatewayConfig->method('getConfig')->willReturn([
+ 'live' => true,
+ 'live_client' => ['client_id' => 'live-id'],
+ 'test_client' => ['client_id' => 'test-id'],
+ ]);
+
+ self::assertSame('live-id', $this->repository()->get('client_id'));
+ }
+
+ public function testGet_testMode_readsFromTestClientScope(): void
+ {
+ $this->gatewayConfig->method('getConfig')->willReturn([
+ 'live' => false,
+ 'live_client' => ['client_id' => 'live-id'],
+ 'test_client' => ['client_id' => 'test-id'],
+ ]);
+
+ self::assertSame('test-id', $this->repository()->get('client_id'));
+ }
+
+ public function testGet_missingKey_returnsNull(): void
+ {
+ $this->gatewayConfig->method('getConfig')->willReturn(['live' => true, 'live_client' => []]);
+
+ self::assertNull($this->repository()->get('client_id'));
+ }
+
+ public function testSet_writesIntoActiveScopeAndPersistsWholeConfig(): void
+ {
+ $this->gatewayConfig->method('getConfig')->willReturn([
+ 'live' => true,
+ 'live_client' => ['client_id' => 'old-id'],
+ 'test_client' => ['client_id' => 'test-id'],
+ ]);
+
+ $this->gatewayConfig->expects(self::once())->method('setConfig')->with([
+ 'live' => true,
+ 'live_client' => ['client_id' => 'new-id'],
+ 'test_client' => ['client_id' => 'test-id'],
+ ]);
+
+ $this->repository()->set('client_id', 'new-id');
+ }
+
+ /**
+ * @dataProvider provideRequiredCredentialGetters
+ */
+ public function testRequiredCredentialGetters_present_returnValue(string $method, string $configKey): void
+ {
+ $this->gatewayConfig->method('getConfig')->willReturn([
+ 'live' => true,
+ 'live_client' => [$configKey => 'the-value'],
+ ]);
+
+ self::assertSame('the-value', $this->repository()->{$method}());
+ }
+
+ /**
+ * @dataProvider provideRequiredCredentialGetters
+ *
+ * The exception message must name the missing key and the factory, never a credential
+ * value — there is nothing sensitive to redact here since the value is simply absent, but
+ * this locks the message shape so a future edit can't start interpolating $value instead.
+ */
+ public function testRequiredCredentialGetters_missing_throwsApiExceptionWithoutLeakingSecrets(
+ string $method,
+ string $configKey,
+ ): void
+ {
+ $this->gatewayConfig->method('getConfig')->willReturn(['live' => true, 'live_client' => []]);
+ $this->gatewayConfig->method('getFactoryName')->willReturn('payplug');
+
+ try {
+ $this->repository()->{$method}();
+ self::fail('Expected ApiException to be thrown.');
+ } catch (ApiException $exception) {
+ self::assertSame(\sprintf('Missing "%s" in gateway configuration "payplug".', $configKey), $exception->getMessage());
+ }
+ }
+
+ /**
+ * @return array
+ */
+ public static function provideRequiredCredentialGetters(): array
+ {
+ return [
+ 'clientId' => ['getClientId', 'client_id'],
+ 'clientSecret' => ['getClientSecret', 'client_secret'],
+ ];
+ }
+
+ /**
+ * @dataProvider provideOptionalCredentialGetters
+ */
+ public function testOptionalCredentialGetters_present_returnValue(string $method, string $configKey): void
+ {
+ $this->gatewayConfig->method('getConfig')->willReturn([
+ 'live' => true,
+ 'live_client' => [$configKey => 'the-value'],
+ ]);
+
+ self::assertSame('the-value', $this->repository()->{$method}());
+ }
+
+ /**
+ * getPublicKeyId()/getPublicKeyValue() have no production writer yet (Hosted Fields isn't
+ * built) — unlike client_id/client_secret, a missing value must not throw, or the contract
+ * would be unimplementable until a future ticket adds that writer.
+ *
+ * @dataProvider provideOptionalCredentialGetters
+ */
+ public function testOptionalCredentialGetters_missing_returnEmptyString(string $method): void
+ {
+ $this->gatewayConfig->method('getConfig')->willReturn(['live' => true, 'live_client' => []]);
+
+ self::assertSame('', $this->repository()->{$method}());
+ }
+
+ /**
+ * @return array
+ */
+ public static function provideOptionalCredentialGetters(): array
+ {
+ return [
+ 'publicKeyId' => ['getPublicKeyId', 'public_key_id'],
+ 'publicKeyValue' => ['getPublicKeyValue', 'public_key_value'],
+ ];
+ }
+
+ private function repository(): PayplugConfigurationRepository
+ {
+ return new PayplugConfigurationRepository($this->gatewayConfig);
+ }
+}
diff --git a/tests/PHPUnit/PaymentProcessing/PayplugOrderStateMutatorTest.php b/tests/PHPUnit/PaymentProcessing/PayplugOrderStateMutatorTest.php
new file mode 100644
index 00000000..d4409908
--- /dev/null
+++ b/tests/PHPUnit/PaymentProcessing/PayplugOrderStateMutatorTest.php
@@ -0,0 +1,127 @@
+orderRepository = $this->createMock(OrderRepositoryInterface::class);
+ $this->stateMachine = $this->createMock(StateMachineInterface::class);
+ $this->entityManager = $this->createMock(EntityManagerInterface::class);
+
+ $this->mutator = new PayplugOrderStateMutator($this->orderRepository, $this->stateMachine, $this->entityManager);
+ }
+
+ /**
+ * THREE_DS_PENDING must stay a no-op — the order remains `new` until the async webhook
+ * resolves it. Neither the order repository nor the state machine should even be touched.
+ */
+ public function testApply_threeDsPending_isNoOp(): void
+ {
+ $this->orderRepository->expects(self::never())->method('findOrderById');
+ $this->stateMachine->expects(self::never())->method('apply');
+
+ $this->mutator->apply('order-1', PaymentOutcome::THREE_DS_PENDING);
+ }
+
+ /**
+ * @dataProvider provideMappedOutcomes
+ */
+ public function testApply_mappedOutcome_appliesExpectedTransition(string $outcome, string $expectedTransition): void
+ {
+ $order = $this->createMock(OrderInterface::class);
+ $payment = $this->createMock(PaymentInterface::class);
+ $order->method('getLastPayment')->willReturn($payment);
+
+ $this->orderRepository->method('findOrderById')->with('order-1')->willReturn($order);
+ $this->stateMachine->method('can')->with($payment, PaymentTransitions::GRAPH, $expectedTransition)->willReturn(true);
+ $this->stateMachine->expects(self::once())->method('apply')->with($payment, PaymentTransitions::GRAPH, $expectedTransition);
+ $this->entityManager->expects(self::once())->method('flush');
+
+ $this->mutator->apply('order-1', $outcome);
+ }
+
+ /**
+ * @return array
+ */
+ public static function provideMappedOutcomes(): array
+ {
+ return [
+ 'paid' => [PaymentOutcome::PAID, PaymentTransitions::TRANSITION_COMPLETE],
+ 'authorized' => [PaymentOutcome::AUTHORIZED, PaymentTransitions::TRANSITION_AUTHORIZE],
+ 'capture_required' => [PaymentOutcome::CAPTURE_REQUIRED, PaymentTransitions::TRANSITION_PROCESS],
+ 'refunded' => [PaymentOutcome::REFUNDED, PaymentTransitions::TRANSITION_REFUND],
+ 'failed' => [PaymentOutcome::FAILED, PaymentTransitions::TRANSITION_FAIL],
+ ];
+ }
+
+ /**
+ * A retried webhook landing on a payment already past this transition is a silent no-op,
+ * not a thrown workflow exception — mirrors the plugin's existing PaymentStateResolver.
+ */
+ public function testApply_transitionNotAvailable_isSilentNoOp(): void
+ {
+ $order = $this->createMock(OrderInterface::class);
+ $payment = $this->createMock(PaymentInterface::class);
+ $order->method('getLastPayment')->willReturn($payment);
+
+ $this->orderRepository->method('findOrderById')->willReturn($order);
+ $this->stateMachine->method('can')->willReturn(false);
+ $this->stateMachine->expects(self::never())->method('apply');
+ $this->entityManager->expects(self::never())->method('flush');
+
+ $this->mutator->apply('order-1', PaymentOutcome::PAID);
+ }
+
+ public function testApply_orderNotFound_throwsPaymentNotFoundException(): void
+ {
+ $this->orderRepository->method('findOrderById')->with('missing-order')->willReturn(null);
+
+ $this->expectException(PaymentNotFoundException::class);
+
+ $this->mutator->apply('missing-order', PaymentOutcome::PAID);
+ }
+
+ public function testApply_orderHasNoPayment_throwsPaymentNotFoundException(): void
+ {
+ $order = $this->createMock(OrderInterface::class);
+ $order->method('getLastPayment')->willReturn(null);
+ $this->orderRepository->method('findOrderById')->willReturn($order);
+
+ $this->expectException(PaymentNotFoundException::class);
+
+ $this->mutator->apply('order-1', PaymentOutcome::PAID);
+ }
+
+ public function testApply_unmappedOutcome_throwsInvalidArgumentException(): void
+ {
+ $this->orderRepository->expects(self::never())->method('findOrderById');
+
+ $this->expectException(\InvalidArgumentException::class);
+
+ $this->mutator->apply('order-1', 'not_a_real_outcome');
+ }
+}
diff --git a/tests/PHPUnit/Spike/SpikeIntegrationTest.php b/tests/PHPUnit/Spike/SpikeIntegrationTest.php
new file mode 100644
index 00000000..c1f82d8d
--- /dev/null
+++ b/tests/PHPUnit/Spike/SpikeIntegrationTest.php
@@ -0,0 +1,177 @@
+entityManager = self::getContainer()->get(EntityManagerInterface::class);
+ }
+
+ public function testOrderStateMutator_realStateMachine_transitionsPaymentToCompleted(): void
+ {
+ $order = $this->createOrderWithFreshPayment();
+ $payment = $order->getLastPayment();
+ self::assertNotNull($payment);
+ self::assertSame(PaymentInterface::STATE_NEW, $payment->getState());
+
+ $mutator = new PayplugOrderStateMutator(
+ self::getContainer()->get('sylius.repository.order'),
+ self::getContainer()->get(StateMachineInterface::class),
+ $this->entityManager,
+ );
+
+ $mutator->apply((string) $order->getId(), PaymentOutcome::PAID);
+
+ $this->entityManager->refresh($payment);
+ self::assertSame(PaymentInterface::STATE_COMPLETED, $payment->getState());
+ }
+
+ public function testOrderStateMutator_threeDsPending_leavesRealPaymentUntouched(): void
+ {
+ $order = $this->createOrderWithFreshPayment();
+ $payment = $order->getLastPayment();
+ self::assertNotNull($payment);
+
+ $mutator = new PayplugOrderStateMutator(
+ self::getContainer()->get('sylius.repository.order'),
+ self::getContainer()->get(StateMachineInterface::class),
+ $this->entityManager,
+ );
+
+ $mutator->apply((string) $order->getId(), PaymentOutcome::THREE_DS_PENDING);
+
+ $this->entityManager->refresh($payment);
+ self::assertSame(PaymentInterface::STATE_NEW, $payment->getState());
+ }
+
+ public function testTokenCache_realCachePool_roundTripsThroughRealAdapter(): void
+ {
+ $cachePool = self::getContainer()->get(CacheItemPoolInterface::class);
+ $tokenCache = new PayplugTokenCache($cachePool);
+
+ $tokenCache->set('pre3469-spike-token', 'jwt-value', 300);
+ self::assertSame('jwt-value', $tokenCache->get('pre3469-spike-token'));
+
+ $tokenCache->delete('pre3469-spike-token');
+ self::assertNull($tokenCache->get('pre3469-spike-token'));
+ }
+
+ public function testConfigurationRepository_realGatewayConfig_survivesADoctrineRoundTrip(): void
+ {
+ $gatewayConfig = new GatewayConfig();
+ $gatewayConfig->setFactoryName('payplug_pre3469_spike');
+ $gatewayConfig->setGatewayName('PRE-3469 spike');
+ $gatewayConfig->setConfig(['live' => false, 'test_client' => ['client_id' => 'spike-client-id']]);
+ $this->entityManager->persist($gatewayConfig);
+ $this->entityManager->flush();
+ $id = $gatewayConfig->getId();
+ $this->entityManager->clear();
+
+ /** @var GatewayConfig $reloaded */
+ $reloaded = $this->entityManager->find(GatewayConfig::class, $id);
+ (new PayplugConfigurationRepository($reloaded))->set('client_secret', 'spike-secret-value');
+ $this->entityManager->flush();
+ $this->entityManager->clear();
+
+ /** @var GatewayConfig $reloadedAgain */
+ $reloadedAgain = $this->entityManager->find(GatewayConfig::class, $id);
+ $repository = new PayplugConfigurationRepository($reloadedAgain);
+
+ self::assertSame('spike-client-id', $repository->getClientId());
+ self::assertSame('spike-secret-value', $repository->getClientSecret());
+ }
+
+ public function testPaymentRepository_realDoctrinePersistence_roundTripsAndTracksIdempotency(): void
+ {
+ $repository = new SyliusPaymentRepository($this->entityManager);
+ $operationId = 'spike-op-' . \bin2hex(\random_bytes(6));
+ $orderId = 'spike-order-' . \bin2hex(\random_bytes(6));
+ $operationData = new OperationData($operationId, '4001', PaymentOutcome::PAID, 1500, $orderId);
+
+ $repository->save($operationData);
+ $this->entityManager->clear();
+
+ self::assertFalse($repository->isTreated($operationId));
+
+ $fetched = $repository->getByOperationId($operationId);
+ self::assertSame($orderId, $fetched->orderId);
+ self::assertSame(1500, $fetched->amount);
+
+ $repository->markTreated($operationId);
+ $this->entityManager->clear();
+
+ self::assertTrue($repository->isTreated($operationId));
+ }
+
+ /**
+ * Self-contained on purpose: earlier drafts hunted for a fixture payment already sitting in
+ * state `new`, but repeated test runs against the same disposable database exhaust that
+ * finite pool (fixtures don't reload between runs) — a real payment always transitions
+ * *away* from `new`, so the pool only shrinks. Attaching a fresh Payment to an existing
+ * fixture Order sidesteps that without needing a full from-scratch Order (channel, customer,
+ * currency, locale, number, token — all fixture-provided here for free).
+ */
+ private function createOrderWithFreshPayment(): OrderInterface
+ {
+ $order = $this->entityManager->getRepository(Order::class)->createQueryBuilder('o')
+ ->setMaxResults(1)
+ ->getQuery()
+ ->getOneOrNullResult()
+ ;
+ self::assertNotNull($order, 'Fixtures must include at least one order.');
+
+ $method = $order->getLastPayment()?->getMethod()
+ ?? $this->entityManager->getRepository(PaymentMethod::class)->createQueryBuilder('m')->setMaxResults(1)->getQuery()->getOneOrNullResult()
+ ;
+ self::assertNotNull($method, 'Fixtures must include at least one payment method.');
+
+ $payment = new Payment();
+ $payment->setState(PaymentInterface::STATE_NEW);
+ $payment->setCurrencyCode($order->getCurrencyCode() ?? 'EUR');
+ $payment->setAmount($order->getTotal());
+ $payment->setMethod($method);
+ $order->addPayment($payment);
+
+ $this->entityManager->persist($payment);
+ $this->entityManager->flush();
+
+ return $order;
+ }
+}
diff --git a/tests/PHPUnit/Spike/SyliusPaymentRepositoryTest.php b/tests/PHPUnit/Spike/SyliusPaymentRepositoryTest.php
new file mode 100644
index 00000000..a1ff7cd9
--- /dev/null
+++ b/tests/PHPUnit/Spike/SyliusPaymentRepositoryTest.php
@@ -0,0 +1,152 @@
+entityManager = $this->createMock(EntityManagerInterface::class);
+ $this->objectRepository = $this->createMock(EntityRepository::class);
+ $this->entityManager->method('getRepository')->with(PayplugOperation::class)->willReturn($this->objectRepository);
+
+ $this->repository = new SyliusPaymentRepository($this->entityManager);
+ }
+
+ public function testGetByOrderId_found_returnsMatchingOperationData(): void
+ {
+ $entity = PayplugOperation::fromOperationData($this->operationData());
+ $this->objectRepository->method('findOneBy')->with(['orderId' => 'order-1'])->willReturn($entity);
+
+ $result = $this->repository->getByOrderId('order-1');
+
+ self::assertSame('op-1', $result->operationId);
+ self::assertSame('order-1', $result->orderId);
+ }
+
+ public function testGetByOrderId_notFound_throwsPaymentNotFoundException(): void
+ {
+ $this->objectRepository->method('findOneBy')->willReturn(null);
+
+ $this->expectException(PaymentNotFoundException::class);
+
+ $this->repository->getByOrderId('missing-order');
+ }
+
+ public function testGetByOperationId_found_returnsMatchingOperationData(): void
+ {
+ $entity = PayplugOperation::fromOperationData($this->operationData());
+ $this->objectRepository->method('findOneBy')->with(['operationId' => 'op-1'])->willReturn($entity);
+
+ $result = $this->repository->getByOperationId('op-1');
+
+ self::assertSame('order-1', $result->orderId);
+ }
+
+ public function testGetByOperationId_notFound_throwsPaymentNotFoundException(): void
+ {
+ $this->objectRepository->method('findOneBy')->willReturn(null);
+
+ $this->expectException(PaymentNotFoundException::class);
+
+ $this->repository->getByOperationId('missing-op');
+ }
+
+ public function testSave_newOperation_persistsAndFlushes(): void
+ {
+ $this->objectRepository->method('findOneBy')->with(['operationId' => 'op-1'])->willReturn(null);
+
+ $this->entityManager->expects(self::once())->method('persist')
+ ->with(self::callback(fn (PayplugOperation $entity): bool => 'op-1' === $entity->getOperationId() && 'order-1' === $entity->getOrderId()));
+ $this->entityManager->expects(self::once())->method('flush');
+
+ $this->repository->save($this->operationData());
+ }
+
+ public function testSave_existingOperation_updatesInPlaceWithoutPersisting(): void
+ {
+ $existing = PayplugOperation::fromOperationData($this->operationData());
+ $this->objectRepository->method('findOneBy')->willReturn($existing);
+
+ $this->entityManager->expects(self::never())->method('persist');
+ $this->entityManager->expects(self::once())->method('flush');
+
+ $updated = new OperationData('op-1', '4001', PaymentOutcome::REFUNDED, 500, 'order-1');
+ $this->repository->save($updated);
+
+ self::assertSame(PaymentOutcome::REFUNDED, $existing->toOperationData()->outcome);
+ self::assertSame(500, $existing->toOperationData()->amount);
+ }
+
+ public function testMarkTreated_existing_marksAndFlushes(): void
+ {
+ $entity = PayplugOperation::fromOperationData($this->operationData());
+ $this->objectRepository->method('findOneBy')->with(['operationId' => 'op-1'])->willReturn($entity);
+ $this->entityManager->expects(self::once())->method('flush');
+
+ $this->repository->markTreated('op-1');
+
+ self::assertTrue($entity->isTreated());
+ }
+
+ public function testMarkTreated_missing_throwsPaymentNotFoundException(): void
+ {
+ $this->objectRepository->method('findOneBy')->willReturn(null);
+
+ $this->expectException(PaymentNotFoundException::class);
+
+ $this->repository->markTreated('missing-op');
+ }
+
+ public function testIsTreated_untreatedOperation_returnsFalse(): void
+ {
+ $entity = PayplugOperation::fromOperationData($this->operationData());
+ $this->objectRepository->method('findOneBy')->willReturn($entity);
+
+ self::assertFalse($this->repository->isTreated('op-1'));
+ }
+
+ public function testIsTreated_treatedOperation_returnsTrue(): void
+ {
+ $entity = PayplugOperation::fromOperationData($this->operationData());
+ $entity->markTreated();
+ $this->objectRepository->method('findOneBy')->willReturn($entity);
+
+ self::assertTrue($this->repository->isTreated('op-1'));
+ }
+
+ /**
+ * Idempotency checks must be tolerant of "never saved" rather than throwing — a webhook
+ * asking "have I already treated this?" before any operation was ever persisted is a
+ * legitimate call, not an error.
+ */
+ public function testIsTreated_unknownOperation_returnsFalse(): void
+ {
+ $this->objectRepository->method('findOneBy')->willReturn(null);
+
+ self::assertFalse($this->repository->isTreated('unknown-op'));
+ }
+
+ private function operationData(): OperationData
+ {
+ return new OperationData('op-1', '4001', PaymentOutcome::PAID, 1000, 'order-1');
+ }
+}
diff --git a/tests/PHPUnit/TokenCache/PayplugTokenCacheTest.php b/tests/PHPUnit/TokenCache/PayplugTokenCacheTest.php
new file mode 100644
index 00000000..866e5fdc
--- /dev/null
+++ b/tests/PHPUnit/TokenCache/PayplugTokenCacheTest.php
@@ -0,0 +1,75 @@
+cache = $this->createMock(CacheItemPoolInterface::class);
+ $this->tokenCache = new PayplugTokenCache($this->cache);
+ }
+
+ public function testGet_hit_returnsStoredValue(): void
+ {
+ $item = $this->createMock(CacheItemInterface::class);
+ $item->method('isHit')->willReturn(true);
+ $item->method('get')->willReturn('jwt-value');
+ $this->cache->method('getItem')->with('token-key')->willReturn($item);
+
+ self::assertSame('jwt-value', $this->tokenCache->get('token-key'));
+ }
+
+ public function testGet_miss_returnsNull(): void
+ {
+ $item = $this->createMock(CacheItemInterface::class);
+ $item->method('isHit')->willReturn(false);
+ $this->cache->method('getItem')->willReturn($item);
+
+ self::assertNull($this->tokenCache->get('token-key'));
+ }
+
+ /**
+ * PSR-6's CacheItemInterface::get() is typed mixed — defend against a non-string value
+ * ever having been stored under this key (e.g. by another caller of the same pool).
+ */
+ public function testGet_hitWithNonStringValue_returnsNull(): void
+ {
+ $item = $this->createMock(CacheItemInterface::class);
+ $item->method('isHit')->willReturn(true);
+ $item->method('get')->willReturn(42);
+ $this->cache->method('getItem')->willReturn($item);
+
+ self::assertNull($this->tokenCache->get('token-key'));
+ }
+
+ public function testSet_storesValueWithTtlAndSaves(): void
+ {
+ $item = $this->createMock(CacheItemInterface::class);
+ $item->expects(self::once())->method('set')->with('jwt-value');
+ $item->expects(self::once())->method('expiresAfter')->with(298);
+ $this->cache->method('getItem')->with('token-key')->willReturn($item);
+ $this->cache->expects(self::once())->method('save')->with($item);
+
+ $this->tokenCache->set('token-key', 'jwt-value', 298);
+ }
+
+ public function testDelete_removesItem(): void
+ {
+ $this->cache->expects(self::once())->method('deleteItem')->with('token-key');
+
+ $this->tokenCache->delete('token-key');
+ }
+}