Files
crm_ecosplay/src/Controller/Admin/ClientsController.php
Serreau Jovann 18daf096fa feat: systeme complet echeancier SEPA, E-Flex, attestations, avertissements clients
Echeancier - Webhooks DocuSeal:
- Webhook form.completed: telecharge PDF signe + audit, state SIGNED, prepare SEPA, notifie client + admin
- Webhook form.declined: state CANCELLED, notifie client + admin
- Reference EC_ECH_XXXXX affichee dans PDF, emails, pages client, admin
- Attestation fin de paiement auto via DocuSeal au completion

Echeancier - SEPA Direct Debit (remplace Subscriptions):
- Page /echeancier/setup-payment/{id}: formulaire IBAN Stripe Elements + mandat SEPA
- Confirmation SetupIntent -> stocke PaymentMethod -> state ACTIVE
- Commande cron app:echeancier:process-payments: preleve les echeances dues via PaymentIntent off_session
- Webhooks payment_intent.succeeded/failed: met a jour EcheancierLine, notifie client
- Regularisation CB via Stripe Checkout en cas d'echec prelevement
- Bouton "Forcer prelevement" par echeance dans admin
- Infos SEPA stockees (last4, bank_code, country) + affichees admin
- Page setup_payment_done quand SEPA deja configure
- Annulation auto apres 2 rejets + sync paiements vers Advert lie

Echeancier - Lien Advert:
- Champ advert (ManyToOne nullable) sur Echeancier
- Select "Avis lie" dans formulaire creation
- AdvertPayment cree a chaque echeance payee
- Advert passe en accepted quand echeancier completed

Comptabilite:
- Export echeanciers CSV/JSON/PDF/PDF signe dans /admin/comptabilite
- Colonnes: reference, client, creance, majoration, total, paye, restant, Stripe PI, avis lie

Stats:
- Case "Total impaye global" = factures impayees + echeances non payees
- Tableau echeanciers en cours avec restant du

Confiance client:
- Statut Confiant/Attention/Danger calcule dynamiquement
- Badge en haut a droite de la fiche client
- Integre warningLevel (1st=Attention, 2nd=Attention, last=Danger)
- Creation echeancier bloquee si Danger (template + controller)

Avertissements client (tab Controle, ROLE_ROOT):
- 3 niveaux: 1st, 2nd (procedure suspension preparee), last (48h)
- Motifs cochables: impayes, irrespect, hors horaires, services gratuits
- PDF signe DocuSeal pour chaque avertissement (ClientWarningPdf)
- PDF levee avertissement signe (ClientWarningResetPdf)
- Webhooks DocuSeal client_warning + client_warning_reset
- Barre progression 4 etapes dans admin
- Mentions legales: huis clos, contestation direction@e-cosplay.fr

Cloture compte:
- Bouton "Envoyer notification de cloture" apres dernier avertissement
- PDF signe DocuSeal (ClientClosurePdf): suppression 24h, recouvrement, commissaire justice, forces ordre
- Bouton "Suspendre le compte" (state suspended)
- Webhook DocuSeal client_closure: envoie PDF signe a client + admin + direction

Factures:
- Auto-generation PDF si absent lors de l'envoi
- Bouton "Envoyer" visible meme sans PDF pour factures payees

E-Flex (financement services):
- Entites EFlex + EFlexLine (reference E_FLEX_XXXXX)
- Methodes: SEPA, CB (Stripe Checkout), virement manuel
- PDF contrat avec 2 signatures DocuSeal (Company + Client)
- Controller admin CRUD + force payment + paiement manuel
- Pages client: verify, process, sign, signed, setup SEPA, paiement CB
- Webhook DocuSeal eflex: telecharge PDFs, prepare Stripe, notifie
- Webhooks Stripe payment_intent: gestion paiements E-Flex
- Cron traite aussi les E-Flex SEPA dans process-payments
- Tab E-Flex dans fiche client avec liste + modal creation
- Emails: signature, signed, verify_code, echeance_payee, echeance_echec

Attestations custom (ROLE_ROOT):
- Entite AttestationCustom avec items JSON + HMAC SHA-256
- Repeater dynamique pour ajouter elements a attester
- PDF avec phrase officielle "Je soussigne(e)..." + QR code verification
- Signature manuelle dans DocuSeal (redirection)
- Webhook attestation_custom: telecharge PDF signe + audit
- Page publique /attestation/verify/{id}/{hmac} avec validation HMAC
- Lien dans sidebar Super Admin

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 07:45:22 +02:00

932 lines
39 KiB
PHP

