feat: page admin tarification + TarificationService + champs PriceAutomatic

src/Entity/PriceAutomatic.php:
- type: ajout contrainte unique pour eviter les doublons
- monthPrice: decimal(10,2) default 0.00, prix mensuel recurrent
- period: smallint default 1, duree de la periode en mois
  (1=mensuel, 3=trimestriel, 12=annuel)
- stripeId: string nullable, ID du Stripe Price pour le paiement unique
- stripeAbonnementId: string nullable, ID du Stripe Price pour l'abonnement

src/Service/TarificationService.php (nouveau):
- Constante DEFAULT_PRICES avec 16 tarifs par defaut:
  esyweb_business (500€ + 100€/mois), esyweb_premium (3200€ + 100€/mois),
  ecommerce_business (999€ + 150€/mois), ecommerce_premium (5110€ + 150€/mois),
  esymail (50€ + 30€/mois), esymailer (50€ + 30€/mois),
  esydefender_pro (50€ + 60€/mois periode 3), esymeet (50€ + 30€/mois),
  esytchat (50€ + 15€/mois), esycreator (500€ + 100€/mois periode 3),
  ndd_depot (20€), ndd_renouvellement (20€/an), ndd_gestion (30€/an),
  ndd_reactivation (50€), formation_pack10h (500€), formation_heure (70€)
- ensureDefaultPrices(): verifie les tarifs existants, cree ceux manquants
- getAll(), getByType(), getDefaultTypes()

src/Controller/Admin/TarificationController.php (nouveau):
- Route /admin/tarification, ROLE_ROOT
- index(): appelle ensureDefaultPrices() pour creer les tarifs manquants
  automatiquement a chaque visite, affiche tous les tarifs editables
- edit(): met a jour titre, description, prixHt, monthPrice, period,
  stripeId, stripeAbonnementId via formulaire POST

templates/admin/tarification/index.html.twig (nouveau):
- Liste de tous les tarifs sous forme de cards glassmorphism
- Header dark avec titre, type (badge) et prix
- Formulaire d'edition inline: titre, prix unique, prix mensuel,
  periode (select 1/2/3/6/12 mois), Stripe Price ID unique,
  Stripe Price ID abonnement, description (textarea)
- Bouton enregistrer par tarif

templates/admin/_layout.html.twig:
- Ajout lien "Tarification" dans la sidebar Super Admin avec icone dollar

migrations/Version20260402204223.php:
- Ajout colonnes month_price, period, stripe_id, stripe_abonnement_id
  sur price_automatic + index unique sur type

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-04-02 22:42:43 +02:00
parent 8136475356
commit 32aa5b0d78
6 changed files with 410 additions and 1 deletions

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260402204223 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE price_automatic ADD month_price NUMERIC(10, 2) DEFAULT \'0.00\' NOT NULL');
$this->addSql('ALTER TABLE price_automatic ADD period SMALLINT DEFAULT 1 NOT NULL');
$this->addSql('ALTER TABLE price_automatic ADD stripe_id VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE price_automatic ADD stripe_abonnement_id VARCHAR(255) DEFAULT NULL');
$this->addSql('CREATE UNIQUE INDEX UNIQ_FAD167EC8CDE5729 ON price_automatic (type)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP INDEX UNIQ_FAD167EC8CDE5729');
$this->addSql('ALTER TABLE price_automatic DROP month_price');
$this->addSql('ALTER TABLE price_automatic DROP period');
$this->addSql('ALTER TABLE price_automatic DROP stripe_id');
$this->addSql('ALTER TABLE price_automatic DROP stripe_abonnement_id');
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Controller\Admin;
use App\Repository\PriceAutomaticRepository;
use App\Service\TarificationService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/admin/tarification', name: 'app_admin_tarification')]
#[IsGranted('ROLE_ROOT')]
class TarificationController extends AbstractController
{
#[Route('', name: '')]
public function index(TarificationService $tarification): Response
{
$created = $tarification->ensureDefaultPrices();
foreach ($created as $type) {
$this->addFlash('success', 'Tarif "'.$type.'" cree automatiquement.');
}
return $this->render('admin/tarification/index.html.twig', [
'prices' => $tarification->getAll(),
]);
}
#[Route('/{id}/edit', name: '_edit', methods: ['POST'])]
public function edit(int $id, Request $request, PriceAutomaticRepository $repository, EntityManagerInterface $em): Response
{
$price = $repository->find($id);
if (null === $price) {
throw $this->createNotFoundException('Tarif introuvable.');
}
$price->setTitle(trim($request->request->getString('title')));
$price->setDescription(trim($request->request->getString('description')) ?: null);
$price->setPriceHt($request->request->getString('priceHt'));
$price->setMonthPrice($request->request->getString('monthPrice'));
$price->setPeriod((int) $request->request->getString('period'));
$price->setStripeId(trim($request->request->getString('stripeId')) ?: null);
$price->setStripeAbonnementId(trim($request->request->getString('stripeAbonnementId')) ?: null);
$em->flush();
$this->addFlash('success', 'Tarif "'.$price->getType().'" mis a jour.');
return $this->redirectToRoute('app_admin_tarification');
}
}

