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:
88
src/Command/StripeSyncCommand.php
Normal file
88
src/Command/StripeSyncCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
130
tests/Command/StripeSyncCommandTest.php
Normal file
130
tests/Command/StripeSyncCommandTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user