Add payouts, PDF attestations, sub-accounts, and webhook improvements

Payout system:
- Create Payout entity (stripePayoutId, status, amount, currency, destination, arrivalDate)
- Webhook handles payout.created/updated/paid/failed/canceled with email notification
- Payout list in /mon-compte virements tab with status badges
- PDF attestation on paid payouts with email attachment

PDF attestation:
- dompdf with DejaVu Sans font, yellow-orange gradient background
- Orange centered title bar, E-Cosplay logo, emitter/beneficiary info blocks
- QR code linking to /attestation/check/{payoutId} for authenticity verification
- Public verification page: shows payout details if valid, error if altered
- Legal disclaimer and CGV reference
- Button visible only when status is paid, opens in new tab

Sub-accounts:
- Add parentOrganizer (self-referencing ManyToOne) and subAccountPermissions (JSON) to User
- Permissions: scanner (validate tickets), events (CRUD), tickets (free invitations)
- Create sub-account with random password, send email with credentials
- Edit page with name/email/permissions checkboxes
- Delete with confirmation
- hasPermission() helper method

Account improvements:
- Block entire page for unapproved organizers with validation pending message
- Display stripeStatus in Stripe Connect banners
- Remove test payout button

Webhook v2 Connect events:
- v2.core.account.created/updated/closed → update stripeStatus
- capability_status_updated → sync charges/payouts enabled from capabilities
- PayoutPdfService for reusable PDF generation

Migrations: stripeStatus, Payout table, sub-account fields, drop pdfPath

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-19 23:49:48 +01:00
parent 93e5ae67c0
commit ab52a8d02f
25 changed files with 1476 additions and 127 deletions

View File

@@ -0,0 +1,35 @@
<?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 Version20260319214513 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 "user" ADD stripe_status VARCHAR(50) DEFAULT NULL');
$this->addSql('ALTER TABLE "user" ALTER stripe_charges_enabled DROP DEFAULT');
$this->addSql('ALTER TABLE "user" ALTER stripe_payouts_enabled DROP DEFAULT');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE "user" DROP stripe_status');
$this->addSql('ALTER TABLE "user" ALTER stripe_charges_enabled SET DEFAULT false');
$this->addSql('ALTER TABLE "user" ALTER stripe_payouts_enabled SET DEFAULT false');
}
}

View File

@@ -0,0 +1,35 @@
<?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 Version20260319215018 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('CREATE TABLE payout (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, stripe_payout_id VARCHAR(255) NOT NULL, status VARCHAR(50) NOT NULL, amount INT NOT NULL, currency VARCHAR(10) NOT NULL, destination VARCHAR(255) DEFAULT NULL, stripe_account_id VARCHAR(255) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, arrival_date TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, organizer_id INT NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE UNIQUE INDEX UNIQ_4E2EA902795B3DB5 ON payout (stripe_payout_id)');
$this->addSql('CREATE INDEX IDX_4E2EA902876C4DDA ON payout (organizer_id)');
$this->addSql('ALTER TABLE payout ADD CONSTRAINT FK_4E2EA902876C4DDA FOREIGN KEY (organizer_id) REFERENCES "user" (id) NOT DEFERRABLE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE payout DROP CONSTRAINT FK_4E2EA902876C4DDA');
$this->addSql('DROP TABLE payout');
}
}

View File

@@ -0,0 +1,31 @@
<?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 Version20260319215953 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 payout ADD pdf_path VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE payout DROP pdf_path');
}
}

View File

@@ -0,0 +1,31 @@
<?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 Version20260319220425 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 payout DROP pdf_path');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE payout ADD pdf_path VARCHAR(255) DEFAULT NULL');
}
}

View File

@@ -0,0 +1,37 @@
<?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 Version20260319222449 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 "user" ADD sub_account_permissions JSON DEFAULT NULL');
$this->addSql('ALTER TABLE "user" ADD parent_organizer_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE "user" ADD CONSTRAINT FK_8D93D649D70210F4 FOREIGN KEY (parent_organizer_id) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_8D93D649D70210F4 ON "user" (parent_organizer_id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE "user" DROP CONSTRAINT FK_8D93D649D70210F4');
$this->addSql('DROP INDEX IDX_8D93D649D70210F4');
$this->addSql('ALTER TABLE "user" DROP sub_account_permissions');
$this->addSql('ALTER TABLE "user" DROP parent_organizer_id');
}
}

View File

@@ -6,8 +6,9 @@ parameters:
- src/Kernel.php - src/Kernel.php
ignoreErrors: ignoreErrors:
- -
message: '#Property App\\Entity\\(EmailTracking|MessengerLog|User)::\$id .* never assigned#' message: '#Property App\\Entity\\(EmailTracking|MessengerLog|User|Payout)::\$id .* never assigned#'
paths: paths:
- src/Entity/EmailTracking.php - src/Entity/EmailTracking.php
- src/Entity/MessengerLog.php - src/Entity/MessengerLog.php
- src/Entity/User.php - src/Entity/User.php
- src/Entity/Payout.php

View File

@@ -2,13 +2,16 @@
namespace App\Controller; namespace App\Controller;
use App\Entity\Payout;
use App\Entity\User; use App\Entity\User;
use App\Service\MailerService;
use App\Service\PayoutPdfService;
use App\Service\StripeService; use App\Service\StripeService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -35,9 +38,24 @@ class AccountController extends AbstractController
} }
} }
$payouts = [];
$subAccounts = [];
if ($isOrganizer) {
$payouts = $em->getRepository(Payout::class)->findBy(
['organizer' => $user],
['createdAt' => 'DESC'],
);
$subAccounts = $em->getRepository(User::class)->findBy(
['parentOrganizer' => $user],
['createdAt' => 'DESC'],
);
}
return $this->render('account/index.html.twig', [ return $this->render('account/index.html.twig', [
'tab' => $tab, 'tab' => $tab,
'isOrganizer' => $isOrganizer, 'isOrganizer' => $isOrganizer,
'payouts' => $payouts,
'subAccounts' => $subAccounts,
]); ]);
} }
@@ -83,6 +101,7 @@ class AccountController extends AbstractController
if (!$user->getStripeAccountId()) { if (!$user->getStripeAccountId()) {
$accountId = $stripeService->createAccountConnect($user); $accountId = $stripeService->createAccountConnect($user);
$user->setStripeAccountId($accountId); $user->setStripeAccountId($accountId);
$user->setStripeStatus('started');
$em->flush(); $em->flush();
} }
@@ -134,6 +153,135 @@ class AccountController extends AbstractController
return $this->redirectToRoute('app_account_stripe_connect'); return $this->redirectToRoute('app_account_stripe_connect');
} }
#[Route('/mon-compte/sous-compte/creer', name: 'app_account_create_subaccount', methods: ['POST'])]
public function createSubAccount(Request $request, EntityManagerInterface $em, UserPasswordHasherInterface $passwordHasher, MailerService $mailerService): Response
{
/** @var User $user */
$user = $this->getUser();
if (!$this->isGranted('ROLE_ORGANIZER')) {
return $this->redirectToRoute('app_account');
}
$plainPassword = bin2hex(random_bytes(8));
$subAccount = new User();
$subAccount->setFirstName(trim($request->request->getString('first_name')));
$subAccount->setLastName(trim($request->request->getString('last_name')));
$subAccount->setEmail(trim($request->request->getString('email')));
$subAccount->setPassword($passwordHasher->hashPassword($subAccount, $plainPassword));
$subAccount->setIsVerified(true);
$subAccount->setEmailVerifiedAt(new \DateTimeImmutable());
$subAccount->setParentOrganizer($user);
$permissions = $request->request->all('permissions');
$subAccount->setSubAccountPermissions($permissions);
$em->persist($subAccount);
$em->flush();
$mailerService->sendEmail(
to: $subAccount->getEmail(),
subject: 'Votre sous-compte E-Ticket a ete cree',
content: $this->renderView('email/subaccount_created.html.twig', [
'firstName' => $subAccount->getFirstName(),
'organizerName' => $user->getCompanyName() ?? $user->getFirstName().' '.$user->getLastName(),
'email' => $subAccount->getEmail(),
'password' => $plainPassword,
'permissions' => $permissions,
]),
withUnsubscribe: false,
);
$this->addFlash('success', sprintf('Sous-compte %s %s cree.', $subAccount->getFirstName(), $subAccount->getLastName()));
return $this->redirectToRoute('app_account', ['tab' => 'subaccounts']);
}
#[Route('/mon-compte/sous-compte/{id}', name: 'app_account_edit_subaccount_page', methods: ['GET'])]
public function editSubAccountPage(User $subAccount): Response
{
/** @var User $user */
$user = $this->getUser();
if ($subAccount->getParentOrganizer()?->getId() !== $user->getId()) {
throw $this->createAccessDeniedException();
}
return $this->render('account/edit_subaccount.html.twig', [
'subAccount' => $subAccount,
]);
}
#[Route('/mon-compte/sous-compte/{id}/modifier', name: 'app_account_edit_subaccount', methods: ['POST'])]
public function editSubAccount(User $subAccount, Request $request, EntityManagerInterface $em): Response
{
/** @var User $user */
$user = $this->getUser();
if ($subAccount->getParentOrganizer()?->getId() !== $user->getId()) {
throw $this->createAccessDeniedException();
}
$permissions = $request->request->all('permissions');
$subAccount->setSubAccountPermissions($permissions);
$subAccount->setFirstName(trim($request->request->getString('first_name')));
$subAccount->setLastName(trim($request->request->getString('last_name')));
$subAccount->setEmail(trim($request->request->getString('email')));
$em->flush();
$this->addFlash('success', sprintf('Sous-compte %s %s mis a jour.', $subAccount->getFirstName(), $subAccount->getLastName()));
return $this->redirectToRoute('app_account', ['tab' => 'subaccounts']);
}
#[Route('/mon-compte/sous-compte/{id}/supprimer', name: 'app_account_delete_subaccount', methods: ['POST'])]
public function deleteSubAccount(User $subAccount, EntityManagerInterface $em): Response
{
/** @var User $user */
$user = $this->getUser();
if ($subAccount->getParentOrganizer()?->getId() !== $user->getId()) {
throw $this->createAccessDeniedException();
}
$name = sprintf('%s %s', $subAccount->getFirstName(), $subAccount->getLastName());
$em->remove($subAccount);
$em->flush();
$this->addFlash('success', sprintf('Sous-compte %s supprime.', $name));
return $this->redirectToRoute('app_account', ['tab' => 'subaccounts']);
}
#[Route('/mon-compte/test-payout', name: 'app_account_test_payout', methods: ['POST'])]
public function testPayout(EntityManagerInterface $em): Response
{
/** @var User $user */
$user = $this->getUser();
if (!$this->isGranted('ROLE_ORGANIZER') || !$user->getStripeAccountId()) {
return $this->redirectToRoute('app_account');
}
$payout = new Payout();
$payout->setOrganizer($user);
$payout->setStripePayoutId('po_test_'.bin2hex(random_bytes(8)));
$payout->setStatus('paid');
$payout->setAmount(random_int(1000, 50000));
$payout->setCurrency('eur');
$payout->setDestination('ba_test_bank');
$payout->setStripeAccountId($user->getStripeAccountId());
$payout->setArrivalDate(new \DateTimeImmutable('+2 days'));
$em->persist($payout);
$em->flush();
$this->addFlash('success', sprintf('Payout test cree : %s (%.2f EUR)', $payout->getStripePayoutId(), $payout->getAmountDecimal()));
return $this->redirectToRoute('app_account', ['tab' => 'payouts']);
}
#[Route('/mon-compte/stripe-dashboard', name: 'app_account_stripe_dashboard')] #[Route('/mon-compte/stripe-dashboard', name: 'app_account_stripe_dashboard')]
public function stripeDashboard(StripeService $stripeService): Response public function stripeDashboard(StripeService $stripeService): Response
{ {
@@ -154,4 +302,20 @@ class AccountController extends AbstractController
return $this->redirectToRoute('app_account'); return $this->redirectToRoute('app_account');
} }
} }
#[Route('/mon-compte/payout/{id}/attestation', name: 'app_account_payout_pdf')]
public function payoutPdf(Payout $payout, PayoutPdfService $pdfService): Response
{
/** @var User $user */
$user = $this->getUser();
if ($payout->getOrganizer()->getId() !== $user->getId()) {
throw $this->createAccessDeniedException();
}
return new Response($pdfService->generate($payout), 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="attestation_'.$payout->getStripePayoutId().'.pdf"',
]);
}
} }

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Controller;
use App\Entity\Payout;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class AttestationController extends AbstractController
{
#[Route('/attestation/check/{stripePayoutId}', name: 'app_attestation_check')]
public function check(string $stripePayoutId, EntityManagerInterface $em): Response
{
$payout = $em->getRepository(Payout::class)->findOneBy(['stripePayoutId' => $stripePayoutId]);
if (!$payout) {
return $this->render('attestation/not_found.html.twig');
}
return $this->render('attestation/check.html.twig', [
'payout' => $payout,
]);
}
}

