Add billing system: subscription, webhooks, and access control

- Add billing fields to User (isBilling, billingAmount, billingState,
  billingStripeSubscriptionId) and OrganizerInvitation (billingAmount)
- Registration: organizer gets billingState="poor" (pending review)
- Admin approval: sets isBilling=true, billingAmount from form, state="good"
- Invitation: billingAmount from invitation, if 0 then isBilling=false
- ROLE_ROOT accounts: billing free (amount=0, state="good")
- Block Stripe Connect creation and all organizer features if state is
  "poor" or "suspendu"
- Hide Stripe configuration section if billing not settled
- Add billing checkout via Stripe subscription with success route
- Webhooks: checkout.session.completed activates billing,
  invoice.payment_failed and customer.subscription.deleted suspend
  account and disable online events
- Show billing alert on /mon-compte with amount and subscribe button
- Display billing info in invitation email and landing page
- Add email templates for billing activated/failed/cancelled

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-24 14:30:21 +01:00
parent b14a15f0a4
commit e4c701456b
17 changed files with 398 additions and 1 deletions

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260324131819 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add billing fields to user and organizer_invitation tables';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE organizer_invitation ADD billing_amount INT DEFAULT NULL');
$this->addSql('ALTER TABLE "user" ADD is_billing BOOLEAN DEFAULT NULL');
$this->addSql('ALTER TABLE "user" ADD billing_amount INT DEFAULT NULL');
$this->addSql('ALTER TABLE "user" ADD billing_state VARCHAR(20) DEFAULT NULL');
$this->addSql('ALTER TABLE "user" ADD billing_stripe_subscription_id VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE organizer_invitation DROP billing_amount');
$this->addSql('ALTER TABLE "user" DROP is_billing');
$this->addSql('ALTER TABLE "user" DROP billing_amount');
$this->addSql('ALTER TABLE "user" DROP billing_state');
$this->addSql('ALTER TABLE "user" DROP billing_stripe_subscription_id');
}
}

View File

@@ -154,6 +154,36 @@ class AccountController extends AbstractController
return $this->redirectToRoute('app_account', ['tab' => 'settings']);
}
/** @codeCoverageIgnore Requires live Stripe API */
#[Route('/mon-compte/abonnement', name: 'app_account_billing_subscribe')]
public function billingSubscribe(StripeService $stripeService, EntityManagerInterface $em): Response
{
/** @var User $user */
$user = $this->getUser();
if (!$this->isGranted('ROLE_ORGANIZER') || !$user->isBilling() || 'poor' !== $user->getBillingState()) {
return $this->redirectToRoute('app_account');
}
try {
$url = $stripeService->createBillingCheckoutSession($user);
return $this->redirect($url);
} catch (\Throwable $e) {
$this->addFlash('error', 'Erreur lors de la creation de l\'abonnement : '.$e->getMessage());
return $this->redirectToRoute('app_account');
}
}
#[Route('/mon-compte/abonnement/succes', name: 'app_account_billing_success')]
public function billingSuccess(): Response
{
$this->addFlash('success', 'Votre abonnement a ete active avec succes.');
return $this->redirectToRoute('app_account');
}
/** @codeCoverageIgnore Requires live Stripe API */
#[Route('/mon-compte/stripe-connect', name: 'app_account_stripe_connect')]
public function stripeConnect(StripeService $stripeService, EntityManagerInterface $em): Response
@@ -165,6 +195,12 @@ class AccountController extends AbstractController
return $this->redirectToRoute('app_account');
}
if ($user->isBilling() && 'good' !== $user->getBillingState()) {
$this->addFlash('error', 'Vous devez regler votre abonnement avant de configurer Stripe.');
return $this->redirectToRoute('app_account');
}
try {
if (!$user->getStripeAccountId()) {
$accountId = $stripeService->createAccountConnect($user);
@@ -1185,6 +1221,10 @@ class AccountController extends AbstractController
return $this->redirectToRoute('app_account');
}
if ($user->isBilling() && 'good' !== $user->getBillingState()) {
return $this->redirectToRoute('app_account');
}
return null;
}

