feat: protection code email + boutons signer/refuser + page refus

Protection :
- /echeancier/verify/{id} : verification par code email 6 chiffres
  (meme pattern que OrderPayment verify, 15 min expiry)
- process redirige vers verify si non authentifie

Pages client :
- Boutons "Signer l'echeancier" (vert) et "Refuser" (rouge) dans process
- /echeancier/sign/{id} : redirige vers DocuSeal
- /echeancier/refuse/{id} : passe en cancelled, affiche page refused
- echeancier/verify.html.twig : saisie code
- echeancier/refused.html.twig : confirmation refus

Email :
- Lien pointe vers /echeancier/verify/{id} (protege)
- Texte Stripe ajoute dans page process

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-04-08 20:18:11 +02:00
parent 06494322bd
commit 4be001967b
5 changed files with 225 additions and 4 deletions

View File

@@ -296,7 +296,7 @@ class EcheancierController extends AbstractController
$slug = $docuSeal->getSubmitterSlug($submitterId);
$signUrl = null !== $slug ? rtrim($docuSealUrl, '/').'/s/'.$slug : null;
$processUrl = $urlGenerator->generate('app_echeancier_process', [
$processUrl = $urlGenerator->generate('app_echeancier_verify', [
'id' => $echeancier->getId(),
], UrlGeneratorInterface::ABSOLUTE_URL);

View File

@@ -3,29 +3,118 @@
namespace App\Controller;
use App\Entity\Echeancier;
use App\Service\DocuSealService;
use App\Service\MailerService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Twig\Environment;
class EcheancierProcessController extends AbstractController
{
public function __construct(
private EntityManagerInterface $em,
private MailerService $mailer,
private Environment $twig,
) {
}
/**
* Page de detail echeancier avant signature (publique).
* Verification par code email avant d'acceder a l'echeancier.
*/
#[Route('/echeancier/process/{id}', name: 'app_echeancier_process', requirements: ['id' => '\d+'])]
public function process(int $id): Response
#[Route('/echeancier/verify/{id}', name: 'app_echeancier_verify', requirements: ['id' => '\d+'])]
public function verify(int $id, Request $request): Response
{
$echeancier = $this->em->getRepository(Echeancier::class)->find($id);
if (null === $echeancier) {
throw $this->createNotFoundException('Echeancier introuvable.');
}
$customer = $echeancier->getCustomer();
$session = $request->getSession();
$sessionKey = 'echeancier_verified_'.$echeancier->getId();
if ($session->get($sessionKey, false)) {
return $this->redirectToRoute('app_echeancier_process', ['id' => $id]);
}
if (null === $customer->getEmail()) {
$session->set($sessionKey, true);
return $this->redirectToRoute('app_echeancier_process', ['id' => $id]);
}
$codeKey = 'echeancier_code_'.$id;
$codeExpiresKey = 'echeancier_code_expires_'.$id;
$error = null;
if ('POST' === $request->getMethod()) {
$code = trim($request->request->getString('code'));
$storedCode = $session->get($codeKey);
$expiresAt = $session->get($codeExpiresKey, 0);
if (time() > $expiresAt) {
$error = 'Code expire. Cliquez sur "Renvoyer" pour en recevoir un nouveau.';
$session->remove($codeKey);
$session->remove($codeExpiresKey);
} elseif ($code === $storedCode) {
$session->set($sessionKey, true);
$session->remove($codeKey);
$session->remove($codeExpiresKey);
return $this->redirectToRoute('app_echeancier_process', ['id' => $id]);
} else {
$error = 'Code incorrect. Veuillez reessayer.';
}
}
// Envoyer le code si pas encore envoye ou expire
if (null === $session->get($codeKey) || time() > $session->get($codeExpiresKey, 0)) {
$code = str_pad((string) random_int(0, 999999), 6, '0', \STR_PAD_LEFT);
$session->set($codeKey, $code);
$session->set($codeExpiresKey, time() + 900);
$this->mailer->sendEmail(
$customer->getEmail(),
'Code de verification - Echeancier',
$this->twig->render('emails/order_verify_code.html.twig', [
'customer' => $customer,
'advert' => null,
'code' => $code,
]),
null,
null,
false,
);
}
return $this->render('echeancier/verify.html.twig', [
'echeancier' => $echeancier,
'customer' => $customer,
'error' => $error,
]);
}
/**
* Page de detail echeancier avant signature (protegee par code).
*/
#[Route('/echeancier/process/{id}', name: 'app_echeancier_process', requirements: ['id' => '\d+'])]
public function process(int $id, Request $request): Response
{
$echeancier = $this->em->getRepository(Echeancier::class)->find($id);
if (null === $echeancier) {
throw $this->createNotFoundException('Echeancier introuvable.');
}
// Verifier si authentifie
$session = $request->getSession();
if (!$session->get('echeancier_verified_'.$echeancier->getId(), false)) {
return $this->redirectToRoute('app_echeancier_verify', ['id' => $id]);
}
if (\in_array($echeancier->getState(), [Echeancier::STATE_SIGNED, Echeancier::STATE_ACTIVE, Echeancier::STATE_COMPLETED], true)) {
return $this->render('echeancier/signed.html.twig', [
'echeancier' => $echeancier,
@@ -33,12 +122,66 @@ class EcheancierProcessController extends AbstractController
]);
}
if (Echeancier::STATE_CANCELLED === $echeancier->getState()) {
return $this->render('echeancier/refused.html.twig', [
'echeancier' => $echeancier,
'customer' => $echeancier->getCustomer(),
]);
}
return $this->render('echeancier/process.html.twig', [
'echeancier' => $echeancier,
'customer' => $echeancier->getCustomer(),
]);
}
/**
* Redirige vers DocuSeal pour signer.
*/
#[Route('/echeancier/sign/{id}', name: 'app_echeancier_sign', requirements: ['id' => '\d+'])]
public function sign(
int $id,
DocuSealService $docuSeal,
#[Autowire(env: 'DOCUSEAL_URL')] string $docuSealUrl = '',
): Response {
$echeancier = $this->em->getRepository(Echeancier::class)->find($id);
if (null === $echeancier) {
throw $this->createNotFoundException('Echeancier introuvable.');
}
$submitterId = (int) ($echeancier->getSubmissionId() ?? '0');
if ($submitterId <= 0) {
throw $this->createNotFoundException('Echeancier non envoye pour signature.');
}
$slug = $docuSeal->getSubmitterSlug($submitterId);
if (null === $slug) {
throw $this->createNotFoundException('Lien de signature introuvable.');
}
return $this->redirect(rtrim($docuSealUrl, '/').'/s/'.$slug);
}
/**
* Refuse l'echeancier.
*/
#[Route('/echeancier/refuse/{id}', name: 'app_echeancier_refuse', requirements: ['id' => '\d+'])]
public function refuse(int $id): Response
{
$echeancier = $this->em->getRepository(Echeancier::class)->find($id);
if (null === $echeancier) {
throw $this->createNotFoundException('Echeancier introuvable.');
}
$echeancier->setState(Echeancier::STATE_CANCELLED);
$this->em->flush();
return $this->render('echeancier/refused.html.twig', [
'echeancier' => $echeancier,
'customer' => $echeancier->getCustomer(),
]);
}
/**
* Callback DocuSeal apres signature du client.
*/

View File

@@ -83,6 +83,25 @@
Une fois le contrat signe, un email vous sera envoye pour effectuer la configuration des prelevements automatiques.
</p>
<p class="text-xs text-gray-500 mb-6">
Les prelevements seront effectues via <strong>Stripe</strong>, plateforme de paiement securisee. Vous recevrez un email de confirmation a chaque echeance.
</p>
{# Boutons signer / refuser #}
{% if echeancier.submissionId %}
<div class="flex justify-center gap-4 mb-6">
<a href="{{ path('app_echeancier_sign', {id: echeancier.id}) }}"
class="px-6 py-3 bg-green-600 text-white font-bold uppercase text-xs tracking-wider hover:bg-green-700 transition-all">
Signer l'echeancier
</a>
<a href="{{ path('app_echeancier_refuse', {id: echeancier.id}) }}"
class="px-6 py-3 bg-red-500/20 text-red-700 font-bold uppercase text-xs tracking-wider hover:bg-red-500 hover:text-white transition-all"
onclick="return confirm('Etes-vous sur de vouloir refuser cet echeancier ?')">
Refuser
</a>
</div>
{% endif %}
<p class="text-center text-xs text-gray-400 mt-6">
Pour toute question : <a href="mailto:contact@e-cosplay.fr" class="font-bold" style="color: #fabf04;">contact@e-cosplay.fr</a>
</p>

View File

@@ -0,0 +1,25 @@
{% extends 'base.html.twig' %}
{% block title %}Echeancier refuse - Association E-Cosplay{% endblock %}
{% block body %}
<div class="min-h-screen flex items-center justify-center p-4" style="background: linear-gradient(135deg, #f5f5f0 0%, #e8e8e0 100%);">
<div class="glass-heavy w-full max-w-lg overflow-hidden">
<div class="glass-dark text-white px-8 py-6 text-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto mb-3 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h1 class="text-xl font-bold uppercase tracking-widest">Echeancier refuse</h1>
</div>
<div class="p-8 text-center">
<p class="text-sm text-gray-600 mb-4">
L'echeancier de paiement a ete refuse.
</p>
<p class="text-xs text-gray-400">
Si vous souhaitez discuter d'autres modalites de paiement, contactez-nous a
<a href="mailto:contact@e-cosplay.fr" class="font-bold" style="color: #fabf04;">contact@e-cosplay.fr</a>
</p>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,34 @@
{% extends 'base.html.twig' %}
{% block title %}Verification - Echeancier - Association E-Cosplay{% endblock %}
{% block body %}
<div class="min-h-screen flex items-center justify-center p-4" style="background: linear-gradient(135deg, #f5f5f0 0%, #e8e8e0 100%);">
<div class="glass-heavy w-full max-w-md overflow-hidden">
<div class="glass-dark text-white px-8 py-6 text-center">
<h1 class="text-lg font-bold uppercase tracking-widest">Verification</h1>
<p class="text-xs text-white/60 mt-1">Un code a ete envoye a {{ customer.email }}</p>
</div>
<div class="p-8">
{% if error %}
<div class="mb-4 p-3 bg-red-500/20 text-red-700 font-bold text-xs">{{ error }}</div>
{% endif %}
<p class="text-sm text-gray-600 mb-4">
Saisissez le code de verification a 6 chiffres recu par email pour acceder a votre echeancier.
</p>
<form method="post" action="{{ path('app_echeancier_verify', {id: echeancier.id}) }}">
<div class="mb-4">
<label for="code" class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Code de verification</label>
<input type="text" id="code" name="code" maxlength="6" pattern="[0-9]{6}" required autofocus
class="input-glass w-full px-4 py-3 text-center text-2xl font-bold tracking-[0.5em]" placeholder="000000">
</div>
<button type="submit" class="w-full btn-gold px-4 py-3 font-bold uppercase text-xs tracking-wider text-gray-900">Verifier</button>
</form>
<p class="text-center text-xs text-gray-400 mt-4">Le code expire dans 15 minutes.</p>
</div>
</div>
</div>
{% endblock %}