View File

@@ -13,7 +13,7 @@ class PriceAutomatic
#[ORM\Column] #[ORM\Column]
private ?int $id = null; private ?int $id = null;
#[ORM\Column(length: 50)] #[ORM\Column(length: 50, unique: true)]
private string $type; private string $type;
#[ORM\Column(length: 255)] #[ORM\Column(length: 255)]
@@ -25,6 +25,18 @@ class PriceAutomatic
#[ORM\Column(type: 'decimal', precision: 10, scale: 2)] #[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
private string $priceHt; private string $priceHt;
#[ORM\Column(type: 'decimal', precision: 10, scale: 2, options: ['default' => '0.00'])]
private string $monthPrice = '0.00';
#[ORM\Column(type: 'smallint', options: ['default' => 1])]
private int $period = 1;
#[ORM\Column(length: 255, nullable: true)]
private ?string $stripeId = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $stripeAbonnementId = null;
public function getId(): ?int public function getId(): ?int
{ {
return $this->id; return $this->id;
@@ -69,4 +81,44 @@ class PriceAutomatic
{ {
$this->priceHt = $priceHt; $this->priceHt = $priceHt;
} }
public function getMonthPrice(): string
{
return $this->monthPrice;
}
public function setMonthPrice(string $monthPrice): void
{
$this->monthPrice = $monthPrice;
}
public function getPeriod(): int
{
return $this->period;
}
public function setPeriod(int $period): void
{
$this->period = $period;
}
public function getStripeId(): ?string
{
return $this->stripeId;
}
public function setStripeId(?string $stripeId): void
{
$this->stripeId = $stripeId;
}
public function getStripeAbonnementId(): ?string
{
return $this->stripeAbonnementId;
}
public function setStripeAbonnementId(?string $stripeAbonnementId): void
{
$this->stripeAbonnementId = $stripeAbonnementId;
}
} }

View File

