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); $slug = $docuSeal->getSubmitterSlug($submitterId);
$signUrl = null !== $slug ? rtrim($docuSealUrl, '/').'/s/'.$slug : null; $signUrl = null !== $slug ? rtrim($docuSealUrl, '/').'/s/'.$slug : null;
$processUrl = $urlGenerator->generate('app_echeancier_process', [ $processUrl = $urlGenerator->generate('app_echeancier_verify', [
'id' => $echeancier->getId(), 'id' => $echeancier->getId(),
], UrlGeneratorInterface::ABSOLUTE_URL); ], UrlGeneratorInterface::ABSOLUTE_URL);

View File

@@ -3,29 +3,118 @@
namespace App\Controller; namespace App\Controller;
use App\Entity\Echeancier; use App\Entity\Echeancier;
use App\Service\DocuSealService;
use App\Service\MailerService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; 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\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Twig\Environment;
class EcheancierProcessController extends AbstractController class EcheancierProcessController extends AbstractController
{ {
public function __construct( public function __construct(
private EntityManagerInterface $em, 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+'])] #[Route('/echeancier/verify/{id}', name: 'app_echeancier_verify', requirements: ['id' => '\d+'])]
public function process(int $id): Response public function verify(int $id, Request $request): Response
{ {
$echeancier = $this->em->getRepository(Echeancier::class)->find($id); $echeancier = $this->em->getRepository(Echeancier::class)->find($id);
if (null === $echeancier) { if (null === $echeancier) {
throw $this->createNotFoundException('Echeancier introuvable.'); 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)) { if (\in_array($echeancier->getState(), [Echeancier::STATE_SIGNED, Echeancier::STATE_ACTIVE, Echeancier::STATE_COMPLETED], true)) {
return $this->render('echeancier/signed.html.twig', [ return $this->render('echeancier/signed.html.twig', [
'echeancier' => $echeancier, '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', [ return $this->render('echeancier/process.html.twig', [
'echeancier' => $echeancier, 'echeancier' => $echeancier,
'customer' => $echeancier->getCustomer(), '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. * 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. Une fois le contrat signe, un email vous sera envoye pour effectuer la configuration des prelevements automatiques.
</p> </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"> <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> Pour toute question : <a href="mailto:contact@e-cosplay.fr" class="font-bold" style="color: #fabf04;">contact@e-cosplay.fr</a>
</p> </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 %}