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

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

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

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

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

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

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

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

View File

@@ -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();

View File

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

View File

@@ -2,12 +2,8 @@
namespace App\Tests\Controller;
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();
}
}

View File

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

View File

@@ -146,20 +146,43 @@ class UserTest extends TestCase
self::assertSame(1.5, $user->getCommissionRate());
}
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());
}