PaymentServiceCaptureTest.php 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. <?php
  2. namespace Webkul\BagistoApi\Tests\Unit\Services;
  3. use Illuminate\Support\Facades\Event;
  4. use Mockery;
  5. use ReflectionMethod;
  6. use Tests\TestCase;
  7. use Webkul\BagistoApi\Dto\PaymentCallbackInput;
  8. use Webkul\BagistoApi\Exception\OperationFailedException;
  9. use Webkul\BagistoApi\Repositories\PaymentAttemptRepository;
  10. use Webkul\BagistoApi\Services\CartTokenService;
  11. use Webkul\BagistoApi\Services\PaymentService;
  12. use Webkul\Checkout\Repositories\CartRepository;
  13. use Webkul\Paypal\Payment\SmartButton;
  14. use Webkul\Sales\Models\Order;
  15. use Webkul\Sales\Repositories\InvoiceRepository;
  16. use Webkul\Sales\Repositories\OrderRepository;
  17. use Webkul\Sales\Repositories\OrderTransactionRepository;
  18. /**
  19. * Coverage for the P0 payment-success hardening:
  20. *
  21. * - callback success is idempotent (no re-capture on an order that
  22. * already moved past pending)
  23. * - the PayPal capture response is normalized correctly
  24. * - the captured amount/currency is verified before the order is paid
  25. * - capture failures / non-completed captures surface as errors
  26. */
  27. class PaymentServiceCaptureTest extends TestCase
  28. {
  29. protected function tearDown(): void
  30. {
  31. Mockery::close();
  32. parent::tearDown();
  33. }
  34. private function makeService(?OrderRepository $orderRepository = null): PaymentService
  35. {
  36. return new PaymentService(
  37. $orderRepository ?? Mockery::mock(OrderRepository::class),
  38. Mockery::mock(CartRepository::class),
  39. Mockery::mock(CartTokenService::class),
  40. Mockery::mock(InvoiceRepository::class),
  41. Mockery::mock(OrderTransactionRepository::class),
  42. Mockery::mock(PaymentAttemptRepository::class),
  43. );
  44. }
  45. private function callProtected(PaymentService $service, string $method, array $args)
  46. {
  47. $ref = new ReflectionMethod($service, $method);
  48. $ref->setAccessible(true);
  49. return $ref->invoke($service, ...$args);
  50. }
  51. /**
  52. * Shape a PayPal capture response the way the SDK would.
  53. */
  54. private function paypalCaptureResponse(
  55. string $orderStatus,
  56. ?string $captureStatus,
  57. ?string $amount,
  58. string $currency = 'USD',
  59. ): object {
  60. $captures = [];
  61. if ($captureStatus !== null) {
  62. $captures[] = (object) [
  63. 'id' => 'CAP-123',
  64. 'status' => $captureStatus,
  65. 'amount' => (object) [
  66. 'value' => $amount,
  67. 'currency_code' => $currency,
  68. ],
  69. ];
  70. }
  71. return (object) [
  72. 'statusCode' => 200,
  73. 'result' => (object) [
  74. 'id' => 'PAYPAL-ORDER-1',
  75. 'status' => $orderStatus,
  76. 'intent' => 'CAPTURE',
  77. 'purchase_units' => [
  78. (object) [
  79. 'amount' => (object) [
  80. 'value' => $amount,
  81. 'currency_code' => $currency,
  82. ],
  83. 'payments' => (object) ['captures' => $captures],
  84. ],
  85. ],
  86. ],
  87. ];
  88. }
  89. private function fakeOrder(array $attributes = []): object
  90. {
  91. return (object) array_merge([
  92. 'id' => 1,
  93. 'status' => Order::STATUS_PENDING,
  94. 'grand_total' => 100.0,
  95. 'order_currency_code' => 'USD',
  96. ], $attributes);
  97. }
  98. public function test_callback_success_is_idempotent_for_already_processed_order(): void
  99. {
  100. $order = $this->fakeOrder(['status' => Order::STATUS_PROCESSING]);
  101. $orderRepository = Mockery::mock(OrderRepository::class);
  102. $orderRepository->shouldReceive('find')->once()->with(1)->andReturn($order);
  103. $input = new PaymentCallbackInput;
  104. $input->orderId = 1;
  105. $input->status = 'success';
  106. $result = $this->makeService($orderRepository)->callback($input);
  107. $this->assertSame('success', $result['status']);
  108. $this->assertSame('already_processed', $result['gatewayStatus']);
  109. $this->assertSame($order, $result['order']);
  110. }
  111. public function test_extract_capture_normalizes_response(): void
  112. {
  113. $service = $this->makeService();
  114. $response = $this->paypalCaptureResponse('COMPLETED', 'COMPLETED', '100.00');
  115. $capture = $this->callProtected($service, 'extractCapture', [$response]);
  116. $this->assertSame('CAP-123', $capture['transaction_id']);
  117. $this->assertSame('PAYPAL-ORDER-1', $capture['gateway_order_id']);
  118. $this->assertSame('COMPLETED', $capture['order_status']);
  119. $this->assertSame('COMPLETED', $capture['capture_status']);
  120. $this->assertSame('CAPTURE', $capture['intent']);
  121. $this->assertSame(100.0, $capture['amount']);
  122. $this->assertSame('USD', $capture['currency']);
  123. }
  124. public function test_assert_amount_matches_passes_on_equal_amount(): void
  125. {
  126. $service = $this->makeService();
  127. $order = $this->fakeOrder(['grand_total' => 100.0, 'order_currency_code' => 'USD']);
  128. $capture = ['amount' => 100.0, 'currency' => 'USD'];
  129. $this->callProtected($service, 'assertAmountMatches', [$order, $capture]);
  130. $this->assertTrue(true);
  131. }
  132. public function test_assert_amount_matches_throws_on_amount_mismatch(): void
  133. {
  134. Event::fake();
  135. $service = $this->makeService();
  136. $order = $this->fakeOrder(['grand_total' => 100.0, 'order_currency_code' => 'USD']);
  137. $capture = ['amount' => 1.0, 'currency' => 'USD'];
  138. try {
  139. $this->callProtected($service, 'assertAmountMatches', [$order, $capture]);
  140. $this->fail('Expected OperationFailedException');
  141. } catch (OperationFailedException $e) {
  142. $this->assertStringContainsString('does not match', $e->getMessage());
  143. }
  144. Event::assertDispatched('bagistoapi.payment.amount-mismatch');
  145. }
  146. public function test_assert_amount_matches_throws_on_currency_mismatch(): void
  147. {
  148. Event::fake();
  149. $service = $this->makeService();
  150. $order = $this->fakeOrder(['grand_total' => 100.0, 'order_currency_code' => 'USD']);
  151. $capture = ['amount' => 100.0, 'currency' => 'EUR'];
  152. $this->expectException(OperationFailedException::class);
  153. $this->callProtected($service, 'assertAmountMatches', [$order, $capture]);
  154. }
  155. public function test_capture_and_verify_throws_when_gateway_capture_fails(): void
  156. {
  157. $smartButton = Mockery::mock(SmartButton::class);
  158. $smartButton->shouldReceive('captureOrder')->once()->andThrow(new \Exception('network down'));
  159. $this->app->instance(SmartButton::class, $smartButton);
  160. $service = $this->makeService();
  161. $order = $this->fakeOrder();
  162. $this->expectException(OperationFailedException::class);
  163. $this->expectExceptionMessage('Gateway capture failed');
  164. $this->callProtected($service, 'captureAndVerify', [$order, 'PAYPAL-ORDER-1']);
  165. }
  166. public function test_capture_and_verify_throws_when_not_completed(): void
  167. {
  168. Event::fake();
  169. $smartButton = Mockery::mock(SmartButton::class);
  170. $smartButton->shouldReceive('captureOrder')->once()
  171. ->andReturn($this->paypalCaptureResponse('PENDING', null, '100.00'));
  172. $this->app->instance(SmartButton::class, $smartButton);
  173. $service = $this->makeService();
  174. $order = $this->fakeOrder();
  175. try {
  176. $this->callProtected($service, 'captureAndVerify', [$order, 'PAYPAL-ORDER-1']);
  177. $this->fail('Expected OperationFailedException');
  178. } catch (OperationFailedException $e) {
  179. $this->assertStringContainsString('not completed', $e->getMessage());
  180. }
  181. Event::assertDispatched('bagistoapi.payment.capture-not-completed');
  182. }
  183. public function test_capture_and_verify_returns_normalized_capture_on_success(): void
  184. {
  185. $smartButton = Mockery::mock(SmartButton::class);
  186. $smartButton->shouldReceive('captureOrder')->once()->with('PAYPAL-ORDER-1')
  187. ->andReturn($this->paypalCaptureResponse('COMPLETED', 'COMPLETED', '100.00'));
  188. $this->app->instance(SmartButton::class, $smartButton);
  189. $service = $this->makeService();
  190. $order = $this->fakeOrder(['grand_total' => 100.0, 'order_currency_code' => 'USD']);
  191. $capture = $this->callProtected($service, 'captureAndVerify', [$order, 'PAYPAL-ORDER-1']);
  192. $this->assertSame('CAP-123', $capture['transaction_id']);
  193. $this->assertSame(100.0, $capture['amount']);
  194. $this->assertSame('USD', $capture['currency']);
  195. }
  196. }