Add app:stripe:sync command to sync Stripe status for all organizers

Fetches charges_enabled and payouts_enabled from Stripe API for each
organizer with a connected account and updates the local database.
Also adds retrieveAccountStatus() to StripeService for testability.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-26 09:56:11 +01:00
parent b43c6bcbab
commit c207fd31b1
3 changed files with 233 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
<?php
namespace App\Command;
use App\Entity\User;
use App\Service\StripeService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:stripe:sync',
description: 'Sync Stripe account status for all organizers',
)]
class StripeSyncCommand extends Command
{
public function __construct(
private EntityManagerInterface $em,
private StripeService $stripeService,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$organizers = array_filter(
$this->em->getRepository(User::class)->findAll(),
fn (User $u) => \in_array('ROLE_ORGANIZER', $u->getRoles(), true) && null !== $u->getStripeAccountId(),
);
if (0 === \count($organizers)) {
$io->info('No organizers with Stripe accounts found.');
return Command::SUCCESS;
}
$io->info(sprintf('Syncing %d organizer(s)...', \count($organizers)));
$synced = 0;
$errors = 0;
foreach ($organizers as $user) {
try {
$status = $this->stripeService->retrieveAccountStatus($user->getStripeAccountId());
$chargesBefore = $user->isStripeChargesEnabled();
$payoutsBefore = $user->isStripePayoutsEnabled();
$user->setStripeChargesEnabled($status['charges_enabled']);
$user->setStripePayoutsEnabled($status['payouts_enabled']);
$changed = $chargesBefore !== $user->isStripeChargesEnabled()
|| $payoutsBefore !== $user->isStripePayoutsEnabled();
$status = $changed ? '<fg=yellow>UPDATED</>' : '<fg=green>OK</>';
$io->text(sprintf(
' [%s] %s (%s) — charges: %s, payouts: %s',
$status,
$user->getCompanyName() ?? $user->getEmail(),
$user->getStripeAccountId(),
$user->isStripeChargesEnabled() ? 'yes' : 'no',
$user->isStripePayoutsEnabled() ? 'yes' : 'no',
));
++$synced;
} catch (\Throwable $e) {
$io->text(sprintf(
' [<fg=red>ERROR</>] %s (%s) — %s',
$user->getCompanyName() ?? $user->getEmail(),
$user->getStripeAccountId(),
$e->getMessage(),
));
++$errors;
}
}
$this->em->flush();
$io->success(sprintf('Done: %d synced, %d error(s).', $synced, $errors));
return $errors > 0 ? Command::FAILURE : Command::SUCCESS;
}
}

View File

@@ -171,4 +171,19 @@ class StripeService
{
return $this->stripe;
}
/**
* @codeCoverageIgnore Requires live Stripe API
*
* @return array{charges_enabled: bool, payouts_enabled: bool}
*/
public function retrieveAccountStatus(string $accountId): array
{
$account = $this->stripe->accounts->retrieve($accountId);
return [
'charges_enabled' => (bool) $account->charges_enabled,
'payouts_enabled' => (bool) $account->payouts_enabled,
];
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace App\Tests\Command;
use App\Command\StripeSyncCommand;
use App\Entity\User;
use App\Service\StripeService;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
class StripeSyncCommandTest extends TestCase
{
private function createOrganizer(string $stripeId, bool $charges = false, bool $payouts = false): User
{
$user = new User();
$user->setEmail('orga-'.uniqid().'@test.fr');
$user->setFirstName('Test');
$user->setLastName('Orga');
$user->setPassword('hashed');
$user->setRoles(['ROLE_ORGANIZER']);
$user->setStripeAccountId($stripeId);
$user->setStripeChargesEnabled($charges);
$user->setStripePayoutsEnabled($payouts);
$user->setCompanyName('Asso Test');
return $user;
}
private function createCommandTester(array $users, StripeService $stripeService): CommandTester
{
$repo = $this->createMock(EntityRepository::class);
$repo->method('findAll')->willReturn($users);
$em = $this->createMock(EntityManagerInterface::class);
$em->method('getRepository')->willReturn($repo);
$command = new StripeSyncCommand($em, $stripeService);
$app = new Application();
$app->addCommand($command);
return new CommandTester($app->find('app:stripe:sync'));
}
public function testSyncUpdatesStripeStatus(): void
{
$user = $this->createOrganizer('acct_123');
$stripeService = $this->createMock(StripeService::class);
$stripeService->method('retrieveAccountStatus')
->with('acct_123')
->willReturn(['charges_enabled' => true, 'payouts_enabled' => true]);
$tester = $this->createCommandTester([$user], $stripeService);
$tester->execute([]);
self::assertTrue($user->isStripeChargesEnabled());
self::assertTrue($user->isStripePayoutsEnabled());
self::assertStringContainsString('1 synced', $tester->getDisplay());
self::assertSame(0, $tester->getStatusCode());
}
public function testSyncDetectsChanges(): void
{
$user = $this->createOrganizer('acct_456', true, true);
$stripeService = $this->createMock(StripeService::class);
$stripeService->method('retrieveAccountStatus')
->willReturn(['charges_enabled' => true, 'payouts_enabled' => false]);
$tester = $this->createCommandTester([$user], $stripeService);
$tester->execute([]);
self::assertTrue($user->isStripeChargesEnabled());
self::assertFalse($user->isStripePayoutsEnabled());
self::assertStringContainsString('UPDATED', $tester->getDisplay());
}
public function testSyncWithNoOrganizers(): void
{
$stripeService = $this->createMock(StripeService::class);
$tester = $this->createCommandTester([], $stripeService);
$tester->execute([]);
self::assertStringContainsString('No organizers', $tester->getDisplay());
self::assertSame(0, $tester->getStatusCode());
}
public function testSyncHandlesStripeError(): void
{
$user = $this->createOrganizer('acct_bad');
$stripeService = $this->createMock(StripeService::class);
$stripeService->method('retrieveAccountStatus')
->willThrowException(new \RuntimeException('Account not found'));
$tester = $this->createCommandTester([$user], $stripeService);
$tester->execute([]);
self::assertStringContainsString('1 error', $tester->getDisplay());
self::assertSame(1, $tester->getStatusCode());
}
public function testSyncSkipsOrganizersWithoutStripeAccount(): void
{
$userWithStripe = $this->createOrganizer('acct_ok');
$userWithoutStripe = new User();
$userWithoutStripe->setEmail('no-stripe@test.fr');
$userWithoutStripe->setFirstName('No');
$userWithoutStripe->setLastName('Stripe');
$userWithoutStripe->setPassword('hashed');
$userWithoutStripe->setRoles(['ROLE_ORGANIZER']);
$stripeService = $this->createMock(StripeService::class);
$stripeService->method('retrieveAccountStatus')
->willReturn(['charges_enabled' => true, 'payouts_enabled' => false]);
$tester = $this->createCommandTester([$userWithStripe, $userWithoutStripe], $stripeService);
$tester->execute([]);
self::assertStringContainsString('Syncing 1 organizer', $tester->getDisplay());
self::assertStringContainsString('1 synced', $tester->getDisplay());
self::assertTrue($userWithStripe->isStripeChargesEnabled());
self::assertFalse($userWithStripe->isStripePayoutsEnabled());
}
}