diff --git a/composer.json b/composer.json index 952522aa..09181d33 100644 --- a/composer.json +++ b/composer.json @@ -49,6 +49,7 @@ "polishsymfonycommunity/symfony-mocker-container": "^1.0", "shipmonk/composer-dependency-analyser": "^1.7", "sylius-labs/coding-standard": "^4.2", + "sylius/refund-plugin": "^1.5", "sylius/sylius": "^1.12", "symfony/browser-kit": "^5.4 || ^6.0", "symfony/debug-bundle": "^5.4 || ^6.0", diff --git a/config/config/winzou_state_machine.php b/config/config/winzou_state_machine.php index 51e6490d..3cf90ee6 100644 --- a/config/config/winzou_state_machine.php +++ b/config/config/winzou_state_machine.php @@ -17,5 +17,16 @@ ], ], ], + 'sylius_refund_refund_payment' => [ + 'callbacks' => [ + 'before' => [ + 'tpay_refund_payment' => [ + 'on' => ['complete'], + 'do' => ['@commerce_weavers_sylius_tpay.refunding.dispatcher.refund', 'dispatch'], + 'args' => ['object'], + ], + ] + ] + ], ]); }; diff --git a/config/services/payum/action.php b/config/services/payum/action.php index 21f13bd7..45af43c6 100644 --- a/config/services/payum/action.php +++ b/config/services/payum/action.php @@ -17,6 +17,7 @@ use CommerceWeavers\SyliusTpayPlugin\Payum\Action\Api\PayWithCardAction; use CommerceWeavers\SyliusTpayPlugin\Payum\Action\CaptureAction; use CommerceWeavers\SyliusTpayPlugin\Payum\Action\GetStatusAction; +use CommerceWeavers\SyliusTpayPlugin\Payum\Action\PartialRefundAction; use CommerceWeavers\SyliusTpayPlugin\Payum\Action\RefundAction; use CommerceWeavers\SyliusTpayPlugin\Payum\Action\ResolveNextRouteAction; use CommerceWeavers\SyliusTpayPlugin\Payum\Factory\TpayGatewayFactory; @@ -99,6 +100,10 @@ ->tag('payum.action', ['factory' => TpayGatewayFactory::NAME, 'alias' => 'cw.tpay.get_status']) ; + $services->set(PartialRefundAction::class) + ->tag('payum.action', ['factory' => TpayGatewayFactory::NAME, 'alias' => 'cw.tpay.partial_refund']) + ; + $services->set(RefundAction::class) ->tag('payum.action', ['factory' => TpayGatewayFactory::NAME, 'alias' => 'cw.tpay.refund']) ; diff --git a/config/services/refunding.php b/config/services/refunding.php index f6357a8b..c7535e19 100644 --- a/config/services/refunding.php +++ b/config/services/refunding.php @@ -4,6 +4,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use CommerceWeavers\SyliusTpayPlugin\Refunding\Checker\RefundPluginAvailabilityCheckerInterface; use CommerceWeavers\SyliusTpayPlugin\Refunding\Dispatcher\RefundDispatcher; use CommerceWeavers\SyliusTpayPlugin\Refunding\Dispatcher\RefundDispatcherInterface; use CommerceWeavers\SyliusTpayPlugin\Refunding\Workflow\Listener\DispatchRefundListener; @@ -12,10 +13,15 @@ return function(ContainerConfigurator $container): void { $services = $container->services(); + $services->set('commerce_weavers_sylius_tpay.refunding.checker.refund_plugin_availability', RefundPluginAvailabilityCheckerInterface::class) + ->alias(RefundPluginAvailabilityCheckerInterface::class, 'commerce_weavers_sylius_tpay.refunding.checker.refund_plugin_availability') + ; + $services->set('commerce_weavers_sylius_tpay.refunding.dispatcher.refund', RefundDispatcher::class) ->public() ->args([ - service('payum'), + service('commerce_weavers_sylius_tpay.gateway'), + service('commerce_weavers_sylius_tpay.refunding.checker.refund_plugin_availability'), ]) ->alias(RefundDispatcherInterface::class, 'commerce_weavers_sylius_tpay.refunding.dispatcher.refund') ; @@ -26,6 +32,7 @@ service('commerce_weavers_sylius_tpay.refunding.dispatcher.refund'), ]) ->tag('kernel.event_listener', ['event' => 'workflow.sylius_payment.transition.refund']) + ->tag('kernel.event_listener', ['event' => 'workflow.sylius_refund_refund_payment.transition.complete']) ; } }; diff --git a/src/CommerceWeaversSyliusTpayPlugin.php b/src/CommerceWeaversSyliusTpayPlugin.php index 173dfa12..230fc5a8 100644 --- a/src/CommerceWeaversSyliusTpayPlugin.php +++ b/src/CommerceWeaversSyliusTpayPlugin.php @@ -4,13 +4,20 @@ namespace CommerceWeavers\SyliusTpayPlugin; +use CommerceWeavers\SyliusTpayPlugin\DependencyInjection\CompilerPass\AddSupportedRefundPaymentMethodPass; use Sylius\Bundle\CoreBundle\Application\SyliusPluginTrait; +use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; final class CommerceWeaversSyliusTpayPlugin extends Bundle { use SyliusPluginTrait; + public function build(ContainerBuilder $container): void + { + $container->addCompilerPass(new AddSupportedRefundPaymentMethodPass()); + } + public function getPath(): string { if (!isset($this->path)) { diff --git a/src/DependencyInjection/CompilerPass/AddSupportedRefundPaymentMethodPass.php b/src/DependencyInjection/CompilerPass/AddSupportedRefundPaymentMethodPass.php new file mode 100644 index 00000000..6699196a --- /dev/null +++ b/src/DependencyInjection/CompilerPass/AddSupportedRefundPaymentMethodPass.php @@ -0,0 +1,26 @@ +hasParameter(self::SUPPORTED_GATEWAYS_PARAM_NAME)) { + return; + } + + /** @var array $supportedGateways */ + $supportedGateways = $container->getParameter(self::SUPPORTED_GATEWAYS_PARAM_NAME); + $supportedGateways[] = 'tpay'; + + $container->setParameter(self::SUPPORTED_GATEWAYS_PARAM_NAME, $supportedGateways); + } +} diff --git a/src/Payum/Action/PartialRefundAction.php b/src/Payum/Action/PartialRefundAction.php new file mode 100644 index 00000000..0ee7c494 --- /dev/null +++ b/src/Payum/Action/PartialRefundAction.php @@ -0,0 +1,67 @@ +getModel(); + $payment = $this->extractPaymentFrom($refundPayment); + $paymentDetails = PaymentDetails::fromArray($payment->getDetails()); + $transactionId = $paymentDetails->getTransactionId(); + + if (null === $transactionId) { + throw new RefundCannotBeMadeException('Tpay transaction id cannot be found.'); + } + + if ($refundPayment->getAmount() === $payment->getAmount()) { + $this->gateway->execute(new Refund($payment)); + + return; + } + + $this->api->transactions()->createRefundByTransactionId( + ['amount' => $this->convertFromMinorToMajorCurrency($refundPayment->getAmount())], + $transactionId, + ); + } + + public function supports(mixed $request): bool + { + return $request instanceof Refund && $request->getModel() instanceof RefundPaymentInterface; + } + + private function extractPaymentFrom(RefundPaymentInterface $refundPayment): PaymentInterface + { + $order = $refundPayment->getOrder(); + $payment = $order->getLastPayment(); + + Assert::notNull($payment); + + return $payment; + } + + private function convertFromMinorToMajorCurrency(int $amount): float + { + return $amount / 100; + } +} diff --git a/src/Refunding/Checker/RefundPluginAvailabilityChecker.php b/src/Refunding/Checker/RefundPluginAvailabilityChecker.php new file mode 100644 index 00000000..027c3c4c --- /dev/null +++ b/src/Refunding/Checker/RefundPluginAvailabilityChecker.php @@ -0,0 +1,15 @@ +getMethod(); - /** @var GatewayConfigInterface $gatewayConfig */ - $gatewayConfig = $paymentMethod->getGatewayConfig(); + if (!$this->checkIfShouldBeDispatched($payment)) { + return; + } - $this->payum->getGateway($gatewayConfig->getGatewayName())->execute(new Refund($payment)); + $this->gateway->execute(new Refund($payment)); + } + + private function checkIfShouldBeDispatched(PaymentInterface|RefundPaymentInterface $payment): bool + { + $isRefundPaymentAvailable = $this->refundPluginAvailabilityChecker->isAvailable(); + $isPayment = $payment instanceof PaymentInterface; + $isRefundPayment = $payment instanceof RefundPaymentInterface; + + return (!$isRefundPaymentAvailable && $isPayment) || ($isRefundPaymentAvailable && $isRefundPayment); } } diff --git a/src/Refunding/Dispatcher/RefundDispatcherInterface.php b/src/Refunding/Dispatcher/RefundDispatcherInterface.php index a460769c..8735752a 100644 --- a/src/Refunding/Dispatcher/RefundDispatcherInterface.php +++ b/src/Refunding/Dispatcher/RefundDispatcherInterface.php @@ -5,8 +5,9 @@ namespace CommerceWeavers\SyliusTpayPlugin\Refunding\Dispatcher; use Sylius\Component\Core\Model\PaymentInterface; +use Sylius\RefundPlugin\Entity\RefundPaymentInterface; interface RefundDispatcherInterface { - public function dispatch(PaymentInterface $payment): void; + public function dispatch(PaymentInterface|RefundPaymentInterface $payment): void; } diff --git a/symfony.lock b/symfony.lock index a4a4d2e4..c3a2d53e 100644 --- a/symfony.lock +++ b/symfony.lock @@ -199,6 +199,15 @@ "knplabs/knp-menu-bundle": { "version": "v3.4.2" }, + "knplabs/knp-snappy-bundle": { + "version": "1.10", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "1.5", + "ref": "c81bdcf4a9d4e7b1959071457f9608631865d381" + } + }, "laminas/laminas-code": { "version": "4.14.0" }, @@ -592,6 +601,15 @@ "sylius/mailer-bundle": { "version": "v2.0.0" }, + "sylius/refund-plugin": { + "version": "1.5", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "0.4", + "ref": "a3f813f608c6f04bd7d0b4cefd73a96bf378c390" + } + }, "sylius/registry": { "version": "v1.6.0" }, diff --git a/tests/Application/config/bundles.php b/tests/Application/config/bundles.php index 7e559905..2da0f7a6 100644 --- a/tests/Application/config/bundles.php +++ b/tests/Application/config/bundles.php @@ -60,6 +60,8 @@ League\FlysystemBundle\FlysystemBundle::class => ['all' => true], Nelmio\Alice\Bridge\Symfony\NelmioAliceBundle::class => ['all' => true], Fidry\AliceDataFixtures\Bridge\Symfony\FidryAliceDataFixturesBundle::class => ['all' => true], + Knp\Bundle\SnappyBundle\KnpSnappyBundle::class => ['all' => true], + Sylius\RefundPlugin\SyliusRefundPlugin::class => ['all' => true], ]; if (SyliusCoreBundle::VERSION_ID >= 11300) { diff --git a/tests/Application/config/packages/sylius_refund.yaml b/tests/Application/config/packages/sylius_refund.yaml new file mode 100644 index 00000000..ce7ff8ae --- /dev/null +++ b/tests/Application/config/packages/sylius_refund.yaml @@ -0,0 +1,2 @@ +imports: + - { resource: "@SyliusRefundPlugin/Resources/config/app/config.yml" } diff --git a/tests/Application/config/routes/sylius_refund.yaml b/tests/Application/config/routes/sylius_refund.yaml new file mode 100644 index 00000000..46e99b16 --- /dev/null +++ b/tests/Application/config/routes/sylius_refund.yaml @@ -0,0 +1,2 @@ +sylius_refund: + resource: "@SyliusRefundPlugin/Resources/config/routing.yml" diff --git a/tests/Unit/Payum/Action/PartialRefundActionTest.php b/tests/Unit/Payum/Action/PartialRefundActionTest.php new file mode 100644 index 00000000..7fbfeb42 --- /dev/null +++ b/tests/Unit/Payum/Action/PartialRefundActionTest.php @@ -0,0 +1,130 @@ +gateway = $this->prophesize(GatewayInterface::class); + $this->tpayApi = $this->prophesize(TpayApi::class); + $this->refundPayment = $this->prophesize(RefundPaymentInterface::class); + $this->refundRequest = $this->prophesize(Refund::class); + + $this->refundRequest->getModel()->willReturn($this->refundPayment); + } + + public function test_it_performs_a_full_refund_if_payment_amount_is_equal_to_refund_amount(): void + { + $paymentDetails = [ + 'tpay' => [ + 'transaction_id' => 'tr4ns4ct!0n_!d', + ], + ]; + $payment = $this->prophesize(PaymentInterface::class); + $payment->getDetails()->willReturn($paymentDetails); + $payment->getAmount()->willReturn(100); + + $order = $this->prophesize(OrderInterface::class); + $order->getLastPayment()->willReturn($payment); + + $this->refundPayment->getAmount()->willReturn(100); + $this->refundPayment->getOrder()->willReturn($order); + + $this->gateway->execute(Argument::that(function (Refund $refund) use ($payment): bool { + return $refund->getModel() === $payment->reveal(); + }))->shouldBeCalled(); + + $this->tpayApi->transactions()->shouldNotBeCalled(); + + $this->createTestSubject()->execute($this->refundRequest->reveal()); + } + + public function test_it_performs_a_partial_refund(): void + { + $paymentDetails = [ + 'tpay' => [ + 'transaction_id' => 'tr4ns4ct!0n_!d', + ], + ]; + $payment = $this->prophesize(PaymentInterface::class); + $payment->getDetails()->willReturn($paymentDetails); + $payment->getAmount()->willReturn(100); + + $order = $this->prophesize(OrderInterface::class); + $order->getLastPayment()->willReturn($payment); + + $this->refundPayment->getAmount()->willReturn(90); + $this->refundPayment->getOrder()->willReturn($order); + + $this->gateway->execute(Argument::any())->shouldNotBeCalled(); + + $transactions = $this->prophesize(TransactionsApi::class); + $transactions->createRefundByTransactionId(['amount' => 0.90], 'tr4ns4ct!0n_!d') + ->willReturn([ + 'result' => 'success', + ]) + ->shouldBeCalled() + ; + + $this->tpayApi->transactions()->willReturn($transactions); + + $this->createTestSubject()->execute($this->refundRequest->reveal()); + } + + public function test_it_throws_an_exception_if_payment_transaction_id_is_missing(): void + { + $this->expectException(RefundCannotBeMadeException::class); + $this->expectExceptionMessage('Tpay transaction id cannot be found.'); + + $payment = $this->prophesize(PaymentInterface::class); + $payment->getDetails()->willReturn([]); + $payment->getAmount()->willReturn(100); + + $order = $this->prophesize(OrderInterface::class); + $order->getLastPayment()->willReturn($payment); + + $this->refundPayment->getAmount()->willReturn(90); + $this->refundPayment->getOrder()->willReturn($order); + + $this->gateway->execute(Argument::any())->shouldNotBeCalled(); + $this->tpayApi->transactions()->shouldNotBeCalled(); + + $this->createTestSubject()->execute($this->refundRequest->reveal()); } + + private function createTestSubject(): PartialRefundAction + { + $action = new PartialRefundAction(); + + $action->setApi($this->tpayApi->reveal()); + $action->setGateway($this->gateway->reveal()); + + return $action; + } +} diff --git a/tests/Unit/Refunding/Dispatcher/RefundDispatcherTest.php b/tests/Unit/Refunding/Dispatcher/RefundDispatcherTest.php index 4f4d5ced..3b700170 100644 --- a/tests/Unit/Refunding/Dispatcher/RefundDispatcherTest.php +++ b/tests/Unit/Refunding/Dispatcher/RefundDispatcherTest.php @@ -4,53 +4,81 @@ namespace Tests\CommerceWeavers\SyliusTpayPlugin\Unit\Refunding\Dispatcher; +use CommerceWeavers\SyliusTpayPlugin\Refunding\Checker\RefundPluginAvailabilityCheckerInterface; use CommerceWeavers\SyliusTpayPlugin\Refunding\Dispatcher\RefundDispatcher; use CommerceWeavers\SyliusTpayPlugin\Refunding\Dispatcher\RefundDispatcherInterface; use Payum\Core\GatewayInterface; -use Payum\Core\Payum; use Payum\Core\Request\Refund; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; -use Sylius\Bundle\PayumBundle\Model\GatewayConfigInterface; use Sylius\Component\Core\Model\PaymentInterface; -use Sylius\Component\Core\Model\PaymentMethodInterface; +use Sylius\RefundPlugin\Entity\RefundPaymentInterface; final class RefundDispatcherTest extends TestCase { use ProphecyTrait; - private Payum|ObjectProphecy $payum; + private GatewayInterface|ObjectProphecy $gateway; + + private RefundPluginAvailabilityCheckerInterface|ObjectProphecy $refundPluginAvailabilityChecker; protected function setUp(): void { - $this->payum = $this->prophesize(Payum::class); + $this->gateway = $this->prophesize(GatewayInterface::class); + $this->refundPluginAvailabilityChecker = $this->prophesize(RefundPluginAvailabilityCheckerInterface::class); } - public function test_it_executes_a_refund_request(): void + public function test_it_executes_a_refund_request_with_payment_if_plugin_is_not_available(): void { - $gatewayConfig = $this->prophesize(GatewayConfigInterface::class); - $gatewayConfig->getGatewayName()->willReturn('tpay'); + $this->refundPluginAvailabilityChecker->isAvailable()->willReturn(false); + $payment = $this->prophesize(PaymentInterface::class); - $paymentMethod = $this->prophesize(PaymentMethodInterface::class); - $paymentMethod->getGatewayConfig()->willReturn($gatewayConfig); + $this->gateway->execute(Argument::that(function (Refund $refund) use ($payment): bool { + return $refund->getModel() === $payment->reveal(); + }))->shouldBeCalled(); + $this->createTestSubject()->dispatch($payment->reveal()); + } + + public function test_it_does_nothing_if_payment_is_passed_and_plugin_is_available(): void + { + $this->refundPluginAvailabilityChecker->isAvailable()->willReturn(true); $payment = $this->prophesize(PaymentInterface::class); - $payment->getMethod()->willReturn($paymentMethod); - $tpayGateway = $this->prophesize(GatewayInterface::class); - $tpayGateway->execute(Argument::that(function (mixed $request) use ($payment) { - return $request instanceof Refund && $request->getModel() === $payment->reveal(); + $this->gateway->execute(Argument::any())->shouldNotBeCalled(); + + $this->createTestSubject()->dispatch($payment->reveal()); + } + + public function test_it_executes_a_refund_request_with_refund_payment_if_plugin_is_available(): void + { + $this->refundPluginAvailabilityChecker->isAvailable()->willReturn(true); + $refundPayment = $this->prophesize(RefundPaymentInterface::class); + + $this->gateway->execute(Argument::that(function (Refund $refund) use ($refundPayment): bool { + return $refund->getModel() === $refundPayment->reveal(); }))->shouldBeCalled(); - $this->payum->getGateway('tpay')->willReturn($tpayGateway); + $this->createTestSubject()->dispatch($refundPayment->reveal()); + } - $this->createTestSubject()->dispatch($payment->reveal()); + public function test_it_does_nothing_if_refund_payment_is_passed_and_plugin_is_not_available(): void + { + $this->refundPluginAvailabilityChecker->isAvailable()->willReturn(false); + $refundPayment = $this->prophesize(RefundPaymentInterface::class); + + $this->gateway->execute(Argument::any())->shouldNotBeCalled(); + + $this->createTestSubject()->dispatch($refundPayment->reveal()); } private function createTestSubject(): RefundDispatcherInterface { - return new RefundDispatcher($this->payum->reveal()); + return new RefundDispatcher( + $this->gateway->reveal(), + $this->refundPluginAvailabilityChecker->reveal(), + ); } }