View File

@@ -345,10 +345,14 @@ class AdminController extends AbstractController
{
$offer = $request->request->getString('offer', 'free');
$commissionRate = (float) $request->request->getString('commission_rate', '3');
$billingAmount = (int) $request->request->getString('billing_amount', '1000');
$user->setIsApproved(true);
$user->setOffer($offer);
$user->setCommissionRate($commissionRate);
$user->setIsBilling(true);
$user->setBillingAmount($billingAmount);
$user->setBillingState('good');
$em->flush();
$meilisearch->createIndexIfNotExists('organizers');
@@ -609,6 +613,7 @@ class AdminController extends AbstractController
$message = trim($request->request->getString('message')) ?: null;
$offer = $request->request->getString('offer', 'free');
$commissionRate = (float) $request->request->getString('commission_rate', '3');
$billingAmount = (int) $request->request->getString('billing_amount', '1000');
if ('' === $companyName || '' === $firstName || '' === $lastName || '' === $email) {
$this->addFlash('error', 'Tous les champs obligatoires doivent etre remplis.');
@@ -624,6 +629,7 @@ class AdminController extends AbstractController
$invitation->setMessage($message);
$invitation->setOffer($offer);
$invitation->setCommissionRate($commissionRate);
$invitation->setBillingAmount($billingAmount);
$em->persist($invitation);
$em->flush();

View File

@@ -424,6 +424,10 @@ class HomeController extends AbstractController
$user->setCommissionRate($invitation->getCommissionRate());
$user->setIsApproved(true);
$user->setIsVerified(true);
$billingAmount = $invitation->getBillingAmount() ?? 1000;
$user->setBillingAmount($billingAmount);
$user->setIsBilling(0 !== $billingAmount);
$user->setBillingState('good');
$user->setSiret(trim($request->request->getString('siret')) ?: null);
$user->setAddress(trim($request->request->getString('address')) ?: null);
$user->setPostalCode(trim($request->request->getString('postal_code')) ?: null);

View File

@@ -4,6 +4,7 @@ namespace App\Controller;
use App\Entity\BilletBuyer;
use App\Entity\BilletOrder;
use App\Entity\Event;
use App\Entity\Payout;
use App\Entity\User;
use App\Service\AuditService;
@@ -51,6 +52,9 @@ class StripeWebhookController extends AbstractController
'payment_intent.succeeded' => $this->handlePaymentIntentSucceeded($event, $em, $billetOrderService),
'payment_intent.payment_failed' => $this->handlePaymentIntentFailed($event, $em, $mailerService, $audit),
'charge.refunded' => $this->handleChargeRefunded($event, $em, $mailerService, $audit, $billetOrderService),
'checkout.session.completed' => $this->handleCheckoutSessionCompleted($event, $em, $mailerService),
'invoice.payment_failed' => $this->handleInvoicePaymentFailed($event, $em, $mailerService),
'customer.subscription.deleted' => $this->handleSubscriptionDeleted($event, $em, $mailerService),
default => null,
};
@@ -315,6 +319,100 @@ class StripeWebhookController extends AbstractController
);
}
private function handleCheckoutSessionCompleted(\Stripe\Event $event, EntityManagerInterface $em, MailerService $mailerService): void
{
$session = $event->data->object;
if ('subscription' !== ($session->mode ?? null)) {
return;
}
$userId = $session->metadata->user_id ?? null;
if (!$userId) {
return;
}
$user = $em->getRepository(User::class)->find((int) $userId);
if (!$user) {
return;
}
$user->setBillingState('good');
$user->setBillingStripeSubscriptionId($session->subscription ?? null);
$em->flush();
$mailerService->sendEmail(
$user->getEmail(),
'Abonnement active - E-Ticket',
$this->renderView('email/billing_activated.html.twig', [
'firstName' => $user->getFirstName(),
'amount' => number_format(($user->getBillingAmount() ?? 0) / 100, 2, ',', ' '),
]),
);
}
private function handleInvoicePaymentFailed(\Stripe\Event $event, EntityManagerInterface $em, MailerService $mailerService): void
{
$invoice = $event->data->object;
$subscriptionId = $invoice->subscription ?? null;
if (!$subscriptionId) {
return;
}
$user = $em->getRepository(User::class)->findOneBy(['billingStripeSubscriptionId' => $subscriptionId]);
if (!$user) {
return;
}
$user->setBillingState('suspendu');
$this->disableUserEvents($user, $em);
$em->flush();
$mailerService->sendEmail(
$user->getEmail(),
'Echec de paiement de votre abonnement - E-Ticket',
$this->renderView('email/billing_failed.html.twig', [
'firstName' => $user->getFirstName(),
]),
);
}
private function handleSubscriptionDeleted(\Stripe\Event $event, EntityManagerInterface $em, MailerService $mailerService): void
{
$subscription = $event->data->object;
$subscriptionId = $subscription->id ?? null;
if (!$subscriptionId) {
return;
}
$user = $em->getRepository(User::class)->findOneBy(['billingStripeSubscriptionId' => $subscriptionId]);
if (!$user) {
return;
}
$user->setBillingState('suspendu');
$this->disableUserEvents($user, $em);
$em->flush();
$mailerService->sendEmail(
$user->getEmail(),
'Abonnement annule - E-Ticket',
$this->renderView('email/billing_cancelled.html.twig', [
'firstName' => $user->getFirstName(),
]),
);
}
private function disableUserEvents(User $user, EntityManagerInterface $em): void
{
$events = $em->getRepository(Event::class)->findBy(['account' => $user, 'isOnline' => true]);
foreach ($events as $event) {
$event->setIsOnline(false);
}
}
/**
* @param array<string, mixed> $data
*/

