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:
@@ -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"',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user