<?php namespace App\Controller; use App\Entity\GlobalParameter; use App\Entity\Payment; use App\Entity\User; use App\Repository\PaymentRepository; use App\Security\LoginAuthenticator; use App\Utils\PaymentUtils; use Doctrine\ORM\EntityManagerInterface; use Payum\Core\Payum; use Payum\Core\Request\GetHumanStatus; use Payum\Core\Request\Notify; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\Form\Form; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Security\Guard\GuardAuthenticatorHandler; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Translation\TranslatorInterface; /** * Gestion des paiements avec Payum. */ class PaymentController extends AbstractController { protected $em; protected $translator; protected $payum; protected $authenticator; protected $guardHandler; protected $paymentUtils; public function __construct( EntityManagerInterface $em, TranslatorInterface $translator, LoginAuthenticator $authenticator, GuardAuthenticatorHandler $guardHandler, Payum $payum, PaymentUtils $paymentUtils ) { $this->em = $em; $this->translator = $translator; $this->payum = $payum; $this->authenticator = $authenticator; $this->guardHandler = $guardHandler; $this->paymentUtils = $paymentUtils; } /** * Crée une instance de Payment, les tokens associés, et redirige vers la page de paiement. */ public function preparePaymentAction(Form $form, $type, $extra_data = null) { /* @var PaymentRepository $repo */ $repo = $this->em->getRepository(Payment::class); //Redirect to starting payment page if a valid starting payment page exists $url = $repo->findUrlOfValidStartingPayment($this->getUser()->getUsername()); $this->em->flush(); //save status updates when looking to valid starting payment page if ($url) { return $this->redirect($url); } // Enregistre les données du Flux en json, pour l'enregistrer une fois le paiement validé $serializer = $this->container->get('serializer'); $toSerialize = Payment::TYPE_ADHESION == $type ? $form->get('cotisation')->getData() : $form->getData(); $data = $serializer->normalize( $toSerialize, null, [AbstractNormalizer::ATTRIBUTES => [ 'reference', 'moyen', 'montant', 'role', 'don' => [ 'reference', 'moyen', 'montant', 'role', 'type', 'expediteur' => ['id'], 'destinataire' => ['id'], 'operateur' => ['id'], ], 'expediteur' => ['id'], 'destinataire' => ['id'], 'operateur' => ['id'], ], ] ); $jsondata = $serializer->serialize($data, 'json'); // Prepare CB Payment if ('true' === $this->em->getRepository(GlobalParameter::class)->val(GlobalParameter::USE_PAYZEN)) { $gatewayName = 'payzen'; } else { $this->addFlash( 'error', $this->translator->trans('Une erreur est survenue due à la configuration du paiement dans l\'application. Il est pour l\'instant impossible de payer par CB, merci de contacter votre monnaie locale.') ); return $this->redirectToRoute('index'); } $storage = $this->payum->getStorage('App\Entity\Payment'); $payment = $storage->create(); $payment->setNumber(uniqid()); $payment->setCurrencyCode('978'); $payment->setDescription($type); $payment->setFluxData($jsondata); // Data to persist when payment is valid (other than Flux data) if (null != $extra_data) { $payment->setExtraData($extra_data); } if (Payment::TYPE_ADHESION == $type) { $payment->setTotalAmount($form->get('cotisation')->get('montant')->getData() * 100); // 1.23 EUR $payment->setClientId('Nouvel adhérent'); $payment->setClientEmail($form->get('user')->get('email')->getData()); } else { if ($form->has('don') && $form->get('don')->getData()->getMontant() > 0) { $payment->setTotalAmount(($form->get('montant')->getData() * 100) + ($form->get('don')->getData()->getMontant() * 100)); // 1.23 EUR } else { $payment->setTotalAmount($form->get('montant')->getData() * 100); // 1.23 EUR } $payment->setClientId($this->getUser()->getId()); $payment->setClientEmail($this->getUser()->getEmail()); } if (Payment::TYPE_PAIEMENT_RECURRENT_COTISATION_TAV == $type) { $payment->setRecurrenceAmount($form->get('montant')->getData() * 100); $payment->setIsRecurrent(true); $payment->setRecurrenceMonthsCount($form->get('nombreMois')->getData()); $payment->setRecurrenceMonthDay($form->get('jourPrelevement')->getData()); } $storage->update($payment); $captureToken = $this->payum->getTokenFactory()->createCaptureToken( $gatewayName, $payment, 'payment_done' // the route to redirect after capture ); // Symfony creates URLs with http and not https -> replace $targetUrl = preg_replace('/^http:/', 'https:', $captureToken->getTargetUrl()); $afterUrl = preg_replace('/^http:/', 'https:', $captureToken->getAfterUrl()); $captureToken->setTargetUrl($targetUrl); $captureToken->setAfterUrl($afterUrl); $targetUrl = $captureToken->getTargetUrl(); /* @var Payment $payment */ if (!$payment->getPayzenStartingPaymentUrl()) { $payment->setPayzenStartingPaymentUrl($targetUrl); } $this->em->persist($captureToken); $this->em->flush(); return $this->redirect($targetUrl); } /* COMMENT LES FLUX SONT-ILS CREES SUITE A LA CREATION D'UN PAIEMENT PAYZEN ? * * * * CAS 1 : Paiement standard * Deux voies de signalement coexistent : * * Voie 1 : via la notification instantannée payzen. * Un paramètre appelé "URL de notification à la fin du paiement" est configuré dans le backoffice payzen et * est renseigné à payement/notify. Cette valeur est utilisée par payzen, sauf si la variable vads_url_check * est transmise, auquel cas c'est cette dernière valeur qui prime. * Ici, vads_url_check est transmise à la valeur payment/notify/{token}. * Remarque : son usage systématique n'est pas recommandé par Payzen. * L'URL est ainsi captée par NotifyController.php. * * Voie 2 : via la redirection du client. * Cette voie n'est pas garantie (si l'utilisateur ferme son navigateur assez tôt, il ne sera pas redirigé). * A vérifier : l'URL de redirection est la combinaison entre une configuration côté backoffice et * un complément que nous transmettons. * Lorsque la redirection a bien lieu, c'est donc le contrôleur doneAction de PaymentController.php qui est sollicité, * avec une requête au format payement/done/?payum_token={token} * * Remarquons dans ces deux stratégie la présence d'un token permettant l'authentification de l'agent Payzen et * la récupération de l'objet paiement préalablement enregistré en base. * * * * CAS 2 : Paiement récurrent * * Dans le cadre de notre implémentation, un paiement récurrent commence par un paiement initial (occurence n°0). * Pour cette occurence n°0, au niveau du mode de communication, on est sur un processus en apparence similaire au * CAS 1. Un examen minutieux de ce process m'a cependant permis de mettre en évidence des comportements que * je n'ai pas réussi à comprendre : * En ouvrant les vannes pour tester le déclenchement d'une création de flux à chaque notification rattachée * à un paiement récurrent, j'ai observé la génération de pas moins de 5 événements, opérant donc * un accroissement de solde de +750 mona au lieu de +150 mona !!! * - 2 événements via /payement/notify/{token} * - 3 événements après l'execution de la ligne $gateway->execute($status = new GetHumanStatus($token)); dans doneAction. * * Le fonctionnement du module Payum est sans doute très intelligent mais il est trop complexe à maitriser pour moi. * Il fait également appel à des mécanismes dont l'usage systématique est déconseillé par Payzen (utilisation de la variable * vads_url_check). * * Bref. De toute façon, la gestion des notifications successives d'un paiement récurrent ne peut pas être gérée * via les voies 1 ou 2 en l'état. * En effet la variable vads_url_check ne fonctionne pas en mode récurrent et Payzen nous notifie à l'aide * d'une requête post au format de l'"URL de notification à la création d'un abonnement" (mal nommé), configurée à payement/notify, * (mais on aurait pu mettre autre chose pour plus de clarté). Il faut donc créer un controlleur spécifique qui va traiter * cette notification sans disposer du token utilisé par les voies 1 ou 2. * * Voie 3 : le traitement de la requête se fait finalement simplement dans notifyRecurringPaymentAction sans utiliser * les mécanismes complexes de Payum. * * Pour d'autres problèmes avec Payum, voir aussi les commentaires liés à * l'utilisation de GetHumanStatus::STATUS_AUTHORIZED. * * Yvon KERDONCUFF * 26/03/2024 */ /** * Collects payzen recurring payment payment occurence in place of default payum system. * * Payum default controler is not able to catch URL recurring payment notifications * because payzen actual used URL has shape /payment/notify instead of /payment/notify/token. * * @param Request $request * * @return Response * @Route("/payment/notify", name="notify_recurring_payment") */ public function notifyRecurringPaymentAction(Request $request) { $vads_cust_email = $request->request->get('vads_cust_email'); //Look for recurring payments from this client. $recurringPayments = $this->em->getRepository(Payment::class)->findBy([ 'isRecurrent' => true, 'clientEmail' => $vads_cust_email, ]); if (!$recurringPayments) { //return error return new Response('No recurring payments with email ' . $vads_cust_email, 405); } $vads_identifier = $request->request->get('vads_identifier'); $vads_trans_status = $request->request->get('vads_trans_status'); $new_status = strtolower($vads_trans_status); foreach ($recurringPayments as $payment) { //Just look for one valid payment. if ( $payment->getDetails() && array_key_exists('vads_identifier', $payment->getDetails()) && $payment->getDetails()['vads_identifier'] == $vads_identifier ) { // Payum GetHumanStatus constants don't match Payzen vads_trans_status here // e.g. GetHumanStatus::STATUS_AUTHORIZED does not match 'authorised' value sent by Payzen if (in_array($new_status, ['captured', 'authorised', GetHumanStatus::STATUS_CAPTURED, GetHumanStatus::STATUS_AUTHORIZED])) { $this->paymentUtils->handlePayzenNotificationCore($payment); $this->em->flush(); } //for debug purpose, display vads_trans_status in payzen backoffice return new Response('Recurring payment occurence taken into account. vads_trans_status = ' . $new_status, 200); } } //return error return new Response('No recurring payments with vads_identifier ' . $vads_identifier, 405); } /** * Ce contrôleur est sollicité lorsque Payzen renvoie le cotisant sur l'URL de retour, * * @Route("/payment/done/", name="payment_done") */ public function doneAction(Request $request) { try { $token = $this->payum->getHttpRequestVerifier()->verify($request); } catch (\Exception $e) { // Token expired return $this->redirectToRoute('index'); } // Get payment $gateway = $this->payum->getGateway($token->getGatewayName()); $gateway->execute($status = new GetHumanStatus($token)); $payment = $status->getFirstModel(); if (GetHumanStatus::STATUS_NEW == $payment->getStatus()) { $gateway->execute(new Notify($token)); } else { $this->payum->getHttpRequestVerifier()->invalidate($token); } // Set flash message according to payment status /* WARNING : THIS PIECE OF CODE IS USING INAPPROPRIATE GetHumanStatus::STATUS_AUTHORIZED : * * This is (another) issue here with Payum. * current_payment_status will never match STATUS_AUTHORIZED * on the opposite, we have no chance to detect 'authorised' value here * because Payzen vads_trans_status is written 'authorised' and not 'authorized' * * @see comment above notifyRecurringPaymentAction for more Payum weird stuff */ if (in_array($payment->getStatus(), ['captured', 'authorised', GetHumanStatus::STATUS_CAPTURED, GetHumanStatus::STATUS_AUTHORIZED])) { $type = $payment->getDescription(); if (Payment::TYPE_ACHAT_MONNAIE_ADHERENT == $type || Payment::TYPE_ACHAT_MONNAIE_PRESTA == $type) { $this->addFlash( 'success', $this->translator->trans('Achat de monnaie locale bien effectué !') ); } elseif (Payment::TYPE_COTISATION_ADHERENT == $type || Payment::TYPE_COTISATION_PRESTA == $type) { $this->addFlash( 'success', $this->translator->trans('Cotisation bien reçue. Merci !') ); } elseif (Payment::TYPE_ADHESION == $type) { $this->addFlash( 'success', $this->translator->trans('Votre adhésion a bien été prise en compte, bienvenue !') ); // Connect new user return $this->guardHandler ->authenticateUserAndHandleSuccess( $this->em->getRepository(User::class)->findOneBy(['id' => $payment->getClientId()]), $request, $this->authenticator, 'main' ); } elseif (Payment::TYPE_PAIEMENT_COTISATION_TAV == $type || Payment::TYPE_PAIEMENT_RECURRENT_COTISATION_TAV) { $this->addFlash( 'success', $this->translator->trans('Cotisation payée !') ); } } elseif ( GetHumanStatus::STATUS_CANCELED == $payment->getStatus() || GetHumanStatus::STATUS_EXPIRED == $payment->getStatus() || GetHumanStatus::STATUS_FAILED == $payment->getStatus() ) { $this->addFlash( 'error', $this->translator->trans('La transaction a été annulée.') ); } return $this->redirectToRoute('index'); } }