View File

@@ -42,6 +42,9 @@ class OrganizerInvitation
#[ORM\Column(nullable: true)]
private ?float $commissionRate = null;
#[ORM\Column(nullable: true)]
private ?int $billingAmount = null;
#[ORM\Column(length: 64, unique: true)]
private string $token;
@@ -158,6 +161,18 @@ class OrganizerInvitation
return $this;
}
public function getBillingAmount(): ?int
{
return $this->billingAmount;
}
public function setBillingAmount(?int $billingAmount): static
{
$this->billingAmount = $billingAmount;
return $this;
}
public function getToken(): string
{
return $this->token;

View File

@@ -118,6 +118,18 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column]
private bool $stripePayoutsEnabled = false;
#[ORM\Column(nullable: true)]
private ?bool $isBilling = null;
#[ORM\Column(nullable: true)]
private ?int $billingAmount = null;
#[ORM\Column(length: 20, nullable: true)]
private ?string $billingState = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $billingStripeSubscriptionId = null;
#[ORM\ManyToOne(targetEntity: self::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?self $parentOrganizer = null;
@@ -445,6 +457,54 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
public function isBilling(): ?bool
{
return $this->isBilling;
}
public function setIsBilling(?bool $isBilling): static
{
$this->isBilling = $isBilling;
return $this;
}
public function getBillingAmount(): ?int
{
return $this->billingAmount;
}
public function setBillingAmount(?int $billingAmount): static
{
$this->billingAmount = $billingAmount;
return $this;
}
public function getBillingState(): ?string
{
return $this->billingState;
}
public function setBillingState(?string $billingState): static
{
$this->billingState = $billingState;
return $this;
}
public function getBillingStripeSubscriptionId(): ?string
{
return $this->billingStripeSubscriptionId;
}
public function setBillingStripeSubscriptionId(?string $billingStripeSubscriptionId): static
{
$this->billingStripeSubscriptionId = $billingStripeSubscriptionId;
return $this;
}
public function getParentOrganizer(): ?self
{
return $this->parentOrganizer;

View File

@@ -84,6 +84,9 @@ class KeycloakAuthenticator extends OAuth2Authenticator
$newUser->setIsApproved(true);
$newUser->setOffer('custom');
$newUser->setEmailVerifiedAt(new \DateTimeImmutable());
$newUser->setIsBilling(false);
$newUser->setBillingAmount(0);
$newUser->setBillingState('good');
$this->em->persist($newUser);
$this->em->flush();

View File

@@ -135,6 +135,35 @@ class StripeService
], ['stripe_account' => $connectedAccountId]);
}
/**
* @codeCoverageIgnore Requires live Stripe API
*/
public function createBillingCheckoutSession(User $user): string
{
$session = $this->stripe->checkout->sessions->create([
'mode' => 'subscription',
'customer_email' => $user->getEmail(),
'line_items' => [[
'price_data' => [
'currency' => 'eur',
'unit_amount' => $user->getBillingAmount(),
'recurring' => ['interval' => 'month'],
'product_data' => [
'name' => 'Abonnement E-Ticket',
],
],
'quantity' => 1,
]],
'metadata' => [
'user_id' => (string) $user->getId(),
],
'success_url' => $this->outsideUrl.'/mon-compte/abonnement/succes',
'cancel_url' => $this->outsideUrl.'/mon-compte',
]);
return $session->url;
}
/**
* @codeCoverageIgnore Simple getter
*/

