<?php
namespace App\Controller;
use App\Entity\ExchangeRate;
use App\Entity\Payement;
use App\Entity\Prices;
use App\Entity\Fees;
use App\Entity\Folder;
use App\Entity\Transaction;
use App\Form\FeesType;
use App\Form\PriceType;
use App\Repository\BankAccountRepository;
use App\Repository\BankRepository;
use App\Service\FeesService;
use App\Service\MakutaAPIV2;
use App\Service\SendNotificationService;
use App\Utils\Utilitaires;
use Exception;
use Psr\Log\LoggerInterface;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
class PriceController extends AbstractController
{
private ManagerRegistry $managerRegistry;
private LoggerInterface $logger;
private SendNotificationService $sendNotificationService;
private MailerInterface $mailer;
private FeesService $feesService;
/**
* @param ManagerRegistry $managerRegistry
* @param LoggerInterface $logger
* @param SendNotificationService $sendNotificationService
* @param MailerInterface $mailer
* @param FeesService $feesService
*/
public function __construct(ManagerRegistry $managerRegistry, LoggerInterface $logger, SendNotificationService $sendNotificationService, MailerInterface $mailer, FeesService $feesService)
{
$this->managerRegistry = $managerRegistry;
$this->logger = $logger;
$this->sendNotificationService = $sendNotificationService;
$this->mailer = $mailer;
$this->feesService = $feesService;
}
public function payUI(Request $request, BankRepository $bankRepository, BankAccountRepository $bankAccountRepository)
{
$form = $this->createForm(FeesType::class);
$form->handleRequest($request);
$demande_id = $request->get('demande_id');
$document_category_id = $request->get('document_category_id');
$site_id = $request->get('site_id');
$bank = $bankRepository->findBankAll();
$bankAccounts = $bankAccountRepository->findAllBankAccount();
$folder = $this->managerRegistry->getRepository(Folder::class)->findOneBy([
'id' => $demande_id,
]);
if ($folder->getStatus() !== 1) {
$this->addFlash('invalid_payment', "Ce document a déjà été payé");
return $this->redirectToRoute('app_folder_view', [
'folder_id' => $folder->getId(),
]);
}
if ($folder->getBeneficiary()->getNationality() != null) {
if ($folder->getBeneficiary()->getNationality()->getId() != "PA001") {
$price = $this->managerRegistry->getRepository(Prices::class)->findOneBy([
'documentCategory' => $document_category_id,
'site' => $site_id,
'tarif' => 'ETRANGER',
'status' => true,
]);
} else {
$price = $this->managerRegistry->getRepository(Prices::class)->findOneBy([
'documentCategory' => $document_category_id,
'site' => $site_id,
'status' => true,
]);
}
} else {
$price = $this->managerRegistry->getRepository(Prices::class)->findOneBy([
'documentCategory' => $document_category_id,
'site' => $site_id,
'status' => true,
]);
}
if ($price == null) {
$this->addFlash('no_price', "Aucun taux n'a été configuré pour ce document! Veuillez contacter l'administrateur");
return $this->redirectToRoute('app_folder_view', [
'folder_id' => $folder->getId(),
]);
}
$encoders = [new JsonEncoder()];
$normalizers = [new ObjectNormalizer()];
$serializer = new Serializer($normalizers, $encoders);
$jsonBankList = $serializer->serialize($bank, 'json', [
'circular_reference_handler' => function ($object) {
return $object->getId();
}
]);
$jsonBankAccountList = $serializer->serialize($bankAccounts, 'json', [
'circular_reference_handler' => function ($object) {
return $object->getId();
}
]);
$exchange = $this->managerRegistry->getRepository(ExchangeRate::class)->findOneBy([
'isActive' => true,
]);
$webCardFees = $this->managerRegistry->getRepository(Fees::class)->findOneBy([
'isWebCard' => true,
'isDeleted' => false
]);
$fees = $this->managerRegistry->getRepository(Fees::class)->findBy([
'isWebCard' => true,
'isDeleted' => false
]);
$WebCardFeesArray = [];
foreach ($fees as $fee) {
if (!is_null($fee)) {
$priceCDF = $exchange->getRate() * $fee->getPrice();
$WebCardFeesArray[] = array(
'id' => $fee->getId(),
'label' => $fee->getLabel(),
'priceCDF' => $priceCDF,
'priceUSD' => $fee->getPrice(),
"require" => $fee->getIsRequired(),
"isWebcard" => $fee->getIsWebCard());
}
}
return $this->render('master/pay_document.html.twig', [
'webCardFees' => $webCardFees,
'webCardFeesArray' => $WebCardFeesArray,
'currentExchange' => $exchange,
'price' => $price,
'folder' => $folder,
'bankList' => $jsonBankList,
'bankAccountList' => $jsonBankAccountList,
'userType' => $this->getUser()->getUserType(),
'isShortcutProcess' => $this->getParameter('shortcut_ops_process'),
'form' => $form->createView(),
]);
}
public function feesbyID(Request $request): Response
{
$data = [];
$fees_id_list = explode(",", $request->get('fees_id'));
$currency = $request->get('currency');
$currentRate = $this->managerRegistry->getRepository(ExchangeRate::class)->findOneBy([
'isActive' => true
]);
foreach ($fees_id_list as $fees_id) {
$fees = $this->managerRegistry->getRepository(Fees::class)->findOneBy([
'id' => $fees_id
]);
if (!is_null($fees)) {
if ($fees->getCurrency()->getAcronym() != $currency && $currency == 'CDF') {
$price = $currentRate->getRate() * $fees->getPrice();
$data[] = array('id' => $fees->getId(), 'label' => $fees->getLabel(), 'price' => $price, 'currency' => $currency, "require" => $fees->getIsRequired());
} else {
$data[] = array('id' => $fees->getId(), 'label' => $fees->getLabel(), 'price' => $fees->getPrice(), 'currency' => $fees->getCurrency(), "require" => $fees->getIsRequired());
}
}
}
$response = new Response(json_encode($data));
$response->headers->set('Content-Type', 'application/json');
return $response;
}
public function initializeMakuta(MakutaAPIV2 $makutaApiV2): Response
{
$response = new Response(json_encode($makutaApiV2->getOperators()));
$response->headers->set('Content-Type', 'application/json');
return $response;
}
public function createMakutaTransaction(Request $request, MakutaAPIV2 $makutaApiV2): Response
{
$folder = $request->get('folder');
$fees = json_decode($request->get('fees'), true);
$em = $this->managerRegistry->getManager();
$defaultAmount = $request->get('defaultAmount');
$phone = $request->get('phone');
$accountNumber = !empty($phone) ? $phone : '243899778838';
$clientCurrency = $request->get('defaultCurrency');
$operator = $request->get('apiKey');
$email = $request->get('email');
$mailist = $this->getParameter('paymentMailList');
$mailListArray = explode(",", $mailist);
$requiresEmail = false;
foreach ($makutaApiV2->getOperators() as $categoryOperators) {
foreach ($categoryOperators as $operatorDefinition) {
if ($operatorDefinition['code'] === $operator) {
$requiresEmail = $operatorDefinition['requiresEmail'] ?? false;
}
}
}
if ($requiresEmail && (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL))) {
$response = new Response(json_encode(['error' => "Une adresse email valide est requise pour ce mode de paiement."]));
$response->headers->set('Content-Type', 'application/json');
$response->setStatusCode(400);
return $response;
}
$folderObj = $this->managerRegistry->getRepository(Folder::class)->findOneBy([
'id' => $folder,
]);
$documentPrice = $this->managerRegistry->getRepository(Prices::class)->findOneBy([
'documentCategory' => $folderObj->getDocumentCategory()->getId(),
'site' => $folderObj->getSite()->getId(),
'status' => true,
]);
$makutaAmount = $this->calculateTotalAmount($documentPrice, $fees, $clientCurrency);
// Chaque soumission crée une transaction neuve avec une référence fraîche : Makuta
// rejette toute réutilisation de thirdPartyReference ("Duplicate entry detected"), donc
// on ne réutilise jamais une transaction PENDING précédente. Le dossier ne peut être
// crédité deux fois de toute façon : voir la vérification de statut dans makutaCallback().
$transaction = new Transaction();
$transaction->setAmount($makutaAmount);
$transaction->setCreationDate(new \DateTime());
$transaction->setClientOperator($operator);
$transaction->setCurrency($clientCurrency);
$transaction->setStatus("PENDING");
$transaction->setUser($this->getUser());
$transaction->setDescription($folderObj->getNumber());
$transaction->setFolder($folderObj);
$transaction->setWallet(0);
try {
$transNum = bin2hex(random_bytes(16));
$transaction->setTransactionNumber($transNum);
} catch (Exception $e) {
}
$em->persist($transaction);
$em->flush();
$payement = new Payement();
$payement->setAmount($defaultAmount);
$payement->setReference($transaction->getTransactionNumber());
$payement->setCurrency($clientCurrency);
$payement->setIntegator('MAKUTA');
$payement->setFees(null);
$payement->setFolder($folderObj);
$payement->setStatus(0);
$em->persist($payement);
foreach ($fees as $fee) {
$feeObj = $this->managerRegistry->getRepository(Fees::class)->findOneBy([
'id' => $fee['id'],
]);
$payement = new Payement();
$payement->setAmount($feeObj->getPrice());
$payement->setReference($transaction->getTransactionNumber());
$payement->setCurrency($feeObj->getCurrency()->getAcronym());
$payement->setIntegator('MAKUTA');
$payement->setFees($feeObj);
$payement->setFolder($folderObj);
$payement->setStatus(0);
$em->persist($payement);
}
$em->flush();
$amountUsd = $makutaAmount;
$exchangeRate = null;
if ($clientCurrency === 'CDF') {
$exchange = $this->managerRegistry->getRepository(ExchangeRate::class)->findOneBy(['isActive' => true]);
$exchangeRate = $exchange->getRate();
$amountUsd = $makutaAmount / $exchangeRate;
}
$result = $makutaApiV2->createTransaction(
$transaction,
$operator,
$accountNumber,
$clientCurrency,
$amountUsd,
$transaction->getDescription(),
$email
);
$transaction->setFinancialTransaction($result['id']);
$transaction->setFinancialTransactionUrl($result['url'] ?? null);
$transaction->setAmountUsd($amountUsd);
$transaction->setExchangeRate($exchangeRate);
$em->persist($transaction);
$em->flush();
$subject = "Paiement du document " . $folderObj->getDocumentCategory()->getLabel();
$notificationMessage = "Le paiement du dossier N° " . $folderObj->getNumber() . " intitulé " . $folderObj->getDocumentCategory()->getLabel() .
" crée dans la commune de " . $folderObj->getSite()->getLabel() . " a été Initié pour un montant de " . $transaction->getAmount() .
" " . $transaction->getCurrency() . " par " . $folderObj->getUserCreate()->getUserName();
Utilitaires::sendMail(
$mailListArray,
$this->mailer,
$subject,
$notificationMessage,
'email/send_payment_notification.html.twig',
[
'folder' => $folderObj,
'transaction' => $transaction,
'message' => ''
],
$this->logger
);
$response = new Response(json_encode($result));
$response->headers->set('Content-Type', 'application/json');
return $response;
}
public function makutaCheckMomoTransaction(Request $request): Response
{
$makutaTransactionId = $request->get('transactionId');
$transaction = $this->managerRegistry->getRepository(Transaction::class)->findOneBy([
'financialTransaction' => $makutaTransactionId,
]);
if (!is_null($transaction)) {
$response = new Response(json_encode(['response' => $transaction->getStatus()]));
} else {
$response = new Response(json_encode(['response' => 'ERROR']));
}
$response->headers->set('Content-Type', 'application/json');
return $response;
}
public function makutaCallback(Request $request, MakutaAPIV2 $makutaApiV2, LoggerInterface $makutaLogger): Response
{
$body = json_decode($request->getContent(), true) ?? [];
$makutaLogger->info('[MAKUTA_V2] Callback reçu', $body);
$result = $makutaApiV2->handleCallback($body);
$mailList = $this->getParameter('paymentMailList');
$mailListArray = explode(",", $mailList);
if (($result['status'] ?? null) === 'SUCCESS') {
$transaction = $this->managerRegistry->getRepository(Transaction::class)->find($result['transaction_id']);
$folder = $transaction->getFolder();
if ($folder->getStatus() === 2) {
// Le dossier est déjà marqué payé par une transaction précédente (plusieurs
// tentatives de paiement sont possibles pour un même dossier). On ignore ce
// callback pour éviter de créditer deux fois le même document.
$makutaLogger->info('[MAKUTA_V2] Callback ignoré : dossier déjà payé', ['folder' => $folder->getId(), 'transaction' => $transaction->getId()]);
$response = new Response(json_encode(['received' => true]));
$response->headers->set('Content-Type', 'application/json');
return $response;
}
$folder->setStatus(2);
$folder->setDateUpdate(new \DateTime());
$em = $this->managerRegistry->getManager();
$em->persist($folder);
$payments = $this->managerRegistry->getRepository(Payement::class)->findBy([
'reference' => $transaction->getTransactionNumber(),
]);
foreach ($payments as $pay) {
$pay->setStatus(1);
$em->persist($pay);
}
$em->flush();
$makutaLogger->info('[MAKUTA_V2] Callback SUCCESS : dossier crédité', [
'folder' => $folder->getId(),
'transaction' => $transaction->getId(),
'paymentsUpdated' => count($payments),
]);
$subject = "Paiement du document " . $folder->getDocumentCategory()->getLabel();
$notificationMessage = "Le paiement du dossier N° " . $folder->getNumber() . " intitulé " . $folder->getDocumentCategory()->getLabel() .
" crée dans la commune de " . $folder->getSite()->getLabel() . "<br> a été effectué pour un montant de " . $transaction->getAmount() .
" " . $transaction->getCurrency() . " par " . $folder->getUserCreate()->getUserName();
Utilitaires::sendMail(
$mailListArray,
$this->mailer,
$subject,
$notificationMessage,
'email/send_payment_notification.html.twig',
[
'folder' => $folder,
'transaction' => $transaction,
'message' => ''
],
$this->logger
);
} elseif (($result['status'] ?? null) === 'FAILED') {
$transaction = $this->managerRegistry->getRepository(Transaction::class)->find($result['transaction_id']);
$folder = $transaction->getFolder();
$makutaLogger->info('[MAKUTA_V2] Callback FAILED : paiement en échec', [
'folder' => $folder->getId(),
'transaction' => $transaction->getId(),
]);
$subject = "Echec de paiement du document " . $folder->getDocumentCategory()->getLabel();
$notificationMessage = "Le paiement du dossier N° " . $folder->getNumber() . " intitulé " . $folder->getDocumentCategory()->getLabel() .
" crée dans la commune de " . $folder->getSite()->getLabel() . " a <b style='color: darkred'> échoué </b> pour un montant de " . $transaction->getAmount() .
" " . $transaction->getCurrency() . " par " . $folder->getUserCreate()->getUserName();
Utilitaires::sendMail(
$mailListArray,
$this->mailer,
$subject,
$notificationMessage,
'email/send_payment_notification.html.twig',
[
'folder' => $folder,
'transaction' => $transaction,
'message' => ''
],
$this->logger
);
}
$response = new Response(json_encode(['received' => true]));
$response->headers->set('Content-Type', 'application/json');
return $response;
}
/**
* Point de retour navigateur après un paiement carte chez Makuta. Makuta ne renvoie
* aucun paramètre identifiant dans cette redirection (vérifié en preprod : query string
* vide), donc on ne peut pas retrouver la transaction depuis l'URL. À la place, on utilise
* la dernière transaction en attente de l'utilisateur encore connecté dans cette session.
*/
public function makutaRedirect(Request $request, MakutaAPIV2 $makutaApiV2, LoggerInterface $makutaLogger): Response
{
$body = json_decode($request->getContent(), true) ?? [];
$txnId = $body['txnId']
?? $body['txn_id']
?? $request->request->get('txnId')
?? $request->request->get('txn_id');
if ($txnId !== null) {
// Observé en preprod : Makuta envoie parfois le callback serveur-à-serveur sur
// cette URL de redirection plutôt que sur /usr/makuta_callback. Si le corps contient
// bien un txnId, on le traite comme un callback plutôt que de l'ignorer. Sinon (ex:
// retour navigateur via formulaire auto-soumis, sans JSON), on continue ci-dessous.
$makutaLogger->warning('[MAKUTA_V2] Callback reçu sur l\'URL de redirection au lieu de l\'URL de callback');
return $this->makutaCallback($request, $makutaApiV2, $makutaLogger);
}
$makutaLogger->info('[MAKUTA_V2] Redirection reçue', [
'method' => $request->getMethod(),
'query' => $request->query->all(),
'body' => $body,
]);
$user = $this->getUser();
if ($user !== null) {
$transaction = $this->managerRegistry->getRepository(Transaction::class)->findOneBy(
['user' => $user, 'status' => 'PENDING'],
['creationDate' => 'DESC']
);
if ($transaction !== null) {
$makutaLogger->info('[MAKUTA_V2] Redirection : dossier retrouvé via la session', [
'user' => $user->getUserIdentifier(),
'transaction' => $transaction->getId(),
'folder' => $transaction->getFolder()->getId(),
]);
return $this->redirectToRoute('app_folder_view', [
'folder_id' => $transaction->getFolder()->getId(),
]);
}
}
$makutaLogger->warning('[MAKUTA_V2] Redirection : aucune transaction en attente trouvée', [
'user' => $user !== null ? $user->getUserIdentifier() : null,
]);
return $this->redirectToRoute('app_core_workspace_surfer');
}
function comparator($object1, $object2): bool
{
return $object1->financialCorporationCategory->label > $object2->financialCorporationCategory->label;
}
public function add(Request $request, SendNotificationService $sendNotificationService)
{
if ($this->getUser()->getUserType() != 6 && $this->getUser()->getUserType() != 1) {
$this->addFlash('workspace_alert_warning', "Vous ne pouvez pas accéder à la ressource sollicitée");
return $this->redirectToRoute('app_core_workspace');
}
$price = new Prices();
$price->setUserCreate($this->getUser());
$price->setDateCreate(new \DateTime());
$price->setIsDeleted(false);
$price->setStatus(true);
$priceForm = $this->createForm(PriceType::class, $price);
$priceForm->handleRequest($request);
if ($priceForm->isSubmitted() && $priceForm->isValid()) {
$p = $this->managerRegistry->getRepository(Prices::class)->findOneBy([
'site' => $price->getSite(),
'documentCategory' => $price->getDocumentCategory(),
'tarif' => $price->getTarif(),
'isDeleted' => false,
]);
if ($p === null) {
$price->setIsDeleted(false);
$em = $this->managerRegistry->getManager();
$em->persist($price);
$em->flush();
$notificationMessage = "Le prix du document '" . $price->getDocumentCategory()->getLabel() . "' a été fixé à " . $price->getPrice() . " " . $price->getCurrency()->getLabel() . " pour la commune de " . $price->getSite()->getLabel() . " par " . $this->getUser()->getUserName();
$sendNotificationService->sendMailNotification("Ajout de prix ", $notificationMessage);
$this->addFlash('add_price_success', "Le taux a été enregistré avec succès !");
} else {
$this->addFlash('not_found_price', $price->getDocumentCategory()->getLabel() . " a déjà un taux fixé avec les mêmes éléments");
}
}
return $this->render('price/add.html.twig', [
'priceForm' => $priceForm->createView(),
]);
}
public function update(Request $request, SendNotificationService $sendNotificationService)
{
if ($this->getUser()->getUserType() != 6 && $this->getUser()->getUserType() != 1) {
$this->addFlash('workspace_alert_warning', "Vous ne pouvez pas accéder à la ressource sollicitée");
return $this->redirectToRoute('app_core_workspace');
}
$price = $this->managerRegistry->getRepository(Prices::class)->findOneBy([
'id' => $request->get('price_id'),
'isDeleted' => false,
]);
$currentPrice = $price->getPrice();
$currency = $price->getCurrency()->getLabel();
if ($price === null) {
$this->addFlash('not_found_price', 'Le taux ' . ucfirst($request->get('price_id')) . ' n\'a pas été trouvé');
return $this->redirectToRoute('app_admin_prices_register');
}
$priceForm = $this->createForm(PriceType::class, $price);
$priceForm->handleRequest($request);
if ($priceForm->isSubmitted() && $priceForm->isValid()) {
$price->setUserUpdate($this->getUser());
$price->setDateUpdate(new \DateTime());
$em = $this->managerRegistry->getManager();
$em->persist($price);
$em->flush();
$this->addFlash('update_price_success', "Le taux a été modifié avec succès !");
$notificationMessage = "Le prix du document '" . $price->getDocumentCategory()->getLabel()
. " a été modifié de " . $currentPrice . " " . $currency
. "' à " . $price->getPrice() . " " . $price->getCurrency()->getLabel() . " pour la commune de " . $price->getSite()->getLabel() . " par " . $this->getUser()->getUserName();
$sendNotificationService->sendMailNotification("Modification de prix ", $notificationMessage);
return $this->redirectToRoute('app_admin_prices_register', []);
}
return $this->render('price/add.html.twig', [
'priceForm' => $priceForm->createView(),
]);
}
public function register(Request $request)
{
if ($this->getUser()->getUserType() != 6 && $this->getUser()->getUserType() != 1) {
$this->addFlash('workspace_alert_warning', "vous n'avez pas le droit d'accéder à cette fonctionnalité, Prière de contacter votre administrateur pour obtenir les privilèges requis");
return $this->redirectToRoute('app_core_workspace');
}
$prices = $this->managerRegistry->getRepository(Prices::class)->findBy([
'isDeleted' => false,
]);
return $this->render('price/register.html.twig', [
'prices' => $prices,
]);
}
/**
* @param Prices $documentPrice
* @param array $fees
* @param $currency
* @return float
*/
private function calculateTotalAmount(Prices $documentPrice, array $fees, $currency): float
{
$currentRate = $this->managerRegistry->getRepository(ExchangeRate::class)->findOneBy([
'isActive' => true
]);
$formatedFees = $this->getFees($fees, $currentRate, $currency);
switch ($currency) {
case 'CDF':
$totalAmount = $documentPrice->getPrice() * $currentRate->getRate();
foreach ($formatedFees as $fee) {
$totalAmount += $fee['priceCDF'];
}
break;
default:
$totalAmount = $documentPrice->getPrice();
foreach ($formatedFees as $fee) {
$totalAmount += $fee['priceUSD'];
}
break;
}
return $totalAmount;
}
private function getFees(array $fees, $currentRate, $currency): array
{
$array_id = array_map(function ($fee) {
return $fee['id'];
}, $fees);
$result = $this->managerRegistry->getRepository(Fees::class)->findByIdIn($array_id);
return $this->feesService->formatFees($result, $currentRate, $currency);
}
}