View File

@@ -2,7 +2,10 @@
namespace App\Controller; namespace App\Controller;
use App\Entity\Payout;
use App\Entity\User; use App\Entity\User;
use App\Service\MailerService;
use App\Service\PayoutPdfService;
use App\Service\StripeService; use App\Service\StripeService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -13,7 +16,7 @@ use Symfony\Component\Routing\Attribute\Route;
class StripeWebhookController extends AbstractController class StripeWebhookController extends AbstractController
{ {
#[Route('/stripe/webhook', name: 'app_stripe_webhook', methods: ['POST'])] #[Route('/stripe/webhook', name: 'app_stripe_webhook', methods: ['POST'])]
public function webhook(Request $request, StripeService $stripeService, EntityManagerInterface $em): Response public function webhook(Request $request, StripeService $stripeService, EntityManagerInterface $em, MailerService $mailerService, PayoutPdfService $pdfService): Response
{ {
$payload = $request->getContent(); $payload = $request->getContent();
$signature = $request->headers->get('Stripe-Signature', ''); $signature = $request->headers->get('Stripe-Signature', '');
@@ -24,34 +27,153 @@ class StripeWebhookController extends AbstractController
return new Response('Invalid signature', 400); return new Response('Invalid signature', 400);
} }
if ('account.updated' === $event->type) { $type = $event->type ?? null;
$this->handleAccountSync($event, $stripeService, $em); $data = json_decode($payload, true) ?? [];
}
match ($type) {
'v2.core.account.created' => $this->handleAccountStatus($data, 'started', $em),
'v2.core.account.updated' => $this->handleAccountStatus($data, 'updated', $em),
'v2.core.account.closed' => $this->handleAccountStatus($data, 'closed', $em),
'v2.core.account[configuration.merchant].capability_status_updated' => $this->handleCapabilityUpdate($data, $em),
'v2.core.account[configuration.recipient].capability_status_updated' => $this->handleCapabilityUpdate($data, $em),
'payout.created', 'payout.updated', 'payout.paid', 'payout.failed', 'payout.canceled' => $this->handlePayout($event, $em, $mailerService, $pdfService),
default => null,
};
return new Response('OK', 200); return new Response('OK', 200);
} }
private function handleAccountSync(\Stripe\Event $event, StripeService $stripeService, EntityManagerInterface $em): void /**
* @param array<string, mixed> $data
*/
private function handleAccountStatus(array $data, string $status, EntityManagerInterface $em): void
{ {
$accountId = $event->data->object->id ?? $event->account ?? null; $user = $this->findUserByEvent($data, $em);
if (!$accountId) {
return;
}
$user = $em->getRepository(User::class)->findOneBy(['stripeAccountId' => $accountId]);
if (!$user) { if (!$user) {
return; return;
} }
try { $user->setStripeStatus($status);
$account = $stripeService->getClient()->accounts->retrieve($accountId);
$user->setStripeChargesEnabled((bool) $account->charges_enabled); if ('closed' === $status) {
$user->setStripePayoutsEnabled((bool) $account->payouts_enabled); $user->setStripeChargesEnabled(false);
$em->flush(); $user->setStripePayoutsEnabled(false);
} catch (\Throwable) { }
// Stripe API unavailable
$em->flush();
}
/**
* @param array<string, mixed> $data
*/
private function handleCapabilityUpdate(array $data, EntityManagerInterface $em): void
{
$user = $this->findUserByEvent($data, $em);
if (!$user) {
return;
}
$after = $data['changes']['after']['configuration'] ?? [];
$merchantCapabilities = $after['merchant']['capabilities'] ?? [];
$cardPayments = $merchantCapabilities['card_payments']['status'] ?? null;
if ('active' === $cardPayments) {
$user->setStripeChargesEnabled(true);
}
$recipientCapabilities = $after['recipient']['capabilities'] ?? [];
$payouts = $recipientCapabilities['stripe_balance']['payouts']['status'] ?? null;
$merchantPayouts = $merchantCapabilities['stripe_balance']['payouts']['status'] ?? null;
if ('active' === $payouts || 'active' === $merchantPayouts) {
$user->setStripePayoutsEnabled(true);
}
if ($user->isStripeChargesEnabled() && $user->isStripePayoutsEnabled()) {
$user->setStripeStatus('active');
}
$em->flush();
}
private function handlePayout(\Stripe\Event $event, EntityManagerInterface $em, MailerService $mailerService, PayoutPdfService $pdfService): void
{
$payoutData = $event->data->object;
$payoutId = $payoutData->id ?? null;
$accountId = $event->account ?? null;
if (!$payoutId) {
return;
}
$payout = $em->getRepository(Payout::class)->findOneBy(['stripePayoutId' => $payoutId]);
$user = null;
if (!$payout) {
$payout = new Payout();
$payout->setStripePayoutId($payoutId);
$payout->setStripeAccountId($accountId);
if ($accountId) {
$user = $em->getRepository(User::class)->findOneBy(['stripeAccountId' => $accountId]);
if ($user) {
$payout->setOrganizer($user);
}
}
$em->persist($payout);
} else {
$user = $payout->getOrganizer();
}
$payout->setStatus((string) ($payoutData->status ?? 'unknown'));
$payout->setAmount((int) ($payoutData->amount ?? 0));
$payout->setCurrency((string) ($payoutData->currency ?? 'eur'));
$payout->setDestination((string) ($payoutData->destination ?? ''));
if (isset($payoutData->arrival_date) && $payoutData->arrival_date) {
$payout->setArrivalDate(new \DateTimeImmutable('@'.$payoutData->arrival_date));
}
$em->flush();
if ($user) {
$attachments = null;
if ('paid' === $payout->getStatus()) {
$pdfPath = $pdfService->generateToFile($payout);
$attachments = [['path' => $pdfPath, 'name' => 'attestation_'.$payout->getStripePayoutId().'.pdf']];
}
$mailerService->sendEmail(
to: $user->getEmail(),
subject: sprintf('Virement %s - E-Ticket', strtoupper($payout->getStatus())),
content: $this->renderView('email/payout_update.html.twig', [
'firstName' => $user->getFirstName(),
'payoutId' => $payout->getStripePayoutId(),
'amount' => number_format($payout->getAmountDecimal(), 2, ',', ' '),
'currency' => $payout->getCurrency(),
'status' => $payout->getStatus(),
'arrivalDate' => $payout->getArrivalDate()?->format('d/m/Y'),
]),
withUnsubscribe: false,
attachments: $attachments,
);
} }
} }
/**
* @param array<string, mixed> $data
*/
private function findUserByEvent(array $data, EntityManagerInterface $em): ?User
{
$accountId = $data['related_object']['id'] ?? null;
if (!$accountId) {
return null;
}
return $em->getRepository(User::class)->findOneBy(['stripeAccountId' => $accountId]);
}
} }

159
src/Entity/Payout.php Normal file
View File

