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>
932 lines
39 KiB
PHP
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']);
|
|
}
|
|
}
|