@@ -0,0 +1,185 @@
<?php
namespace App\Service;
use App\Entity\PriceAutomatic;
use App\Repository\PriceAutomaticRepository;
use Doctrine\ORM\EntityManagerInterface;
class TarificationService
{
private const DEFAULT_PRICES = [
'esyweb_business' => [
'title' => 'Esy-Web Business',
'description' => 'Licence Esy-Web + installation technique. Le client remplit le site en autonomie.',
'priceHt' => '500.00',
'monthPrice' => '100.00',
'period' => 1,
],
'esyweb_premium' => [
'title' => 'Esy-Web Premium',
'description' => 'Creation complete du site par SITECONSEIL avec accompagnement jusqu\'a la mise en ligne.',
'priceHt' => '3200.00',
'monthPrice' => '100.00',
'period' => 1,
],
'ecommerce_business' => [
'title' => 'E-Commerce Business',
'description' => 'Licence Esy-Web full options + E-boutique. Produits illimites.',
'priceHt' => '999.00',
'monthPrice' => '150.00',
'period' => 1,
],
'ecommerce_premium' => [
'title' => 'E-Commerce Premium',
'description' => 'Creation complete du site E-Commerce par SITECONSEIL. Produits illimites.',
'priceHt' => '5110.00',
'monthPrice' => '150.00',
'period' => 1,
],
'esymail' => [
'title' => 'Esy-Mail',
'description' => 'Messagerie professionnelle. 2 boites mail, 5 Go, antispam, RGPD.',
'priceHt' => '50.00',
'monthPrice' => '30.00',
'period' => 1,
],
'esymailer' => [
'title' => 'Esy-Mailer',
'description' => 'Envoi de mail en masse. 15 000 mails/mois.',
'priceHt' => '50.00',
'monthPrice' => '30.00',
'period' => 1,
],
'esydefender_pro' => [
'title' => 'Esy-Defender Pro',
'description' => 'Cyber defense avancee. Anti-DDoS, pare-feu, filtrage geo, surveillance.',
'priceHt' => '50.00',
'monthPrice' => '60.00',
'period' => 3,
],
'esymeet' => [
'title' => 'Esy-Meet',
'description' => 'Prise de rendez-vous en ligne via Cal.com.',
'priceHt' => '50.00',
'monthPrice' => '30.00',
'period' => 1,
],
'esytchat' => [
'title' => 'Esy-Tchat',
'description' => 'Chat en ligne sur votre site via Chatwoot.',
'priceHt' => '50.00',
'monthPrice' => '15.00',
'period' => 1,
],
'esycreator' => [
'title' => 'Esy-Creator',
'description' => 'Maintenance graphique et editoriale du site.',
'priceHt' => '500.00',
'monthPrice' => '100.00',
'period' => 3,
],
'ndd_depot' => [
'title' => 'Nom de domaine - Depot',
'description' => 'Enregistrement initial avec SPF, DMARC, DNSSEC, Whois, HTTPS.',
'priceHt' => '20.00',
'monthPrice' => '0.00',
'period' => 1,
],
'ndd_renouvellement' => [
'title' => 'Nom de domaine - Renouvellement',
'description' => 'Renouvellement annuel du nom de domaine.',
'priceHt' => '20.00',
'monthPrice' => '0.00',
'period' => 12,
],
'ndd_gestion' => [
'title' => 'Nom de domaine - Gestion',
'description' => 'Gestion technique DNS, HTTPS, redirections, surveillance.',
'priceHt' => '30.00',
'monthPrice' => '0.00',
'period' => 12,
],
'ndd_reactivation' => [
'title' => 'Nom de domaine - Reactivation',
'description' => 'Reactivation d\'un nom de domaine expire.',
'priceHt' => '50.00',
'monthPrice' => '0.00',
'period' => 1,
],
'formation_pack10h' => [
'title' => 'Pack 10 heures de formation',
'description' => 'Construction du site ensemble avec formation.',
'priceHt' => '500.00',
'monthPrice' => '0.00',
'period' => 1,
],
'formation_heure' => [
'title' => 'Formation a la demande',
'description' => 'Formation individuelle a l\'heure.',
'priceHt' => '70.00',
'monthPrice' => '0.00',
'period' => 1,
],
];
public function __construct(
private PriceAutomaticRepository $repository,
private EntityManagerInterface $em,
) {
}
/**
* Verifie que tous les tarifs par defaut existent, cree ceux qui manquent.
*
* @return list<string> liste des types crees
*/
public function ensureDefaultPrices(): array
{
$existing = $this->repository->findAll();
$existingTypes = array_map(fn (PriceAutomatic $p) => $p->getType(), $existing);
$created = [];
foreach (self::DEFAULT_PRICES as $type => $data) {
if (!\in_array($type, $existingTypes, true)) {
$price = new PriceAutomatic();
$price->setType($type);
$price->setTitle($data['title']);
$price->setDescription($data['description']);
$price->setPriceHt($data['priceHt']);
$price->setMonthPrice($data['monthPrice']);
$price->setPeriod($data['period']);
$this->em->persist($price);
$created[] = $type;
}
}
if ([] !== $created) {
$this->em->flush();
}
return $created;
}
/**
* @return list<PriceAutomatic>
*/
public function getAll(): array
{
return $this->repository->findAll();
}
public function getByType(string $type): ?PriceAutomatic
{
return $this->repository->findOneBy(['type' => $type]);
}
/**
* @return array<string, array{title: string, description: string, priceHt: string, monthPrice: string, period: int}>
*/
public static function getDefaultTypes(): array
{
return self::DEFAULT_PRICES;
}
}

View File