@@ -0,0 +1,159 @@
<?php
namespace App\Entity;
use App\Repository\PayoutRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: PayoutRepository::class)]
class Payout
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false)]
private User $organizer;
#[ORM\Column(length: 255, unique: true)]
private string $stripePayoutId;
#[ORM\Column(length: 50)]
private string $status;
#[ORM\Column]
private int $amount;
#[ORM\Column(length: 10)]
private string $currency;
#[ORM\Column(length: 255, nullable: true)]
private ?string $destination = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $stripeAccountId = null;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $arrivalDate = null;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getOrganizer(): User
{
return $this->organizer;
}
public function setOrganizer(User $organizer): static
{
$this->organizer = $organizer;
return $this;
}
public function getStripePayoutId(): string
{
return $this->stripePayoutId;
}
public function setStripePayoutId(string $stripePayoutId): static
{
$this->stripePayoutId = $stripePayoutId;
return $this;
}
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status): static
{
$this->status = $status;
return $this;
}
public function getAmount(): int
{
return $this->amount;
}
public function setAmount(int $amount): static
{
$this->amount = $amount;
return $this;
}
public function getCurrency(): string
{
return $this->currency;
}
public function setCurrency(string $currency): static
{
$this->currency = $currency;
return $this;
}
public function getDestination(): ?string
{
return $this->destination;
}
public function setDestination(?string $destination): static
{
$this->destination = $destination;
return $this;
}
public function getStripeAccountId(): ?string
{
return $this->stripeAccountId;
}
public function setStripeAccountId(?string $stripeAccountId): static
{
$this->stripeAccountId = $stripeAccountId;
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
public function getArrivalDate(): ?\DateTimeImmutable
{
return $this->arrivalDate;
}
public function setArrivalDate(?\DateTimeImmutable $arrivalDate): static
{
$this->arrivalDate = $arrivalDate;
return $this;
}
public function getAmountDecimal(): float
{
return $this->amount / 100;
}
}

View File

