diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 01561ac69..fc50bfe6f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -13,6 +13,7 @@ v3.0.0 - Added support for Python 3.11, Django 4.1 and Django 4.2. - Stripe backends now supports webhooks - New :ref:`webhook settings ` +- PayPal backends now perform a full refund if ``amount=None``. v2.0.0 ------ diff --git a/payments/paypal/__init__.py b/payments/paypal/__init__.py index b7f0adebb..b02bb88d0 100644 --- a/payments/paypal/__init__.py +++ b/payments/paypal/__init__.py @@ -310,15 +310,19 @@ def release(self, payment): self.post(payment, url) def refund(self, payment, amount=None): - if amount is None: - amount = payment.captured_amount - amount_data = self.get_amount_data(payment, amount) - refund_data = {"amount": amount_data} + refund_data = {} + if amount is not None: + refund_data["amount"] = self.get_amount_data(payment, amount) links = self._get_links(payment) url = links["refund"]["href"] - self.post(payment, url, data=refund_data) + response = self.post(payment, url, data=refund_data) payment.change_status(PaymentStatus.REFUNDED) - return amount + if response["amount"]["currency"] != payment.currency: + raise NotImplementedError( + f"refund's currency other than {payment.currency} not supported yet: " + f"{response['amount']['currency']}" + ) + return Decimal(response["amount"]["total"]) class PaypalCardProvider(PaypalProvider): diff --git a/payments/paypal/test_paypal.py b/payments/paypal/test_paypal.py index 28fc7149d..c0b3b6ee8 100644 --- a/payments/paypal/test_paypal.py +++ b/payments/paypal/test_paypal.py @@ -129,17 +129,53 @@ def test_provider_handles_captured_payment(self, mocked_post): self.assertEqual(self.payment.status, PaymentStatus.CONFIRMED) @patch("requests.post") - def test_provider_refunds_payment(self, mocked_post): + def test_provider_refunds_payment_fully(self, mocked_post): data = MagicMock() - data.return_value = { - "token_type": "test_token_type", - "access_token": "test_access_token", - } + data.side_effect = [ + { + "token_type": "test_token_type", + "access_token": "test_access_token", + }, + {"amount": {"total": "220.00", "currency": "USD"}}, + ] post = MagicMock() post.json = data post.status_code = 200 mocked_post.return_value = post self.provider.refund(self.payment) + mocked_post.assert_called_with( + "http://refund.com", + headers={ + "Content-Type": "application/json", + "Authorization": "test_token_type test_access_token", + }, + data="{}", + ) + self.assertEqual(self.payment.status, PaymentStatus.REFUNDED) + + @patch("requests.post") + def test_provider_refunds_payment_partially(self, mocked_post): + data = MagicMock() + data.side_effect = [ + { + "token_type": "test_token_type", + "access_token": "test_access_token", + }, + {"amount": {"total": "1.00", "currency": "USD"}}, + ] + post = MagicMock() + post.json = data + post.status_code = 200 + mocked_post.return_value = post + self.provider.refund(self.payment, amount=Decimal(1)) + mocked_post.assert_called_with( + "http://refund.com", + headers={ + "Content-Type": "application/json", + "Authorization": "test_token_type test_access_token", + }, + data='{"amount": {"currency": "USD", "total": "1.00"}}', + ) self.assertEqual(self.payment.status, PaymentStatus.REFUNDED) @patch("requests.post")