Skip to content

PRE-3552: Add UHF refund - #316

Closed
jhoaraupp wants to merge 1 commit into
developfrom
feature/PRE-3552
Closed

PRE-3552: Add UHF refund#316
jhoaraupp wants to merge 1 commit into
developfrom
feature/PRE-3552

Conversation

@jhoaraupp

@jhoaraupp jhoaraupp commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Description

Cette PR ajoute le remboursement pour les paiements Hosted Fields (UHF) côté back-office Sylius, avec confirmation asynchrone via webhook, et inclut l'adaptation nécessaire suite à la fusion de payplug/unified-plugin-core's PRE-3589 sur develop (qui embarque aussi le refactoring PRE-3590 "HF aliasing").

Remboursement (synchrone + partiel/total) :

  • RefundPaymentProcessor route les paiements Hosted Fields vers UPC's createRefund() via un nouvel adaptateur Upc/UnifiedApiRefundCreator (implémentant RefundCreatorInterface), au lieu du SDK legacy
  • L'orderId du remboursement (numéro de commande, ou l'id du paiement à défaut) et le submerchantExternalId/accountId (compte/sous-marchand) sont résolus depuis la config du payment method spécifique au paiement remboursé (Upc/PaymentReferenceHelper::resolveGatewayCredentials()), avec un garde-fou local (LogicException) si l'un des deux n'est pas configuré — évite un aller-retour réseau pour un 400 dont le message ne remonte pas, et évite qu'un remboursement soit routé vers le mauvais compte si le marchand a plusieurs payment methods Hosted Fields configurés
  • Un remboursement total (processHostedFields()) enregistre désormais aussi l'id d'opération du remboursement dans Payment::details['refunds'] — nécessaire pour que le webhook de confirmation puisse retrouver le paiement
  • Remboursement total après un remboursement partiel : le montant enregistré est le solde réellement restant (montant d'origine - somme des remboursements déjà confirmés), pas le montant total d'origine — UnifiedApiPaymentService::createRefund() rembourse le solde restant quand $amount est omis, pas le total
  • Remboursements total et partiel protégés par un même verrou (ILock, clé = id du paiement) : les deux méthodes se bloquent mutuellement en cas de déclenchement concurrent, faute d'idempotency key côté API UPC pour ce flux

Notification webhook du remboursement :

  • HostedFieldsWebhookNotificationHandler distingue maintenant une confirmation de remboursement (via les ids qu'on a nous-mêmes enregistrés dans Payment::details['refunds']) d'une notification de paiement classique, et applique PaymentOutcome::REFUNDED au lieu de PAID — sans ce fix, une confirmation de remboursement aurait été mal interprétée
  • Une confirmation de remboursement dont l'execCode indique un échec n'est jamais forcée en REFUNDED (l'argent n'a pas bougé) ni transmise telle quelle à la state machine du paiement (FAILED y signifie "ce paiement a échoué", pas "ce remboursement a échoué") — seuls le log et le suivi d'idempotence sont appliqués ; l'entrée correspondante dans Payment::details['refunds'] est marquée failed => true pour ne plus compter dans un calcul de solde restant ultérieur
  • Le fallback qui associe une confirmation à un remboursement dont l'id n'a pas pu être capturé à la création exclut désormais explicitement l'id d'opération du paiement d'origine, pour ne pas mal classer une redélivrance tardive de la notification de paiement comme un remboursement
  • Vérifié en conditions réelles (staging) : remboursement + notification bout en bout confirmés fonctionnels

Adaptation à PRE-3590 (UPC) :

  • payplug/unified-plugin-core pointe sur ^1.1.0 (version taguée incluant PRE-3589, publiée depuis) au lieu de dev-develop
  • UnifiedApiHostedPaymentService a été supprimée par PRE-3590 (fusionnée dans UnifiedApiPaymentService::createPayment()) et HostedPaymentOutput renommée en PaymentOutputUpc/UnifiedApiHostedPaymentCreator, Upc/HostedPaymentCreatorInterface, et CaptureHostedPaymentRequestHandler adaptés en conséquence
  • HostedFieldDto a un nouveau paramètre (recurringMode, pour l'aliasing PRE-3590) inséré avant browser/customer dans son constructeur — la construction dans CaptureHostedPaymentRequestHandler est passée en arguments nommés pour éviter ce type de désalignement silencieux à l'avenir

Nettoyage : extraction d'un helper partagé Upc/PaymentReferenceHelper (resolveOrderId(), idToString(), resolveGatewayCredentials()), qui remplace les copies précédemment dupliquées dans RefundPaymentProcessor, HostedFieldsWebhookNotificationHandler, CaptureHostedPaymentRequestHandler et UnifiedApiRefundCreator.

Related Issue

Ticket: PRE-3552

Type of Change

  • 🐛 Bug fix
  • ✨ New feature
  • 💥 Breaking change

✅ Quality Checklist

Local Environment & Hooks

  • Local Git hooks (CaptainHook) are installed and executed cleanly (make install).
  • Commit messages strictly follow the (PRE|SMP)-XXXX: description pattern.
  • Branch name follows (feature|fix|hotfix|refactor)/(PRE|SMP)-XXXX... or (release|patch)/x.y.z.

Testing & Code Quality

  • Coding style rules have been applied locally (ECS — 0 errors).
  • Static analysis passes with no new regressions (PHPStan level max — 0 errors).
  • I have added/updated PHPUnit tests if applicable (364/364 passing locally, 67/67 re-verified against the real embedded app vendor in plugin-dockerized-sylius).
  • No PHP syntax newer than 7.1 introduced in src/ or tests/ — n/a pour ce repo (cible PHP ^8.2), et payplug/unified-plugin-core est maintenant consommé via une version taguée stable plutôt qu'une branche de dev.

CI/CD Deployment Context

  • The CI pipeline passes fully on GitHub, including the compatibility matrix (PHP 7.1 / 7.4 / 8.0 / 8.1 / 8.2) and the quality job.

Notes for Reviewer

  • Cette PR a été revue (review automatisée + review humaine de @hdelaforce-payplug) et tous les points valides ont été corrigés : override incorrect de l'outcome sur échec de remboursement, montant erroné sur un remboursement total après un partiel, faux positif dans le matching webhook, verrous à espaces de noms disjoints, et duplication de code (resolveGatewayCredentials/resolveOrderId/idToString) désormais centralisée dans Upc/PaymentReferenceHelper. Le seul point non retenu (dispatch isHostedFields() répété) a été jugé peu pertinent : le trait DelegatesToHostedFieldsCommandProviderTrait existant est conçu pour un autre usage (délégation entre command providers) et ne s'applique pas tel quel ici.
  • payplug/unified-plugin-core pointe maintenant sur ^1.1.0 (version stable taguée) — plus besoin de suivre dev-develop.
  • Vérification DI faite en conditions réelles dans plugin-dockerized-sylius : debug:autowiring/debug:container/cache:clear confirment que le nouveau paramètre ILock de RefundPaymentProcessor se résout sans ambiguïté.
  • Voir CaptureHostedPaymentRequestHandler::buildHostedFieldDto() pour le changement d'arguments nommés — comportement identique, juste plus sûr face à un futur changement de signature de HostedFieldDto.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

Claude Code Review is paused for this repository. To reconnect it, an admin of this repository's GitHub organization (or the account owner, for personal repositories) who can also manage your Claude organization's Code Review settings needs to re-link GitHub in Code Review settings. This is a one-time step.

Tip: disable this comment in your organization's Code Review settings.

@hdelaforce-payplug hdelaforce-payplug left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Revue automatisée (bugs de correction + nettoyage) — voir les commentaires en ligne. Les points 1 à 3 (webhook remboursement) et 4 (verrous) méritent une attention particulière avant merge.

Comment thread src/Handler/HostedFieldsWebhookNotificationHandler.php
Comment thread src/PaymentProcessing/RefundPaymentProcessor.php Outdated
Comment thread src/Handler/HostedFieldsWebhookNotificationHandler.php Outdated
Comment thread src/PaymentProcessing/RefundPaymentProcessor.php Outdated
Comment thread composer.json Outdated
Comment thread src/PaymentProcessing/RefundPaymentProcessor.php Outdated
Comment thread src/PaymentProcessing/RefundPaymentProcessor.php Outdated
Comment thread src/Upc/UnifiedApiRefundCreator.php Outdated
* check-then-act on here (full refunds don't create one, mirroring process()'s legacy
* behavior), so the lock is the only guard available for this path.
*/
private function processHostedFields(PaymentInterface $payment): void

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simplification possible : processHostedFields() et processHostedFieldsWithAmount() (ligne 227) partagent ~50 lignes quasi identiques (acquisition/relâchement du verrou, bloc try/catch/UpdateHandlingException, log d'erreur "no operationIds", ajout à $details['refunds']). Un helper commun paramétré par la clé de verrou et les valeurs qui diffèrent réduirait la duplication et le risque de divergence future.

{
$this->prepare($payment);

if (self::isHostedFields($payment)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remarque d'architecture : le dispatch isHostedFields() est répété inline dans process(), processWithAmount() et prepare(), alors que le plugin a déjà un mécanisme dédié pour ce type de dispatch (DelegatesToHostedFieldsCommandProviderTrait, utilisé par les command providers Capture/Notify/Status). Centraliser ce check éviterait qu'il soit oublié pour un futur ajout (comme cela a failli arriver dans prepare()).

@jhoaraupp
jhoaraupp force-pushed the feature/PRE-3552 branch 4 times, most recently from 10c8280 to 16f998a Compare September 2, 2026 15:01
* RefundHistory/refundId to check-then-act on here (full refunds don't create one, mirroring
* process()'s legacy behavior), so the lock is the only guard available for this path.
*/
private function processHostedFields(PaymentInterface $payment): void

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possible lost-update race between refund creation and webhook confirmation on Payment::details

This method reads $details = $payment->getDetails() before the (network) call to createRefund(), then writes it back afterwards from that same pre-call snapshot — no re-read before the write. Meanwhile HostedFieldsWebhookNotificationHandler::markMatchedRefundAsFailed() does its own independent read-modify-write of the same details['refunds'] array, and it runs unlocked (before applyLocked() is even called). The two paths also use disjoint lock namespaces (payplug_upc_refund_<paymentId> here vs payplug_upc_treat_<operationId> there), so they never mutually exclude each other.

Failure scenario: an admin triggers a full refund while an async webhook confirming an earlier refund's failure arrives around the same time (independent HTTP requests — plausible, since PayPlug can deliver a webhook within milliseconds of the synchronous API response).

  1. processHostedFields() reads $details (no failed flag yet on the earlier refund).
  2. Concurrently, the webhook handler reads $details fresh, sets the earlier entry failed => true, and calls setDetails().
  3. processHostedFields()'s createRefund() call returns; it computes sumRecordedRefunds() off its stale $details (still missing the failed flag) and calls setDetails() with that stale array.
  4. Whichever setDetails()/flush happens last wins — the failed => true flag can be silently dropped.

Consequence: a refund PayPlug confirmed never actually completed goes back to counting as refunded money in sumRecordedRefunds(), so a later full refund under-refunds the customer by that amount (or, in the reverse ordering, a legitimate refund record is lost entirely).

Confidence: medium — depends on whether Sylius's Payment entity has Doctrine optimistic locking (@ORM\Version); if not (the Sylius core default), this is a silent lost-update, not just theoretical.

@wiz-14d684d7a6

wiz-14d684d7a6 Bot commented Sep 3, 2026

Copy link
Copy Markdown

Wiz Scan Summary

Scanner Findings
Vulnerability Finding Vulnerabilities -
Data Finding Sensitive Data -
Secret Finding Secrets -
IaC Misconfiguration IaC Misconfigurations 12 Medium 2 Low
SAST Finding SAST Findings 4 Medium 3 Low
Software Management Finding Software Management Findings -
Total 16 Medium 5 Low

View scan details in Wiz

To detect these findings earlier in the dev lifecycle, try the Wiz Code extension for VS Code, JetBrains, or Visual Studio.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants