feat(Contrats): Ajoute la gestion de l'état de la caution (restituée/encaissée).
```
This commit is contained in:
Serreau Jovann
2026-01-23 13:10:42 +01:00
parent 418bb13785
commit 42d588765d
5 changed files with 189 additions and 34 deletions

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260123115335 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE contrats ADD caution_state VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE contrats DROP caution_state');
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Controller\Dashboard;
use App\Entity\Contrats;
use App\Entity\ContratsLine;
use App\Entity\ContratsOption;
use App\Entity\ContratsPayments;
use App\Entity\Devis;
use App\Event\Signature\ContratEvent;
use App\Form\Type\ContratsType;
@@ -79,6 +80,7 @@ class ContratsController extends AbstractController
EntityManagerInterface $entityManager,
Request $request,
Client $client,
\App\Service\Stripe\Client $stripeClient,
DevisRepository $devisRepository,
AppLogger $appLogger,
EventDispatcherInterface $eventDispatcher,
@@ -128,6 +130,40 @@ class ContratsController extends AbstractController
$solde = $totalHt - $dejaPaye;
if($request->query->has('act') && $request->query->get('act') === 'cautionCapture') {
$amount = $request->query->get('amountToCapture');
$paiementCaution = $entityManager->getRepository(ContratsPayments::class)->findOneBy([
'contrat' => $contrat,
'type' => 'caution',
]);
$result = $stripeClient->capture($paiementCaution->getPaymentId());
if($result['state']) {
$contrat->setCautionState("recover");
$entityManager->persist($contrat);
$entityManager->flush();
$this->addFlash("success","Caution restitué");
} else {
$this->addFlash("error",$result['message']);
}
}
if($request->query->has('act') && $request->query->get('act') === 'cautionRelease') {
$paiementCaution = $entityManager->getRepository(ContratsPayments::class)->findOneBy([
'contrat' => $contrat,
'type' => 'caution',
]);
$result = $stripeClient->cancelPayment($paiementCaution->getPaymentId());
if($result['state']) {
$contrat->setCautionState("restitue");
$entityManager->persist($contrat);
$entityManager->flush();
$this->addFlash("success","Caution restitué");
} else {
$this->addFlash("error",$result['message']);
}
}
return $this->render('dashboard/contrats/view.twig', [
'contrat' => $contrat,
'days' => $days,

View File

@@ -140,6 +140,9 @@ class Contrats
#[ORM\OneToOne(mappedBy: 'contrat', cascade: ['persist', 'remove'])]
private ?Facture $facture = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $cautionState = null;
public function __construct()
{
$this->contratsPayments = new ArrayCollection();
@@ -815,6 +818,18 @@ class Contrats
return $this;
}
public function getCautionState(): ?string
{
return $this->cautionState;
}
public function setCautionState(?string $cautionState): static
{
$this->cautionState = $cautionState;
return $this;
}
}

View File

@@ -572,4 +572,36 @@ class Client
return $newSession;
}
public function cancelPayment(?string $getPaymentId)
{
$session = $this->client->checkout->sessions->retrieve($getPaymentId);
$paymentIntent = $this->client->paymentIntents->retrieve($session->payment_intent);
if($paymentIntent->status == "requires_capture") {
$this->client->paymentIntents->cancel($paymentIntent->id);
return [
'state' => true
];
}
return [
'state' => false,
'message' => 'Impossible d\'annuler la caution'
];
}
public function capture(?string $getPaymentId)
{
$session = $this->client->checkout->sessions->retrieve($getPaymentId);
$paymentIntent = $this->client->paymentIntents->retrieve($session->payment_intent);
if($paymentIntent->status == "requires_capture") {
$this->client->paymentIntents->capture($paymentIntent->id);
return [
'state' => true
];
}
return [
'state' => false,
'message' => 'Impossible de récupérer la caution'
];
}
}

View File

@@ -11,9 +11,36 @@
<svg class="w-4 h-4 text-red-500 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
Télécharger Contrat PDF
Télécharger Devis PDF
</a>
{% endif %}
{% if contrat.signed %}
<a href="{{ vich_uploader_asset(contrat,'devisSignFile') }}"
download
class="flex items-center gap-2 px-6 py-2.5 bg-white/5 hover:bg-white/10 border border-white/10 backdrop-blur-md rounded-xl text-white text-xs font-black uppercase italic transition-all group">
<svg class="w-4 h-4 text-red-500 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
Télécharger Contrat Signée PDF
</a>
<a href="{{ vich_uploader_asset(contrat,'devisAuditFile') }}"
download
class="flex items-center gap-2 px-6 py-2.5 bg-white/5 hover:bg-white/10 border border-white/10 backdrop-blur-md rounded-xl text-white text-xs font-black uppercase italic transition-all group">
<svg class="w-4 h-4 text-red-500 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
Télécharger Audit Signée PDF
</a>
{% else %}
<a href="{{ vich_uploader_asset(contrat,'devisFile') }}"
download
class="flex items-center gap-2 px-6 py-2.5 bg-white/5 hover:bg-white/10 border border-white/10 backdrop-blur-md rounded-xl text-white text-xs font-black uppercase italic transition-all group">
<svg class="w-4 h-4 text-red-500 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
Télécharger Contrat PDF
</a>
{% endif %}
</div>
{% endblock %}
@@ -64,54 +91,67 @@
{# 3. CAUTION (MODE ADMINISTRATION) #}
<div class="relative overflow-hidden bg-white/5 border border-white/10 backdrop-blur-xl p-6 rounded-[2rem]">
<div class="flex flex-col gap-4">
{# Icône dynamique : Pulse si non déposée, fixe si OK #}
<div class="w-10 h-10 rounded-xl flex items-center justify-center {{ cautionOk ? 'bg-purple-500/20 text-purple-400' : 'bg-rose-500/20 text-rose-500 animate-pulse' }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div>
<p class="text-slate-500 text-[9px] font-black uppercase tracking-widest">Garantie (Caution)</p>
{% if not cautionOk %}
{# Affichage simple du statut sans lien #}
{# ÉTAT 1 : ATTENTE DE DÉPÔT #}
<div class="mt-1">
<p class="text-rose-500 font-black italic uppercase text-sm leading-tight">
Non Déposée
</p>
<p class="text-rose-500 font-black italic uppercase text-sm leading-tight">Non Déposée</p>
<p class="text-[9px] text-slate-500 uppercase font-bold italic">Attendu : {{ totalCaution|number_format(2, ',', ' ') }}€</p>
</div>
{% else %}
{# État : Caution déposée - Actions d'administration #}
<div class="space-y-3 mt-2">
<p class="text-white font-bold italic uppercase text-[10px] mb-2 opacity-70">Empreinte active</p>
<div class="flex flex-col gap-2">
{# ACTION LIBÉRER #}
<a href="{{ path('app_crm_contrats_view', {id: contrat.id, act: 'cautionRelease'}) }}"
data-turbo="false"
onclick="return confirm('Confirmer la libération de la caution ?')"
class="w-full py-1.5 bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/30 rounded-lg text-emerald-400 text-[9px] font-black uppercase italic text-center transition-all">
Libérer la caution
</a>
{# ÉTAT 2 : CAUTION DÉPOSÉE #}
{% if contrat.cautionState == null %}
{# ACTIONS DISPONIBLES #}
<div class="space-y-3 mt-2">
<p class="text-white font-bold italic uppercase text-[10px] mb-2 opacity-70">Empreinte active</p>
<div class="flex flex-col gap-2">
{# LIBÉRER #}
<a href="{{ path('app_crm_contrats_view', {id: contrat.id, act: 'cautionRelease'}) }}"
data-turbo="false"
onclick="return confirm('Confirmer la libération de la caution ?')"
class="w-full py-1.5 bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/30 rounded-lg text-emerald-400 text-[9px] font-black uppercase italic text-center transition-all">
Libérer la caution
</a>
{# ACTION ENCAISSER #}
<form action="{{ path('app_crm_contrats_view', {id: contrat.id}) }}" method="GET" data-turbo="false" class="space-y-1">
<input type="hidden" name="act" value="cautionCapture">
<div class="flex items-center bg-rose-500/5 rounded-lg border border-rose-500/20 px-2 py-1">
<input type="number"
name="amountToCapture"
step="0.01"
max="{{ totalCaution }}"
value="{{ totalCaution }}"
class="bg-transparent border-none text-rose-500 text-[10px] font-black w-14 p-0 focus:ring-0">
<button type="submit"
onclick="return confirm('Confirmer l\'encaissement partiel ou total ?')"
class="ml-auto text-rose-500 text-[9px] font-black uppercase italic hover:text-white transition-colors">
Encaisser
</button>
</div>
</form>
{# ENCAISSER #}
<form action="{{ path('app_crm_contrats_view', {id: contrat.id}) }}" method="GET" data-turbo="false" class="space-y-1">
<input type="hidden" name="act" value="cautionCapture">
<div class="flex items-center bg-rose-500/5 rounded-lg border border-rose-500/20 px-2 py-1">
<input type="number" name="amountToCapture" step="0.01" max="{{ totalCaution }}" value="{{ totalCaution }}"
class="bg-transparent border-none text-rose-500 text-[10px] font-black w-14 p-0 focus:ring-0">
<button type="submit" onclick="return confirm('Confirmer l\'encaissement ?')"
class="ml-auto text-rose-500 text-[9px] font-black uppercase italic hover:text-white transition-colors">
Encaisser
</button>
</div>
</form>
</div>
</div>
</div>
{% else %}
{# ÉTAT 3 : ARCHIVÉ (RESTITUÉ OU RÉCUPÉRÉ) #}
<div class="mt-2">
{% if contrat.cautionState == 'restitue' %}
<span class="px-3 py-1 bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-[10px] font-black uppercase italic rounded-full">
Restituée
</span>
{% elseif contrat.cautionState == 'recover' %}
<span class="px-3 py-1 bg-amber-500/10 border border-amber-500/20 text-amber-400 text-[10px] font-black uppercase italic rounded-full">
Encaissée
</span>
{% else %}
<span class="text-white font-bold text-xs uppercase opacity-50">{{ contrat.cautionState }}</span>
{% endif %}
</div>
{% endif %}
{% endif %}
</div>
</div>