feat(contrats): Ajoute gestion manuelle des paiements et états de caution

Ajoute la gestion manuelle des paiements (accompte, solde, caution) et permet la libération/encaissement de caution avec envoi de mail.
```
This commit is contained in:
Serreau Jovann
2026-01-29 10:51:03 +01:00
parent e530538af8
commit d0d2e73e78
5 changed files with 273 additions and 309 deletions

View File

@@ -11,7 +11,6 @@ use App\Entity\Product;
use App\Event\Signature\ContratEvent;
use App\Form\Type\ContratsType;
use App\Logger\AppLogger;
use App\Repository\AccountRepository;
use App\Repository\DevisRepository;
use App\Repository\ContratsRepository;
use App\Service\Mailer\Mailer;
@@ -19,7 +18,6 @@ use App\Service\Pdf\ContratPdfService;
use App\Service\Pdf\PlPdf;
use App\Service\Signature\Client;
use Doctrine\ORM\EntityManagerInterface;
use Illuminate\Support\Facades\Mail;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
@@ -32,7 +30,7 @@ use Symfony\Component\Routing\Attribute\Route;
class ContratsController extends AbstractController
{
/**
* Liste des contrats
* Liste des contrats avec pagination et renvoi de mail
*/
#[Route(path: '/crm/contrats', name: 'app_crm_contrats', options: ['sitemap' => false], methods: ['GET'])]
public function contrats(
@@ -43,51 +41,42 @@ class ContratsController extends AbstractController
Request $request
): Response {
// --- ACTION D'ENVOI PAR EMAIL ---
if ($request->query->has('idSend')) {
$contrat = $contratsRepository->find($request->query->get('idSend'));
if (!$contrat) {
$this->addFlash("danger", "Contrat introuvable.");
return $this->redirectToRoute('app_crm_contrats');
}
// Déclenchement de l'événement (ton Subscriber s'occupe de l'envoi du mail)
$event = new ContratEvent($contrat);
$eventDispatcher->dispatch($event);
$this->addFlash("success", "Le contrat a bien été envoyé à " . $contrat->getCustomer()->getEmail());
$appLogger->record('RESEND', "Renvoi du contrat N°" . $contrat->getNumReservation() . " effectué");
$appLogger->record('RESEND', "Renvoi du contrat N°" . $contrat->getNumReservation());
return $this->redirectToRoute('app_crm_contrats');
}
// --- LOG DE CONSULTATION ---
$appLogger->record('VIEW', 'Consultation de la liste des contrats');
// --- AFFICHAGE DE LA LISTE ---
$query = $contratsRepository->findBy([], ['createAt' => 'DESC']);
$pagination = $paginator->paginate(
$query,
$request->query->getInt('page', 1),
10
);
$pagination = $paginator->paginate($query, $request->query->getInt('page', 1), 10);
return $this->render('dashboard/contrats/list.twig', [
'contrats' => $pagination,
]);
}
/**
* Vue détaillée et gestion des paiements/actions
*/
#[Route(path: '/crm/contrats/view/{id}', name: 'app_crm_contrats_view', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function contratsView(
?Contrats $contrat,
EntityManagerInterface $entityManager,
Request $request,
Client $client,
\App\Service\Stripe\Client $stripeClient,
DevisRepository $devisRepository,
AppLogger $appLogger,
EventDispatcherInterface $eventDispatcher,
KernelInterface $kernel,
Mailer $mailer,
): Response {
@@ -95,222 +84,121 @@ class ContratsController extends AbstractController
throw $this->createNotFoundException('Contrat non trouvé.');
}
// --- CALCULS DES MONTANTS ---
$totalHt = 0;
$totalCaution = 0;
$days = $contrat->getDateAt()->diff($contrat->getEndAt())->days + 1;
// 1. Calcul de la durée en jours
$dateStart = $contrat->getDateAt();
$dateEnd = $contrat->getEndAt();
// On ajoute +1 pour inclure le jour de début et de fin
$interval = $dateStart->diff($dateEnd);
$days = $interval->days + 1;
// 2. Calcul des lignes avec tarif dégressif
foreach ($contrat->getContratsLines() as $line) {
// Premier jour
$priceLine = $line->getPrice1DayHt();
// Jours supplémentaires
if ($days > 1) {
$priceLine += ($line->getPriceSupDayHt() * ($days - 1));
}
$priceLine = $line->getPrice1DayHt() + ($line->getPriceSupDayHt() * ($days - 1));
$totalHt += $priceLine;
$totalCaution += $line->getCaution();
}
// 3. Ajout des options (forfaitaires)
foreach ($contrat->getContratsOptions() as $option) {
$totalHt += $option->getPrice();
}
// 4. Calcul du solde (Total - paiements déjà effectués)
$dejaPaye = 0;
foreach ($contrat->getContratsPayments() as $payment) {
if ($payment->getState() === 'complete' && $payment->getType() !== 'caution') {
$dejaPaye += $payment->getAmount();
foreach ($contrat->getContratsPayments() as $p) {
if ($p->getState() === 'complete' && $p->getType() !== 'caution') {
$dejaPaye += $p->getAmount();
}
}
$solde = $totalHt - $dejaPaye;
$customer = $contrat->getCustomer();
$customerName = $customer->getSurname() . ' ' . $customer->getName();
if($request->query->has('type') && $request->query->get('type') === 'accompte') {
$paiementAccompte = $entityManager->getRepository(ContratsPayments::class)->findOneBy([
'contrat' => $contrat,
'type' => 'accompte',
]);
if(!$paiementAccompte) {
$paiementAccompte = new ContratsPayments();
$paiementAccompte->setContrat($contrat);
$paiementAccompte->setType('accompte');
}
$paiementAccompte->setValidateAt(new \DateTimeImmutable());
$paiementAccompte->setUpdateAt(new \DateTimeImmutable());
$paiementAccompte->setPaymentAt(new \DateTimeImmutable());
$paiementAccompte->setAmount( $totalHt * 0.25);
$paiementAccompte->setState("complete");
$paiementAccompte->setPaymentId("");
$paiementAccompte->setCard([
'type' => 'manuel'
]);
$pdf = new PlPdf($kernel, $paiementAccompte, $contrat);
$pdf->generate();
$content = $pdf->Output('S');
$tmpSigned = sys_get_temp_dir() . '/sign_' . uniqid() . '.pdf';
file_put_contents($tmpSigned, $content);
// On utilise UploadedFile pour simuler un upload propre pour VichUploader
$paiementAccompte->setPaymentFile(new UploadedFile($tmpSigned, "confirmed-" . $paiementAccompte->getId() . ".pdf", "application/pdf", null, true));
$paiementAccompte->setUpdateAt(new \DateTimeImmutable('now'));
$entityManager->persist($paiementAccompte);
$entityManager->flush();
$data = $client->autoSignConfirmedPayment($paiementAccompte);
// 1. Gestion du PDF SIGNÉ
$tmpSigned = sys_get_temp_dir() . '/sign_' . uniqid() . '.pdf';
$signedContent = file_get_contents($data);
file_put_contents($tmpSigned, $signedContent);
// On utilise UploadedFile pour simuler un upload propre pour VichUploader
$paiementAccompte->setPaymentSignedFile(new UploadedFile($tmpSigned, "confirmed-certificate-" . $paiementAccompte->getId() . ".pdf", "application/pdf", null, true));
$paiementAccompte->setUpdateAt(new \DateTimeImmutable('now'));
$entityManager->persist($paiementAccompte);
$entityManager->flush();
$customer = $contrat->getCustomer();
$subjectCustomer = "[Ludikevent] Confirmation de votre acompte - #" . $contrat->getNumReservation();
$mailer->send(
$customer->getEmail(),
$customer->getSurname() . ' ' . $customer->getName(),
$subjectCustomer,
"mails/customer/accompte_confirmation.twig",
[
'contrat' => $contrat,
'payment' => $paiementAccompte,
'customer' => $customer,
'reservationLink' => "https://reservation.ludikevent.fr" . $this->generateUrl('gestion_contrat_view', ['num' => $contrat->getNumReservation()])
]
);
$appLogger->record('PAYMENT','Validation accompte manuel pour contrat #' . $contrat->getNumReservation());
$this->addFlash("success","Validation accompte effectuée");
return $this->redirectToRoute('app_crm_contrats_view', ['id' => $contrat->getId()]);
}
if($request->query->has('type') && $request->query->get('type') === 'caution') {
$paiementAccompte = $entityManager->getRepository(ContratsPayments::class)->findOneBy([
// --- TRAITEMENT : ACTIONS SUR CAUTION (Libérer / Encaisser) ---
if ($request->query->has('action')) {
$action = $request->query->get('action');
$paymentCaution = $entityManager->getRepository(ContratsPayments::class)->findOneBy([
'contrat' => $contrat,
'type' => 'caution',
]);
if(!$paiementAccompte) {
$paiementAccompte = new ContratsPayments();
$paiementAccompte->setContrat($contrat);
$paiementAccompte->setType('caution');
if ($paymentCaution) {
if ($action === 'liberer') {
$paymentCaution->setState("release");
$subject = "[Ludikevent] Votre caution a été libérée - #" . $contrat->getNumReservation();
$template = "mails/customer/caution_release.twig";
$logAction = "Libération caution";
} else {
$paymentCaution->setState("recup");
$subject = "[Ludikevent] Votre caution a été encaissée - #" . $contrat->getNumReservation();
$template = "mails/customer/caution_encaissement.twig";
$logAction = "Encaissement caution";
}
$entityManager->flush();
$mailer->send($customer->getEmail(), $customerName, $subject, $template, [
'datas' => [
'contrat' => $contrat,
'payment' => $paymentCaution,
'customer' => $customer,
'reservationLink' => "https://reservation.ludikevent.fr" . $this->generateUrl('gestion_contrat_view', ['num' => $contrat->getNumReservation()])
]
]);
$appLogger->record('PAYMENT', "$logAction pour #" . $contrat->getNumReservation());
$this->addFlash("success", "Action effectuée avec succès.");
return $this->redirectToRoute('app_crm_contrats_view', ['id' => $contrat->getId()]);
}
$paiementAccompte->setValidateAt(new \DateTimeImmutable());
$paiementAccompte->setUpdateAt(new \DateTimeImmutable());
$paiementAccompte->setPaymentAt(new \DateTimeImmutable());
$paiementAccompte->setAmount( $totalCaution);
$paiementAccompte->setState("complete");
$paiementAccompte->setPaymentId("");
$paiementAccompte->setCard([
'type' => 'manuel'
]);
$pdf = new PlPdf($kernel, $paiementAccompte, $contrat);
$pdf->generate();
$content = $pdf->Output('S');
$tmpSigned = sys_get_temp_dir() . '/sign_' . uniqid() . '.pdf';
file_put_contents($tmpSigned, $content);
// On utilise UploadedFile pour simuler un upload propre pour VichUploader
$paiementAccompte->setPaymentFile(new UploadedFile($tmpSigned, "confirmed-" . $paiementAccompte->getId() . ".pdf", "application/pdf", null, true));
$paiementAccompte->setUpdateAt(new \DateTimeImmutable('now'));
$entityManager->persist($paiementAccompte);
$entityManager->flush();
$data = $client->autoSignConfirmedPayment($paiementAccompte);
// 1. Gestion du PDF SIGNÉ
$tmpSigned = sys_get_temp_dir() . '/sign_' . uniqid() . '.pdf';
$signedContent = file_get_contents($data);
file_put_contents($tmpSigned, $signedContent);
// On utilise UploadedFile pour simuler un upload propre pour VichUploader
$paiementAccompte->setPaymentSignedFile(new UploadedFile($tmpSigned, "confirmed-certificate-" . $paiementAccompte->getId() . ".pdf", "application/pdf", null, true));
$paiementAccompte->setUpdateAt(new \DateTimeImmutable('now'));
$entityManager->persist($paiementAccompte);
$entityManager->flush();
$customer = $contrat->getCustomer();
$subjectCustomer = "[Ludikevent] Confirmation de votre caution - #" . $contrat->getNumReservation();
$mailer->send(
$customer->getEmail(),
$customer->getSurname() . ' ' . $customer->getName(),
$subjectCustomer,
"mails/customer/accompte_confirmation.twig",
[
'contrat' => $contrat,
'payment' => $paiementAccompte,
'customer' => $customer,
'reservationLink' => "https://reservation.ludikevent.fr" . $this->generateUrl('gestion_contrat_view', ['num' => $contrat->getNumReservation()])
]
);
$appLogger->record('PAYMENT','Validation caution manuel pour contrat #' . $contrat->getNumReservation());
$this->addFlash("success","Validation caution effectuée");
return $this->redirectToRoute('app_crm_contrats_view', ['id' => $contrat->getId()]);
}
if($request->query->has('type') && $request->query->get('type') === 'solde') {
$paiementAccompte = $entityManager->getRepository(ContratsPayments::class)->findOneBy([
// --- TRAITEMENT : ENREGISTREMENT PAIEMENT MANUEL ---
if ($request->query->has('type')) {
$type = $request->query->get('type');
$amount = match($type) {
'accompte' => $totalHt * 0.25,
'caution' => $totalCaution,
'solde' => $solde,
default => 0
};
$payment = $entityManager->getRepository(ContratsPayments::class)->findOneBy([
'contrat' => $contrat,
'type' => 'solde',
]);
if(!$paiementAccompte) {
$paiementAccompte = new ContratsPayments();
$paiementAccompte->setContrat($contrat);
$paiementAccompte->setType('solde');
}
$paiementAccompte->setValidateAt(new \DateTimeImmutable());
$paiementAccompte->setUpdateAt(new \DateTimeImmutable());
$paiementAccompte->setPaymentAt(new \DateTimeImmutable());
$paiementAccompte->setAmount( $totalHt);
$paiementAccompte->setState("complete");
$paiementAccompte->setPaymentId("");
$paiementAccompte->setCard([
'type' => 'manuel'
]);
$pdf = new PlPdf($kernel, $paiementAccompte, $contrat);
'type' => $type,
]) ?? new ContratsPayments();
$payment->setContrat($contrat);
$payment->setType($type);
$payment->setAmount($amount);
$payment->setState("complete");
$payment->setPaymentAt(new \DateTimeImmutable());
$payment->setValidateAt(new \DateTimeImmutable());
$payment->setUpdateAt(new \DateTimeImmutable());
$payment->setCard(['type' => 'manuel']);
$payment->setPaymentId("MANUAL-" . uniqid());
// Génération PDF et signature
$pdf = new PlPdf($kernel, $payment, $contrat);
$pdf->generate();
$content = $pdf->Output('S');
$tmpSigned = sys_get_temp_dir() . '/sign_' . uniqid() . '.pdf';
file_put_contents($tmpSigned, $content);
$tmpPath = sys_get_temp_dir() . '/pay_' . uniqid() . '.pdf';
file_put_contents($tmpPath, $pdf->Output('S'));
// On utilise UploadedFile pour simuler un upload propre pour VichUploader
$paiementAccompte->setPaymentFile(new UploadedFile($tmpSigned, "confirmed-" . $paiementAccompte->getId() . ".pdf", "application/pdf", null, true));
$paiementAccompte->setUpdateAt(new \DateTimeImmutable('now'));
$entityManager->persist($paiementAccompte);
$payment->setPaymentFile(new UploadedFile($tmpPath, "recu-" . $type . ".pdf", "application/pdf", null, true));
$entityManager->persist($payment);
$entityManager->flush();
$data = $client->autoSignConfirmedPayment($paiementAccompte);
// 1. Gestion du PDF SIGNÉ
$tmpSigned = sys_get_temp_dir() . '/sign_' . uniqid() . '.pdf';
$signedContent = file_get_contents($data);
file_put_contents($tmpSigned, $signedContent);
// On utilise UploadedFile pour simuler un upload propre pour VichUploader
$paiementAccompte->setPaymentSignedFile(new UploadedFile($tmpSigned, "confirmed-certificate-" . $paiementAccompte->getId() . ".pdf", "application/pdf", null, true));
$paiementAccompte->setUpdateAt(new \DateTimeImmutable('now'));
$entityManager->persist($paiementAccompte);
$signedUrl = $client->autoSignConfirmedPayment($payment);
$tmpSigned = sys_get_temp_dir() . '/signed_' . uniqid() . '.pdf';
file_put_contents($tmpSigned, file_get_contents($signedUrl));
$payment->setPaymentSignedFile(new UploadedFile($tmpSigned, "sign-" . $type . ".pdf", "application/pdf", null, true));
$entityManager->flush();
$customer = $contrat->getCustomer();
$subjectCustomer = "[Ludikevent] Votre réservation est désormais soldée - #" . $contrat->getNumReservation();
$mailer->send(
$customer->getEmail(),
$customer->getSurname() . ' ' . $customer->getName(),
$subjectCustomer,
"mails/customer/accompte_confirmation.twig",
[
$mailer->send($customer->getEmail(), $customerName, "[Ludikevent] Confirmation de paiement - " . $type, "mails/customer/accompte_confirmation.twig", [
'datas' => [
'contrat' => $contrat,
'payment' => $paiementAccompte,
'payment' => $payment,
'customer' => $customer,
'reservationLink' => "https://reservation.ludikevent.fr" . $this->generateUrl('gestion_contrat_view', ['num' => $contrat->getNumReservation()])
]
);
$appLogger->record('PAYMENT','Validation solde manuel pour contrat #' . $contrat->getNumReservation());
$this->addFlash("success","Validation solde effectuée");
]);
$appLogger->record('PAYMENT', "Validation manuelle $type pour #" . $contrat->getNumReservation());
$this->addFlash("success", "Paiement $type enregistré.");
return $this->redirectToRoute('app_crm_contrats_view', ['id' => $contrat->getId()]);
}
@@ -319,14 +207,13 @@ class ContratsController extends AbstractController
'days' => $days,
'solde' => $solde,
'totalHT' => $totalHt,
'signedNumber' => $contrat->getSignID(),
'arrhes' => $totalHt * 0.25,
'totalCaution' => $totalCaution,
'arrhes' => $totalHt * 0.25,
]);
}
/**
* Création d'un contrat à partir d'un devis
* Création d'un contrat
*/
#[Route(path: '/crm/contrats/add', name: 'app_crm_contrats_create', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function contratsAdd(
@@ -335,143 +222,70 @@ class ContratsController extends AbstractController
Client $client,
DevisRepository $devisRepository,
AppLogger $appLogger,
EventDispatcherInterface $eventDispatcher,
KernelInterface $kernel,
): Response {
$devis = $devisRepository->find($request->get('idDevis', 0));
$c = new Contrats();
$lines = [['id' => 0, 'name' => '', 'priceHt1Day' => 0, 'priceHtSupDay' => 0, 'caution' => 0]];
$options = [['id' => 0, 'name' => '', 'priceHt' => 0,'details'=>'']];
// Logique de pré-remplissage via Devis...
if ($devis instanceof Devis) {
$c->setDateAt($devis->getStartAt());
$c->setEndAt($devis->getEndAt());
$c->setCustomer($devis->getCustomer());
$c->setDevis($devis);
// Mapping adresse de l'événement
if ($devis->getAddressShip()) {
$c->setAddressEvent($devis->getAddressShip()->getAddress());
$c->setAddress2Event($devis->getAddressShip()->getAddress2());
$c->setAddress3Event($devis->getAddressShip()->getAddress3());
$c->setZipCodeEvent($devis->getAddressShip()->getZipcode());
$c->setTownEvent($devis->getAddressShip()->getCity());
}
$lines = [];
$options = [];
foreach ($devis->getDevisLines() as $line) {
$p = $entityManager->getRepository(Product::class)->findOneBy(['name'=>$line->getProduct()]);
$lines[] = [
'id' => $line->getId(),
'name' =>$p->getName() . " - " . $p->getRef(),
'priceHt1Day' => $line->getPriceHt(),
'priceHtSupDay' => $line->getPriceHtSup(),
'caution' => $p->getCaution(),
];
}
foreach ($devis->getDevisOptions() as $line) {
$options[] = [
'id' => $line->getId(),
'name' => $line->getOption(),
'details' => $line->getDetails(),
'priceHt' => $line->getPriceHt(),
];
}
}
$form = $this->createForm(ContratsType::class, $c);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// Récupération sécurisée des données de lignes et d'options
$postData = $request->request->all();
if (isset($postData['lines'])) {
foreach ($postData['lines'] as $line) {
$vc = new ContratsLine();
$vc->setContrat($c);
$vc->setType("");
$vc->setName($line['name']);
$vc->setPrice1DayHt($line['priceHt1Day']);
$vc->setPriceSupDayHt($line['priceHtSupDay']);
$vc->setCaution($line['caution']);
$entityManager->persist($vc);
}
foreach ($postData['lines'] ?? [] as $line) {
$vc = (new ContratsLine())->setContrat($c)->setName($line['name'])->setPrice1DayHt($line['priceHt1Day'])->setPriceSupDayHt($line['priceHtSupDay'])->setCaution($line['caution']);
$entityManager->persist($vc);
}
if (isset($postData['options'])) {
foreach ($postData['options'] as $line) {
$vc = new ContratsOption();
$vc->setContrat($c);
$vc->setName($line['name']);
$vc->setDetails($line['details']);
$vc->setPrice($line['priceHt']);
$entityManager->persist($vc);
}
foreach ($postData['options'] ?? [] as $opt) {
$vo = (new ContratsOption())->setContrat($c)->setName($opt['name'])->setDetails($opt['details'])->setPrice($opt['priceHt']);
$entityManager->persist($vo);
}
// Génération des données de réservation
$reservationNumber = $this->generateReservationNumber();
$c->setNumReservation($reservationNumber);
$c->setIsSigned(false);
$c->setNumReservation($this->generateReservationNumber());
$c->setCreateAt(new \DateTimeImmutable());
$contrateService = new ContratPdfService($kernel,$c,true);
$contentDocuseal = $contrateService->generate();
$tmpPathDocuseal = sys_get_temp_dir() . '/docuseal_' . uniqid() . '.pdf';
file_put_contents($tmpPathDocuseal, $contentDocuseal);
$fileDocuseal = new UploadedFile($tmpPathDocuseal, 'dc_' . $devis->getNum() . '.pdf', 'application/pdf', 0, true);
$c->setDevisDocuSealFile($fileDocuseal);
// PDFs
foreach ([true, false] as $isDocuseal) {
$service = new ContratPdfService($kernel, $c, $isDocuseal);
$tmp = sys_get_temp_dir() . '/' . uniqid() . '.pdf';
file_put_contents($tmp, $service->generate());
$file = new UploadedFile($tmp, 'doc.pdf', 'application/pdf', null, true);
$isDocuseal ? $c->setDevisDocuSealFile($file) : $c->setDevisFile($file);
}
$contrateService = new ContratPdfService($kernel,$c,false);
$contentDevis = $contrateService->generate();
$tmpPathDevis = sys_get_temp_dir() . '/devis_' . uniqid() . '.pdf';
file_put_contents($tmpPathDevis, $contentDevis);
$fileDevis = new UploadedFile($tmpPathDevis, 'devis_' . $devis->getNum() . '.pdf', 'application/pdf', 0, true);
$c->setDevisFile($fileDevis);
$entityManager->persist($c);
$entityManager->flush();
$client->createSubmissionContrat($c);
// Flash & Logs
$this->addFlash('success', "Le contrat $reservationNumber a été généré avec succès.");
$appLogger->record('CREATE', "Génération contrat : $reservationNumber pour le client " . $c->getCustomer()->getName());
$appLogger->record('CREATE', "Contrat généré : " . $c->getNumReservation());
return $this->redirectToRoute('app_crm_contrats');
}
$appLogger->record('VIEW', 'Consultation page création contrat');
return $this->render('dashboard/contrats/add.twig', [
'devis' => $devis,
'form' => $form->createView(),
'lines' => $lines,
'options' => $options,
'lines' => $lines ?? [],
'options' => $options ?? [],
]);
}
/**
* Génère un numéro de réservation sécurisé
*/
private function generateReservationNumber(): string
{
$prefix = 'RESERV-' . date('Ymd');
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
$randomString = '';
for ($i = 0; $i < 10; $i++) {
$randomString .= $alphabet[random_int(0, strlen($alphabet) - 1)];
}
return $prefix . '-' . $randomString;
return 'RESERV-' . date('Ymd') . '-' . substr(str_shuffle('ABCDEFGHIJKLMNOPQRSTUVWXYZ'), 0, 10);
}
}

View File

@@ -184,6 +184,18 @@ class StripeExtension extends AbstractExtension
public function contratPaymentPay(Contrats $contrat,string $type): bool
{
if($type == "caution_free"){
$pl = $this->em->getRepository(ContratsPayments::class)->findOneBy(['type'=>'caution','contrat'=>$contrat]);
if($pl instanceof ContratsPayments) {
return $pl->getState() == "release";
}
}
if($type == "caution_recup"){
$pl = $this->em->getRepository(ContratsPayments::class)->findOneBy(['type'=>'caution','contrat'=>$contrat]);
if($pl instanceof ContratsPayments) {
return $pl->getState() == "recup";
}
}
$pl = $this->em->getRepository(ContratsPayments::class)->findOneBy(['type'=>$type,'contrat'=>$contrat]);
if($pl instanceof ContratsPayments) {
return $pl->getState() == "complete";

View File

@@ -35,6 +35,7 @@
{# Définition des états de paiement #}
{% set acompteOk = contratPaymentPay(contrat, 'accompte') %}
{% set cautionOk = contratPaymentPay(contrat, 'caution') %}
{% set soldeOk = (solde <= 0.05) %}
<div class="space-y-8 pb-20">
@@ -149,22 +150,46 @@
<div>
<span class="block text-[9px] font-black uppercase tracking-widest {{ cautionOk ? 'text-emerald-500' : 'text-rose-500' }}">Caution</span>
<div class="flex flex-col gap-2 mt-3">
{% if not cautionOk %}
{% set cautionRelase = contratPaymentPay(contrat, 'caution_free') %}
{% set cautionEncaisser = contratPaymentPay(contrat, 'caution_recup') %}
{# 1. ON TESTE D'ABORD LES ÉTATS FINAUX #}
{% if cautionRelase %}
<div class="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-500/10 border border-emerald-500/20 rounded-lg">
<svg class="w-3 h-3 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"/>
</svg>
<span class="text-[9px] font-black text-emerald-500 uppercase italic">Caution Libérée</span>
</div>
{% elseif cautionEncaisser %}
<div class="flex items-center gap-1.5 px-3 py-1.5 bg-amber-500/10 border border-amber-500/20 rounded-lg">
<svg class="w-3 h-3 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<span class="text-[9px] font-black text-amber-500 uppercase italic">Caution Encaissée</span>
</div>
{# 2. SI PAS DE STATUT FINAL, ON REGARDE SI ELLE EST AU MOINS REÇUE #}
{% elseif cautionOk %}
<div class="flex gap-2">
<a data-turbo="false" href="{{ path('app_crm_contrats_view', {id: contrat.id, action: 'encaisser'}) }}"
onclick="return confirm('CONFIRMATION : Voulez-vous vraiment ENCAISSER la caution ?')"
class="px-3 py-1.5 bg-amber-500/20 hover:bg-amber-500/40 border border-amber-500/30 rounded-lg text-[9px] font-black text-amber-500 uppercase transition-all shadow-lg shadow-amber-900/10">
Encaisser
</a>
<a data-turbo="false" href="{{ path('app_crm_contrats_view', {id: contrat.id, action: 'liberer'}) }}"
class="px-3 py-1.5 bg-emerald-500/20 hover:bg-emerald-500/40 border border-emerald-500/30 rounded-lg text-[9px] font-black text-emerald-400 uppercase transition-all shadow-lg shadow-emerald-900/10">
Libérer
</a>
</div>
{# 3. SINON, C'EST QU'ELLE N'EST PAS ENCORE ENREGISTRÉE #}
{% else %}
<a data-turbo="false" href="{{ path('app_crm_contrats_view', {id: contrat.id, type: 'caution'}) }}"
class="px-4 py-1.5 bg-rose-500/20 hover:bg-rose-500/30 border border-rose-500/30 rounded-lg text-[10px] font-black text-rose-400 uppercase transition-all">
Marquer reçue
</a>
{% else %}
<div class="flex gap-2">
<a data-turbo="false" href="{{ path('app_crm_contrats_view', {id: contrat.id, action: 'encaisser'}) }}"
class="px-3 py-1.5 bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500/30 rounded-lg text-[9px] font-black text-amber-500 uppercase transition-all">
Encaisser
</a>
<a data-turbo="false" href="{{ path('app_crm_contrats_view', {id: contrat.id, action: 'liberer'}) }}"
class="px-3 py-1.5 bg-emerald-500/20 hover:bg-emerald-500/30 border border-emerald-500/30 rounded-lg text-[9px] font-black text-emerald-400 uppercase transition-all">
Libérer
</a>
</div>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,58 @@
{% extends 'mails/base.twig' %}
{% block content %}
<mj-section padding="20px">
<mj-column background-color="#ffffff" border-radius="24px" padding="30px" css-class="shadow">
{# Icône d'alerte / prélèvement #}
<mj-image src="https://cdn-icons-png.flaticon.com/512/595/595067.png" width="60px" padding-bottom="20px" />
<mj-text align="center" font-size="28px" font-weight="900" color="#0f172a" text-transform="uppercase" font-style="italic" padding-bottom="0px">
Caution <span color="#e11d48">Encaissée</span>
</mj-text>
<mj-text align="center" font-size="10px" font-weight="800" color="#64748b" letter-spacing="2px" text-transform="uppercase" padding-top="10px">
Réservation #{{ datas.contrat.numReservation }}
</mj-text>
<mj-divider border-width="1px" border-color="#f1f5f9" padding="30px 0" />
<mj-text font-size="15px" color="#334155" line-height="1.5">
Bonjour <strong>{{ datas.customer.surname }}</strong>,
<br /><br />
Nous vous informons que suite au retour de votre location et après expertise de notre équipe, nous avons procédé à l'**encaissement de votre caution**.
<br /><br />
Cette décision fait suite au constat suivant :
<p style="background-color: #fff1f2; border-left: 4px solid #e11d48; padding: 15px; color: #9f1239; font-style: italic;">
{{ datas.reason|default('Non-respect des conditions générales de location ou dégradation du matériel constaté lors du retour.') }}
</p>
</mj-text>
<mj-wrapper background-color="#f8fafc" border-radius="16px" padding="20px">
<mj-section padding="0">
<mj-column width="50%">
<mj-text font-size="10px" font-weight="800" color="#94a3b8" text-transform="uppercase">Montant prélevé</mj-text>
<mj-text font-size="22px" font-weight="900" font-style="italic" color="#e11d48" padding-top="5px">
{{ datas.payment.amount|number_format(2, ',', ' ') }}
</mj-text>
</mj-column>
<mj-column width="50%">
<mj-text font-size="10px" font-weight="800" color="#94a3b8" text-transform="uppercase" align="right">Date d'effet</mj-text>
<mj-text font-size="14px" font-weight="700" color="#475569" align="right" padding-top="10px">
{{ "now"|date('d/m/Y') }}
</mj-text>
</mj-column>
</mj-section>
</mj-wrapper>
<mj-text font-size="13px" color="#64748b" padding-top="20px">
Vous recevrez prochainement par email une facture détaillée justifiant les frais de remise en état ou les pénalités appliquées.
</mj-text>
<mj-button background-color="#e11d48" color="#ffffff" border-radius="12px" font-weight="800" text-transform="uppercase" font-size="12px" padding-top="30px" href="{{ datas.contactLink }}">
Contacter le support
</mj-button>
</mj-column>
</mj-section>
{% endblock %}

View File

@@ -0,0 +1,55 @@
{% extends 'mails/base.twig' %}
{% block content %}
<mj-section padding="20px">
<mj-column background-color="#ffffff" border-radius="24px" padding="30px" css-class="shadow">
{# Icône de succès / déverrouillage #}
<mj-image src="https://cdn-icons-png.flaticon.com/512/7124/7124533.png" width="60px" padding-bottom="20px" />
<mj-text align="center" font-size="28px" font-weight="900" color="#0f172a" text-transform="uppercase" font-style="italic" padding-bottom="0px">
Caution <span color="#16a34a">Libérée</span>
</mj-text>
<mj-text align="center" font-size="10px" font-weight="800" color="#64748b" letter-spacing="2px" text-transform="uppercase" padding-top="10px">
Réservation #{{ datas.contrat.numReservation }}
</mj-text>
<mj-divider border-width="1px" border-color="#f1f5f9" padding="30px 0" />
<mj-text font-size="15px" color="#334155" line-height="1.5">
Bonjour <strong>{{ datas.customer.surname }}</strong>,
<br /><br />
Bonne nouvelle ! Suite à la vérification du matériel de votre réservation, nous vous confirmons que votre <strong>caution a été intégralement libérée</strong>.
<br /><br />
L'empreinte bancaire qui avait été effectuée est désormais annulée. Votre plafond bancaire sera mis à jour automatiquement par votre établissement bancaire sous un délai habituel de 24h à 48h.
</mj-text>
<mj-wrapper background-color="#f0fdf4" border-radius="16px" padding="20px">
<mj-section padding="0">
<mj-column width="50%">
<mj-text font-size="10px" font-weight="800" color="#16a34a" text-transform="uppercase">Statut final</mj-text>
<mj-text font-size="20px" font-weight="900" font-style="italic" color="#16a34a" padding-top="5px">
ANNULÉE
</mj-text>
</mj-column>
<mj-column width="50%">
<mj-text font-size="10px" font-weight="800" color="#94a3b8" text-transform="uppercase" align="right">Montant libéré</mj-text>
<mj-text font-size="20px" font-weight="900" color="#475569" align="right" padding-top="5px">
{{ datas.payment.amount|number_format(2, ',', ' ') }}
</mj-text>
</mj-column>
</mj-section>
</mj-wrapper>
<mj-text font-size="12px" color="#94a3b8" align="center" padding-top="20px" line-height="1.4">
<em>Note : Puisqu'il s'agissait d'une pré-autorisation, aucune transaction de "remboursement" n'apparaîtra sur votre relevé, la ligne de débit initial disparaîtra simplement ou passera en statut annulé.</em>
</mj-text>
<mj-button background-color="#0f172a" color="#ffffff" border-radius="12px" font-weight="800" text-transform="uppercase" font-size="12px" padding-top="30px" href="{{ datas.reservationLink }}">
Voir ma réservation
</mj-button>
</mj-column>
</mj-section>
{% endblock %}