<?php
namespace App\Controller\Admin;
use App\Entity\Advert;
use App\Entity\Customer;
use App\Entity\Domain;
use App\Entity\Echeancier;
use App\Entity\User;
use App\Repository\CustomerRepository;
use App\Repository\RevendeurRepository;
use App\Service\CloudflareService;
use App\Service\DnsCheckService;
use App\Service\EsyMailDnsService;
use App\Service\MailerService;
use App\Service\MeilisearchService;
use App\Service\OvhService;
use App\Service\UserManagementService;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Twig\Environment;
#[Route('/admin/clients', name: 'app_admin_clients_')]
#[IsGranted('ROLE_EMPLOYE')]
class ClientsController extends AbstractController
{
private const WELCOME_SUBJECT = 'CRM E-Cosplay - Bienvenue dans votre espace client';
private const WELCOME_TEMPLATE = 'emails/client_created.html.twig';
#[Route('', name: 'index')]
public function index(CustomerRepository $customerRepository, EntityManagerInterface $em): Response
{
$customers = $customerRepository->findBy([], ['createdAt' => 'DESC']);
$customersInfo = $this->buildCustomersInfo($customers, $em);
return $this->render('admin/clients/index.html.twig', [
'customers' => $customers,
'customersInfo' => $customersInfo,
]);
}
#[Route('/create', name: 'create')]
public function create(
Request $request,
CustomerRepository $customerRepository,
RevendeurRepository $revendeurRepository,
EntityManagerInterface $em,
MeilisearchService $meilisearch,
UserManagementService $userService,
LoggerInterface $logger,
HttpClientInterface $httpClient,
MailerService $mailer,
Environment $twig,
#[Autowire(env: 'STRIPE_SK')] string $stripeSecretKey,
): Response {
if ('POST' !== $request->getMethod()) {
return $this->render('admin/clients/create.html.twig', [
'revendeurs' => $revendeurRepository->findBy(['isActive' => true], ['codeRevendeur' => 'ASC']),
]);
}
try {
$firstName = trim($request->request->getString('firstName'));
$lastName = trim($request->request->getString('lastName'));
$email = trim($request->request->getString('email'));
$user = $userService->createBaseUser($email, $firstName, $lastName, ['ROLE_CUSTOMER']);
$customer = new Customer($user);
$this->populateCustomerData($request, $customer);
$this->geocodeIfNeeded($customer, $httpClient);
$this->setupStripeCustomer($customer, $stripeSecretKey);
$em->persist($customer);
$em->flush();
$this->finalizeStripeMetadata($customer, $user, $stripeSecretKey);
$codeComptable = trim($request->request->getString('codeComptable'));
if ('' !== $codeComptable) {
$customer->setCodeComptable(str_starts_with($codeComptable, 'EC-') ? $codeComptable : 'EC-'.$codeComptable);
} else {
$customer->setCodeComptable($customerRepository->generateUniqueCodeComptable($customer));
}
$em->flush();
try {
$meilisearch->indexCustomer($customer);
} catch (\Throwable $e) {
$logger->error('Meilisearch: impossible d\'indexer le client '.$customer->getId().': '.$e->getMessage());
}
$this->ensureDefaultContact($customer, $em);
$setPasswordUrl = $this->generateUrl('app_set_password', ['token' => $user->getTempPassword()], UrlGeneratorInterface::ABSOLUTE_URL);
$mailer->sendEmail(
$user->getEmail(),
self::WELCOME_SUBJECT,
$twig->render(self::WELCOME_TEMPLATE, [
'firstName' => $user->getFirstName(),
'email' => $user->getEmail(),
'setPasswordUrl' => $setPasswordUrl,
]),
);
$this->addFlash('success', 'Client '.$customer->getFullName().' cree. Email de bienvenue envoye.');
return $this->redirectToRoute('app_admin_clients_index');
} catch (\InvalidArgumentException $e) {
$this->addFlash('error', $e->getMessage());
} catch (\Throwable $e) {
$this->addFlash('error', 'Erreur : '.$e->getMessage());
}
return $this->render('admin/clients/create.html.twig');
}
/**
* @param list<Customer> $customers
*
* @return array<int, array{sites: int, domains: int, emails: int, esySign: bool, esyNewsletter: bool, esyMail: bool, unpaid: int}>
*/
private function buildCustomersInfo(array $customers, EntityManagerInterface $em): array
{
$domainRepo = $em->getRepository(Domain::class);
$emailRepo = $em->getRepository(\App\Entity\DomainEmail::class);
$websiteRepo = $em->getRepository(\App\Entity\Website::class);
$info = [];
foreach ($customers as $customer) {
$domains = $domainRepo->findBy(['customer' => $customer]);
$emailCount = 0;
foreach ($domains as $domain) {
$emailCount += $emailRepo->count(['domain' => $domain]);
}
$info[$customer->getId()] = [
'sites' => $websiteRepo->count(['customer' => $customer]),
'domains' => \count($domains),
'emails' => $emailCount,
'esySign' => false,
'esyNewsletter' => false,
'esyMail' => $emailCount > 0,
'unpaid' => 0,
];
}
return $info;
}
/**
* @param list<Domain> $domains
*
* @return array<int, array{esyMail: bool, emailCount: int, esyMailer: bool, configDnsEsyMail: bool, configDnsEsyMailer: bool}>
*/
private function buildDomainsInfo(array $domains, EntityManagerInterface $em, EsyMailDnsService $dnsService, bool $checkDns = false): array
{
$emailRepo = $em->getRepository(\App\Entity\DomainEmail::class);
$info = [];
foreach ($domains as $domain) {
$emailCount = $emailRepo->count(['domain' => $domain]);
$configMail = false;
$configMailer = false;
if ($checkDns) {
$configMail = $dnsService->checkDnsEsyMail($domain->getFqdn())['ok'];
$configMailer = $dnsService->checkDnsEsyMailer($domain->getFqdn())['ok'];
}
$info[$domain->getId()] = [
'esyMail' => $emailCount > 0,
'emailCount' => $emailCount,
'esyMailer' => false,
'configDnsEsyMail' => $configMail,
'configDnsEsyMailer' => $configMailer,
];
}
return $info;
}
private function ensureDefaultContact(Customer $customer, EntityManagerInterface $em): void
{
$contactRepo = $em->getRepository(\App\Entity\CustomerContact::class);
$existing = $contactRepo->findBy(['customer' => $customer]);
if ([] !== $existing) {
return;
}
$firstName = $customer->getFirstName();
$lastName = $customer->getLastName();
if (null === $firstName || null === $lastName || '' === $firstName || '' === $lastName) {
return;
}
$contact = new \App\Entity\CustomerContact($customer, $firstName, $lastName);
$contact->setEmail($customer->getEmail());
$contact->setPhone($customer->getPhone());
$contact->setRole('Directeur');
$contact->setIsBillingEmail(true);
$em->persist($contact);
$em->flush();
}
private function populateCustomerData(Request $request, Customer $customer): void
{
$customer->setFirstName(trim($request->request->getString('firstName')));
$customer->setLastName(trim($request->request->getString('lastName')));
$customer->setEmail(trim($request->request->getString('email')));
$customer->setPhone(trim($request->request->getString('phone')) ?: null);
$customer->setRaisonSociale(trim($request->request->getString('raisonSociale')) ?: null);
$customer->setSiret(trim($request->request->getString('siret')) ?: null);
$customer->setRcs(trim($request->request->getString('rcs')) ?: null);
$customer->setNumTva(trim($request->request->getString('numTva')) ?: null);
$customer->setApe(trim($request->request->getString('ape')) ?: null);
$customer->setRna(trim($request->request->getString('rna')) ?: null);
$customer->setAddress(trim($request->request->getString('address')) ?: null);
$customer->setAddress2(trim($request->request->getString('address2')) ?: null);
$customer->setZipCode(trim($request->request->getString('zipCode')) ?: null);
$customer->setCity(trim($request->request->getString('city')) ?: null);
$customer->setTypeCompany(trim($request->request->getString('typeCompany')) ?: null);
$customer->setRevendeurCode(trim($request->request->getString('revendeurCode')) ?: null);
$customer->setGeoLat(trim($request->request->getString('geoLat')) ?: null);
$customer->setGeoLong(trim($request->request->getString('geoLong')) ?: null);
}
/** @codeCoverageIgnore */
private function geocodeIfNeeded(Customer $customer, HttpClientInterface $httpClient): void
{
if (null !== $customer->getGeoLat() || null === $customer->getAddress()) {
return;
}
$q = implode(' ', array_filter([$customer->getAddress(), $customer->getZipCode(), $customer->getCity()]));
try {
$response = $httpClient->request('GET', 'https://data.geopf.fr/geocodage/search', [
'query' => ['q' => $q, 'limit' => 1],
]);
$data = $response->toArray();
$coords = $data['features'][0]['geometry']['coordinates'] ?? null;
if (null !== $coords) {
$customer->setGeoLong((string) $coords[0]);
$customer->setGeoLat((string) $coords[1]);
}
} catch (\Throwable) {
// Geocoding is best-effort, ignore failures silently
}
}
/** @codeCoverageIgnore */
private function setupStripeCustomer(Customer $customer, string $stripeSecretKey): void
{
if ('' === $stripeSecretKey || 'sk_test_***' === $stripeSecretKey) {
return;
}
\Stripe\Stripe::setApiKey($stripeSecretKey);
$params = [
'email' => $customer->getEmail(),
'name' => $customer->getRaisonSociale() ?? $customer->getFullName(),
'phone' => $customer->getPhone(),
'metadata' => [
'crm_user_id' => 'pending',
'siret' => $customer->getSiret() ?? '',
'code_comptable' => $customer->getCodeComptable() ?? '',
],
];
$address = array_filter([
'line1' => $customer->getAddress(),
'line2' => $customer->getAddress2(),
'postal_code' => $customer->getZipCode(),
'city' => $customer->getCity(),
'country' => 'FR',
]);
if (isset($address['line1'])) {
$params['address'] = $address;
}
if ($customer->getNumTva()) {
$params['tax_id_data'] = [['type' => 'eu_vat', 'value' => $customer->getNumTva()]];
}
$stripeCustomer = \Stripe\Customer::create($params);
$customer->setStripeCustomerId($stripeCustomer->id);
}
/** @codeCoverageIgnore */
private function finalizeStripeMetadata(Customer $customer, User $user, string $stripeSecretKey): void
{
if (null === $customer->getStripeCustomerId() || '' === $stripeSecretKey || 'sk_test_***' === $stripeSecretKey) {
return;
}
\Stripe\Stripe::setApiKey($stripeSecretKey);
\Stripe\Customer::update($customer->getStripeCustomerId(), [
'metadata' => [
'crm_user_id' => (string) $user->getId(),
'code_comptable' => $customer->getCodeComptable() ?? '',
],
]);
}
#[Route('/search', name: 'search', methods: ['GET'])]
public function search(Request $request, MeilisearchService $meilisearch): JsonResponse
{
$query = trim($request->query->getString('q'));
if ('' === $query) {
return new JsonResponse([]);
}
return new JsonResponse($meilisearch->searchCustomers($query));
}
#[Route('/entreprise-search', name: 'entreprise_search', methods: ['GET'])]
public function entrepriseSearch(Request $request, \App\Service\EntrepriseSearchService $searchService): JsonResponse
{
return $searchService->search(trim($request->query->getString('q')));
}
#[Route('/{id}', name: 'show')]
public function show(Customer $customer, Request $request, EntityManagerInterface $em, OvhService $ovhService, CloudflareService $cloudflareService, DnsCheckService $dnsCheckService, EsyMailDnsService $esyMailDnsService, UserPasswordHasherInterface $passwordHasher, MailerService $mailer, Environment $twig): Response
{
$tab = $request->query->getString('tab', 'info');
if ('POST' === $request->getMethod()) {
if ('info' === $tab) {
$this->populateCustomerData($request, $customer);
$customer->setUpdatedAt(new \DateTimeImmutable());
$em->flush();
$this->addFlash('success', 'Informations mises a jour.');
return $this->redirectToRoute('app_admin_clients_show', ['id' => $customer->getId()]);
}
return match ($tab) {
'contacts' => $this->handleContactForm($request, $customer, $em),
'ndd' => $this->handleDomainForm($request, $customer, $em, $ovhService, $cloudflareService, $dnsCheckService),
'securite' => $this->handleSecurityForm($request, $customer, $em, $passwordHasher, $mailer, $twig),
default => $this->redirectToRoute('app_admin_clients_show', ['id' => $customer->getId(), 'tab' => $tab]),
};
}
$this->ensureDefaultContact($customer, $em);
$contacts = $em->getRepository(\App\Entity\CustomerContact::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
$domains = $em->getRepository(Domain::class)->findBy(['customer' => $customer]);
$domainsInfo = $this->buildDomainsInfo($domains, $em, $esyMailDnsService, 'ndd' === $tab);
$websites = $em->getRepository(\App\Entity\Website::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
$devisList = $em->getRepository(\App\Entity\Devis::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
$advertsList = $em->getRepository(\App\Entity\Advert::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
$facturesList = $em->getRepository(\App\Entity\Facture::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
$echeancierList = $em->getRepository(\App\Entity\Echeancier::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
$eflexList = $em->getRepository(\App\Entity\EFlex::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
$trustStatus = $this->computeTrustStatus($advertsList, $echeancierList, $customer);
return $this->render('admin/clients/show.html.twig', [
'customer' => $customer,
'contacts' => $contacts,
'domains' => $domains,
'domainsInfo' => $domainsInfo,
'websites' => $websites,
'devisList' => $devisList,
'advertsList' => $advertsList,
'facturesList' => $facturesList,
'echeancierList' => $echeancierList,
'eflexList' => $eflexList,
'tab' => $tab,
'trustStatus' => $trustStatus,
]);
}
/**
* Calcule le statut de confiance du client.
*
* Confiant : 0 impaye
* Attention : 1 impaye (avis ou echeance)
* Danger : echeancier annule avec rejets, ou 3+ avis impayes, ou 2+ impayes
*
* @param list<Advert> $adverts
* @param list<Echeancier> $echeanciers
*
* @return array{status: string, label: string, color: string, reason: string}
*/
private function computeTrustStatus(array $adverts, array $echeanciers, Customer $customer): array
{
// Avertissements : 2nd = Attention, last = Danger
$warningLevel = $customer->getWarningLevel();
if ('last' === $warningLevel) {
return ['status' => 'danger', 'label' => 'Danger', 'color' => 'red', 'reason' => 'Dernier avertissement envoye'];
}
if ('2nd' === $warningLevel) {
return ['status' => 'attention', 'label' => 'Attention', 'color' => 'yellow', 'reason' => '2eme avertissement envoye'];
}
// Compter les avis impayes (envoyes mais pas acceptes/refuses/annules)
$nbUnpaidAdverts = 0;
foreach ($adverts as $advert) {
if (\in_array($advert->getState(), [Advert::STATE_CREATED, Advert::STATE_SEND], true)) {
++$nbUnpaidAdverts;
}
}
// Verifier les echeanciers annules avec rejets
$hasCancelledWithRejects = false;
$nbUnpaidEcheances = 0;
foreach ($echeanciers as $echeancier) {
if (Echeancier::STATE_CANCELLED === $echeancier->getState() && $echeancier->getNbFailed() > 0) {
$hasCancelledWithRejects = true;
}
if (\in_array($echeancier->getState(), [Echeancier::STATE_ACTIVE, Echeancier::STATE_PENDING_SETUP, Echeancier::STATE_SIGNED], true)) {
foreach ($echeancier->getLines() as $line) {
if ('ok' !== $line->getState()) {
++$nbUnpaidEcheances;
}
}
}
}
$totalUnpaid = $nbUnpaidAdverts + ($nbUnpaidEcheances > 0 ? 1 : 0);
// Danger
if ($hasCancelledWithRejects) {
return ['status' => 'danger', 'label' => 'Danger', 'color' => 'red', 'reason' => 'Echeancier annule suite a des rejets de prelevement'];
}
if ($nbUnpaidAdverts >= 3) {
return ['status' => 'danger', 'label' => 'Danger', 'color' => 'red', 'reason' => $nbUnpaidAdverts.' avis de paiement impayes'];
}
if ($totalUnpaid >= 2) {
return ['status' => 'danger', 'label' => 'Danger', 'color' => 'red', 'reason' => $totalUnpaid.' impayes (avis + echeanciers)'];
}
// Attention (1er avertissement ou 1 impaye)
if ('1st' === $warningLevel) {
return ['status' => 'attention', 'label' => 'Attention', 'color' => 'yellow', 'reason' => '1er avertissement envoye'];
}
if ($totalUnpaid >= 1) {
$reason = $nbUnpaidAdverts > 0 ? $nbUnpaidAdverts.' avis impaye(s)' : 'Echeancier en cours';
return ['status' => 'attention', 'label' => 'Attention', 'color' => 'yellow', 'reason' => $reason];
}
return ['status' => 'confiant', 'label' => 'Confiant', 'color' => 'green', 'reason' => 'Aucun impaye'];
}
private function handleContactForm(Request $request, Customer $customer, EntityManagerInterface $em): Response
{
$action = $request->request->getString('contact_action');
if ('create' === $action) {
$this->persistNewContact($request, $customer, $em);
} elseif ('delete' === $action) {
$contactId = $request->request->getInt('contact_id');
$contact = $em->getRepository(\App\Entity\CustomerContact::class)->find($contactId);
if (null !== $contact && $contact->getCustomer() === $customer) {
$em->remove($contact);
$em->flush();
$this->addFlash('success', 'Contact supprime.');
}
}
return $this->redirectToRoute('app_admin_clients_show', ['id' => $customer->getId(), 'tab' => 'contacts']);
}
private function persistNewContact(Request $request, Customer $customer, EntityManagerInterface $em): void
{
$firstName = trim($request->request->getString('contact_firstName'));
$lastName = trim($request->request->getString('contact_lastName'));
if ('' === $firstName || '' === $lastName) {
return;
}
$contact = new \App\Entity\CustomerContact($customer, $firstName, $lastName);
$contact->setEmail(trim($request->request->getString('contact_email')) ?: null);
$contact->setPhone(trim($request->request->getString('contact_phone')) ?: null);
$contact->setRole(trim($request->request->getString('contact_role')) ?: null);
$contact->setIsBillingEmail($request->request->getBoolean('contact_isBilling'));
$em->persist($contact);
$em->flush();
$this->addFlash('success', 'Contact '.$firstName.' '.$lastName.' ajoute.');
}
private function handleDomainForm(Request $request, Customer $customer, EntityManagerInterface $em, OvhService $ovhService, CloudflareService $cloudflareService, DnsCheckService $dnsCheckService): Response
{
$action = $request->request->getString('domain_action');
if ('create' === $action) {
$fqdn = strtolower(trim($request->request->getString('domain_fqdn')));
$registrar = $request->request->getString('domain_registrar') ?: null;
if ('' === $fqdn) {
$this->addFlash('error', 'Le nom de domaine est requis.');
return $this->redirectToRoute('app_admin_clients_show', ['id' => $customer->getId(), 'tab' => 'ndd']);
}
$existing = $em->getRepository(Domain::class)->findOneBy(['fqdn' => $fqdn]);
if (null !== $existing) {
$this->addFlash('error', 'Le domaine '.$fqdn.' existe deja.');
return $this->redirectToRoute('app_admin_clients_show', ['id' => $customer->getId(), 'tab' => 'ndd']);
}
$domain = new Domain($customer, $fqdn);
$domain->setRegistrar($registrar);
$this->autoDetectDomain($domain, $ovhService, $cloudflareService, $dnsCheckService);
$em->persist($domain);
$em->flush();
$this->addFlash('success', 'Domaine '.$fqdn.' ajoute.');
}
if ('delete' === $action) {
$domainId = $request->request->getInt('domain_id');
$domain = $em->getRepository(Domain::class)->find($domainId);
if (null !== $domain && $domain->getCustomer() === $customer) {
$em->remove($domain);
$em->flush();
$this->addFlash('success', 'Domaine supprime.');
}
}
return $this->redirectToRoute('app_admin_clients_show', ['id' => $customer->getId(), 'tab' => 'ndd']);
}
private function autoDetectDomain(Domain $domain, OvhService $ovhService, CloudflareService $cloudflareService, DnsCheckService $dnsCheckService): void
{
$fqdn = $domain->getFqdn();
// Check OVH
if ($ovhService->isDomainManaged($fqdn)) {
$domain->setRegistrar('OVH');
$domain->setIsGestion(true);
$domain->setIsBilling(true);
$serviceInfo = $ovhService->getDomainServiceInfo($fqdn);
if (null !== $serviceInfo) {
$domain->setExpiredAt(new \DateTimeImmutable($serviceInfo['expiration']));
$domain->setUpdatedAt(new \DateTimeImmutable());
}
$zoneInfo = $ovhService->getZoneInfo($fqdn);
if (null !== $zoneInfo) {
$domain->setZoneCloudflare(null);
$domain->setZoneIdCloudflare(null);
}
}
// Check Cloudflare
if ($cloudflareService->isAvailable()) {
$zoneId = $cloudflareService->getZoneId($fqdn);
if (null !== $zoneId) {
$domain->setZoneCloudflare('active');
$domain->setZoneIdCloudflare($zoneId);
if (null === $domain->getRegistrar()) {
$domain->setRegistrar('Cloudflare');
$domain->setIsGestion(true);
$domain->setIsBilling(true);
}
}
}
// Fallback RDAP pour l'expiration si pas encore trouvée
if (null === $domain->getExpiredAt()) {
$expiration = $dnsCheckService->getExpirationDate($fqdn);
if (null !== $expiration) {
$domain->setExpiredAt($expiration);
}
}
}
private function handleSecurityForm(Request $request, Customer $customer, EntityManagerInterface $em, UserPasswordHasherInterface $passwordHasher, MailerService $mailer, Environment $twig): Response
{
$action = $request->request->getString('security_action');
$user = $customer->getUser();
if ('send_reset_password' === $action) {
$tempPassword = bin2hex(random_bytes(8));
$user->setPassword($passwordHasher->hashPassword($user, $tempPassword));
$user->setTempPassword($tempPassword);
$em->flush();
$setPasswordUrl = $this->generateUrl('app_set_password', ['token' => $user->getTempPassword()], UrlGeneratorInterface::ABSOLUTE_URL);
$mailer->sendEmail(
$user->getEmail(),
self::WELCOME_SUBJECT,
$twig->render(self::WELCOME_TEMPLATE, [
'firstName' => $user->getFirstName(),
'email' => $user->getEmail(),
'setPasswordUrl' => $setPasswordUrl,
]),
);
$this->addFlash('success', 'Un email de reinitialisation a ete envoye a '.$user->getEmail().'.');
}
if ('disable_2fa' === $action) {
$user->setIsEmailAuthEnabled(false);
$user->setIsGoogleAuthEnabled(false);
$user->setGoogleAuthenticatorSecret(null);
$user->setBackupCodes([]);
$em->flush();
$this->addFlash('success', 'Authentification a deux facteurs desactivee.');
}
return $this->redirectToRoute('app_admin_clients_show', ['id' => $customer->getId(), 'tab' => 'securite']);
}
#[Route('/{id}/resend-welcome', name: 'resend_welcome', methods: ['POST'])]
public function resendWelcome(Customer $customer, MailerService $mailer, Environment $twig): Response
{
$user = $customer->getUser();
if (!$user->hasTempPassword()) {
$this->addFlash('error', 'Le mot de passe temporaire n\'est plus disponible. Le client a deja active son compte.');
return $this->redirectToRoute('app_admin_clients_show', ['id' => $customer->getId()]);
}
$setPasswordUrl = $this->generateUrl('app_set_password', ['token' => $user->getTempPassword()], UrlGeneratorInterface::ABSOLUTE_URL);
$mailer->sendEmail(
$user->getEmail(),
self::WELCOME_SUBJECT,
$twig->render(self::WELCOME_TEMPLATE, [
'firstName' => $user->getFirstName(),
'email' => $user->getEmail(),
'setPasswordUrl' => $setPasswordUrl,
]),
);
$this->addFlash('success', 'Email de bienvenue renvoye a '.$user->getEmail().'.');
return $this->redirectToRoute('app_admin_clients_show', ['id' => $customer->getId()]);
}
#[Route('/{id}/toggle', name: 'toggle', methods: ['POST'])]
public function toggle(Customer $customer, EntityManagerInterface $em, MeilisearchService $meilisearch, LoggerInterface $logger): Response
{
$newState = $customer->isActive() ? Customer::STATE_SUSPENDED : Customer::STATE_ACTIVE;
$customer->setState($newState);
$em->flush();
try {
$meilisearch->indexCustomer($customer);
} catch (\Throwable $e) {
$logger->error('Meilisearch: impossible d\'indexer le client '.$customer->getId().': '.$e->getMessage());
}
$this->addFlash('success', 'Client '.($customer->isActive() ? 'active' : 'suspendu').'.');
return $this->redirectToRoute('app_admin_clients_index');
}
#[Route('/{id}/delete', name: 'delete', methods: ['POST'])]
public function delete(Customer $customer, EntityManagerInterface $em): Response
{
if ($customer->isPendingDelete()) {
$this->addFlash('error', 'Ce client est deja en attente de suppression.');
return $this->redirectToRoute('app_admin_clients_index');
}
$customer->setState(Customer::STATE_PENDING_DELETE);
$em->flush();
$this->addFlash('success', 'Client "'.$customer->getFullName().'" marque pour suppression. Il sera supprime automatiquement cette nuit.');
return $this->redirectToRoute('app_admin_clients_index');
}
/**
* Envoie un avertissement au client (1st, 2nd, last).
*/
#[Route('/{id}/send-warning/{level}', name: 'send_warning', requirements: ['id' => '\d+', 'level' => '1st|2nd|last'], methods: ['POST'])]
#[IsGranted('ROLE_ROOT')]
public function sendWarning(
int $id,
string $level,
Request $request,
EntityManagerInterface $em,
\App\Service\DocuSealService $docuSeal,
\Symfony\Component\HttpKernel\KernelInterface $kernel,
): Response {
$customer = $em->getRepository(Customer::class)->find($id);
if (null === $customer) {
throw $this->createNotFoundException('Client introuvable');
}
if (null === $customer->getEmail()) {
$this->addFlash('error', 'Email client introuvable.');
return $this->redirectToRoute('app_admin_clients_show', ['id' => $id, 'tab' => 'controle']);
}
$reasons = $request->request->all('reasons');
// Generer le PDF
$pdf = new \App\Service\Pdf\ClientWarningPdf($kernel, $customer, $level, $reasons);
$pdf->generate();
$tmpPath = tempnam(sys_get_temp_dir(), 'warning_').'.pdf';
$pdf->Output('F', $tmpPath);
$warningLabels = [
'1st' => '1er avertissement',
'2nd' => '2eme avertissement',
'last' => 'Dernier avertissement avant suspension',
];
// Envoyer a DocuSeal pour auto-signature
// Le webhook (doc_type=client_warning) enverra le mail avec le PDF signe
// @codeCoverageIgnoreStart
try {
$pdfBase64 = base64_encode(file_get_contents($tmpPath));
$docuSeal->getApi()->createSubmissionFromPdf([
'name' => 'Avertissement '.$warningLabels[$level].' - '.$customer->getFullName(),
'send_email' => false,
'flatten' => true,
'documents' => [[
'name' => 'avertissement-'.$level.'-'.$customer->getId().'.pdf',
'file' => 'data:application/pdf;base64,'.$pdfBase64,
]],
'submitters' => [[
'email' => 'contact@e-cosplay.fr',
'name' => 'Association E-Cosplay',
'role' => 'First Party',
'completed' => true,
'send_email' => false,
'values' => ['Sign' => $docuSeal->getLogoBase64()],
'metadata' => [
'doc_type' => 'client_warning',
'customer_id' => $customer->getId(),
'level' => $level,
'reasons' => implode(',', $reasons),
],
]],
]);
$this->addFlash('success', $warningLabels[$level].' envoye pour signature. Le client recevra le PDF signe automatiquement.');
} catch (\Throwable $e) {
$this->addFlash('error', 'Erreur DocuSeal : '.$e->getMessage());
}
// @codeCoverageIgnoreEnd
@unlink($tmpPath);
$customer->setWarningLevel($level);
$customer->setWarningAt(new \DateTimeImmutable());
$em->flush();
return $this->redirectToRoute('app_admin_clients_show', ['id' => $id, 'tab' => 'controle']);
}
/**
* Reinitialise les avertissements du client.
*/
/**
* Envoie la notification de cloture (PDF signe via DocuSeal) - n'effectue PAS la suppression.
*/
#[Route('/{id}/close-account', name: 'close_account', requirements: ['id' => '\d+'], methods: ['POST'])]
#[IsGranted('ROLE_ROOT')]
public function closeAccount(
int $id,
EntityManagerInterface $em,
\App\Service\DocuSealService $docuSeal,
\Symfony\Component\HttpKernel\KernelInterface $kernel,
): Response {
$customer = $em->getRepository(Customer::class)->find($id);
if (null === $customer) {
throw $this->createNotFoundException('Client introuvable');
}
if (null === $customer->getEmail()) {
$this->addFlash('error', 'Email client introuvable.');
return $this->redirectToRoute('app_admin_clients_show', ['id' => $id, 'tab' => 'controle']);
}
// Generer le PDF de cloture
$pdf = new \App\Service\Pdf\ClientClosurePdf($kernel, $customer);
$pdf->generate();
$tmpPath = tempnam(sys_get_temp_dir(), 'closure_').'.pdf';
$pdf->Output('F', $tmpPath);
// Envoyer a DocuSeal pour auto-signature
// Le webhook (doc_type=client_closure) enverra le mail avec le PDF signe
// @codeCoverageIgnoreStart
try {
$pdfBase64 = base64_encode(file_get_contents($tmpPath));
$docuSeal->getApi()->createSubmissionFromPdf([
'name' => 'Cloture compte - '.$customer->getFullName(),
'send_email' => false,
'flatten' => true,
'documents' => [[
'name' => 'cloture-'.$customer->getId().'.pdf',
'file' => 'data:application/pdf;base64,'.$pdfBase64,
]],
'submitters' => [[
'email' => 'contact@e-cosplay.fr',
'name' => 'Association E-Cosplay',
'role' => 'First Party',
'completed' => true,
'send_email' => false,
'values' => ['Sign' => $docuSeal->getLogoBase64()],
'metadata' => [
'doc_type' => 'client_closure',
'customer_id' => $customer->getId(),
],
]],
]);
$this->addFlash('success', 'Notification de cloture envoyee pour signature. Le client recevra le PDF signe.');
} catch (\Throwable $e) {
$this->addFlash('error', 'Erreur DocuSeal : '.$e->getMessage());
}
// @codeCoverageIgnoreEnd
@unlink($tmpPath);
return $this->redirectToRoute('app_admin_clients_show', ['id' => $id, 'tab' => 'controle']);
}
/**
* Effectue la suspension reelle du compte (state = suspended).
*/
#[Route('/{id}/suspend-account', name: 'suspend_account', requirements: ['id' => '\d+'], methods: ['POST'])]
#[IsGranted('ROLE_ROOT')]
public function suspendAccount(int $id, EntityManagerInterface $em): Response
{
$customer = $em->getRepository(Customer::class)->find($id);
if (null === $customer) {
throw $this->createNotFoundException('Client introuvable');
}
$customer->setState('suspended');
$customer->setUpdatedAt(new \DateTimeImmutable());
$em->flush();
$this->addFlash('success', 'Compte de '.$customer->getFullName().' suspendu.');
return $this->redirectToRoute('app_admin_clients_show', ['id' => $id, 'tab' => 'controle']);
}
#[Route('/{id}/reset-warning', name: 'reset_warning', requirements: ['id' => '\d+'], methods: ['POST'])]
#[IsGranted('ROLE_ROOT')]
public function resetWarning(
int $id,
EntityManagerInterface $em,
\App\Service\DocuSealService $docuSeal,
\Symfony\Component\HttpKernel\KernelInterface $kernel,
): Response {
$customer = $em->getRepository(Customer::class)->find($id);
if (null === $customer) {
throw $this->createNotFoundException('Client introuvable');
}
$customer->setWarningLevel(null);
$customer->setWarningAt(null);
$em->flush();
// Generer le PDF de levee d'avertissement et envoyer a DocuSeal
// Le webhook (doc_type=client_warning_reset) enverra le mail avec le PDF signe
// @codeCoverageIgnoreStart
if (null !== $customer->getEmail()) {
try {
$pdf = new \App\Service\Pdf\ClientWarningResetPdf($kernel, $customer);
$pdf->generate();
$tmpPath = tempnam(sys_get_temp_dir(), 'reset_').'.pdf';
$pdf->Output('F', $tmpPath);
$pdfBase64 = base64_encode(file_get_contents($tmpPath));
$docuSeal->getApi()->createSubmissionFromPdf([
'name' => 'Levee avertissement - '.$customer->getFullName(),
'send_email' => false,
'flatten' => true,
'documents' => [[
'name' => 'levee-avertissement-'.$customer->getId().'.pdf',
'file' => 'data:application/pdf;base64,'.$pdfBase64,
]],
'submitters' => [[
'email' => 'contact@e-cosplay.fr',
'name' => 'Association E-Cosplay',
'role' => 'First Party',
'completed' => true,
'send_email' => false,
'values' => ['Sign' => $docuSeal->getLogoBase64()],
'metadata' => [
'doc_type' => 'client_warning_reset',
'customer_id' => $customer->getId(),
],
]],
]);
@unlink($tmpPath);
$this->addFlash('success', 'Avertissements reinitialises. Le client recevra le PDF signe de levee d\'avertissement.');
} catch (\Throwable $e) {
$this->addFlash('warning', 'Avertissements reinitialises mais erreur DocuSeal : '.$e->getMessage());
}
} else {
$this->addFlash('success', 'Avertissements reinitialises.');
}
// @codeCoverageIgnoreEnd
return $this->redirectToRoute('app_admin_clients_show', ['id' => $id, 'tab' => 'controle']);
}
}