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:
35
migrations/Version20260319214513.php
Normal file
35
migrations/Version20260319214513.php
Normal 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');
|
||||
}
|
||||
}
|
||||
35
migrations/Version20260319215018.php
Normal file
35
migrations/Version20260319215018.php
Normal 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');
|
||||
}
|
||||
}
|
||||
31
migrations/Version20260319215953.php
Normal file
31
migrations/Version20260319215953.php
Normal 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');
|
||||
}
|
||||
}
|
||||
31
migrations/Version20260319220425.php
Normal file
31
migrations/Version20260319220425.php
Normal 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');
|
||||
}
|
||||
}
|
||||
37
migrations/Version20260319222449.php
Normal file
37
migrations/Version20260319222449.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,9 @@ parameters:
|
||||
- src/Kernel.php
|
||||
ignoreErrors:
|
||||
-
|
||||
message: '#Property App\\Entity\\(EmailTracking|MessengerLog|User)::\$id .* never assigned#'
|
||||
message: '#Property App\\Entity\\(EmailTracking|MessengerLog|User|Payout)::\$id .* never assigned#'
|
||||
paths:
|
||||
- src/Entity/EmailTracking.php
|
||||
- src/Entity/MessengerLog.php
|
||||
- src/Entity/User.php
|
||||
- src/Entity/Payout.php
|
||||
|
||||
@@ -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"',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
26
src/Controller/AttestationController.php
Normal file
26
src/Controller/AttestationController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
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;
|
||||
@@ -13,7 +16,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
class StripeWebhookController extends AbstractController
|
||||
{
|
||||
#[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();
|
||||
$signature = $request->headers->get('Stripe-Signature', '');
|
||||
@@ -24,34 +27,153 @@ class StripeWebhookController extends AbstractController
|
||||
return new Response('Invalid signature', 400);
|
||||
}
|
||||
|
||||
if ('account.updated' === $event->type) {
|
||||
$this->handleAccountSync($event, $stripeService, $em);
|
||||
}
|
||||
$type = $event->type ?? null;
|
||||
$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);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (!$accountId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $em->getRepository(User::class)->findOneBy(['stripeAccountId' => $accountId]);
|
||||
$user = $this->findUserByEvent($data, $em);
|
||||
|
||||
if (!$user) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$account = $stripeService->getClient()->accounts->retrieve($accountId);
|
||||
$user->setStripeChargesEnabled((bool) $account->charges_enabled);
|
||||
$user->setStripePayoutsEnabled((bool) $account->payouts_enabled);
|
||||
$em->flush();
|
||||
} catch (\Throwable) {
|
||||
// Stripe API unavailable
|
||||
$user->setStripeStatus($status);
|
||||
|
||||
if ('closed' === $status) {
|
||||
$user->setStripeChargesEnabled(false);
|
||||
$user->setStripePayoutsEnabled(false);
|
||||
}
|
||||
|
||||
$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
159
src/Entity/Payout.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -88,12 +88,23 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $stripeAccountId = null;
|
||||
|
||||
#[ORM\Column(length: 50, nullable: true)]
|
||||
private ?string $stripeStatus = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private bool $stripeChargesEnabled = false;
|
||||
|
||||
#[ORM\Column]
|
||||
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)]
|
||||
private ?string $emailVerificationToken = null;
|
||||
|
||||
@@ -317,6 +328,18 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStripeStatus(): ?string
|
||||
{
|
||||
return $this->stripeStatus;
|
||||
}
|
||||
|
||||
public function setStripeStatus(?string $stripeStatus): static
|
||||
{
|
||||
$this->stripeStatus = $stripeStatus;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isStripeChargesEnabled(): bool
|
||||
{
|
||||
return $this->stripeChargesEnabled;
|
||||
@@ -341,6 +364,41 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
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
|
||||
{
|
||||
return $this->resetCode;
|
||||
|
||||
18
src/Repository/PayoutRepository.php
Normal file
18
src/Repository/PayoutRepository.php
Normal 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);
|
||||
}
|
||||
}
|
||||
69
src/Service/PayoutPdfService.php
Normal file
69
src/Service/PayoutPdfService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
61
templates/account/edit_subaccount.html.twig
Normal file
61
templates/account/edit_subaccount.html.twig
Normal 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 %}
|
||||
@@ -20,17 +20,31 @@
|
||||
</div>
|
||||
{% 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;">⏳</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 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;">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{% 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;">
|
||||
<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>
|
||||
<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>
|
||||
@@ -42,7 +56,9 @@
|
||||
|
||||
{% 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;">
|
||||
<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;">
|
||||
<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;">
|
||||
@@ -53,7 +69,9 @@
|
||||
|
||||
{% 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;">
|
||||
<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>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -117,18 +135,89 @@
|
||||
</div>
|
||||
|
||||
{% 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="padding:0.75rem 1.5rem;background:#111827;">
|
||||
<h2 class="text-[10px] font-black uppercase tracking-widest text-white">Sous-comptes</h2>
|
||||
</div>
|
||||
{% if isOrganizer and (not app.user.stripeChargesEnabled or not app.user.stripePayoutsEnabled) %}
|
||||
<div style="padding:1.5rem;background:#fef3c7;border-bottom:2px solid #e5e7eb;">
|
||||
<p class="font-black text-sm">Configuration Stripe ou validation est requise !</p>
|
||||
</div>
|
||||
{% if subAccounts|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">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;">
|
||||
<p class="text-gray-400 font-bold text-sm">Aucun sous-compte pour le moment.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% elseif tab == 'payouts' %}
|
||||
@@ -141,9 +230,50 @@
|
||||
<p class="font-black text-sm">Configuration Stripe ou validation est requise !</p>
|
||||
</div>
|
||||
{% 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;">
|
||||
<p class="text-gray-400 font-bold text-sm">Aucun virement pour le moment.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% elseif tab == 'settings' %}
|
||||
@@ -215,4 +345,5 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
60
templates/attestation/check.html.twig
Normal file
60
templates/attestation/check.html.twig
Normal 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 %}
|
||||
14
templates/attestation/not_found.html.twig
Normal file
14
templates/attestation/not_found.html.twig
Normal 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 %}
|
||||
44
templates/email/payout_update.html.twig
Normal file
44
templates/email/payout_update.html.twig
Normal 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 %}
|
||||
39
templates/email/subaccount_created.html.twig
Normal file
39
templates/email/subaccount_created.html.twig
Normal 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 %}
|
||||
165
templates/pdf/payout_attestation.html.twig
Normal file
165
templates/pdf/payout_attestation.html.twig
Normal 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>
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Tests\Controller;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Service\StripeService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
@@ -94,7 +93,7 @@ class AccountControllerTest extends WebTestCase
|
||||
public function testOrganizerEventsTab(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$user = $this->createUser(['ROLE_ORGANIZER']);
|
||||
$user = $this->createUser(['ROLE_ORGANIZER'], true);
|
||||
|
||||
$client->loginUser($user);
|
||||
$client->request('GET', '/mon-compte?tab=events');
|
||||
@@ -105,7 +104,7 @@ class AccountControllerTest extends WebTestCase
|
||||
public function testOrganizerSubaccountsTab(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$user = $this->createUser(['ROLE_ORGANIZER']);
|
||||
$user = $this->createUser(['ROLE_ORGANIZER'], true);
|
||||
|
||||
$client->loginUser($user);
|
||||
$client->request('GET', '/mon-compte?tab=subaccounts');
|
||||
@@ -116,7 +115,7 @@ class AccountControllerTest extends WebTestCase
|
||||
public function testOrganizerPayoutsTab(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$user = $this->createUser(['ROLE_ORGANIZER']);
|
||||
$user = $this->createUser(['ROLE_ORGANIZER'], true);
|
||||
|
||||
$client->loginUser($user);
|
||||
$client->request('GET', '/mon-compte?tab=payouts');
|
||||
@@ -127,7 +126,7 @@ class AccountControllerTest extends WebTestCase
|
||||
public function testOrganizerSettingsDisablesNameFields(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$user = $this->createUser(['ROLE_ORGANIZER']);
|
||||
$user = $this->createUser(['ROLE_ORGANIZER'], true);
|
||||
|
||||
$client->loginUser($user);
|
||||
$client->request('POST', '/mon-compte/parametres', [
|
||||
@@ -138,10 +137,22 @@ class AccountControllerTest extends WebTestCase
|
||||
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
|
||||
{
|
||||
$client = static::createClient();
|
||||
$user = $this->createUser(['ROLE_ORGANIZER']);
|
||||
$user = $this->createUser(['ROLE_ORGANIZER'], true);
|
||||
|
||||
$client->loginUser($user);
|
||||
$client->request('GET', '/mon-compte');
|
||||
@@ -163,7 +174,7 @@ class AccountControllerTest extends WebTestCase
|
||||
public function testOrganizerWithoutStripeShowsSetupMessage(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$user = $this->createUser(['ROLE_ORGANIZER']);
|
||||
$user = $this->createUser(['ROLE_ORGANIZER'], true);
|
||||
|
||||
$client->loginUser($user);
|
||||
$crawler = $client->request('GET', '/mon-compte');
|
||||
@@ -176,7 +187,7 @@ class AccountControllerTest extends WebTestCase
|
||||
{
|
||||
$client = static::createClient();
|
||||
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||
$user = $this->createUser(['ROLE_ORGANIZER']);
|
||||
$user = $this->createUser(['ROLE_ORGANIZER'], true);
|
||||
$user->setStripeAccountId('acct_pending');
|
||||
$em->flush();
|
||||
|
||||
@@ -191,7 +202,7 @@ class AccountControllerTest extends WebTestCase
|
||||
{
|
||||
$client = static::createClient();
|
||||
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||
$user = $this->createUser(['ROLE_ORGANIZER']);
|
||||
$user = $this->createUser(['ROLE_ORGANIZER'], true);
|
||||
$user->setStripeAccountId('acct_active');
|
||||
$user->setStripeChargesEnabled(true);
|
||||
$user->setStripePayoutsEnabled(true);
|
||||
@@ -207,7 +218,7 @@ class AccountControllerTest extends WebTestCase
|
||||
public function testStripeConnectReturn(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$user = $this->createUser(['ROLE_ORGANIZER']);
|
||||
$user = $this->createUser(['ROLE_ORGANIZER'], true);
|
||||
|
||||
$client->loginUser($user);
|
||||
$client->request('GET', '/stripe/connect/return');
|
||||
@@ -218,7 +229,7 @@ class AccountControllerTest extends WebTestCase
|
||||
public function testStripeConnectRefresh(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$user = $this->createUser(['ROLE_ORGANIZER']);
|
||||
$user = $this->createUser(['ROLE_ORGANIZER'], true);
|
||||
|
||||
$client->loginUser($user);
|
||||
$client->request('GET', '/stripe/connect/refresh');
|
||||
@@ -230,7 +241,7 @@ class AccountControllerTest extends WebTestCase
|
||||
{
|
||||
$client = static::createClient();
|
||||
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||
$user = $this->createUser(['ROLE_ORGANIZER']);
|
||||
$user = $this->createUser(['ROLE_ORGANIZER'], true);
|
||||
$user->setStripeAccountId('acct_cancel');
|
||||
$em->flush();
|
||||
|
||||
@@ -246,7 +257,10 @@ class AccountControllerTest extends WebTestCase
|
||||
/**
|
||||
* @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);
|
||||
|
||||
@@ -257,6 +271,10 @@ class AccountControllerTest extends WebTestCase
|
||||
$user->setPassword('$2y$13$hashed');
|
||||
$user->setRoles($roles);
|
||||
|
||||
if ($approved) {
|
||||
$user->setIsApproved(true);
|
||||
}
|
||||
|
||||
$em->persist($user);
|
||||
$em->flush();
|
||||
|
||||
|
||||
47
tests/Controller/AttestationControllerTest.php
Normal file
47
tests/Controller/AttestationControllerTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,8 @@
|
||||
|
||||
namespace App\Tests\Controller;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Service\StripeService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Stripe\Account;
|
||||
use Stripe\Event;
|
||||
use Stripe\StripeClient;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
class StripeWebhookControllerTest extends WebTestCase
|
||||
@@ -42,84 +38,4 @@ class StripeWebhookControllerTest extends WebTestCase
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
45
tests/Entity/PayoutTest.php
Normal file
45
tests/Entity/PayoutTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -146,20 +146,43 @@ class UserTest extends TestCase
|
||||
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
|
||||
{
|
||||
$user = new User();
|
||||
|
||||
self::assertNull($user->getStripeAccountId());
|
||||
self::assertNull($user->getStripeStatus());
|
||||
self::assertFalse($user->isStripeChargesEnabled());
|
||||
self::assertFalse($user->isStripePayoutsEnabled());
|
||||
|
||||
$result = $user->setStripeAccountId('acct_1234567890')
|
||||
->setStripeStatus('started')
|
||||
->setStripeChargesEnabled(true)
|
||||
->setStripePayoutsEnabled(true);
|
||||
|
||||
self::assertSame($user, $result);
|
||||
self::assertSame('acct_1234567890', $user->getStripeAccountId());
|
||||
self::assertSame('started', $user->getStripeStatus());
|
||||
self::assertTrue($user->isStripeChargesEnabled());
|
||||
self::assertTrue($user->isStripePayoutsEnabled());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user