diff --git a/migrations/Version20260324131819.php b/migrations/Version20260324131819.php new file mode 100644 index 0000000..942572a --- /dev/null +++ b/migrations/Version20260324131819.php @@ -0,0 +1,34 @@ +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'); + } +} diff --git a/src/Controller/AccountController.php b/src/Controller/AccountController.php index 306a959..71353ef 100644 --- a/src/Controller/AccountController.php +++ b/src/Controller/AccountController.php @@ -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; } diff --git a/src/Controller/AdminController.php b/src/Controller/AdminController.php index 39d3794..12a2de1 100644 --- a/src/Controller/AdminController.php +++ b/src/Controller/AdminController.php @@ -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(); diff --git a/src/Controller/HomeController.php b/src/Controller/HomeController.php index 55397db..c51a057 100644 --- a/src/Controller/HomeController.php +++ b/src/Controller/HomeController.php @@ -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); diff --git a/src/Controller/StripeWebhookController.php b/src/Controller/StripeWebhookController.php index 92d490f..dc14ea2 100644 --- a/src/Controller/StripeWebhookController.php +++ b/src/Controller/StripeWebhookController.php @@ -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 $data */ diff --git a/src/Entity/OrganizerInvitation.php b/src/Entity/OrganizerInvitation.php index 9d34e61..7d2b482 100644 --- a/src/Entity/OrganizerInvitation.php +++ b/src/Entity/OrganizerInvitation.php @@ -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; diff --git a/src/Entity/User.php b/src/Entity/User.php index 1f2e244..8750e64 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -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; diff --git a/src/Security/KeycloakAuthenticator.php b/src/Security/KeycloakAuthenticator.php index 313abc1..c570129 100644 --- a/src/Security/KeycloakAuthenticator.php +++ b/src/Security/KeycloakAuthenticator.php @@ -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(); diff --git a/src/Service/StripeService.php b/src/Service/StripeService.php index ee92282..180f5ef 100644 --- a/src/Service/StripeService.php +++ b/src/Service/StripeService.php @@ -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 */ diff --git a/templates/account/index.html.twig b/templates/account/index.html.twig index c000972..03f64a7 100644 --- a/templates/account/index.html.twig +++ b/templates/account/index.html.twig @@ -30,7 +30,21 @@ {% else %} - {% if isOrganizer %} + {% if isOrganizer and app.user.billing and app.user.billingState != 'good' %} +
+
+ {% if app.user.billingState == 'suspendu' %} +

Abonnement suspendu

+

Votre abonnement a ete suspendu suite a un echec de paiement. Vos evenements ne sont plus accessibles. Regularisez votre situation pour reactiver votre compte.

+ {% else %} +

Abonnement requis

+

Vous devez regler les frais de votre abonnement pour utiliser notre plateforme. Votre abonnement mensuel est de {{ (app.user.billingAmount / 100)|number_format(2, ',', ' ') }} €.

+ {% endif %} + Regler mon abonnement +
+ {% endif %} + + {% if isOrganizer and (not app.user.billing or app.user.billingState == 'good') %} {% if not app.user.stripeAccountId %}

Configuration Stripe requise diff --git a/templates/email/billing_activated.html.twig b/templates/email/billing_activated.html.twig new file mode 100644 index 0000000..0c88333 --- /dev/null +++ b/templates/email/billing_activated.html.twig @@ -0,0 +1,12 @@ +{% extends 'email/base.html.twig' %} + +{% block title %}Abonnement active{% endblock %} + +{% block content %} +

Bonjour {{ firstName }},

+

Votre abonnement E-Ticket de {{ amount }} €/mois a ete active avec succes.

+

Vous pouvez desormais utiliser toutes les fonctionnalites de la plateforme.

+

+ Acceder a mon compte +

+{% endblock %} diff --git a/templates/email/billing_cancelled.html.twig b/templates/email/billing_cancelled.html.twig new file mode 100644 index 0000000..369ab3e --- /dev/null +++ b/templates/email/billing_cancelled.html.twig @@ -0,0 +1,12 @@ +{% extends 'email/base.html.twig' %} + +{% block title %}Abonnement annule{% endblock %} + +{% block content %} +

Bonjour {{ firstName }},

+

Votre abonnement E-Ticket a ete annule.

+

Votre compte organisateur est suspendu. Pour reactiver vos services, souscrivez a un nouvel abonnement depuis votre espace.

+

+ Acceder a mon compte +

+{% endblock %} diff --git a/templates/email/billing_failed.html.twig b/templates/email/billing_failed.html.twig new file mode 100644 index 0000000..b139618 --- /dev/null +++ b/templates/email/billing_failed.html.twig @@ -0,0 +1,13 @@ +{% extends 'email/base.html.twig' %} + +{% block title %}Echec de paiement{% endblock %} + +{% block content %} +

Bonjour {{ firstName }},

+

Le paiement de votre abonnement E-Ticket a echoue.

+

Votre compte organisateur est suspendu jusqu'a la regularisation du paiement. Veuillez mettre a jour votre moyen de paiement.

+

Si vous pensez qu'il s'agit d'une erreur, contactez contact@e-cosplay.fr.

+

+ Acceder a mon compte +

+{% endblock %} diff --git a/templates/email/organizer_invitation.html.twig b/templates/email/organizer_invitation.html.twig index 6a7662a..08f19e6 100644 --- a/templates/email/organizer_invitation.html.twig +++ b/templates/email/organizer_invitation.html.twig @@ -16,6 +16,15 @@ avec un taux de commission de {{ invitation.commissionRate }}% {% endif %}

+ {% if invitation.billingAmount is not null %} +

+ {% if invitation.billingAmount == 0 %} + Aucun abonnement mensuel — utilisation gratuite de la plateforme. + {% else %} + Abonnement mensuel : {{ (invitation.billingAmount / 100)|number_format(2, ',', ' ') }} €/mois + {% endif %} +

+ {% endif %}

(hors frais de commission Stripe)

{% endif %} diff --git a/templates/home/invitation_landing.html.twig b/templates/home/invitation_landing.html.twig index 3d6eeec..116b681 100644 --- a/templates/home/invitation_landing.html.twig +++ b/templates/home/invitation_landing.html.twig @@ -27,6 +27,15 @@ {% if invitation.commissionRate is not null %}

Taux de commission E-Ticket : {{ invitation.commissionRate }}% (hors frais Stripe)

{% endif %} + {% if invitation.billingAmount is not null %} +

+ {% if invitation.billingAmount == 0 %} + Aucun abonnement mensuel — utilisation gratuite + {% else %} + Abonnement mensuel : {{ (invitation.billingAmount / 100)|number_format(2, ',', ' ') }} €/mois + {% endif %} +

+ {% endif %} {% endif %} diff --git a/tests/Entity/OrganizerInvitationTest.php b/tests/Entity/OrganizerInvitationTest.php index 45eb338..cf33fb3 100644 --- a/tests/Entity/OrganizerInvitationTest.php +++ b/tests/Entity/OrganizerInvitationTest.php @@ -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(); diff --git a/tests/Entity/UserTest.php b/tests/Entity/UserTest.php index b7a96e6..e219071 100644 --- a/tests/Entity/UserTest.php +++ b/tests/Entity/UserTest.php @@ -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();