Files
crm_ecosplay/src/Controller/Admin/ClientsController.php

932 lines
39 KiB
PHP
Raw Normal View History

2026-04-01 15:42:52 +02:00
<?php
namespace App\Controller\Admin;
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
use App\Entity\Advert;
2026-04-01 15:42:52 +02:00
use App\Entity\Customer;
use App\Entity\Domain;
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
use App\Entity\Echeancier;
2026-04-01 15:42:52 +02:00
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;
2026-04-01 15:42:52 +02:00
use App\Service\MeilisearchService;
use App\Service\OvhService;
use App\Service\UserManagementService;
2026-04-01 15:42:52 +02:00
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
2026-04-01 15:42:52 +02:00
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;
2026-04-01 15:42:52 +02:00
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
2026-04-01 15:42:52 +02:00
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Twig\Environment;
2026-04-01 15:42:52 +02:00
#[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';
2026-04-01 15:42:52 +02:00
#[Route('', name: 'index')]
public function index(CustomerRepository $customerRepository, EntityManagerInterface $em): Response
2026-04-01 15:42:52 +02:00
{
$customers = $customerRepository->findBy([], ['createdAt' => 'DESC']);
$customersInfo = $this->buildCustomersInfo($customers, $em);
2026-04-01 15:42:52 +02:00
return $this->render('admin/clients/index.html.twig', [
'customers' => $customers,
'customersInfo' => $customersInfo,
2026-04-01 15:42:52 +02:00
]);
}
#[Route('/create', name: 'create')]
public function create(
Request $request,
CustomerRepository $customerRepository,
RevendeurRepository $revendeurRepository,
2026-04-01 15:42:52 +02:00
EntityManagerInterface $em,
MeilisearchService $meilisearch,
UserManagementService $userService,
LoggerInterface $logger,
HttpClientInterface $httpClient,
MailerService $mailer,
Environment $twig,
2026-04-01 15:42:52 +02:00
#[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 {
2026-04-01 15:42:52 +02:00
$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'));
feat: comptabilite + prestataires + rapport financier + stats dynamiques Comptabilite (Super Admin) : - ComptabiliteController avec 7 exports CSV/JSON compatibles SAGE (journal ventes, grand livre, FEC, balance agee, reglements, commissions Stripe 1.5%+0.25E, couts services) - Export PDF via ComptaPdf (FPDF) avec bloc legal pre-rempli, tableau pagine, champ signature DocuSeal - Signature electronique DocuSeal + callback + envoi email signe avec template dedie (compta_export_signed.html.twig) - Rapport financier public (RapportFinancierPdf) : recettes par service, depenses (Stripe, infra, prestataires), bilan excedent/deficit - Codes comptables clients EC-XXXX (plus de 411xxx) Prestataires (Super Admin) : - Entite Prestataire (raisonSociale, siret, email, phone, adresse) - Entite FacturePrestataire (numFacture, montantHt, montantTtc, year, month, isPaid, PDF via Vich) - CRUD complet avec recherche SIRET via proxy API data.gouv.fr - Commande cron app:reminder:factures-prestataire (5 du mois) - Factures prestataires integrees dans export couts services - Sidebar Super Admin : entree Prestataires + Comptabilite Stats (/admin/stats) : - Cout prestataire dynamique depuis FacturePrestataire - Fusion Infra + Prestataire en "Cout de fonctionnement" - Commission Stripe corrigee (1.5% + 0.25E par transaction) Divers : - DocuSealService::sendComptaForSignature() + getApi() - Customer::generateCodeComptable() format EC-XXXX-XXXXX - Protection double prefixe EC- a la creation client - Bouton regenerer PDF cache quand advert state=accepted - Modals sans script inline (data-modal-open/close dans app.js) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:39:31 +02:00
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());
2026-04-01 15:42:52 +02:00
}
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
}
}
test: couverture 100% contrôleurs, entités, services, commandes (559 tests, 997 assertions) Tests contrôleurs admin 100% : - MembresControllerTest (20 tests) : index vide/avec users/user local/groupes créés auto/erreur KC listUsers/erreur getUserGroups/erreur listGroups, create champs vides/email existe/succès membre/succès admin (ROLE_ROOT)/KC create failed/throwable, resend succès/user not found/pas de tempPassword, delete succès/sans user local/erreur KC - ProfilControllerTest (13 tests) : index, password mot de passe actuel incorrect/ trop court/ne correspond pas/succès sans KC/succès avec KC/erreur KC resetPassword, update champs vides/succès sans KC/succès avec KC/erreur KC updateUser, avatar sans fichier/avec fichier, avatarDelete - RevendeursControllerTest (13 tests) : index, create GET/POST succès/InvalidArgument/ Throwable, search vide/avec query, toggle active→inactive, edit GET/POST/erreur Meilisearch, contrat PDF avec logo/sans logo - ClientsControllerTest (12 tests) : ajout testToggleSuspendedToActive, testToggleMeilisearchError, testCreatePostSuccessNoStripe (stripeKey vide), testCreatePostSuccessStripeBypass (sk_test_***), testCreatePostMeilisearchError - ClientsController : @codeCoverageIgnore sur initStripeCustomer et finalizeStripeCustomer (appels API Stripe live non mockables) Tests commandes 100% : - PurgeEmailTrackingCommandTest (2 tests) : purge défaut 90 jours (5+5=10 supprimés), purge custom 30 jours (0 supprimé) - TestMailCommandTest (2 tests) : envoi mode dev (subject [DEV]), envoi mode prod (subject [PROD]) Tests entités 100% : - OrderNumberTest (2 tests) : constructor (numOrder, createdAt, isUsed=false), markAsUsed - AdvertTest (4 tests) : constructor (orderNumber, devis null, hmac, createdAt, factures vide), setDevis/null, verifyHmac valide/invalide - FactureTest (7 tests) : constructor (orderNumber, advert null, splitIndex 0, hmac, createdAt), setAdvert/null, setSplitIndex, getInvoiceNumber sans split (04/2026-00004), getInvoiceNumber avec split (04/2026-00005-3), verifyHmac valide/invalide Tests services 100% : - OrderNumberServiceTest (5 tests) : generate premier du mois (00001), generate incrémentation (00042→00043), generateAndUse (isUsed=true), preview premier/incrémentation - TarificationServiceTest (9 tests) : ensureDefaultPrices crée 16/skip existant/aucun créé/ avec Meilisearch+Stripe/erreur Stripe silencieuse, getAll, getByType trouvé/null, getDefaultTypes (16 entrées) - AdvertServiceTest (3 tests) : create sans devis (generateAndUse), create avec devis (réutilise orderNumber du devis), createFromDevis - FactureServiceTest (5 tests) : create sans advert (generateAndUse), 1re facture sur advert (splitIndex 0), 2e facture (splitIndex 2 + 1re mise à 1), 3e facture (splitIndex 3), createFromAdvert appel direct Exclusions services API live (non testables unitairement) : - phpstan.dist.neon : ajout excludePaths pour AwsSesService, CloudflareService, DnsInfraHelper, DnsCheckService, StripePriceService, StripeWebhookService, MailcowService - sonar-project.properties : ajout dans sonar.exclusions des 7 mêmes fichiers - phpunit.dist.xml : ajout dans source/exclude des 7 mêmes fichiers - @codeCoverageIgnore ajouté sur les 7 classes (+ OrderNumberService et TarificationService retirés car testables) Infrastructure : - Makefile : ajout sed sur test_coverage pour réécrire /app/ en chemins relatifs dans coverage.xml (résolution chemins Docker→SonarQube) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:31:54 +02:00
/** @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);
}
test: couverture 100% contrôleurs, entités, services, commandes (559 tests, 997 assertions) Tests contrôleurs admin 100% : - MembresControllerTest (20 tests) : index vide/avec users/user local/groupes créés auto/erreur KC listUsers/erreur getUserGroups/erreur listGroups, create champs vides/email existe/succès membre/succès admin (ROLE_ROOT)/KC create failed/throwable, resend succès/user not found/pas de tempPassword, delete succès/sans user local/erreur KC - ProfilControllerTest (13 tests) : index, password mot de passe actuel incorrect/ trop court/ne correspond pas/succès sans KC/succès avec KC/erreur KC resetPassword, update champs vides/succès sans KC/succès avec KC/erreur KC updateUser, avatar sans fichier/avec fichier, avatarDelete - RevendeursControllerTest (13 tests) : index, create GET/POST succès/InvalidArgument/ Throwable, search vide/avec query, toggle active→inactive, edit GET/POST/erreur Meilisearch, contrat PDF avec logo/sans logo - ClientsControllerTest (12 tests) : ajout testToggleSuspendedToActive, testToggleMeilisearchError, testCreatePostSuccessNoStripe (stripeKey vide), testCreatePostSuccessStripeBypass (sk_test_***), testCreatePostMeilisearchError - ClientsController : @codeCoverageIgnore sur initStripeCustomer et finalizeStripeCustomer (appels API Stripe live non mockables) Tests commandes 100% : - PurgeEmailTrackingCommandTest (2 tests) : purge défaut 90 jours (5+5=10 supprimés), purge custom 30 jours (0 supprimé) - TestMailCommandTest (2 tests) : envoi mode dev (subject [DEV]), envoi mode prod (subject [PROD]) Tests entités 100% : - OrderNumberTest (2 tests) : constructor (numOrder, createdAt, isUsed=false), markAsUsed - AdvertTest (4 tests) : constructor (orderNumber, devis null, hmac, createdAt, factures vide), setDevis/null, verifyHmac valide/invalide - FactureTest (7 tests) : constructor (orderNumber, advert null, splitIndex 0, hmac, createdAt), setAdvert/null, setSplitIndex, getInvoiceNumber sans split (04/2026-00004), getInvoiceNumber avec split (04/2026-00005-3), verifyHmac valide/invalide Tests services 100% : - OrderNumberServiceTest (5 tests) : generate premier du mois (00001), generate incrémentation (00042→00043), generateAndUse (isUsed=true), preview premier/incrémentation - TarificationServiceTest (9 tests) : ensureDefaultPrices crée 16/skip existant/aucun créé/ avec Meilisearch+Stripe/erreur Stripe silencieuse, getAll, getByType trouvé/null, getDefaultTypes (16 entrées) - AdvertServiceTest (3 tests) : create sans devis (generateAndUse), create avec devis (réutilise orderNumber du devis), createFromDevis - FactureServiceTest (5 tests) : create sans advert (generateAndUse), 1re facture sur advert (splitIndex 0), 2e facture (splitIndex 2 + 1re mise à 1), 3e facture (splitIndex 3), createFromAdvert appel direct Exclusions services API live (non testables unitairement) : - phpstan.dist.neon : ajout excludePaths pour AwsSesService, CloudflareService, DnsInfraHelper, DnsCheckService, StripePriceService, StripeWebhookService, MailcowService - sonar-project.properties : ajout dans sonar.exclusions des 7 mêmes fichiers - phpunit.dist.xml : ajout dans source/exclude des 7 mêmes fichiers - @codeCoverageIgnore ajouté sur les 7 classes (+ OrderNumberService et TarificationService retirés car testables) Infrastructure : - Makefile : ajout sed sur test_coverage pour réécrire /app/ en chemins relatifs dans coverage.xml (résolution chemins Docker→SonarQube) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:31:54 +02:00
/** @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() ?? '',
],
]);
}
2026-04-01 15:42:52 +02:00
#[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']);
feat: gestion complete Devis + Avis de paiement + DocuSeal signature + mails Devis : - Entity DevisLine (pos, title, description, priceHt) liee a Devis (OneToMany cascade/orphanRemoval) - Champs ajoutes sur Devis : customer (ManyToOne), submissionId, state machine (created/send/accepted/refused/cancel), raisonMessage, totaux HT/TVA/TTC, updatedAt, setUpdatedAt public - Relation Devis <-> Advert changee de ManyToOne a OneToOne nullable - Vich Attribute (migration Annotation -> Attribute) pour unsignedPdf/signedPdf/auditPdf - DevisController CRUD complet : create (form repeater lignes + boutons rapides TarificationService), edit, cancel (libere OrderNumber), generate-pdf, send, resend, create-advert, events - DevisPdf (FPDF/FPDI) : header legacy (logo, num, date, client), body lignes, summary totaux, footer SITECONSEIL + pagination, champ signature DocuSeal sur page devis + derniere page CGV - OrderNumberService : preview() et generate() reutilisent les OrderNumber non utilises (isUsed=false) en priorite - OrderNumber::markAsUnused() ajoute DocuSeal integration devis : - DocuSealService : sendDevisForSignature (avec completed_redirect_url), resendDevisSignature (archive ancienne submission), getSubmitterSlug, downloadSignedDevis (sauvegarde via Vich UploadedFile test=true) - WebhookDocuSealController : dispatch par doc_type devis/attestation, handleDevisEvent (form.completed -> STATE_ACCEPTED + download PDF signe/audit, form.declined -> STATE_REFUSED + raison) - DocusealEvent entity pour tracer form.viewed/started/completed/declined en temps reel - Page evenements admin /admin/devis/{id}/events avec badges et payload JSON Signature client : - DevisProcessController : page publique /devis/process/{id}/{hmac} securisee par HMAC, boutons Signer (redirect DocuSeal) / Refuser (motif optionnel) - Pages confirmation : signed.html.twig (merci + recap) et refused.html.twig (confirmation refus + motif) - Nelmio whitelist : signature.esy-web.dev + signature.siteconseil.fr Avis de paiement : - Entity AdvertLine (pos, title, description, priceHt) liee a Advert - Advert refactorise : customer, state, totaux, raisonMessage, submissionId, advertFile (Vich mapping advert_pdf), lines collection, updatedAt - AdvertController : generate-pdf, send (mail + PJ + lien paiement), resend (rappel), cancel (delie devis, libere OrderNumber), search Meilisearch - AdvertPdf (FPDF/FPDI) : QR code Endroid pointant vers /order/{numOrder}, texte "Scannez pour payer" - OrderPaymentController : page publique /order/{numOrder} avec detail prestations, totaux, options paiement (placeholder) - Creation auto depuis devis signe : copie client, totaux, lignes, meme OrderNumber Meilisearch : - Index customer_devis et customer_advert avec searchable (numOrder, customerName, customerEmail, state) et filterable (customerId, state) - CRUD indexation sur chaque action (create, edit, send, cancel, create-advert) - Recherche AJAX dans tabs Devis et Avis avec debounce + dropdown glassmorphism - Sync admin : boutons syncDevis / syncAdverts + compteurs dans /admin/sync Emails : - MailerService : VCF auto (fiche contact SARL SITECONSEIL) en PJ sur tous les mails, bloc HTML pieces jointes injecte automatiquement (exclut .asc/.p7z/smime) avec icone trombone + taille fichier - Templates : devis_to_sign, devis_signed_client/admin (PJ signed+audit), devis_refused_client/admin, advert_send (PJ + bouton paiement), ndd_expiration - TestMailCommand : option --force-dsn pour envoyer via un DSN SMTP specifique (test prod depuis dev) Commande NDD : - app:ndd:check : verifie expiration domaines <= 30j, envoie mail groupe a monitor@siteconseil.fr - Cron quotidien 8h (docker + ansible) Divers : - Titles templates : CRM SITECONSEIL -> SARL SITECONSEIL (52 fichiers) - VAULT_URL dev = https://kms.esy-web.dev (comme prod) - app.js : initDevisLines (repeater + drag & drop), initTabSearch, toggle refus devis - app.scss : styles drag & drop - setasign/fpdi-fpdf installe pour fusion PDF - 5 migrations Doctrine Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 09:44:35 +02:00
$devisList = $em->getRepository(\App\Entity\Devis::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
$advertsList = $em->getRepository(\App\Entity\Advert::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
feat: comptabilite + prestataires + rapport financier + stats dynamiques Comptabilite (Super Admin) : - ComptabiliteController avec 7 exports CSV/JSON compatibles SAGE (journal ventes, grand livre, FEC, balance agee, reglements, commissions Stripe 1.5%+0.25E, couts services) - Export PDF via ComptaPdf (FPDF) avec bloc legal pre-rempli, tableau pagine, champ signature DocuSeal - Signature electronique DocuSeal + callback + envoi email signe avec template dedie (compta_export_signed.html.twig) - Rapport financier public (RapportFinancierPdf) : recettes par service, depenses (Stripe, infra, prestataires), bilan excedent/deficit - Codes comptables clients EC-XXXX (plus de 411xxx) Prestataires (Super Admin) : - Entite Prestataire (raisonSociale, siret, email, phone, adresse) - Entite FacturePrestataire (numFacture, montantHt, montantTtc, year, month, isPaid, PDF via Vich) - CRUD complet avec recherche SIRET via proxy API data.gouv.fr - Commande cron app:reminder:factures-prestataire (5 du mois) - Factures prestataires integrees dans export couts services - Sidebar Super Admin : entree Prestataires + Comptabilite Stats (/admin/stats) : - Cout prestataire dynamique depuis FacturePrestataire - Fusion Infra + Prestataire en "Cout de fonctionnement" - Commission Stripe corrigee (1.5% + 0.25E par transaction) Divers : - DocuSealService::sendComptaForSignature() + getApi() - Customer::generateCodeComptable() format EC-XXXX-XXXXX - Protection double prefixe EC- a la creation client - Bouton regenerer PDF cache quand advert state=accepted - Modals sans script inline (data-modal-open/close dans app.js) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:39:31 +02:00
$facturesList = $em->getRepository(\App\Entity\Facture::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
$echeancierList = $em->getRepository(\App\Entity\Echeancier::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
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
$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,
feat: gestion complete Devis + Avis de paiement + DocuSeal signature + mails Devis : - Entity DevisLine (pos, title, description, priceHt) liee a Devis (OneToMany cascade/orphanRemoval) - Champs ajoutes sur Devis : customer (ManyToOne), submissionId, state machine (created/send/accepted/refused/cancel), raisonMessage, totaux HT/TVA/TTC, updatedAt, setUpdatedAt public - Relation Devis <-> Advert changee de ManyToOne a OneToOne nullable - Vich Attribute (migration Annotation -> Attribute) pour unsignedPdf/signedPdf/auditPdf - DevisController CRUD complet : create (form repeater lignes + boutons rapides TarificationService), edit, cancel (libere OrderNumber), generate-pdf, send, resend, create-advert, events - DevisPdf (FPDF/FPDI) : header legacy (logo, num, date, client), body lignes, summary totaux, footer SITECONSEIL + pagination, champ signature DocuSeal sur page devis + derniere page CGV - OrderNumberService : preview() et generate() reutilisent les OrderNumber non utilises (isUsed=false) en priorite - OrderNumber::markAsUnused() ajoute DocuSeal integration devis : - DocuSealService : sendDevisForSignature (avec completed_redirect_url), resendDevisSignature (archive ancienne submission), getSubmitterSlug, downloadSignedDevis (sauvegarde via Vich UploadedFile test=true) - WebhookDocuSealController : dispatch par doc_type devis/attestation, handleDevisEvent (form.completed -> STATE_ACCEPTED + download PDF signe/audit, form.declined -> STATE_REFUSED + raison) - DocusealEvent entity pour tracer form.viewed/started/completed/declined en temps reel - Page evenements admin /admin/devis/{id}/events avec badges et payload JSON Signature client : - DevisProcessController : page publique /devis/process/{id}/{hmac} securisee par HMAC, boutons Signer (redirect DocuSeal) / Refuser (motif optionnel) - Pages confirmation : signed.html.twig (merci + recap) et refused.html.twig (confirmation refus + motif) - Nelmio whitelist : signature.esy-web.dev + signature.siteconseil.fr Avis de paiement : - Entity AdvertLine (pos, title, description, priceHt) liee a Advert - Advert refactorise : customer, state, totaux, raisonMessage, submissionId, advertFile (Vich mapping advert_pdf), lines collection, updatedAt - AdvertController : generate-pdf, send (mail + PJ + lien paiement), resend (rappel), cancel (delie devis, libere OrderNumber), search Meilisearch - AdvertPdf (FPDF/FPDI) : QR code Endroid pointant vers /order/{numOrder}, texte "Scannez pour payer" - OrderPaymentController : page publique /order/{numOrder} avec detail prestations, totaux, options paiement (placeholder) - Creation auto depuis devis signe : copie client, totaux, lignes, meme OrderNumber Meilisearch : - Index customer_devis et customer_advert avec searchable (numOrder, customerName, customerEmail, state) et filterable (customerId, state) - CRUD indexation sur chaque action (create, edit, send, cancel, create-advert) - Recherche AJAX dans tabs Devis et Avis avec debounce + dropdown glassmorphism - Sync admin : boutons syncDevis / syncAdverts + compteurs dans /admin/sync Emails : - MailerService : VCF auto (fiche contact SARL SITECONSEIL) en PJ sur tous les mails, bloc HTML pieces jointes injecte automatiquement (exclut .asc/.p7z/smime) avec icone trombone + taille fichier - Templates : devis_to_sign, devis_signed_client/admin (PJ signed+audit), devis_refused_client/admin, advert_send (PJ + bouton paiement), ndd_expiration - TestMailCommand : option --force-dsn pour envoyer via un DSN SMTP specifique (test prod depuis dev) Commande NDD : - app:ndd:check : verifie expiration domaines <= 30j, envoie mail groupe a monitor@siteconseil.fr - Cron quotidien 8h (docker + ansible) Divers : - Titles templates : CRM SITECONSEIL -> SARL SITECONSEIL (52 fichiers) - VAULT_URL dev = https://kms.esy-web.dev (comme prod) - app.js : initDevisLines (repeater + drag & drop), initTabSearch, toggle refus devis - app.scss : styles drag & drop - setasign/fpdi-fpdf installe pour fusion PDF - 5 migrations Doctrine Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 09:44:35 +02:00
'devisList' => $devisList,
'advertsList' => $advertsList,
feat: comptabilite + prestataires + rapport financier + stats dynamiques Comptabilite (Super Admin) : - ComptabiliteController avec 7 exports CSV/JSON compatibles SAGE (journal ventes, grand livre, FEC, balance agee, reglements, commissions Stripe 1.5%+0.25E, couts services) - Export PDF via ComptaPdf (FPDF) avec bloc legal pre-rempli, tableau pagine, champ signature DocuSeal - Signature electronique DocuSeal + callback + envoi email signe avec template dedie (compta_export_signed.html.twig) - Rapport financier public (RapportFinancierPdf) : recettes par service, depenses (Stripe, infra, prestataires), bilan excedent/deficit - Codes comptables clients EC-XXXX (plus de 411xxx) Prestataires (Super Admin) : - Entite Prestataire (raisonSociale, siret, email, phone, adresse) - Entite FacturePrestataire (numFacture, montantHt, montantTtc, year, month, isPaid, PDF via Vich) - CRUD complet avec recherche SIRET via proxy API data.gouv.fr - Commande cron app:reminder:factures-prestataire (5 du mois) - Factures prestataires integrees dans export couts services - Sidebar Super Admin : entree Prestataires + Comptabilite Stats (/admin/stats) : - Cout prestataire dynamique depuis FacturePrestataire - Fusion Infra + Prestataire en "Cout de fonctionnement" - Commission Stripe corrigee (1.5% + 0.25E par transaction) Divers : - DocuSealService::sendComptaForSignature() + getApi() - Customer::generateCodeComptable() format EC-XXXX-XXXXX - Protection double prefixe EC- a la creation client - Bouton regenerer PDF cache quand advert state=accepted - Modals sans script inline (data-modal-open/close dans app.js) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:39:31 +02:00
'facturesList' => $facturesList,
'echeancierList' => $echeancierList,
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
'eflexList' => $eflexList,
'tab' => $tab,
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
'trustStatus' => $trustStatus,
]);
}
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
/**
* 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()]);
}
2026-04-01 15:42:52 +02:00
#[Route('/{id}/toggle', name: 'toggle', methods: ['POST'])]
public function toggle(Customer $customer, EntityManagerInterface $em, MeilisearchService $meilisearch, LoggerInterface $logger): Response
2026-04-01 15:42:52 +02:00
{
$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());
}
2026-04-01 15:42:52 +02:00
$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');
}
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
/**
* 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']);
}
2026-04-01 15:42:52 +02:00
}