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

@@ -2,13 +2,16 @@
namespace App\Controller;
use App\Entity\Payout;
use App\Entity\User;
use App\Service\MailerService;
use App\Service\PayoutPdfService;
use App\Service\StripeService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
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', [
'tab' => $tab,
'isOrganizer' => $isOrganizer,
'payouts' => $payouts,
'subAccounts' => $subAccounts,
]);
}
@@ -83,6 +101,7 @@ class AccountController extends AbstractController
if (!$user->getStripeAccountId()) {
$accountId = $stripeService->createAccountConnect($user);
$user->setStripeAccountId($accountId);
$user->setStripeStatus('started');
$em->flush();
}
@@ -134,6 +153,135 @@ class AccountController extends AbstractController
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')]
public function stripeDashboard(StripeService $stripeService): Response
{
@@ -154,4 +302,20 @@ class AccountController extends AbstractController
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"',
]);
}
}