@@ -81,6 +81,10 @@
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/></svg> <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/></svg>
Numerotation Numerotation
</a> </a>
<a href="{{ path('app_admin_tarification') }}" class="sidebar-nav-item {{ current_route starts with 'app_admin_tarification' ? 'active-danger' : '' }}" style="color: {{ current_route starts with 'app_admin_tarification' ? 'white' : 'rgba(248,113,113,0.7)' }}">
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
Tarification
</a>
</div> </div>
{% endif %} {% endif %}
</nav> </nav>

View File

@@ -0,0 +1,75 @@
{% extends 'admin/_layout.html.twig' %}
{% block title %}Tarification - Administration - CRM SITECONSEIL{% endblock %}
{% block admin_content %}
<div class="page-container">
<h1 class="text-2xl font-bold heading-page mb-8">Tarification</h1>
{% for type, messages in app.flashes %}
{% for message in messages %}
<div class="mb-6 p-4 glass font-medium text-sm rounded-xl" style="border-color: {{ type == 'success' ? 'rgba(34,197,94,0.3)' : 'rgba(220,38,38,0.3)' }}; color: {{ type == 'success' ? '#166534' : '#991b1b' }};">
{{ message }}
</div>
{% endfor %}
{% endfor %}
<div class="flex flex-col gap-6">
{% for price in prices %}
<div class="glass overflow-hidden">
<div class="glass-dark px-4 py-3 flex items-center justify-between" style="border-radius: 0;">
<div>
<span class="text-white font-bold text-sm">{{ price.title }}</span>
<span class="ml-2 px-2 py-0.5 bg-white/10 text-white/60 text-[9px] font-bold uppercase rounded">{{ price.type }}</span>
</div>
<div class="flex items-center gap-3 text-white/80 text-sm font-bold">
<span>{{ price.priceHt }} &#8364; HT</span>
{% if price.monthPrice != '0.00' %}
<span class="text-[#fabf04]">+ {{ price.monthPrice }} &#8364;/mois</span>
{% endif %}
</div>
</div>
<form method="post" action="{{ path('app_admin_tarification_edit', {id: price.id}) }}" class="p-4">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label class="block text-xs font-bold uppercase tracking-wider mb-1 text-gray-600">Titre</label>
<input type="text" name="title" value="{{ price.title }}" required class="input-glass w-full px-3 py-2 text-sm font-medium">
</div>
<div>
<label class="block text-xs font-bold uppercase tracking-wider mb-1 text-gray-600">Prix unique HT (&#8364;)</label>
<input type="text" name="priceHt" value="{{ price.priceHt }}" required class="input-glass w-full px-3 py-2 text-sm font-medium">
</div>
<div>
<label class="block text-xs font-bold uppercase tracking-wider mb-1 text-gray-600">Prix mensuel HT (&#8364;)</label>
<input type="text" name="monthPrice" value="{{ price.monthPrice }}" required class="input-glass w-full px-3 py-2 text-sm font-medium">
</div>
<div>
<label class="block text-xs font-bold uppercase tracking-wider mb-1 text-gray-600">Periode (mois)</label>
<select name="period" class="input-glass w-full px-3 py-2 text-sm font-medium">
{% for p in [1, 2, 3, 6, 12] %}
<option value="{{ p }}" {{ price.period == p ? 'selected' }}>{{ p }} mois</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-xs font-bold uppercase tracking-wider mb-1 text-gray-600">Stripe Price ID (unique)</label>
<input type="text" name="stripeId" value="{{ price.stripeId }}" placeholder="price_xxx" class="input-glass w-full px-3 py-2 text-sm font-medium font-mono">
</div>
<div>
<label class="block text-xs font-bold uppercase tracking-wider mb-1 text-gray-600">Stripe Price ID (abonnement)</label>
<input type="text" name="stripeAbonnementId" value="{{ price.stripeAbonnementId }}" placeholder="price_xxx" class="input-glass w-full px-3 py-2 text-sm font-medium font-mono">
</div>
<div class="md:col-span-2 lg:col-span-3">
<label class="block text-xs font-bold uppercase tracking-wider mb-1 text-gray-600">Description</label>
<textarea name="description" rows="2" class="input-glass w-full px-3 py-2 text-sm font-medium">{{ price.description }}</textarea>
</div>
</div>
<div class="mt-4 flex justify-end">
<button type="submit" class="btn-gold px-4 py-2 text-xs font-bold uppercase tracking-wider text-gray-900">Enregistrer</button>
</div>
</form>
</div>
{% endfor %}
</div>
</div>
{% endblock %}