diff --git a/api/order-functions.php b/api/order-functions.php index 9a8ee4ecd..8f9f0648a 100644 --- a/api/order-functions.php +++ b/api/order-functions.php @@ -20,6 +20,7 @@ use WooCommerce\PayPalCommerce\ApiClient\Entity\Order; use WooCommerce\PayPalCommerce\PPCP; use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway; use WooCommerce\PayPalCommerce\WcGateway\Processor\AuthorizedPaymentsProcessor; +use WooCommerce\PayPalCommerce\WcGateway\Processor\RefundProcessor; /** * Returns the PayPal order. @@ -70,3 +71,37 @@ function ppcp_capture_order( WC_Order $wc_order ): void { throw new RuntimeException( 'Capture failed.' ); } } + +/** + * Captures the PayPal order. + * + * @param WC_Order $wc_order The WC order. + * @param float $amount The refund amount. + * @param string $reason The reason for the refund. + * @throws InvalidArgumentException When the order cannot be refunded. + * @throws Exception When the operation fails. + */ +function ppcp_refund_order( WC_Order $wc_order, float $amount, string $reason = '' ): void { + $order = ppcp_get_paypal_order( $wc_order ); + + $refund_processor = PPCP::container()->get( 'wcgateway.processor.refunds' ); + assert( $refund_processor instanceof RefundProcessor ); + + $refund_processor->refund( $order, $wc_order, $amount, $reason ); +} + +/** + * Voids the authorization. + * + * @param WC_Order $wc_order The WC order. + * @throws InvalidArgumentException When the order cannot be voided. + * @throws Exception When the operation fails. + */ +function ppcp_void_order( WC_Order $wc_order ): void { + $order = ppcp_get_paypal_order( $wc_order ); + + $refund_processor = PPCP::container()->get( 'wcgateway.processor.refunds' ); + assert( $refund_processor instanceof RefundProcessor ); + + $refund_processor->void( $order ); +} diff --git a/modules/ppcp-wc-gateway/src/Processor/RefundProcessor.php b/modules/ppcp-wc-gateway/src/Processor/RefundProcessor.php index 16f9b4698..cafea380a 100644 --- a/modules/ppcp-wc-gateway/src/Processor/RefundProcessor.php +++ b/modules/ppcp-wc-gateway/src/Processor/RefundProcessor.php @@ -11,12 +11,14 @@ namespace WooCommerce\PayPalCommerce\WcGateway\Processor; use Exception; use Psr\Log\LoggerInterface; +use WC_Order; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PaymentsEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Entity\Amount; use WooCommerce\PayPalCommerce\ApiClient\Entity\Authorization; use WooCommerce\PayPalCommerce\ApiClient\Entity\AuthorizationStatus; use WooCommerce\PayPalCommerce\ApiClient\Entity\Money; +use WooCommerce\PayPalCommerce\ApiClient\Entity\Order; use WooCommerce\PayPalCommerce\ApiClient\Entity\Payments; use WooCommerce\PayPalCommerce\ApiClient\Entity\Refund; use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException; @@ -70,7 +72,7 @@ class RefundProcessor { /** * Processes a refund. * - * @param \WC_Order $wc_order The WooCommerce order. + * @param WC_Order $wc_order The WooCommerce order. * @param float|null $amount The refund amount. * @param string $reason The reason for the refund. * @@ -78,7 +80,7 @@ class RefundProcessor { * * @phpcs:ignore Squiz.Commenting.FunctionCommentThrowTag.Missing */ - public function process( \WC_Order $wc_order, float $amount = null, string $reason = '' ) : bool { + public function process( WC_Order $wc_order, float $amount = null, string $reason = '' ) : bool { try { $order_id = $wc_order->get_meta( PayPalGateway::ORDER_ID_META_KEY ); if ( ! $order_id ) { @@ -87,15 +89,7 @@ class RefundProcessor { $order = $this->order_endpoint->order( $order_id ); - $purchase_units = $order->purchase_units(); - if ( ! $purchase_units ) { - throw new RuntimeException( 'No purchase units.' ); - } - - $payments = $purchase_units[0]->payments(); - if ( ! $payments ) { - throw new RuntimeException( 'No payments.' ); - } + $payments = $this->get_payments( $order ); $this->logger->debug( sprintf( @@ -109,39 +103,11 @@ class RefundProcessor { switch ( $mode ) { case self::REFUND_MODE_REFUND: - $captures = $payments->captures(); - if ( ! $captures ) { - throw new RuntimeException( 'No capture.' ); - } - - $capture = $captures[0]; - $refund = new Refund( - $capture, - $capture->invoice_id(), - $reason, - new Amount( - new Money( $amount, $wc_order->get_currency() ) - ) - ); - $refund_id = $this->payments_endpoint->refund( $refund ); - - $this->add_refund_to_meta( $wc_order, $refund_id ); + $this->refund( $order, $wc_order, $amount, $reason ); break; case self::REFUND_MODE_VOID: - $voidable_authorizations = array_filter( - $payments->authorizations(), - function ( Authorization $authorization ): bool { - return $authorization->is_voidable(); - } - ); - if ( ! $voidable_authorizations ) { - throw new RuntimeException( 'No voidable authorizations.' ); - } - - foreach ( $voidable_authorizations as $authorization ) { - $this->payments_endpoint->void( $authorization ); - } + $this->void( $order ); $wc_order->set_status( 'refunded' ); $wc_order->save(); @@ -158,6 +124,68 @@ class RefundProcessor { } } + /** + * Adds a refund to the PayPal order. + * + * @param Order $order The PayPal order. + * @param WC_Order $wc_order The WooCommerce order. + * @param float $amount The refund amount. + * @param string $reason The reason for the refund. + * + * @throws RuntimeException When operation fails. + */ + public function refund( + Order $order, + WC_Order $wc_order, + float $amount, + string $reason = '' + ): void { + $payments = $this->get_payments( $order ); + + $captures = $payments->captures(); + if ( ! $captures ) { + throw new RuntimeException( 'No capture.' ); + } + + $capture = $captures[0]; + $refund = new Refund( + $capture, + $capture->invoice_id(), + $reason, + new Amount( + new Money( $amount, $wc_order->get_currency() ) + ) + ); + + $refund_id = $this->payments_endpoint->refund( $refund ); + + $this->add_refund_to_meta( $wc_order, $refund_id ); + } + + /** + * Voids the authorization. + * + * @param Order $order The PayPal order. + * @throws RuntimeException When operation fails. + */ + public function void( Order $order ): void { + $payments = $this->get_payments( $order ); + + $voidable_authorizations = array_filter( + $payments->authorizations(), + function ( Authorization $authorization ): bool { + return $authorization->is_voidable(); + } + ); + if ( ! $voidable_authorizations ) { + throw new RuntimeException( 'No voidable authorizations.' ); + } + + foreach ( $voidable_authorizations as $authorization ) { + $this->payments_endpoint->void( $authorization ); + } + } + /** * Determines the refunding mode. * @@ -181,4 +209,24 @@ class RefundProcessor { return self::REFUND_MODE_UNKNOWN; } + + /** + * Returns the payments object or throws. + * + * @param Order $order The order. + * @throws RuntimeException When payment not available. + */ + protected function get_payments( Order $order ): Payments { + $purchase_units = $order->purchase_units(); + if ( ! $purchase_units ) { + throw new RuntimeException( 'No purchase units.' ); + } + + $payments = $purchase_units[0]->payments(); + if ( ! $payments ) { + throw new RuntimeException( 'No payments.' ); + } + + return $payments; + } } diff --git a/tests/PHPUnit/Api/OrderRefundTest.php b/tests/PHPUnit/Api/OrderRefundTest.php new file mode 100644 index 000000000..53b0e2792 --- /dev/null +++ b/tests/PHPUnit/Api/OrderRefundTest.php @@ -0,0 +1,88 @@ +refundProcessor = Mockery::mock(RefundProcessor::class); + $this->orderEndpoint = Mockery::mock(OrderEndpoint::class); + + $this->bootstrapModule([ + 'wcgateway.processor.refunds' => function () { + return $this->refundProcessor; + }, + 'api.endpoint.order' => function () { + return $this->orderEndpoint; + }, + ]); + } + + public function testSuccess(): void { + $wcOrder = Mockery::mock(WC_Order::class); + $wcOrder->expects('get_meta') + ->with(PayPalGateway::ORDER_ID_META_KEY) + ->andReturn('123abc'); + + $this->orderEndpoint + ->expects('order') + ->with('123abc') + ->andReturn(Mockery::mock(Order::class)) + ->once(); + + $this->refundProcessor + ->expects('refund') + ->once(); + + ppcp_refund_order($wcOrder, 42.0, 'reason'); + } + + public function testOrderWithoutId(): void { + $wcOrder = Mockery::mock(WC_Order::class); + $wcOrder->expects('get_meta') + ->with(PayPalGateway::ORDER_ID_META_KEY) + ->andReturn(false); + + $this->expectException(InvalidArgumentException::class); + + ppcp_refund_order($wcOrder, 42.0, 'reason'); + } + + public function testFailure(): void { + $wcOrder = Mockery::mock(WC_Order::class); + $wcOrder->expects('get_meta') + ->with(PayPalGateway::ORDER_ID_META_KEY) + ->andReturn('123abc'); + + $this->orderEndpoint + ->expects('order') + ->with('123abc') + ->andReturn(Mockery::mock(Order::class)) + ->once(); + + $this->refundProcessor + ->expects('refund') + ->andThrow(new RuntimeException()) + ->once(); + + $this->expectException(RuntimeException::class); + + ppcp_refund_order($wcOrder, 42.0, 'reason'); + } +} diff --git a/tests/PHPUnit/Api/OrderVoidTest.php b/tests/PHPUnit/Api/OrderVoidTest.php new file mode 100644 index 000000000..5bbae93a6 --- /dev/null +++ b/tests/PHPUnit/Api/OrderVoidTest.php @@ -0,0 +1,88 @@ +refundProcessor = Mockery::mock(RefundProcessor::class); + $this->orderEndpoint = Mockery::mock(OrderEndpoint::class); + + $this->bootstrapModule([ + 'wcgateway.processor.refunds' => function () { + return $this->refundProcessor; + }, + 'api.endpoint.order' => function () { + return $this->orderEndpoint; + }, + ]); + } + + public function testSuccess(): void { + $wcOrder = Mockery::mock(WC_Order::class); + $wcOrder->expects('get_meta') + ->with(PayPalGateway::ORDER_ID_META_KEY) + ->andReturn('123abc'); + + $this->orderEndpoint + ->expects('order') + ->with('123abc') + ->andReturn(Mockery::mock(Order::class)) + ->once(); + + $this->refundProcessor + ->expects('void') + ->once(); + + ppcp_void_order($wcOrder); + } + + public function testOrderWithoutId(): void { + $wcOrder = Mockery::mock(WC_Order::class); + $wcOrder->expects('get_meta') + ->with(PayPalGateway::ORDER_ID_META_KEY) + ->andReturn(false); + + $this->expectException(InvalidArgumentException::class); + + ppcp_void_order($wcOrder); + } + + public function testFailure(): void { + $wcOrder = Mockery::mock(WC_Order::class); + $wcOrder->expects('get_meta') + ->with(PayPalGateway::ORDER_ID_META_KEY) + ->andReturn('123abc'); + + $this->orderEndpoint + ->expects('order') + ->with('123abc') + ->andReturn(Mockery::mock(Order::class)) + ->once(); + + $this->refundProcessor + ->expects('void') + ->andThrow(new RuntimeException()) + ->once(); + + $this->expectException(RuntimeException::class); + + ppcp_void_order($wcOrder); + } +}