@@ -88,12 +88,23 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
private ?string $stripeAccountId = null; private ?string $stripeAccountId = null;
#[ORM\Column(length: 50, nullable: true)]
private ?string $stripeStatus = null;
#[ORM\Column] #[ORM\Column]
private bool $stripeChargesEnabled = false; private bool $stripeChargesEnabled = false;
#[ORM\Column] #[ORM\Column]
private bool $stripePayoutsEnabled = false; private bool $stripePayoutsEnabled = false;
#[ORM\ManyToOne(targetEntity: self::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?self $parentOrganizer = null;
/** @var list<string> */
#[ORM\Column(nullable: true)]
private ?array $subAccountPermissions = null;
#[ORM\Column(length: 64, nullable: true)] #[ORM\Column(length: 64, nullable: true)]
private ?string $emailVerificationToken = null; private ?string $emailVerificationToken = null;
@@ -317,6 +328,18 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this; return $this;
} }
public function getStripeStatus(): ?string
{
return $this->stripeStatus;
}
public function setStripeStatus(?string $stripeStatus): static
{
$this->stripeStatus = $stripeStatus;
return $this;
}
public function isStripeChargesEnabled(): bool public function isStripeChargesEnabled(): bool
{ {
return $this->stripeChargesEnabled; return $this->stripeChargesEnabled;
@@ -341,6 +364,41 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this; return $this;
} }
public function getParentOrganizer(): ?self
{
return $this->parentOrganizer;
}
public function setParentOrganizer(?self $parentOrganizer): static
{
$this->parentOrganizer = $parentOrganizer;
return $this;
}
/**
* @return list<string>|null
*/
public function getSubAccountPermissions(): ?array
{
return $this->subAccountPermissions;
}
/**
* @param list<string>|null $subAccountPermissions
*/
public function setSubAccountPermissions(?array $subAccountPermissions): static
{
$this->subAccountPermissions = $subAccountPermissions;
return $this;
}
public function hasPermission(string $permission): bool
{
return null !== $this->subAccountPermissions && \in_array($permission, $this->subAccountPermissions, true);
}
public function getResetCode(): ?string public function getResetCode(): ?string
{ {
return $this->resetCode; return $this->resetCode;

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Repository;
use App\Entity\Payout;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Payout>
*/
class PayoutRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Payout::class);
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Service;
use App\Entity\Payout;
use Dompdf\Dompdf;
use Endroid\QrCode\Builder\Builder;
use Endroid\QrCode\Encoding\Encoding;
use Endroid\QrCode\Writer\PngWriter;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Twig\Environment;
class PayoutPdfService
{
public function __construct(
private Environment $twig,
private UrlGeneratorInterface $urlGenerator,
#[Autowire('%kernel.project_dir%')] private string $projectDir,
) {
}
public function generate(Payout $payout): string
{
$logoPath = $this->projectDir.'/public/logo.png';
$logoBase64 = file_exists($logoPath) ? 'data:image/png;base64,'.base64_encode((string) file_get_contents($logoPath)) : '';
$checkUrl = $this->urlGenerator->generate('app_attestation_check', [
'stripePayoutId' => $payout->getStripePayoutId(),
], UrlGeneratorInterface::ABSOLUTE_URL);
$qrCode = (new Builder(
writer: new PngWriter(),
data: $checkUrl,
encoding: new Encoding('UTF-8'),
size: 150,
margin: 5,
))->build();
$qrBase64 = 'data:image/png;base64,'.base64_encode($qrCode->getString());
$html = $this->twig->render('pdf/payout_attestation.html.twig', [
'payout' => $payout,
'logoBase64' => $logoBase64,
'qrBase64' => $qrBase64,
'checkUrl' => $checkUrl,
]);
$dompdf = new Dompdf();
$dompdf->loadHtml($html);
$dompdf->setPaper('A4');
$dompdf->render();
return $dompdf->output();
}
public function generateToFile(Payout $payout): string
{
$dir = $this->projectDir.'/var/payouts';
if (!is_dir($dir)) {
mkdir($dir, 0o755, true);
}
$filename = $dir.'/attestation_'.$payout->getStripePayoutId().'.pdf';
file_put_contents($filename, $this->generate($payout));
return $filename;
}
}

View File

@@ -0,0 +1,61 @@
{% extends 'base.html.twig' %}
{% block title %}Modifier sous-compte - E-Ticket{% endblock %}
{% block body %}
<div style="max-width:36rem;margin:0 auto;padding:3rem 1rem;">
<h1 class="text-3xl font-black uppercase tracking-tighter italic" style="border-bottom:4px solid #111827;display:inline-block;margin-bottom:0.5rem;">Modifier le sous-compte</h1>
<p class="font-bold text-gray-500 italic" style="margin-bottom:2rem;">{{ subAccount.firstName }} {{ subAccount.lastName }}</p>
<div style="border:4px solid #111827;box-shadow:6px 6px 0 rgba(0,0,0,1);background:white;padding:1.5rem;">
<form method="post" action="{{ path('app_account_edit_subaccount', {id: subAccount.id}) }}" style="display:flex;flex-direction:column;gap:1.5rem;">
<div style="display:flex;flex-wrap:wrap;gap:1.5rem;">
<div style="flex:1;min-width:150px;">
<label for="edit_sub_last" style="font-size:10px;letter-spacing:0.1em;display:block;margin-bottom:0.5rem;" class="font-black uppercase text-gray-400">Nom</label>
<input type="text" id="edit_sub_last" name="last_name" value="{{ subAccount.lastName }}" required
style="width:100%;padding:0.75rem 1rem;border:3px solid #111827;font-weight:700;outline:none;">
</div>
<div style="flex:1;min-width:150px;">
<label for="edit_sub_first" style="font-size:10px;letter-spacing:0.1em;display:block;margin-bottom:0.5rem;" class="font-black uppercase text-gray-400">Prenom</label>
<input type="text" id="edit_sub_first" name="first_name" value="{{ subAccount.firstName }}" required
style="width:100%;padding:0.75rem 1rem;border:3px solid #111827;font-weight:700;outline:none;">
</div>
</div>
<div>
<label for="edit_sub_email" style="font-size:10px;letter-spacing:0.1em;display:block;margin-bottom:0.5rem;" class="font-black uppercase text-gray-400">Email</label>
<input type="email" id="edit_sub_email" name="email" value="{{ subAccount.email }}" required
style="width:100%;padding:0.75rem 1rem;border:3px solid #111827;font-weight:700;outline:none;">
</div>
<div>
<p style="font-size:10px;letter-spacing:0.1em;margin-bottom:0.5rem;" class="font-black uppercase text-gray-400">Permissions</p>
<div style="display:flex;flex-direction:column;gap:0.75rem;">
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;" class="text-sm font-bold">
<input type="checkbox" name="permissions[]" value="scanner" {{ subAccount.hasPermission('scanner') ? 'checked' : '' }} style="width:1.25rem;height:1.25rem;"> Scanner (valider les billets)
</label>
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;" class="text-sm font-bold">
<input type="checkbox" name="permissions[]" value="events" {{ subAccount.hasPermission('events') ? 'checked' : '' }} style="width:1.25rem;height:1.25rem;"> Evenements (creer, modifier, supprimer)
</label>
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;" class="text-sm font-bold">
<input type="checkbox" name="permissions[]" value="tickets" {{ subAccount.hasPermission('tickets') ? 'checked' : '' }} style="width:1.25rem;height:1.25rem;"> Billets (invitations gratuites)
</label>
</div>
</div>
<div style="display:flex;gap:0.75rem;">
<button type="submit"
style="padding:0.75rem 1.5rem;border:3px solid #111827;box-shadow:4px 4px 0 rgba(0,0,0,1);background:#fabf04;cursor:pointer;"
class="font-black uppercase text-sm tracking-widest hover:bg-green-500 hover:text-black transition-all">
Enregistrer
</button>
<a href="{{ path('app_account', {tab: 'subaccounts'}) }}"
style="padding:0.75rem 1.5rem;border:3px solid #111827;display:inline-flex;align-items:center;"
class="font-black uppercase text-sm tracking-widest bg-white hover:bg-gray-100 transition-all">
Annuler
</a>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -20,17 +20,31 @@
</div> </div>
{% endfor %} {% endfor %}
{% if isOrganizer and not app.user.approved %}
<div style="border:4px solid #111827;box-shadow:6px 6px 0 rgba(0,0,0,1);background:#fef3c7;padding:2rem;text-align:center;">
<div style="font-size:2rem;margin-bottom:1rem;">&#9203;</div>
<h2 class="text-xl font-black uppercase tracking-tighter italic" style="margin-bottom:0.5rem;">Compte en cours de validation</h2>
<p class="font-bold text-gray-700 text-sm">Votre compte organisateur est en cours de validation par l'equipe E-Ticket. Vous recevrez un email une fois votre compte approuve.</p>
<p class="text-gray-500 text-xs font-bold" style="margin-top:1rem;">Contactez <a href="mailto:contact@e-cosplay.fr" class="text-indigo-600 hover:underline">contact@e-cosplay.fr</a> pour toute question.</p>
</div>
{% else %}
{% if isOrganizer %} {% if isOrganizer %}
{% if not app.user.stripeAccountId %} {% if not app.user.stripeAccountId %}
<div style="border:4px solid #111827;box-shadow:6px 6px 0 rgba(0,0,0,1);background:#fef3c7;padding:1.5rem;margin-bottom:2rem;"> <div style="border:4px solid #111827;box-shadow:6px 6px 0 rgba(0,0,0,1);background:#fef3c7;padding:1.5rem;margin-bottom:2rem;">
<h2 class="text-sm font-black uppercase tracking-widest" style="margin-bottom:0.5rem;">Configuration Stripe requise</h2> <h2 class="text-sm font-black uppercase tracking-widest" style="margin-bottom:0.5rem;">Configuration Stripe requise
<span style="background:white;border:2px solid #111827;padding:0.1rem 0.4rem;font-size:10px;margin-left:0.5rem;">Statut : non configure</span>
</h2>
<p class="text-sm font-bold text-gray-700" style="margin-bottom:1rem;">Pour creer vos evenements, vendre des billets et recevoir vos paiements, vous devez creer votre compte vendeur via Stripe.</p> <p class="text-sm font-bold text-gray-700" style="margin-bottom:1rem;">Pour creer vos evenements, vendre des billets et recevoir vos paiements, vous devez creer votre compte vendeur via Stripe.</p>
<a href="{{ path('app_account_stripe_connect') }}" style="padding:0.5rem 1rem;border:3px solid #111827;box-shadow:4px 4px 0 rgba(0,0,0,1);background:#fabf04;display:inline-block;" class="font-black uppercase text-xs tracking-widest hover:bg-indigo-600 hover:text-black transition-all">Creer mon compte Stripe</a> <a href="{{ path('app_account_stripe_connect') }}" style="padding:0.5rem 1rem;border:3px solid #111827;box-shadow:4px 4px 0 rgba(0,0,0,1);background:#fabf04;display:inline-block;" class="font-black uppercase text-xs tracking-widest hover:bg-indigo-600 hover:text-black transition-all">Creer mon compte Stripe</a>
</div> </div>
{% elseif not app.user.stripeChargesEnabled and not app.user.stripePayoutsEnabled %} {% elseif not app.user.stripeChargesEnabled and not app.user.stripePayoutsEnabled %}
<div style="border:4px solid #111827;box-shadow:6px 6px 0 rgba(0,0,0,1);background:#fef3c7;padding:1.5rem;margin-bottom:2rem;"> <div style="border:4px solid #111827;box-shadow:6px 6px 0 rgba(0,0,0,1);background:#fef3c7;padding:1.5rem;margin-bottom:2rem;">
<h2 class="text-sm font-black uppercase tracking-widest" style="margin-bottom:0.5rem;">Verification Stripe en cours</h2> <h2 class="text-sm font-black uppercase tracking-widest" style="margin-bottom:0.5rem;">Verification Stripe en cours
<span style="background:white;border:2px solid #111827;padding:0.1rem 0.4rem;font-size:10px;margin-left:0.5rem;">Statut : {{ app.user.stripeStatus ?? 'en attente' }}</span>
</h2>
<p class="text-sm font-bold text-gray-700" style="margin-bottom:1rem;">Votre compte Stripe est en cours de verification. Merci de patienter, vous serez notifie une fois la verification terminee.</p> <p class="text-sm font-bold text-gray-700" style="margin-bottom:1rem;">Votre compte Stripe est en cours de verification. Merci de patienter, vous serez notifie une fois la verification terminee.</p>
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;"> <div style="display:flex;gap:0.75rem;flex-wrap:wrap;">
<a href="{{ path('app_account_stripe_connect') }}" style="padding:0.5rem 1rem;border:3px solid #111827;background:white;display:inline-block;" class="font-black uppercase text-xs tracking-widest hover:bg-gray-100 transition-all">Completer ma verification</a> <a href="{{ path('app_account_stripe_connect') }}" style="padding:0.5rem 1rem;border:3px solid #111827;background:white;display:inline-block;" class="font-black uppercase text-xs tracking-widest hover:bg-gray-100 transition-all">Completer ma verification</a>
@@ -42,7 +56,9 @@
{% elseif app.user.stripeChargesEnabled and app.user.stripePayoutsEnabled %} {% elseif app.user.stripeChargesEnabled and app.user.stripePayoutsEnabled %}
<div style="border:4px solid #111827;box-shadow:6px 6px 0 rgba(0,0,0,1);background:#d1fae5;padding:1rem 1.5rem;margin-bottom:2rem;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:1rem;"> <div style="border:4px solid #111827;box-shadow:6px 6px 0 rgba(0,0,0,1);background:#d1fae5;padding:1rem 1.5rem;margin-bottom:2rem;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:1rem;">
<p class="font-black text-sm">Stripe Connect actif — Paiements et virements actives.</p> <p class="font-black text-sm">Stripe Connect actif — Paiements et virements actives.
<span style="background:white;border:2px solid #111827;padding:0.1rem 0.4rem;font-size:10px;margin-left:0.5rem;">Statut : {{ app.user.stripeStatus }}</span>
</p>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;"> <div style="display:flex;gap:0.5rem;flex-wrap:wrap;">
<a href="{{ path('app_account_stripe_dashboard') }}" target="_blank" style="padding:0.4rem 0.75rem;border:2px solid #111827;background:white;display:inline-block;" class="text-xs font-black uppercase tracking-widest hover:bg-gray-100 transition-all">Dashboard Stripe</a> <a href="{{ path('app_account_stripe_dashboard') }}" target="_blank" style="padding:0.4rem 0.75rem;border:2px solid #111827;background:white;display:inline-block;" class="text-xs font-black uppercase tracking-widest hover:bg-gray-100 transition-all">Dashboard Stripe</a>
<form method="post" action="{{ path('app_account_stripe_cancel') }}" data-confirm="Etes-vous sur de vouloir cloturer votre compte Stripe ? Vous ne pourrez plus recevoir de paiements." style="display:inline;"> <form method="post" action="{{ path('app_account_stripe_cancel') }}" data-confirm="Etes-vous sur de vouloir cloturer votre compte Stripe ? Vous ne pourrez plus recevoir de paiements." style="display:inline;">
@@ -53,7 +69,9 @@
{% else %} {% else %}
<div style="border:4px solid #991b1b;box-shadow:6px 6px 0 rgba(0,0,0,1);background:#fee2e2;padding:1.5rem;margin-bottom:2rem;"> <div style="border:4px solid #991b1b;box-shadow:6px 6px 0 rgba(0,0,0,1);background:#fee2e2;padding:1.5rem;margin-bottom:2rem;">
<h2 class="text-sm font-black uppercase tracking-widest" style="margin-bottom:0.5rem;color:#991b1b;">Compte Stripe refuse</h2> <h2 class="text-sm font-black uppercase tracking-widest" style="margin-bottom:0.5rem;color:#991b1b;">Compte Stripe refuse
<span style="background:white;border:2px solid #991b1b;padding:0.1rem 0.4rem;font-size:10px;margin-left:0.5rem;">Statut : {{ app.user.stripeStatus ?? 'refuse' }}</span>
</h2>
<p class="text-sm font-bold text-gray-700">Stripe a refuse votre compte vendeur. Vous ne pouvez pas utiliser nos services de vente pour le moment. Contactez <a href="mailto:contact@e-cosplay.fr" class="text-indigo-600 hover:underline">contact@e-cosplay.fr</a> pour plus d'informations.</p> <p class="text-sm font-bold text-gray-700">Stripe a refuse votre compte vendeur. Vous ne pouvez pas utiliser nos services de vente pour le moment. Contactez <a href="mailto:contact@e-cosplay.fr" class="text-indigo-600 hover:underline">contact@e-cosplay.fr</a> pour plus d'informations.</p>
</div> </div>
{% endif %} {% endif %}
@@ -117,18 +135,89 @@
</div> </div>
{% elseif tab == 'subaccounts' %} {% elseif tab == 'subaccounts' %}
<div style="border:4px solid #111827;box-shadow:6px 6px 0 rgba(0,0,0,1);background:white;padding:1.5rem;margin-bottom:2rem;">
<h2 class="text-sm font-black uppercase tracking-widest" style="margin-bottom:1rem;">Creer un sous-compte</h2>
<form method="post" action="{{ path('app_account_create_subaccount') }}" style="display:flex;flex-direction:column;gap:1rem;">
<div style="display:flex;flex-wrap:wrap;gap:1rem;">
<div style="flex:1;min-width:140px;">
<label for="sub_last_name" style="font-size:10px;letter-spacing:0.1em;display:block;margin-bottom:0.25rem;" class="font-black uppercase text-gray-400">Nom</label>
<input type="text" id="sub_last_name" name="last_name" required style="width:100%;padding:0.5rem 0.75rem;border:2px solid #111827;font-weight:700;outline:none;" placeholder="Dupont">
</div>
<div style="flex:1;min-width:140px;">
<label for="sub_first_name" style="font-size:10px;letter-spacing:0.1em;display:block;margin-bottom:0.25rem;" class="font-black uppercase text-gray-400">Prenom</label>
<input type="text" id="sub_first_name" name="first_name" required style="width:100%;padding:0.5rem 0.75rem;border:2px solid #111827;font-weight:700;outline:none;" placeholder="Jean">
</div>
<div style="flex:2;min-width:200px;">
<label for="sub_email" style="font-size:10px;letter-spacing:0.1em;display:block;margin-bottom:0.25rem;" class="font-black uppercase text-gray-400">Email</label>
<input type="email" id="sub_email" name="email" required style="width:100%;padding:0.5rem 0.75rem;border:2px solid #111827;font-weight:700;outline:none;" placeholder="collaborateur@exemple.fr">
</div>
</div>
<div>
<p style="font-size:10px;letter-spacing:0.1em;margin-bottom:0.5rem;" class="font-black uppercase text-gray-400">Permissions</p>
<div style="display:flex;flex-wrap:wrap;gap:1rem;">
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;" class="text-sm font-bold">
<input type="checkbox" name="permissions[]" value="scanner" style="width:1rem;height:1rem;"> Scanner (valider les billets)
</label>
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;" class="text-sm font-bold">
<input type="checkbox" name="permissions[]" value="events" style="width:1rem;height:1rem;"> Evenements (creer, modifier, supprimer)
</label>
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;" class="text-sm font-bold">
<input type="checkbox" name="permissions[]" value="tickets" style="width:1rem;height:1rem;"> Billets (invitations gratuites)
</label>
</div>
</div>
<button type="submit" style="padding:0.5rem 1rem;border:2px solid #111827;background:#fabf04;cursor:pointer;align-self:flex-start;" class="font-black uppercase text-xs tracking-widest hover:bg-green-500 hover:text-black transition-all">Creer</button>
</form>
</div>
<div style="border:4px solid #111827;box-shadow:6px 6px 0 rgba(0,0,0,1);background:white;"> <div style="border:4px solid #111827;box-shadow:6px 6px 0 rgba(0,0,0,1);background:white;">
<div style="padding:0.75rem 1.5rem;background:#111827;"> <div style="padding:0.75rem 1.5rem;background:#111827;">
<h2 class="text-[10px] font-black uppercase tracking-widest text-white">Sous-comptes</h2> <h2 class="text-[10px] font-black uppercase tracking-widest text-white">Sous-comptes</h2>
</div> </div>
{% if isOrganizer and (not app.user.stripeChargesEnabled or not app.user.stripePayoutsEnabled) %} {% if subAccounts|length > 0 %}
<div style="padding:1.5rem;background:#fef3c7;border-bottom:2px solid #e5e7eb;"> <table style="width:100%;border-collapse:collapse;">
<p class="font-black text-sm">Configuration Stripe ou validation est requise !</p> <thead>
</div> <tr style="border-bottom:2px solid #e5e7eb;">
{% endif %} <th style="padding:0.75rem 1.5rem;text-align:left;" class="text-[10px] font-black uppercase tracking-widest text-gray-400">Nom</th>
<th style="padding:0.75rem 1.5rem;text-align:left;" class="text-[10px] font-black uppercase tracking-widest text-gray-400">Email</th>
<th style="padding:0.75rem 1.5rem;text-align:left;" class="text-[10px] font-black uppercase tracking-widest text-gray-400">Permissions</th>
<th style="padding:0.75rem 1.5rem;text-align:right;" class="text-[10px] font-black uppercase tracking-widest text-gray-400">Actions</th>
</tr>
</thead>
<tbody>
{% for sub in subAccounts %}
<tr style="border-bottom:1px solid #e5e7eb;" class="hover:bg-gray-50 transition-all">
<td style="padding:0.75rem 1.5rem;" class="font-bold text-sm">{{ sub.firstName }} {{ sub.lastName }}</td>
<td style="padding:0.75rem 1.5rem;" class="text-sm text-gray-600">{{ sub.email }}</td>
<td style="padding:0.75rem 1.5rem;">
{% for perm in sub.subAccountPermissions ?? [] %}
{% if perm == 'scanner' %}
<span style="background:#dbeafe;border:2px solid #111827;padding:0.1rem 0.4rem;" class="text-xs font-black uppercase">Scanner</span>
{% elseif perm == 'events' %}
<span style="background:#e0e7ff;border:2px solid #111827;padding:0.1rem 0.4rem;" class="text-xs font-black uppercase">Evenements</span>
{% elseif perm == 'tickets' %}
<span style="background:#fef3c7;border:2px solid #111827;padding:0.1rem 0.4rem;" class="text-xs font-black uppercase">Billets</span>
{% endif %}
{% endfor %}
</td>
<td style="padding:0.75rem 1.5rem;text-align:right;">
<div style="display:flex;gap:0.5rem;justify-content:flex-end;">
<a href="{{ path('app_account_edit_subaccount_page', {id: sub.id}) }}" style="border:2px solid #111827;padding:0.3rem 0.5rem;background:#fabf04;display:inline-block;" class="text-xs font-black uppercase tracking-widest hover:bg-indigo-600 hover:text-black transition-all">Editer</a>
<form method="post" action="{{ path('app_account_delete_subaccount', {id: sub.id}) }}" data-confirm="Supprimer le sous-compte de {{ sub.firstName }} {{ sub.lastName }} ?" style="display:inline;">
<button type="submit" style="border:2px solid #991b1b;padding:0.3rem 0.5rem;background:#dc2626;color:white;cursor:pointer;" class="text-xs font-black uppercase tracking-widest hover:bg-red-800 transition-all">Supprimer</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div style="padding:3rem;text-align:center;"> <div style="padding:3rem;text-align:center;">
<p class="text-gray-400 font-bold text-sm">Aucun sous-compte pour le moment.</p> <p class="text-gray-400 font-bold text-sm">Aucun sous-compte pour le moment.</p>
</div> </div>
{% endif %}
</div> </div>
{% elseif tab == 'payouts' %} {% elseif tab == 'payouts' %}
@@ -141,9 +230,50 @@
<p class="font-black text-sm">Configuration Stripe ou validation est requise !</p> <p class="font-black text-sm">Configuration Stripe ou validation est requise !</p>
</div> </div>
{% endif %} {% endif %}
{% if payouts|length > 0 %}
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr style="border-bottom:2px solid #e5e7eb;">
<th style="padding:0.75rem 1.5rem;text-align:left;" class="text-[10px] font-black uppercase tracking-widest text-gray-400">ID</th>
<th style="padding:0.75rem 1.5rem;text-align:left;" class="text-[10px] font-black uppercase tracking-widest text-gray-400">Montant</th>
<th style="padding:0.75rem 1.5rem;text-align:left;" class="text-[10px] font-black uppercase tracking-widest text-gray-400">Statut</th>
<th style="padding:0.75rem 1.5rem;text-align:left;" class="text-[10px] font-black uppercase tracking-widest text-gray-400">Destination</th>
<th style="padding:0.75rem 1.5rem;text-align:left;" class="text-[10px] font-black uppercase tracking-widest text-gray-400">Arrivee</th>
<th style="padding:0.75rem 1.5rem;text-align:left;" class="text-[10px] font-black uppercase tracking-widest text-gray-400">Date</th>
<th style="padding:0.75rem 1.5rem;text-align:right;" class="text-[10px] font-black uppercase tracking-widest text-gray-400">Attestation</th>
</tr>
</thead>
<tbody>
{% for payout in payouts %}
<tr style="border-bottom:1px solid #e5e7eb;" class="hover:bg-gray-50 transition-all">
<td style="padding:0.75rem 1.5rem;" class="text-xs font-mono text-gray-500">{{ payout.stripePayoutId }}</td>
<td style="padding:0.75rem 1.5rem;" class="text-sm font-bold">{{ payout.amountDecimal|number_format(2, ',', ' ') }} {{ payout.currency|upper }}</td>
<td style="padding:0.75rem 1.5rem;">
{% if payout.status == 'paid' %}
<span style="background:#d1fae5;border:2px solid #111827;padding:0.15rem 0.5rem;" class="text-xs font-black uppercase">{{ payout.status }}</span>
{% elseif payout.status == 'failed' or payout.status == 'canceled' %}
<span style="background:#fee2e2;border:2px solid #111827;padding:0.15rem 0.5rem;" class="text-xs font-black uppercase">{{ payout.status }}</span>
{% else %}
<span style="background:#fef3c7;border:2px solid #111827;padding:0.15rem 0.5rem;" class="text-xs font-black uppercase">{{ payout.status }}</span>
{% endif %}
</td>
<td style="padding:0.75rem 1.5rem;" class="text-xs font-mono text-gray-500">{{ payout.destination ?? '—' }}</td>
<td style="padding:0.75rem 1.5rem;" class="text-sm text-gray-500">{{ payout.arrivalDate ? payout.arrivalDate|date('d/m/Y') : '—' }}</td>
<td style="padding:0.75rem 1.5rem;" class="text-sm text-gray-400">{{ payout.createdAt|date('d/m/Y H:i') }}</td>
<td style="padding:0.75rem 1.5rem;text-align:right;">
{% if payout.status == 'paid' %}
<a href="{{ path('app_account_payout_pdf', {id: payout.id}) }}" target="_blank" style="border:2px solid #111827;padding:0.3rem 0.5rem;background:#fabf04;display:inline-block;" class="text-xs font-black uppercase tracking-widest hover:bg-indigo-600 hover:text-black transition-all">PDF</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div style="padding:3rem;text-align:center;"> <div style="padding:3rem;text-align:center;">
<p class="text-gray-400 font-bold text-sm">Aucun virement pour le moment.</p> <p class="text-gray-400 font-bold text-sm">Aucun virement pour le moment.</p>
</div> </div>
{% endif %}
</div> </div>
{% elseif tab == 'settings' %} {% elseif tab == 'settings' %}
@@ -215,4 +345,5 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,60 @@
{% extends 'base.html.twig' %}
{% block title %}Verification attestation - E-Ticket{% endblock %}
{% block body %}
<div style="max-width:40rem;margin:0 auto;padding:3rem 1rem;">
<h1 class="text-3xl font-black uppercase tracking-tighter italic" style="border-bottom:4px solid #111827;display:inline-block;margin-bottom:0.5rem;">Verification attestation</h1>
<p class="font-bold text-gray-500 italic" style="margin-bottom:2rem;">Ce document a ete verifie par la plateforme E-Ticket.</p>
<div style="border:4px solid #111827;box-shadow:6px 6px 0 rgba(0,0,0,1);background:#d1fae5;padding:1.5rem;margin-bottom:2rem;">
<p class="font-black text-sm" style="margin-bottom:0.5rem;">Attestation authentique</p>
<p class="text-sm text-gray-700">Ce document correspond a un virement enregistre dans notre systeme.</p>
</div>
<div style="border:4px solid #111827;box-shadow:6px 6px 0 rgba(0,0,0,1);background:white;padding:1.5rem;">
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr style="border-bottom:2px solid #e5e7eb;">
<th style="padding:0.5rem 0;text-align:left;" class="text-[10px] font-black uppercase tracking-widest text-gray-400">Detail</th>
<th style="padding:0.5rem 0;text-align:left;" class="text-[10px] font-black uppercase tracking-widest text-gray-400">Valeur</th>
</tr>
</thead>
<tbody>
<tr style="border-bottom:1px solid #e5e7eb;">
<td style="padding:0.5rem 0;" class="font-bold text-sm text-gray-400">Reference</td>
<td style="padding:0.5rem 0;" class="text-sm font-mono">{{ payout.stripePayoutId }}</td>
</tr>
<tr style="border-bottom:1px solid #e5e7eb;">
<td style="padding:0.5rem 0;" class="font-bold text-sm text-gray-400">Montant</td>
<td style="padding:0.5rem 0;" class="text-sm font-bold">{{ payout.amountDecimal|number_format(2, ',', ' ') }} {{ payout.currency|upper }}</td>
</tr>
<tr style="border-bottom:1px solid #e5e7eb;">
<td style="padding:0.5rem 0;" class="font-bold text-sm text-gray-400">Statut</td>
<td style="padding:0.5rem 0;">
{% if payout.status == 'paid' %}
<span style="background:#d1fae5;border:2px solid #111827;padding:0.15rem 0.5rem;" class="text-xs font-black uppercase">{{ payout.status }}</span>
{% elseif payout.status == 'failed' or payout.status == 'canceled' %}
<span style="background:#fee2e2;border:2px solid #111827;padding:0.15rem 0.5rem;" class="text-xs font-black uppercase">{{ payout.status }}</span>
{% else %}
<span style="background:#fef3c7;border:2px solid #111827;padding:0.15rem 0.5rem;" class="text-xs font-black uppercase">{{ payout.status }}</span>
{% endif %}
</td>
</tr>
<tr style="border-bottom:1px solid #e5e7eb;">
<td style="padding:0.5rem 0;" class="font-bold text-sm text-gray-400">Beneficiaire</td>
<td style="padding:0.5rem 0;" class="text-sm">{{ payout.organizer.companyName ?? payout.organizer.firstName ~ ' ' ~ payout.organizer.lastName }}</td>
</tr>
<tr style="border-bottom:1px solid #e5e7eb;">
<td style="padding:0.5rem 0;" class="font-bold text-sm text-gray-400">Date d'arrivee</td>
<td style="padding:0.5rem 0;" class="text-sm">{{ payout.arrivalDate ? payout.arrivalDate|date('d/m/Y') : '—' }}</td>
</tr>
<tr>
<td style="padding:0.5rem 0;" class="font-bold text-sm text-gray-400">Date de creation</td>
<td style="padding:0.5rem 0;" class="text-sm">{{ payout.createdAt|date('d/m/Y H:i') }}</td>
</tr>
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,14 @@
{% extends 'base.html.twig' %}
{% block title %}Attestation introuvable - E-Ticket{% endblock %}
{% block body %}
<div style="max-width:40rem;margin:0 auto;padding:3rem 1rem;">
<h1 class="text-3xl font-black uppercase tracking-tighter italic" style="border-bottom:4px solid #111827;display:inline-block;margin-bottom:0.5rem;">Verification attestation</h1>
<div style="border:4px solid #991b1b;box-shadow:6px 6px 0 rgba(0,0,0,1);background:#fee2e2;padding:1.5rem;margin-top:2rem;">
<p class="font-black text-sm" style="margin-bottom:0.5rem;color:#991b1b;">Attestation introuvable</p>
<p class="text-sm text-gray-700">Ce document ne correspond a aucun virement enregistre dans notre systeme. Il est possible que l'attestation ait ete alteree ou falsifiee.</p>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,44 @@
{% extends 'email/base.html.twig' %}
{% block title %}Mise a jour de votre virement{% endblock %}
{% block content %}
<h2>Bonjour {{ firstName }} !</h2>
<p>Votre virement a ete mis a jour.</p>
<table style="width:100%;border-collapse:collapse;margin:24px 0;">
<thead>
<tr>
<th style="padding:8px 12px;font-weight:700;font-size:12px;color:#a1a1aa;text-align:left;text-transform:uppercase;letter-spacing:0.05em;border-bottom:2px solid #e4e4e7;">Detail</th>
<th style="padding:8px 12px;font-weight:700;font-size:12px;color:#a1a1aa;text-align:left;text-transform:uppercase;letter-spacing:0.05em;border-bottom:2px solid #e4e4e7;">Valeur</th>
</tr>
</thead>
<tbody>
<tr>
<td style="padding:8px 12px;font-weight:700;font-size:14px;color:#18181b;border-bottom:1px solid #e4e4e7;">Reference</td>
<td style="padding:8px 12px;font-size:14px;color:#3f3f46;border-bottom:1px solid #e4e4e7;font-family:monospace;">{{ payoutId }}</td>
</tr>
<tr>
<td style="padding:8px 12px;font-weight:700;font-size:14px;color:#18181b;border-bottom:1px solid #e4e4e7;">Montant</td>
<td style="padding:8px 12px;font-size:14px;color:#3f3f46;border-bottom:1px solid #e4e4e7;font-weight:700;">{{ amount }} {{ currency|upper }}</td>
</tr>
<tr>
<td style="padding:8px 12px;font-weight:700;font-size:14px;color:#18181b;border-bottom:1px solid #e4e4e7;">Statut</td>
<td style="padding:8px 12px;font-size:14px;border-bottom:1px solid #e4e4e7;">
{% if status == 'paid' %}
<span style="background:#d1fae5;padding:2px 8px;font-weight:700;">{{ status|upper }}</span>
{% elseif status == 'failed' or status == 'canceled' %}
<span style="background:#fee2e2;padding:2px 8px;font-weight:700;">{{ status|upper }}</span>
{% else %}
<span style="background:#fef3c7;padding:2px 8px;font-weight:700;">{{ status|upper }}</span>
{% endif %}
</td>
</tr>
{% if arrivalDate %}
<tr>
<td style="padding:8px 12px;font-weight:700;font-size:14px;color:#18181b;">Date d'arrivee</td>
<td style="padding:8px 12px;font-size:14px;color:#3f3f46;">{{ arrivalDate }}</td>
</tr>
{% endif %}
</tbody>
</table>
{% endblock %}

View File

@@ -0,0 +1,39 @@
{% extends 'email/base.html.twig' %}
{% block title %}Votre sous-compte E-Ticket{% endblock %}
{% block content %}
<h2>Bonjour {{ firstName }} !</h2>
<p>Un sous-compte E-Ticket a ete cree pour vous par <strong>{{ organizerName }}</strong>.</p>
<p>Voici vos identifiants de connexion :</p>
<table style="width:100%;border-collapse:collapse;margin:24px 0;">
<thead>
<tr>
<th style="padding:8px 12px;font-weight:700;font-size:12px;color:#a1a1aa;text-align:left;text-transform:uppercase;letter-spacing:0.05em;border-bottom:2px solid #e4e4e7;">Detail</th>
<th style="padding:8px 12px;font-weight:700;font-size:12px;color:#a1a1aa;text-align:left;text-transform:uppercase;letter-spacing:0.05em;border-bottom:2px solid #e4e4e7;">Valeur</th>
</tr>
</thead>
<tbody>
<tr>
<td style="padding:8px 12px;font-weight:700;font-size:14px;color:#18181b;border-bottom:1px solid #e4e4e7;">Email</td>
<td style="padding:8px 12px;font-size:14px;color:#3f3f46;border-bottom:1px solid #e4e4e7;">{{ email }}</td>
</tr>
<tr>
<td style="padding:8px 12px;font-weight:700;font-size:14px;color:#18181b;border-bottom:1px solid #e4e4e7;">Mot de passe</td>
<td style="padding:8px 12px;font-size:14px;color:#3f3f46;border-bottom:1px solid #e4e4e7;font-family:monospace;font-weight:700;">{{ password }}</td>
</tr>
<tr>
<td style="padding:8px 12px;font-weight:700;font-size:14px;color:#18181b;">Permissions</td>
<td style="padding:8px 12px;font-size:14px;color:#3f3f46;">
{% for perm in permissions %}
{% if perm == 'scanner' %}Scanner{% endif %}
{% if perm == 'events' %}Evenements{% endif %}
{% if perm == 'tickets' %}Billets{% endif %}
{% if not loop.last %}, {% endif %}
{% endfor %}
</td>
</tr>
</tbody>
</table>
<p style="font-size:13px;color:#a1a1aa;">Nous vous recommandons de changer votre mot de passe apres votre premiere connexion.</p>
{% endblock %}

View File

@@ -0,0 +1,165 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<style>
@page { margin: 0; }
body {
font-family: 'DejaVu Sans', sans-serif;
color: #111827;
margin: 0;
padding: 0;
font-size: 10px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='210' height='297' viewBox='0 0 210 297'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0%25' stop-color='%23fabf04'/%3E%3Cstop offset='100%25' stop-color='%23f97316'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='210' height='297' fill='url(%23g)'/%3E%3C/svg%3E");
background-size: 100% 100%;
}
.title-bar {
background: #f97316;
padding: 15px 0;
text-align: center;
margin-bottom: 20px;
}
.title-bar h1 {
font-family: 'DejaVu Sans', sans-serif;
font-size: 18px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 4px;
color: white;
margin: 0;
}
.title-bar span {
font-size: 8px;
color: rgba(255,255,255,0.8);
letter-spacing: 1px;
}
.page { padding: 0 25px 25px 25px; }
.two-columns { width: 100%; margin-bottom: 12px; }
.two-columns td { vertical-align: top; width: 50%; padding: 0; border: none; }
.info-block { border: 2px solid #111827; padding: 10px; background: rgba(255,255,255,0.85); }
.info-block-left { border: 2px solid #111827; padding: 10px; margin-right: 8px; background: rgba(255,255,255,0.85); }
.info-block h3 { font-size: 8px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px; color: #6b7280; margin: 0 0 6px 0; border-bottom: 1px solid #e5e7eb; padding-bottom: 3px; }
.info-block p { margin: 2px 0; font-size: 9px; }
.info-block .name { font-size: 11px; font-weight: 900; }
.attestation { font-size: 9px; font-style: italic; color: #111827; margin-bottom: 10px; background: rgba(255,255,255,0.7); padding: 5px 8px; }
.amount-block { border: 2px solid #111827; background: rgba(255,255,255,0.9); padding: 10px 15px; margin-bottom: 10px; }
.amount-block .label { font-size: 8px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 3px; }
.amount-block .amount { font-size: 22px; font-weight: 900; }
table.details { width: 100%; border-collapse: collapse; margin-bottom: 8px; border: 2px solid #111827; }
table.details th { background: #111827; color: white; padding: 5px 10px; text-align: left; font-size: 8px; text-transform: uppercase; letter-spacing: 1px; font-weight: 900; }
table.details td { padding: 5px 10px; border-bottom: 1px solid #e5e7eb; font-size: 10px; background: rgba(255,255,255,0.85); }
.status-paid { background: #d1fae5; border: 1px solid #111827; padding: 1px 5px; font-weight: 900; font-size: 9px; text-transform: uppercase; }
.status-failed { background: #fee2e2; border: 1px solid #111827; padding: 1px 5px; font-weight: 900; font-size: 9px; text-transform: uppercase; }
.status-pending { background: #fef3c7; border: 1px solid #111827; padding: 1px 5px; font-weight: 900; font-size: 9px; text-transform: uppercase; }
.legal { font-size: 7px; color: #111827; line-height: 1.4; }
.footer { padding: 8px 12px; background: rgba(0,0,0,0.1); border-top: 2px solid #111827; font-size: 8px; font-weight: 700; }
.footer p { margin: 1px 0; }
</style>
</head>
<body>
<div class="title-bar">
<h1>Attestation de virement</h1>
<span>N° {{ payout.stripePayoutId }}</span>
</div>
<div class="page">
<table class="two-columns">
<tr>
<td>
<div class="info-block-left">
<h3>Emetteur</h3>
{% if logoBase64 %}
<img src="{{ logoBase64 }}" alt="E-Cosplay" style="height:25px;margin-bottom:5px;">
{% endif %}
<p class="name">E-Ticket</p>
<p>Association E-Cosplay</p>
<p>RNA : W022006988 — SIREN : 943121517</p>
<p>42 rue de Saint-Quentin, 02800 Beautor</p>
<p>contact@e-cosplay.fr</p>
</div>
</td>
<td>
<div class="info-block">
<h3>Beneficiaire</h3>
<p class="name">{{ payout.organizer.companyName ?? payout.organizer.firstName ~ ' ' ~ payout.organizer.lastName }}</p>
<p>{{ payout.organizer.firstName }} {{ payout.organizer.lastName }}</p>
{% if payout.organizer.siret %}<p>SIRET : {{ payout.organizer.siret }}</p>{% endif %}
{% if payout.organizer.address %}<p>{{ payout.organizer.address }}, {{ payout.organizer.postalCode }} {{ payout.organizer.city }}</p>{% endif %}
{% if payout.organizer.phone %}<p>Tel : {{ payout.organizer.phone }}</p>{% endif %}
<p>{{ payout.organizer.email }}</p>
</div>
</td>
</tr>
</table>
<p class="attestation">L'association E-Cosplay atteste les informations suivantes suite aux informations recues par Stripe.</p>
<div class="amount-block">
<div class="label">Montant du virement</div>
<div class="amount">{{ payout.amountDecimal|number_format(2, ',', ' ') }} {{ payout.currency|upper }}</div>
</div>
<table class="details">
<thead>
<tr>
<th style="width:40%;">Detail</th>
<th>Valeur</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Reference</strong></td>
<td style="font-family:monospace;">{{ payout.stripePayoutId }}</td>
</tr>
<tr>
<td><strong>Statut</strong></td>
<td>
{% if payout.status == 'paid' %}
<span class="status-paid">{{ payout.status }}</span>
{% elseif payout.status == 'failed' or payout.status == 'canceled' %}
<span class="status-failed">{{ payout.status }}</span>
{% else %}
<span class="status-pending">{{ payout.status }}</span>
{% endif %}
</td>
</tr>
<tr>
<td><strong>Destination bancaire</strong></td>
<td>{{ payout.destination ?? '—' }}</td>
</tr>
<tr>
<td><strong>Date d'arrivee estimee</strong></td>
<td>{{ payout.arrivalDate ? payout.arrivalDate|date('d/m/Y') : '—' }}</td>
</tr>
<tr>
<td><strong>Date de creation</strong></td>
<td>{{ payout.createdAt|date('d/m/Y H:i') }}</td>
</tr>
<tr>
<td><strong>Compte Stripe</strong></td>
<td style="font-family:monospace;">{{ payout.stripeAccountId ?? '—' }}</td>
</tr>
</tbody>
</table>
<table style="width:100%;margin-bottom:6px;">
<tr>
<td style="vertical-align:top;width:70%;padding:0;border:none;">
<p style="font-size:9px;color:#111827;line-height:1.5;">L'association E-Cosplay ne pourra etre tenue responsable des erreurs de virement emises par Stripe. Les informations presentees dans ce document sont conformes aux Conditions Generales de Vente (CGV) du site E-Ticket consultables a l'adresse ticket.e-cosplay.fr/cgv.</p>
<p style="font-size:9px;color:#111827;font-weight:700;margin-top:5px;">Verifiez l'authenticite : {{ checkUrl }}</p>
</td>
<td style="vertical-align:top;text-align:right;padding:0;border:none;">
{% if qrBase64 %}
<img src="{{ qrBase64 }}" alt="QR Code" style="width:70px;height:70px;">
{% endif %}
</td>
</tr>
</table>
<div class="footer">
<p>E-TICKET — Association E-Cosplay (RNA W022006988) — 42 rue de Saint-Quentin, 02800 Beautor — contact@e-cosplay.fr</p>
<p style="color:#6b7280;font-size:7px;">Document genere le {{ "now"|date("d/m/Y a H:i") }}</p>
</div>
</div>
</body>
</html>

View File

@@ -3,7 +3,6 @@
namespace App\Tests\Controller; namespace App\Tests\Controller;
use App\Entity\User; use App\Entity\User;
use App\Service\StripeService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
@@ -94,7 +93,7 @@ class AccountControllerTest extends WebTestCase
public function testOrganizerEventsTab(): void public function testOrganizerEventsTab(): void
{ {
$client = static::createClient(); $client = static::createClient();
$user = $this->createUser(['ROLE_ORGANIZER']); $user = $this->createUser(['ROLE_ORGANIZER'], true);
$client->loginUser($user); $client->loginUser($user);
$client->request('GET', '/mon-compte?tab=events'); $client->request('GET', '/mon-compte?tab=events');
@@ -105,7 +104,7 @@ class AccountControllerTest extends WebTestCase
public function testOrganizerSubaccountsTab(): void public function testOrganizerSubaccountsTab(): void
{ {
$client = static::createClient(); $client = static::createClient();
$user = $this->createUser(['ROLE_ORGANIZER']); $user = $this->createUser(['ROLE_ORGANIZER'], true);
$client->loginUser($user); $client->loginUser($user);
$client->request('GET', '/mon-compte?tab=subaccounts'); $client->request('GET', '/mon-compte?tab=subaccounts');
@@ -116,7 +115,7 @@ class AccountControllerTest extends WebTestCase
public function testOrganizerPayoutsTab(): void public function testOrganizerPayoutsTab(): void
{ {
$client = static::createClient(); $client = static::createClient();
$user = $this->createUser(['ROLE_ORGANIZER']); $user = $this->createUser(['ROLE_ORGANIZER'], true);
$client->loginUser($user); $client->loginUser($user);
$client->request('GET', '/mon-compte?tab=payouts'); $client->request('GET', '/mon-compte?tab=payouts');
@@ -127,7 +126,7 @@ class AccountControllerTest extends WebTestCase
public function testOrganizerSettingsDisablesNameFields(): void public function testOrganizerSettingsDisablesNameFields(): void
{ {
$client = static::createClient(); $client = static::createClient();
$user = $this->createUser(['ROLE_ORGANIZER']); $user = $this->createUser(['ROLE_ORGANIZER'], true);
$client->loginUser($user); $client->loginUser($user);
$client->request('POST', '/mon-compte/parametres', [ $client->request('POST', '/mon-compte/parametres', [
@@ -138,10 +137,22 @@ class AccountControllerTest extends WebTestCase
self::assertResponseRedirects('/mon-compte?tab=settings'); self::assertResponseRedirects('/mon-compte?tab=settings');
} }
public function testOrganizerNotApprovedShowsBlockingMessage(): void
{
$client = static::createClient();
$user = $this->createUser(['ROLE_ORGANIZER'], false);
$client->loginUser($user);
$client->request('GET', '/mon-compte');
self::assertResponseIsSuccessful();
self::assertSelectorTextContains('body', 'en cours de validation');
}
public function testOrganizerDefaultTabIsEvents(): void public function testOrganizerDefaultTabIsEvents(): void
{ {
$client = static::createClient(); $client = static::createClient();
$user = $this->createUser(['ROLE_ORGANIZER']); $user = $this->createUser(['ROLE_ORGANIZER'], true);
$client->loginUser($user); $client->loginUser($user);
$client->request('GET', '/mon-compte'); $client->request('GET', '/mon-compte');
@@ -163,7 +174,7 @@ class AccountControllerTest extends WebTestCase
public function testOrganizerWithoutStripeShowsSetupMessage(): void public function testOrganizerWithoutStripeShowsSetupMessage(): void
{ {
$client = static::createClient(); $client = static::createClient();
$user = $this->createUser(['ROLE_ORGANIZER']); $user = $this->createUser(['ROLE_ORGANIZER'], true);
$client->loginUser($user); $client->loginUser($user);
$crawler = $client->request('GET', '/mon-compte'); $crawler = $client->request('GET', '/mon-compte');
@@ -176,7 +187,7 @@ class AccountControllerTest extends WebTestCase
{ {
$client = static::createClient(); $client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class); $em = static::getContainer()->get(EntityManagerInterface::class);
$user = $this->createUser(['ROLE_ORGANIZER']); $user = $this->createUser(['ROLE_ORGANIZER'], true);
$user->setStripeAccountId('acct_pending'); $user->setStripeAccountId('acct_pending');
$em->flush(); $em->flush();
@@ -191,7 +202,7 @@ class AccountControllerTest extends WebTestCase
{ {
$client = static::createClient(); $client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class); $em = static::getContainer()->get(EntityManagerInterface::class);
$user = $this->createUser(['ROLE_ORGANIZER']); $user = $this->createUser(['ROLE_ORGANIZER'], true);
$user->setStripeAccountId('acct_active'); $user->setStripeAccountId('acct_active');
$user->setStripeChargesEnabled(true); $user->setStripeChargesEnabled(true);
$user->setStripePayoutsEnabled(true); $user->setStripePayoutsEnabled(true);
@@ -207,7 +218,7 @@ class AccountControllerTest extends WebTestCase
public function testStripeConnectReturn(): void public function testStripeConnectReturn(): void
{ {
$client = static::createClient(); $client = static::createClient();
$user = $this->createUser(['ROLE_ORGANIZER']); $user = $this->createUser(['ROLE_ORGANIZER'], true);
$client->loginUser($user); $client->loginUser($user);
$client->request('GET', '/stripe/connect/return'); $client->request('GET', '/stripe/connect/return');
@@ -218,7 +229,7 @@ class AccountControllerTest extends WebTestCase
public function testStripeConnectRefresh(): void public function testStripeConnectRefresh(): void
{ {
$client = static::createClient(); $client = static::createClient();
$user = $this->createUser(['ROLE_ORGANIZER']); $user = $this->createUser(['ROLE_ORGANIZER'], true);
$client->loginUser($user); $client->loginUser($user);
$client->request('GET', '/stripe/connect/refresh'); $client->request('GET', '/stripe/connect/refresh');
@@ -230,7 +241,7 @@ class AccountControllerTest extends WebTestCase
{ {
$client = static::createClient(); $client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class); $em = static::getContainer()->get(EntityManagerInterface::class);
$user = $this->createUser(['ROLE_ORGANIZER']); $user = $this->createUser(['ROLE_ORGANIZER'], true);
$user->setStripeAccountId('acct_cancel'); $user->setStripeAccountId('acct_cancel');
$em->flush(); $em->flush();
@@ -246,7 +257,10 @@ class AccountControllerTest extends WebTestCase
/** /**
* @param list<string> $roles * @param list<string> $roles
*/ */
private function createUser(array $roles = []): User /**
* @param list<string> $roles
*/
private function createUser(array $roles = [], bool $approved = false): User
{ {
$em = static::getContainer()->get(EntityManagerInterface::class); $em = static::getContainer()->get(EntityManagerInterface::class);
@@ -257,6 +271,10 @@ class AccountControllerTest extends WebTestCase
$user->setPassword('$2y$13$hashed'); $user->setPassword('$2y$13$hashed');
$user->setRoles($roles); $user->setRoles($roles);
if ($approved) {
$user->setIsApproved(true);
}
$em->persist($user); $em->persist($user);
$em->flush(); $em->flush();

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Tests\Controller;
use App\Entity\Payout;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class AttestationControllerTest extends WebTestCase
{
public function testCheckWithValidPayout(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = new User();
$user->setEmail('test-attest-'.uniqid().'@example.com');
$user->setFirstName('Test');
$user->setLastName('User');
$user->setPassword('$2y$13$hashed');
$em->persist($user);
$payout = new Payout();
$payout->setOrganizer($user);
$payout->setStripePayoutId('po_check_'.uniqid());
$payout->setStatus('paid');
$payout->setAmount(10000);
$payout->setCurrency('eur');
$em->persist($payout);
$em->flush();
$client->request('GET', '/attestation/check/'.$payout->getStripePayoutId());
self::assertResponseIsSuccessful();
self::assertSelectorTextContains('body', 'Attestation authentique');
}
public function testCheckWithInvalidPayout(): void
{
$client = static::createClient();
$client->request('GET', '/attestation/check/po_fake_invalid');
self::assertResponseIsSuccessful();
self::assertSelectorTextContains('body', 'Attestation introuvable');
}
}

View File

@@ -2,12 +2,8 @@
namespace App\Tests\Controller; namespace App\Tests\Controller;
use App\Entity\User;
use App\Service\StripeService; use App\Service\StripeService;
use Doctrine\ORM\EntityManagerInterface;
use Stripe\Account;
use Stripe\Event; use Stripe\Event;
use Stripe\StripeClient;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class StripeWebhookControllerTest extends WebTestCase class StripeWebhookControllerTest extends WebTestCase
@@ -42,84 +38,4 @@ class StripeWebhookControllerTest extends WebTestCase
self::assertResponseStatusCodeSame(400); self::assertResponseStatusCodeSame(400);
} }
public function testWebhookAccountUpdatedSetsFlags(): void
{
$client = static::createClient();
$event = Event::constructFrom([
'type' => 'account.updated',
'data' => [
'object' => [
'id' => 'acct_test_webhook',
'charges_enabled' => true,
'payouts_enabled' => true,
],
],
]);
$account = Account::constructFrom([
'id' => 'acct_test_webhook',
'charges_enabled' => true,
'payouts_enabled' => true,
]);
$accountsService = $this->createMock(\Stripe\Service\AccountService::class);
$accountsService->method('retrieve')->willReturn($account);
$stripeClient = $this->createMock(StripeClient::class);
$stripeClient->accounts = $accountsService;
$stripeService = $this->createMock(StripeService::class);
$stripeService->method('verifyWebhookSignature')->willReturn($event);
$stripeService->method('getClient')->willReturn($stripeClient);
static::getContainer()->set(StripeService::class, $stripeService);
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = new User();
$user->setEmail('test-stripe-wh-'.uniqid().'@example.com');
$user->setFirstName('Stripe');
$user->setLastName('Test');
$user->setPassword('$2y$13$hashed');
$user->setStripeAccountId('acct_test_webhook');
$em->persist($user);
$em->flush();
$client->request('POST', '/stripe/webhook', [], [], [
'HTTP_STRIPE_SIGNATURE' => 'valid',
], '{}');
self::assertResponseIsSuccessful();
$freshEm = static::getContainer()->get(EntityManagerInterface::class);
$updatedUser = $freshEm->getRepository(User::class)->findOneBy(['stripeAccountId' => 'acct_test_webhook']);
self::assertNotNull($updatedUser);
self::assertTrue($updatedUser->isStripeChargesEnabled());
self::assertTrue($updatedUser->isStripePayoutsEnabled());
}
public function testWebhookAccountUpdatedUnknownAccount(): void
{
$client = static::createClient();
$event = Event::constructFrom([
'type' => 'account.updated',
'data' => [
'object' => [
'id' => 'acct_unknown',
],
],
]);
$stripeService = $this->createMock(StripeService::class);
$stripeService->method('verifyWebhookSignature')->willReturn($event);
static::getContainer()->set(StripeService::class, $stripeService);
$client->request('POST', '/stripe/webhook', [], [], [
'HTTP_STRIPE_SIGNATURE' => 'valid',
], '{}');
self::assertResponseIsSuccessful();
}
} }

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Tests\Entity;
use App\Entity\Payout;
use App\Entity\User;
use PHPUnit\Framework\TestCase;
class PayoutTest extends TestCase
{
public function testNewPayoutHasCreatedAt(): void
{
$payout = new Payout();
self::assertInstanceOf(\DateTimeImmutable::class, $payout->getCreatedAt());
}
public function testPayoutFields(): void
{
$user = new User();
$arrival = new \DateTimeImmutable('2026-03-20');
$payout = new Payout();
$result = $payout->setOrganizer($user)
->setStripePayoutId('po_test123')
->setStatus('paid')
->setAmount(15000)
->setCurrency('eur')
->setDestination('ba_xxx')
->setStripeAccountId('acct_xxx')
->setArrivalDate($arrival);
self::assertSame($payout, $result);
self::assertNull($payout->getId());
self::assertSame($user, $payout->getOrganizer());
self::assertSame('po_test123', $payout->getStripePayoutId());
self::assertSame('paid', $payout->getStatus());
self::assertSame(15000, $payout->getAmount());
self::assertSame(150.0, $payout->getAmountDecimal());
self::assertSame('eur', $payout->getCurrency());
self::assertSame('ba_xxx', $payout->getDestination());
self::assertSame('acct_xxx', $payout->getStripeAccountId());
self::assertSame($arrival, $payout->getArrivalDate());
}
}

View File

@@ -146,20 +146,43 @@ class UserTest extends TestCase
self::assertSame(1.5, $user->getCommissionRate()); self::assertSame(1.5, $user->getCommissionRate());
} }
public function testSubAccountFields(): void
{
$parent = new User();
$sub = new User();
self::assertNull($sub->getParentOrganizer());
self::assertNull($sub->getSubAccountPermissions());
self::assertFalse($sub->hasPermission('scanner'));
$result = $sub->setParentOrganizer($parent)
->setSubAccountPermissions(['scanner', 'events']);
self::assertSame($sub, $result);
self::assertSame($parent, $sub->getParentOrganizer());
self::assertSame(['scanner', 'events'], $sub->getSubAccountPermissions());
self::assertTrue($sub->hasPermission('scanner'));
self::assertTrue($sub->hasPermission('events'));
self::assertFalse($sub->hasPermission('tickets'));
}
public function testStripeFields(): void public function testStripeFields(): void
{ {
$user = new User(); $user = new User();
self::assertNull($user->getStripeAccountId()); self::assertNull($user->getStripeAccountId());
self::assertNull($user->getStripeStatus());
self::assertFalse($user->isStripeChargesEnabled()); self::assertFalse($user->isStripeChargesEnabled());
self::assertFalse($user->isStripePayoutsEnabled()); self::assertFalse($user->isStripePayoutsEnabled());
$result = $user->setStripeAccountId('acct_1234567890') $result = $user->setStripeAccountId('acct_1234567890')
->setStripeStatus('started')
->setStripeChargesEnabled(true) ->setStripeChargesEnabled(true)
->setStripePayoutsEnabled(true); ->setStripePayoutsEnabled(true);
self::assertSame($user, $result); self::assertSame($user, $result);
self::assertSame('acct_1234567890', $user->getStripeAccountId()); self::assertSame('acct_1234567890', $user->getStripeAccountId());
self::assertSame('started', $user->getStripeStatus());
self::assertTrue($user->isStripeChargesEnabled()); self::assertTrue($user->isStripeChargesEnabled());
self::assertTrue($user->isStripePayoutsEnabled()); self::assertTrue($user->isStripePayoutsEnabled());
} }