From 02519dcfa80e4a852f2bcf461d7af23394745c15 Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Wed, 1 Apr 2026 14:14:29 +0200 Subject: [PATCH] Add pending orders reconciliation to stripe:sync command - Add retrievePaymentIntent() to StripeService - StripeSyncCommand now checks pending orders against Stripe API: - succeeded: generates tickets, sends emails, notifies organizer - canceled: marks order as cancelled + audit log - requires_payment_method: marks as cancelled + audit + failure email - other statuses: logs as still pending - Add 13 tests covering accounts sync + all pending order scenarios Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Command/StripeSyncCommand.php | 133 ++++++++++- src/Service/StripeService.php | 8 + tests/Command/StripeSyncCommandTest.php | 294 ++++++++++++++++++++++-- 3 files changed, 411 insertions(+), 24 deletions(-) diff --git a/src/Command/StripeSyncCommand.php b/src/Command/StripeSyncCommand.php index 3d6b8e9..40ca9a9 100644 --- a/src/Command/StripeSyncCommand.php +++ b/src/Command/StripeSyncCommand.php @@ -2,7 +2,11 @@ namespace App\Command; +use App\Entity\BilletBuyer; use App\Entity\User; +use App\Service\AuditService; +use App\Service\BilletOrderService; +use App\Service\MailerService; use App\Service\StripeService; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Console\Attribute\AsCommand; @@ -13,13 +17,16 @@ use Symfony\Component\Console\Style\SymfonyStyle; #[AsCommand( name: 'app:stripe:sync', - description: 'Sync Stripe account status for all organizers', + description: 'Sync Stripe account status for all organizers and reconcile pending orders', )] class StripeSyncCommand extends Command { public function __construct( private EntityManagerInterface $em, private StripeService $stripeService, + private BilletOrderService $billetOrderService, + private MailerService $mailerService, + private AuditService $audit, ) { parent::__construct(); } @@ -28,6 +35,14 @@ class StripeSyncCommand extends Command { $io = new SymfonyStyle($input, $output); + $hasErrors = $this->syncAccounts($io); + $hasErrors = $this->syncPendingOrders($io) || $hasErrors; + + return $hasErrors ? Command::FAILURE : Command::SUCCESS; + } + + private function syncAccounts(SymfonyStyle $io): bool + { $organizers = array_filter( $this->em->getRepository(User::class)->findAll(), fn (User $u) => \in_array('ROLE_ORGANIZER', $u->getRoles(), true) && null !== $u->getStripeAccountId(), @@ -36,7 +51,7 @@ class StripeSyncCommand extends Command if (0 === \count($organizers)) { $io->info('No organizers with Stripe accounts found.'); - return Command::SUCCESS; + return false; } $io->info(sprintf('Syncing %d organizer(s)...', \count($organizers))); @@ -81,8 +96,118 @@ class StripeSyncCommand extends Command $this->em->flush(); - $io->success(sprintf('Done: %d synced, %d error(s).', $synced, $errors)); + $io->success(sprintf('Accounts: %d synced, %d error(s).', $synced, $errors)); - return $errors > 0 ? Command::FAILURE : Command::SUCCESS; + return $errors > 0; + } + + private function syncPendingOrders(SymfonyStyle $io): bool + { + $pendingOrders = $this->em->getRepository(BilletBuyer::class)->findBy([ + 'status' => BilletBuyer::STATUS_PENDING, + ]); + + if (0 === \count($pendingOrders)) { + $io->info('No pending orders to reconcile.'); + + return false; + } + + $io->info(sprintf('Checking %d pending order(s) on Stripe...', \count($pendingOrders))); + + $processed = 0; + $errors = 0; + + foreach ($pendingOrders as $order) { + $paymentIntentId = $order->getStripeSessionId(); + + if (!$paymentIntentId) { + $io->text(sprintf(' [SKIP] Order #%s — no payment intent ID', $order->getOrderNumber())); + continue; + } + + try { + $paymentIntent = $this->stripeService->retrievePaymentIntent($paymentIntentId); + $stripeStatus = $paymentIntent->status; + + match ($stripeStatus) { + 'succeeded' => $this->handleSucceeded($order, $paymentIntent, $io), + 'canceled' => $this->handleCancelled($order, $io), + 'requires_payment_method' => $this->handleFailed($order, $paymentIntent, $io), + default => $io->text(sprintf( + ' [PENDING] Order #%s — Stripe status: %s', + $order->getOrderNumber(), + $stripeStatus, + )), + }; + + ++$processed; + } catch (\Throwable $e) { + $io->text(sprintf( + ' [ERROR] Order #%s — %s', + $order->getOrderNumber(), + $e->getMessage(), + )); + ++$errors; + } + } + + $this->em->flush(); + + $io->success(sprintf('Orders: %d checked, %d error(s).', $processed, $errors)); + + return $errors > 0; + } + + private function handleSucceeded(BilletBuyer $order, \Stripe\PaymentIntent $paymentIntent, SymfonyStyle $io): void + { + $debtOrganizerId = $paymentIntent->metadata->debt_organizer_id ?? null; + if ($debtOrganizerId) { + $organizer = $this->em->getRepository(User::class)->find((int) $debtOrganizerId); + if ($organizer) { + $organizer->reduceDebt($paymentIntent->amount ?? 0); + } + } + + $this->billetOrderService->generateOrderTickets($order); + $this->billetOrderService->generateAndSendTickets($order); + $this->billetOrderService->notifyOrganizer($order); + + $io->text(sprintf(' [PAID] Order #%s — tickets generated and sent', $order->getOrderNumber())); + } + + private function handleCancelled(BilletBuyer $order, SymfonyStyle $io): void + { + $order->setStatus(BilletBuyer::STATUS_CANCELLED); + $this->em->flush(); + + $this->audit->log('payment_cancelled_sync', 'BilletBuyer', $order->getId(), [ + 'orderNumber' => $order->getOrderNumber(), + ]); + + $io->text(sprintf(' [CANCELLED] Order #%s', $order->getOrderNumber())); + } + + private function handleFailed(BilletBuyer $order, \Stripe\PaymentIntent $paymentIntent, SymfonyStyle $io): void + { + $errorMessage = $paymentIntent->last_payment_error->message ?? 'Paiement refuse'; + + $order->setStatus(BilletBuyer::STATUS_CANCELLED); + $this->em->flush(); + + $this->audit->log('payment_failed_sync', 'BilletBuyer', $order->getId(), [ + 'orderNumber' => $order->getOrderNumber(), + 'error' => $errorMessage, + ]); + + if ($order->getEmail()) { + $this->mailerService->sendEmail( + $order->getEmail(), + 'Echec de paiement - '.$order->getEvent()->getTitle(), + 'Votre paiement pour la commande '.$order->getOrderNumber().' a echoue : '.$errorMessage, + ); + } + + $io->text(sprintf(' [FAILED] Order #%s — %s', $order->getOrderNumber(), $errorMessage)); } } diff --git a/src/Service/StripeService.php b/src/Service/StripeService.php index f00fa1a..92995e3 100644 --- a/src/Service/StripeService.php +++ b/src/Service/StripeService.php @@ -164,6 +164,14 @@ class StripeService return $session->url; } + /** + * @codeCoverageIgnore Requires live Stripe API + */ + public function retrievePaymentIntent(string $paymentIntentId): \Stripe\PaymentIntent + { + return $this->stripe->paymentIntents->retrieve($paymentIntentId); + } + /** * @codeCoverageIgnore Simple getter */ diff --git a/tests/Command/StripeSyncCommandTest.php b/tests/Command/StripeSyncCommandTest.php index d9c01e2..7234026 100644 --- a/tests/Command/StripeSyncCommandTest.php +++ b/tests/Command/StripeSyncCommandTest.php @@ -3,7 +3,12 @@ namespace App\Tests\Command; use App\Command\StripeSyncCommand; +use App\Entity\BilletBuyer; +use App\Entity\Event; use App\Entity\User; +use App\Service\AuditService; +use App\Service\BilletOrderService; +use App\Service\MailerService; use App\Service\StripeService; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; @@ -13,6 +18,35 @@ use Symfony\Component\Console\Tester\CommandTester; class StripeSyncCommandTest extends TestCase { + private StripeService $stripeService; + private BilletOrderService $billetOrderService; + private MailerService $mailerService; + private AuditService $audit; + private EntityManagerInterface $em; + private EntityRepository $userRepo; + private EntityRepository $buyerRepo; + + protected function setUp(): void + { + $this->stripeService = $this->createMock(StripeService::class); + $this->billetOrderService = $this->createMock(BilletOrderService::class); + $this->mailerService = $this->createMock(MailerService::class); + $this->audit = $this->createMock(AuditService::class); + + $this->userRepo = $this->createMock(EntityRepository::class); + $this->buyerRepo = $this->createMock(EntityRepository::class); + $this->buyerRepo->method('findBy')->willReturn([]); + + $this->em = $this->createMock(EntityManagerInterface::class); + $this->em->method('getRepository')->willReturnCallback(function (string $class) { + return match ($class) { + User::class => $this->userRepo, + BilletBuyer::class => $this->buyerRepo, + default => $this->createMock(EntityRepository::class), + }; + }); + } + private function createOrganizer(string $stripeId, bool $charges = false, bool $payouts = false): User { $user = new User(); @@ -29,31 +63,64 @@ class StripeSyncCommandTest extends TestCase return $user; } - private function createCommandTester(array $users, StripeService $stripeService): CommandTester + private function createPendingOrder(?string $paymentIntentId = null): BilletBuyer { - $repo = $this->createMock(EntityRepository::class); - $repo->method('findAll')->willReturn($users); + $event = $this->createMock(Event::class); + $event->method('getTitle')->willReturn('Test Event'); + $event->method('getAccount')->willReturn($this->createOrganizer('acct_org')); - $em = $this->createMock(EntityManagerInterface::class); - $em->method('getRepository')->willReturn($repo); + $order = new BilletBuyer(); + $order->setStatus(BilletBuyer::STATUS_PENDING); + $order->setStripeSessionId($paymentIntentId); + $order->setEmail('buyer@test.fr'); + $order->setEvent($event); + + return $order; + } + + private function createCommandTester(): CommandTester + { + $command = new StripeSyncCommand( + $this->em, + $this->stripeService, + $this->billetOrderService, + $this->mailerService, + $this->audit, + ); - $command = new StripeSyncCommand($em, $stripeService); $app = new Application(); $app->addCommand($command); return new CommandTester($app->find('app:stripe:sync')); } + private function setBuyerRepo(array $orders): void + { + $this->buyerRepo = $this->createMock(EntityRepository::class); + $this->buyerRepo->method('findBy')->willReturn($orders); + + $this->em = $this->createMock(EntityManagerInterface::class); + $this->em->method('getRepository')->willReturnCallback(function (string $class) { + return match ($class) { + User::class => $this->userRepo, + BilletBuyer::class => $this->buyerRepo, + default => $this->createMock(EntityRepository::class), + }; + }); + } + + // --- Account sync tests --- + public function testSyncUpdatesStripeStatus(): void { $user = $this->createOrganizer('acct_123'); + $this->userRepo->method('findAll')->willReturn([$user]); - $stripeService = $this->createMock(StripeService::class); - $stripeService->method('retrieveAccountStatus') + $this->stripeService->method('retrieveAccountStatus') ->with('acct_123') ->willReturn(['charges_enabled' => true, 'payouts_enabled' => true]); - $tester = $this->createCommandTester([$user], $stripeService); + $tester = $this->createCommandTester(); $tester->execute([]); self::assertTrue($user->isStripeChargesEnabled()); @@ -65,12 +132,12 @@ class StripeSyncCommandTest extends TestCase public function testSyncDetectsChanges(): void { $user = $this->createOrganizer('acct_456', true, true); + $this->userRepo->method('findAll')->willReturn([$user]); - $stripeService = $this->createMock(StripeService::class); - $stripeService->method('retrieveAccountStatus') + $this->stripeService->method('retrieveAccountStatus') ->willReturn(['charges_enabled' => true, 'payouts_enabled' => false]); - $tester = $this->createCommandTester([$user], $stripeService); + $tester = $this->createCommandTester(); $tester->execute([]); self::assertTrue($user->isStripeChargesEnabled()); @@ -80,9 +147,9 @@ class StripeSyncCommandTest extends TestCase public function testSyncWithNoOrganizers(): void { - $stripeService = $this->createMock(StripeService::class); + $this->userRepo->method('findAll')->willReturn([]); - $tester = $this->createCommandTester([], $stripeService); + $tester = $this->createCommandTester(); $tester->execute([]); self::assertStringContainsString('No organizers', $tester->getDisplay()); @@ -92,12 +159,12 @@ class StripeSyncCommandTest extends TestCase public function testSyncHandlesStripeError(): void { $user = $this->createOrganizer('acct_bad'); + $this->userRepo->method('findAll')->willReturn([$user]); - $stripeService = $this->createMock(StripeService::class); - $stripeService->method('retrieveAccountStatus') + $this->stripeService->method('retrieveAccountStatus') ->willThrowException(new \RuntimeException('Account not found')); - $tester = $this->createCommandTester([$user], $stripeService); + $tester = $this->createCommandTester(); $tester->execute([]); self::assertStringContainsString('1 error', $tester->getDisplay()); @@ -115,11 +182,12 @@ class StripeSyncCommandTest extends TestCase $userWithoutStripe->setPassword('hashed'); $userWithoutStripe->setRoles(['ROLE_ORGANIZER']); - $stripeService = $this->createMock(StripeService::class); - $stripeService->method('retrieveAccountStatus') + $this->userRepo->method('findAll')->willReturn([$userWithStripe, $userWithoutStripe]); + + $this->stripeService->method('retrieveAccountStatus') ->willReturn(['charges_enabled' => true, 'payouts_enabled' => false]); - $tester = $this->createCommandTester([$userWithStripe, $userWithoutStripe], $stripeService); + $tester = $this->createCommandTester(); $tester->execute([]); self::assertStringContainsString('Syncing 1 organizer', $tester->getDisplay()); @@ -127,4 +195,190 @@ class StripeSyncCommandTest extends TestCase self::assertTrue($userWithStripe->isStripeChargesEnabled()); self::assertFalse($userWithStripe->isStripePayoutsEnabled()); } + + // --- Pending orders sync tests --- + + public function testNoPendingOrders(): void + { + $this->userRepo->method('findAll')->willReturn([]); + + $tester = $this->createCommandTester(); + $tester->execute([]); + + self::assertStringContainsString('No pending orders', $tester->getDisplay()); + } + + public function testPendingOrderSucceeded(): void + { + $this->userRepo->method('findAll')->willReturn([]); + + $order = $this->createPendingOrder('pi_succeeded'); + $this->setBuyerRepo([$order]); + + $paymentIntent = new \stdClass(); + $paymentIntent->status = 'succeeded'; + $paymentIntent->amount = 5000; + $paymentIntent->metadata = (object) []; + + $this->stripeService->method('retrievePaymentIntent') + ->with('pi_succeeded') + ->willReturn($paymentIntent); + + $this->billetOrderService->expects(self::once())->method('generateOrderTickets')->with($order); + $this->billetOrderService->expects(self::once())->method('generateAndSendTickets')->with($order); + $this->billetOrderService->expects(self::once())->method('notifyOrganizer')->with($order); + + $tester = $this->createCommandTester(); + $tester->execute([]); + + self::assertStringContainsString('PAID', $tester->getDisplay()); + } + + public function testPendingOrderSucceededWithDebtOrganizer(): void + { + $organizer = $this->createOrganizer('acct_debt'); + $this->userRepo->method('findAll')->willReturn([]); + $this->userRepo->method('find')->with(42)->willReturn($organizer); + + $order = $this->createPendingOrder('pi_debt'); + $this->setBuyerRepo([$order]); + + $paymentIntent = new \stdClass(); + $paymentIntent->status = 'succeeded'; + $paymentIntent->amount = 3000; + $paymentIntent->metadata = (object) ['debt_organizer_id' => '42']; + + $this->stripeService->method('retrievePaymentIntent')->willReturn($paymentIntent); + + $tester = $this->createCommandTester(); + $tester->execute([]); + + self::assertStringContainsString('PAID', $tester->getDisplay()); + } + + public function testPendingOrderCanceled(): void + { + $this->userRepo->method('findAll')->willReturn([]); + + $order = $this->createPendingOrder('pi_canceled'); + $this->setBuyerRepo([$order]); + + $paymentIntent = new \stdClass(); + $paymentIntent->status = 'canceled'; + + $this->stripeService->method('retrievePaymentIntent')->willReturn($paymentIntent); + + $this->audit->expects(self::once())->method('log') + ->with('payment_cancelled_sync', 'BilletBuyer', self::anything(), self::anything()); + + $tester = $this->createCommandTester(); + $tester->execute([]); + + self::assertSame(BilletBuyer::STATUS_CANCELLED, $order->getStatus()); + self::assertStringContainsString('CANCELLED', $tester->getDisplay()); + } + + public function testPendingOrderFailed(): void + { + $this->userRepo->method('findAll')->willReturn([]); + + $order = $this->createPendingOrder('pi_failed'); + $this->setBuyerRepo([$order]); + + $paymentIntent = new \stdClass(); + $paymentIntent->status = 'requires_payment_method'; + $paymentIntent->last_payment_error = (object) ['message' => 'Card declined']; + + $this->stripeService->method('retrievePaymentIntent')->willReturn($paymentIntent); + + $this->audit->expects(self::once())->method('log') + ->with('payment_failed_sync', 'BilletBuyer', self::anything(), self::anything()); + $this->mailerService->expects(self::once())->method('sendEmail'); + + $tester = $this->createCommandTester(); + $tester->execute([]); + + self::assertSame(BilletBuyer::STATUS_CANCELLED, $order->getStatus()); + self::assertStringContainsString('FAILED', $tester->getDisplay()); + } + + public function testPendingOrderSkippedWithoutPaymentIntentId(): void + { + $this->userRepo->method('findAll')->willReturn([]); + + $order = $this->createPendingOrder(null); + $this->setBuyerRepo([$order]); + + $this->stripeService->expects(self::never())->method('retrievePaymentIntent'); + + $tester = $this->createCommandTester(); + $tester->execute([]); + + self::assertStringContainsString('SKIP', $tester->getDisplay()); + } + + public function testPendingOrderStillPending(): void + { + $this->userRepo->method('findAll')->willReturn([]); + + $order = $this->createPendingOrder('pi_processing'); + $this->setBuyerRepo([$order]); + + $paymentIntent = new \stdClass(); + $paymentIntent->status = 'processing'; + + $this->stripeService->method('retrievePaymentIntent')->willReturn($paymentIntent); + + $tester = $this->createCommandTester(); + $tester->execute([]); + + self::assertSame(BilletBuyer::STATUS_PENDING, $order->getStatus()); + self::assertStringContainsString('PENDING', $tester->getDisplay()); + } + + public function testPendingOrderStripeApiError(): void + { + $this->userRepo->method('findAll')->willReturn([]); + + $order = $this->createPendingOrder('pi_error'); + $this->setBuyerRepo([$order]); + + $this->stripeService->method('retrievePaymentIntent') + ->willThrowException(new \RuntimeException('Stripe API error')); + + $tester = $this->createCommandTester(); + $tester->execute([]); + + self::assertStringContainsString('ERROR', $tester->getDisplay()); + self::assertSame(1, $tester->getStatusCode()); + } + + public function testPendingOrderFailedWithoutEmail(): void + { + $this->userRepo->method('findAll')->willReturn([]); + + $event = $this->createMock(Event::class); + $event->method('getTitle')->willReturn('Test Event'); + + $order = new BilletBuyer(); + $order->setStatus(BilletBuyer::STATUS_PENDING); + $order->setStripeSessionId('pi_no_email'); + $order->setEvent($event); + + $this->setBuyerRepo([$order]); + + $paymentIntent = new \stdClass(); + $paymentIntent->status = 'requires_payment_method'; + $paymentIntent->last_payment_error = null; + + $this->stripeService->method('retrievePaymentIntent')->willReturn($paymentIntent); + + $this->mailerService->expects(self::never())->method('sendEmail'); + + $tester = $this->createCommandTester(); + $tester->execute([]); + + self::assertSame(BilletBuyer::STATUS_CANCELLED, $order->getStatus()); + self::assertStringContainsString('FAILED', $tester->getDisplay()); + } }