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:
@@ -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