diff --git a/src/Console/Command/CompletePaidPaymentsCommand.php b/src/Console/Command/CompletePaidPaymentsCommand.php index bf89bdfb..70ee001d 100644 --- a/src/Console/Command/CompletePaidPaymentsCommand.php +++ b/src/Console/Command/CompletePaidPaymentsCommand.php @@ -65,6 +65,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int $token = $this->authorizeClientApi->authorize($paymentMethod); $details = $this->orderDetailsApi->get($token, $payPalOrderId); + // When the PayPal API call fails (e.g. 404 RESOURCE_NOT_FOUND once an + // uncaptured order has expired after ~3h), PayPalClient::request() + // silently returns the error payload instead of throwing, so the array + // has no "status" key. Skip those payments — an error has already been + // logged inside the client — rather than emitting a PHP warning on + // every hourly cron tick forever. + if (!isset($details['status'])) { + continue; + } + if ($details['status'] === 'COMPLETED') { $this->stateMachine->apply($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_COMPLETE); diff --git a/tests/Unit/Console/Command/CompletePaidPaymentsCommandTest.php b/tests/Unit/Console/Command/CompletePaidPaymentsCommandTest.php new file mode 100644 index 00000000..afc75304 --- /dev/null +++ b/tests/Unit/Console/Command/CompletePaidPaymentsCommandTest.php @@ -0,0 +1,198 @@ +&MockObject */ + private PaymentRepositoryInterface&MockObject $paymentRepository; + + private ObjectManager&MockObject $paymentManager; + + private CacheAuthorizeClientApiInterface&MockObject $authorizeClientApi; + + private OrderDetailsApiInterface&MockObject $orderDetailsApi; + + private StateMachineInterface&MockObject $stateMachine; + + private CommandTester $commandTester; + + protected function setUp(): void + { + parent::setUp(); + + $this->paymentRepository = $this->createMock(PaymentRepositoryInterface::class); + $this->paymentManager = $this->createMock(ObjectManager::class); + $this->authorizeClientApi = $this->createMock(CacheAuthorizeClientApiInterface::class); + $this->orderDetailsApi = $this->createMock(OrderDetailsApiInterface::class); + $this->stateMachine = $this->createMock(StateMachineInterface::class); + + $this->commandTester = new CommandTester(new CompletePaidPaymentsCommand( + $this->paymentRepository, + $this->paymentManager, + $this->authorizeClientApi, + $this->orderDetailsApi, + $this->stateMachine, + )); + } + + #[Test] + public function it_completes_processing_paypal_payments_whose_order_is_completed_on_paypal(): void + { + $payment = $this->createPayPalPayment(['paypal_order_id' => 'PP-ORDER-123']); + + $this->paymentRepository + ->method('findBy') + ->with(['state' => PaymentInterface::STATE_PROCESSING]) + ->willReturn([$payment]) + ; + + $this->authorizeClientApi->method('authorize')->willReturn('auth-token'); + $this->orderDetailsApi + ->method('get') + ->with('auth-token', 'PP-ORDER-123') + ->willReturn(['id' => 'PP-ORDER-123', 'status' => 'COMPLETED']) + ; + + $payment + ->expects($this->once()) + ->method('setDetails') + ->with($this->callback(static fn (array $details): bool => ($details['status'] ?? null) === StatusAction::STATUS_COMPLETED && + ($details['paypal_order_id'] ?? null) === 'PP-ORDER-123')) + ; + + $this->stateMachine + ->expects($this->once()) + ->method('apply') + ->with($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_COMPLETE) + ; + + $this->paymentManager->expects($this->once())->method('flush'); + + $this->assertSame(0, $this->commandTester->execute([])); + } + + #[Test] + public function it_skips_payments_whose_paypal_order_returned_an_error_payload_without_status_key(): void + { + // Reproduces the real-world case where a PayPal order that was never + // captured expires after ~3h; subsequent GET /v2/checkout/orders/{id} + // calls return a RESOURCE_NOT_FOUND error payload. Before this fix the + // command emitted a PHP warning on every hourly cron tick and the stale + // payment stayed processing forever. + $payment = $this->createPayPalPayment(['paypal_order_id' => 'PP-EXPIRED-ORDER']); + + $this->paymentRepository->method('findBy')->willReturn([$payment]); + $this->authorizeClientApi->method('authorize')->willReturn('auth-token'); + $this->orderDetailsApi + ->method('get') + ->willReturn([ + 'name' => 'RESOURCE_NOT_FOUND', + 'message' => 'The specified resource does not exist.', + 'debug_id' => 'abc123def456', + ]) + ; + + $this->stateMachine->expects($this->never())->method('apply'); + $payment->expects($this->never())->method('setDetails'); + $this->paymentManager->expects($this->once())->method('flush'); + + $previous = set_error_handler(static function (int $severity, string $message): bool { + if (str_contains($message, 'Undefined array key')) { + throw new \AssertionError('Unexpected PHP warning: ' . $message); + } + + return false; + }); + + try { + $this->assertSame(0, $this->commandTester->execute([])); + } finally { + set_error_handler($previous); + } + } + + #[Test] + public function it_does_not_complete_payments_whose_paypal_order_is_not_completed_yet(): void + { + $payment = $this->createPayPalPayment(['paypal_order_id' => 'PP-ORDER-APPROVED']); + + $this->paymentRepository->method('findBy')->willReturn([$payment]); + $this->authorizeClientApi->method('authorize')->willReturn('auth-token'); + $this->orderDetailsApi + ->method('get') + ->willReturn(['id' => 'PP-ORDER-APPROVED', 'status' => 'APPROVED']) + ; + + $this->stateMachine->expects($this->never())->method('apply'); + $payment->expects($this->never())->method('setDetails'); + $this->paymentManager->expects($this->once())->method('flush'); + + $this->assertSame(0, $this->commandTester->execute([])); + } + + #[Test] + public function it_skips_processing_payments_handled_by_a_non_paypal_gateway(): void + { + $payment = $this->createMock(PaymentInterface::class); + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + + $payment->method('getMethod')->willReturn($paymentMethod); + $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig); + $gatewayConfig->method('getFactoryName')->willReturn('stripe'); + + $this->paymentRepository->method('findBy')->willReturn([$payment]); + + $this->authorizeClientApi->expects($this->never())->method('authorize'); + $this->orderDetailsApi->expects($this->never())->method('get'); + $this->stateMachine->expects($this->never())->method('apply'); + + $this->assertSame(0, $this->commandTester->execute([])); + } + + /** + * @param array $details + */ + private function createPayPalPayment(array $details): PaymentInterface&MockObject + { + $payment = $this->createMock(PaymentInterface::class); + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + + $payment->method('getMethod')->willReturn($paymentMethod); + $payment->method('getDetails')->willReturn($details); + $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig); + $gatewayConfig->method('getFactoryName')->willReturn(SyliusPayPalExtension::PAYPAL_FACTORY_NAME); + + return $payment; + } +}