View File

@@ -30,7 +30,21 @@
{% else %}
{% if isOrganizer %}
{% if isOrganizer and app.user.billing and app.user.billingState != 'good' %}
<div class="card-brutal-error p-8 text-center mb-8">
<div class="text-4xl mb-4">&#9888;</div>
{% if app.user.billingState == 'suspendu' %}
<h2 class="text-xl font-black uppercase tracking-tighter italic mb-2">Abonnement suspendu</h2>
<p class="font-bold text-gray-700 text-sm mb-4">Votre abonnement a ete suspendu suite a un echec de paiement. Vos evenements ne sont plus accessibles. Regularisez votre situation pour reactiver votre compte.</p>
{% else %}
<h2 class="text-xl font-black uppercase tracking-tighter italic mb-2">Abonnement requis</h2>
<p class="font-bold text-gray-700 text-sm mb-4">Vous devez regler les frais de votre abonnement pour utiliser notre plateforme. Votre abonnement mensuel est de <strong>{{ (app.user.billingAmount / 100)|number_format(2, ',', ' ') }} &euro;</strong>.</p>
{% endif %}
<a href="{{ path('app_account_billing_subscribe') }}" class="btn-brutal font-black uppercase text-xs tracking-widest hover:bg-indigo-600 hover:text-black transition-all">Regler mon abonnement</a>
</div>
{% endif %}
{% if isOrganizer and (not app.user.billing or app.user.billingState == 'good') %}
{% if not app.user.stripeAccountId %}
<div class="card-brutal-warn mb-8">
<h2 class="text-sm font-black uppercase tracking-widest mb-2">Configuration Stripe requise

View File

@@ -0,0 +1,12 @@
{% extends 'email/base.html.twig' %}
{% block title %}Abonnement active{% endblock %}
{% block content %}
<h2>Bonjour {{ firstName }},</h2>
<p>Votre abonnement E-Ticket de <strong>{{ amount }} &euro;/mois</strong> a ete active avec succes.</p>
<p>Vous pouvez desormais utiliser toutes les fonctionnalites de la plateforme.</p>
<p style="text-align:center;margin:32px 0;">
<a href="{{ url('app_account') }}" class="btn">Acceder a mon compte</a>
</p>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% extends 'email/base.html.twig' %}
{% block title %}Abonnement annule{% endblock %}
{% block content %}
<h2>Bonjour {{ firstName }},</h2>
<p>Votre abonnement E-Ticket a ete <strong>annule</strong>.</p>
<p>Votre compte organisateur est suspendu. Pour reactiver vos services, souscrivez a un nouvel abonnement depuis votre espace.</p>
<p style="text-align:center;margin:32px 0;">
<a href="{{ url('app_account') }}" class="btn">Acceder a mon compte</a>
</p>
{% endblock %}

View File

@@ -0,0 +1,13 @@
{% extends 'email/base.html.twig' %}
{% block title %}Echec de paiement{% endblock %}
{% block content %}
<h2>Bonjour {{ firstName }},</h2>
<p>Le paiement de votre abonnement E-Ticket a <strong>echoue</strong>.</p>
<p>Votre compte organisateur est suspendu jusqu'a la regularisation du paiement. Veuillez mettre a jour votre moyen de paiement.</p>
<p>Si vous pensez qu'il s'agit d'une erreur, contactez <a href="mailto:contact@e-cosplay.fr">contact@e-cosplay.fr</a>.</p>
<p style="text-align:center;margin:32px 0;">
<a href="{{ url('app_account') }}" class="btn">Acceder a mon compte</a>
</p>
{% endblock %}

View File

@@ -16,6 +16,15 @@
avec un taux de commission de {{ invitation.commissionRate }}%
{% endif %}
</p>
{% if invitation.billingAmount is not null %}
<p style="margin: 8px 0 0; font-size: 13px; font-weight: 700; color: #111827;">
{% if invitation.billingAmount == 0 %}
Aucun abonnement mensuel — utilisation gratuite de la plateforme.
{% else %}
Abonnement mensuel : <strong>{{ (invitation.billingAmount / 100)|number_format(2, ',', ' ') }} &euro;/mois</strong>
{% endif %}
</p>
{% endif %}
<p style="margin: 6px 0 0; font-size: 12px; font-weight: 700; color: #374151;">(hors frais de commission Stripe)</p>
</div>
{% endif %}

View File

@@ -27,6 +27,15 @@
{% if invitation.commissionRate is not null %}
<p class="text-sm font-bold text-gray-400 mt-1">Taux de commission E-Ticket : {{ invitation.commissionRate }}% <span class="text-gray-500">(hors frais Stripe)</span></p>
{% endif %}
{% if invitation.billingAmount is not null %}
<p class="text-sm font-bold mt-3">
{% if invitation.billingAmount == 0 %}
<span class="text-green-400">Aucun abonnement mensuel — utilisation gratuite</span>
{% else %}
<span class="text-[#fabf04]">Abonnement mensuel : {{ (invitation.billingAmount / 100)|number_format(2, ',', ' ') }} &euro;/mois</span>
{% endif %}
</p>
{% endif %}
</div>
</section>
{% endif %}

View File

@@ -76,6 +76,21 @@ class OrganizerInvitationTest extends TestCase
self::assertSame($inv, $result);
}
public function testSetAndGetBillingAmount(): void
{
$inv = new OrganizerInvitation();
self::assertNull($inv->getBillingAmount());
$result = $inv->setBillingAmount(1000);
self::assertSame(1000, $inv->getBillingAmount());
self::assertSame($inv, $result);
$inv->setBillingAmount(0);
self::assertSame(0, $inv->getBillingAmount());
}
public function testSetAndGetStatus(): void
{
$inv = new OrganizerInvitation();

View File

@@ -187,6 +187,30 @@ class UserTest extends TestCase
self::assertTrue($user->isStripePayoutsEnabled());
}
public function testBillingFields(): void
{
$user = new User();
self::assertNull($user->isBilling());
self::assertNull($user->getBillingAmount());
self::assertNull($user->getBillingState());
self::assertNull($user->getBillingStripeSubscriptionId());
$result = $user->setIsBilling(true)
->setBillingAmount(2990)
->setBillingState('good')
->setBillingStripeSubscriptionId('sub_123456');
self::assertSame($user, $result);
self::assertTrue($user->isBilling());
self::assertSame(2990, $user->getBillingAmount());
self::assertSame('good', $user->getBillingState());
self::assertSame('sub_123456', $user->getBillingStripeSubscriptionId());
$user->setBillingState('suspendu');
self::assertSame('suspendu', $user->getBillingState());
}
public function testEmailVerificationFields(): void
{
$user = new User();