From e77137105e676500ddcc0950c9b68ba1e794608e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Hol=C3=BD?= Date: Wed, 13 Mar 2024 12:04:02 +0100 Subject: [PATCH 1/5] Update payment.captured_amount only when order is completed --- AUTHORS.rst | 2 +- HISTORY.rst | 4 ++++ payments_payu/provider.py | 9 ++++++--- tests/test_payu.py | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 43 insertions(+), 4 deletions(-) diff --git a/AUTHORS.rst b/AUTHORS.rst index 7118fd1..661c530 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -10,4 +10,4 @@ Development Lead Contributors ------------ -None yet. Why not be the first? +* Radek HolĂ˝ diff --git a/HISTORY.rst b/HISTORY.rst index 188b328..32faa17 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,10 @@ History ------- +Unreleased +++++++++++ +* update payment.captured_amount only when order is completed + 1.2.3 (2022-01-25) ++++++++++++++++++ * better distinct PayU API errors diff --git a/payments_payu/provider.py b/payments_payu/provider.py index 0064270..7320b73 100644 --- a/payments_payu/provider.py +++ b/payments_payu/provider.py @@ -556,7 +556,6 @@ def process_notification(self, payment, request): else: raise Exception("Refund was not finelized", data) else: - status = data["order"]["status"] status_map = { "COMPLETED": PaymentStatus.CONFIRMED, "PENDING": PaymentStatus.INPUT, @@ -564,12 +563,16 @@ def process_notification(self, payment, request): "CANCELED": PaymentStatus.REJECTED, "NEW": PaymentStatus.WAITING, } - if PaymentStatus.CONFIRMED and "totalAmount" in data["order"]: + status = status_map[data["order"]["status"]] + if ( + status == PaymentStatus.CONFIRMED + and "totalAmount" in data["order"] + ): payment.captured_amount = dequantize_price( data["order"]["totalAmount"], data["order"]["currencyCode"], ) - payment.change_status(status_map[status]) + payment.change_status(status) return HttpResponse("ok", status=200) return HttpResponse("not ok", status=500) diff --git a/tests/test_payu.py b/tests/test_payu.py index e30740d..f199d6b 100644 --- a/tests/test_payu.py +++ b/tests/test_payu.py @@ -592,6 +592,38 @@ def test_process_notification(self): self.assertEqual(self.payment.status, PaymentStatus.CONFIRMED) self.assertEqual(self.payment.captured_amount, Decimal("0")) + def test_process_notification_cancelled(self): + """Test processing PayU cancelled notification""" + self.set_up_provider(True, True) + self.payment.transaction_id = "123" + self.payment.save() + mocked_request = MagicMock() + mocked_request.body = json.dumps( + { + "order": dict( + self.provider.get_processor(self.payment).as_json(), + orderId=self.payment.transaction_id, + orderCreateDate="2012-12-31T12:00:00", + status="CANCELED", + ) + } + ).encode("utf8") + mocked_request.META = { + "CONTENT_TYPE": "application/json", + "HTTP_OPENPAYU_SIGNATURE": "signature=f376048898aa0c629d1f64317ce13736;algorithm=MD5", + } + mocked_request.status_code = 200 + + ret_val = self.provider.process_data( + payment=self.payment, request=mocked_request + ) + + self.assertEqual(ret_val.__class__.__name__, "HttpResponse") + self.assertEqual(ret_val.status_code, 200) + self.assertEqual(ret_val.content, b"ok") + self.assertEqual(self.payment.status, PaymentStatus.REJECTED) + self.assertEqual(self.payment.captured_amount, Decimal("0")) + def test_process_notification_refund(self): """Test processing PayU refund notification""" self.set_up_provider(True, True) From 848502342cb28f3667dd4e897b21463444f16ad5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Hol=C3=BD?= Date: Wed, 13 Mar 2024 15:16:01 +0100 Subject: [PATCH 2/5] Subtract refunds from payment.captured_amount rather than from payment.total --- HISTORY.rst | 1 + payments_payu/provider.py | 4 ++-- tests/test_payu.py | 11 +++++++++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index 32faa17..37eb98c 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -6,6 +6,7 @@ History Unreleased ++++++++++ * update payment.captured_amount only when order is completed +* subtract refunds from payment.captured_amount rather than from payment.total 1.2.3 (2022-01-25) ++++++++++++++++++ diff --git a/payments_payu/provider.py b/payments_payu/provider.py index 7320b73..7c3b497 100644 --- a/payments_payu/provider.py +++ b/payments_payu/provider.py @@ -547,10 +547,10 @@ def process_notification(self, payment, request): print(refunded_price, payment.total) if data["refund"]["status"] == "FINALIZED": payment.message += data["refund"]["reasonDescription"] - if refunded_price == payment.total: + if refunded_price == payment.captured_amount: payment.change_status(PaymentStatus.REFUNDED) else: - payment.total -= refunded_price + payment.captured_amount -= refunded_price payment.save() return HttpResponse("ok", status=200) else: diff --git a/tests/test_payu.py b/tests/test_payu.py index f199d6b..fd9c879 100644 --- a/tests/test_payu.py +++ b/tests/test_payu.py @@ -626,6 +626,10 @@ def test_process_notification_cancelled(self): def test_process_notification_refund(self): """Test processing PayU refund notification""" + self.payment.captured_amount = self.payment.total + self.payment.change_status(PaymentStatus.CONFIRMED) + self.payment.save() + self.set_up_provider(True, True) mocked_request = MagicMock() mocked_request.body = json.dumps( @@ -651,12 +655,14 @@ def test_process_notification_refund(self): self.assertEqual(ret_val.status_code, 200) self.assertEqual(ret_val.content, b"ok") self.assertEqual(self.payment.status, PaymentStatus.REFUNDED) - self.assertEqual(self.payment.captured_amount, Decimal("0")) + self.assertEqual(self.payment.total, Decimal(220)) + self.assertEqual(self.payment.captured_amount, Decimal(220)) def test_process_notification_partial_refund(self): """Test processing PayU partial refund notification""" self.payment.change_status(PaymentStatus.CONFIRMED) self.payment.total = 220 + self.payment.captured_amount = self.payment.total self.payment.save() self.payment.refresh_from_db() @@ -685,7 +691,8 @@ def test_process_notification_partial_refund(self): self.assertEqual(ret_val.status_code, 200) self.assertEqual(ret_val.content, b"ok") self.payment.refresh_from_db() - self.assertEqual(self.payment.total, Decimal("110")) + self.assertEqual(self.payment.total, Decimal(220)) + self.assertEqual(self.payment.captured_amount, Decimal("110")) self.assertEqual(self.payment.status, PaymentStatus.CONFIRMED) def test_process_notification_refund_not_finalized(self): From 2702eff9935cd00f0e7670bda7303aa50b46a5e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Hol=C3=BD?= Date: Thu, 14 Mar 2024 10:18:16 +0100 Subject: [PATCH 3/5] Add PayuProvider.refund --- HISTORY.rst | 1 + README.rst | 26 +- payments_payu/provider.py | 101 +++++++- tests/test_payu.py | 519 +++++++++++++++++++++++++++++++++++++- 4 files changed, 629 insertions(+), 18 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index 37eb98c..a2e6d30 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -5,6 +5,7 @@ History Unreleased ++++++++++ +* add PayuProvider.refund * update payment.captured_amount only when order is completed * subtract refunds from payment.captured_amount rather than from payment.total diff --git a/README.rst b/README.rst index 4bcd5ab..2033abb 100644 --- a/README.rst +++ b/README.rst @@ -26,7 +26,7 @@ Quickstart Install `django-payments `_ and set up PayU payment provider backend according to `django-payments documentation `_: -.. class:: payments_payu.provider.PayuProvider(client_secret, second_key, pos_id, [sandbox=False, endpoint="https://secure.payu.com/", recurring_payments=False, express_payments=False, widget_branding=False]) +.. class:: payments_payu.provider.PayuProvider(client_secret, second_key, pos_id, get_refund_description, [sandbox=False, endpoint="https://secure.payu.com/", recurring_payments=False, express_payments=False, widget_branding=False, get_refund_ext_id=_DEFAULT_GET_REFUND_EXT_ID]) This backend implements payments using `PayU.com `_. @@ -42,20 +42,24 @@ Example:: 'client_secret': 'peopleiseedead', 'sandbox': True, 'capture': False, + 'get_refund_description': lambda payment, amount: 'My refund', + 'get_refund_ext_id': lambda payment, amount: str(uuid.uuid4()), }), } Here are valid parameters for the provider: - :client_secret: PayU OAuth protocol client secret - :pos_id: PayU POS ID - :second_key: PayU second key (MD5) - :shop_name: Name of the shop send to the API - :sandbox: if ``True``, set the endpoint to sandbox - :endpoint: endpoint URL, if not set, the will be automatically set based on `sandbox` settings - :recurring_payments: enable recurring payments, only valid with ``express_payments=True``, see bellow for additional setup, that is needed - :express_payments: use PayU express form - :widget_branding: tell express form to show PayU branding - :store_card: (default: False) whether PayU should store the card + :client_secret: PayU OAuth protocol client secret + :pos_id: PayU POS ID + :second_key: PayU second key (MD5) + :shop_name: Name of the shop send to the API + :sandbox: if ``True``, set the endpoint to sandbox + :endpoint: endpoint URL, if not set, the will be automatically set based on `sandbox` settings + :recurring_payments: enable recurring payments, only valid with ``express_payments=True``, see bellow for additional setup, that is needed + :express_payments: use PayU express form + :widget_branding: tell express form to show PayU branding + :store_card: (default: False) whether PayU should store the card + :get_refund_description: A mandatory callable that is called with two keyword arguments `payment` and `amount` in order to get the string description of the particular refund whenever ``provider.refund(payment, amount)`` is called. + :get_refund_ext_id: An optional callable that is called with two keyword arguments `payment` and `amount` in order to get the External string refund ID of the particular refund whenever ``provider.refund(payment, amount)`` is called. If ``None`` is returned, no External refund ID is set. An External refund ID is not necessary if partial refunds won't be performed more than once per second. Otherwise, a unique ID is recommended since `PayuProvider.refund` is idempotent and if exactly same data will be provided, it will return the result of the already previously performed refund instead of performing a new refund. Defaults to a random UUID version 4 in the standard form. NOTE: notifications about the payment status from PayU are requested to be sent to `django-payments` `process_payment` url. The request from PayU can fail for several reasons (i.e. it can be blocked by proxy). Use "Show reports" page in PayU administration to get more information about the requests. diff --git a/payments_payu/provider.py b/payments_payu/provider.py index 7c3b497..f7396ed 100644 --- a/payments_payu/provider.py +++ b/payments_payu/provider.py @@ -1,6 +1,7 @@ import hashlib import json import logging +import uuid from decimal import ROUND_HALF_UP, Decimal from urllib.parse import urljoin @@ -175,13 +176,17 @@ def __init__(self, *args, **kwargs): self.payu_token_url = kwargs.pop( "token_url", urljoin(self.payu_api_url, "tokens/") ) - self.payu_api_order_url = urljoin(self.payu_api_url, "orders/") + self.payu_api_orders_url = urljoin(self.payu_api_url, "orders/") self.payu_api_paymethods_url = urljoin(self.payu_api_url, "paymethods/") self.payu_widget_branding = kwargs.pop("widget_branding", False) self.payu_store_card = kwargs.pop("store_card", False) self.payu_shop_name = kwargs.pop("shop_name", "") self.grant_type = kwargs.pop("grant_type", "client_credentials") self.recurring_payments = kwargs.pop("recurring_payments", False) + self.get_refund_description = kwargs.pop("get_refund_description") + self.get_refund_ext_id = kwargs.pop( + "get_refund_ext_id", lambda payment, amount: str(uuid.uuid4()) + ) # Use card on file paremeter instead of recurring. # PayU asks CVV2 every time with this setting which can be used for testing purposes. @@ -196,6 +201,9 @@ def __init__(self, *args, **kwargs): ) super(PayuProvider, self).__init__(*args, **kwargs) + def _get_payu_api_order_url(self, order_id): + return urljoin(self.payu_api_orders_url, order_id) + def get_sig(self, payu_data): string = "".join( str(payu_data[key]) for key in sig_sorted_key_list if key in payu_data @@ -401,7 +409,7 @@ def create_order(self, payment, payment_processor, auto_renew=False): payment_processor.pos_id = self.pos_id json_data = payment_processor.as_json() response_dict = self.post_request( - self.payu_api_order_url, + self.payu_api_orders_url, data=json.dumps(json_data), allow_redirects=False, ) @@ -485,10 +493,7 @@ def get_paymethod_tokens(self): def reject_order(self, payment): "Reject order" - url = urljoin( - self.payu_api_order_url, - payment.transaction_id, - ) + url = self._get_payu_api_order_url(payment.transaction_id) try: # If the payment have status WAITING_FOR_CONFIRMATION, it is needed to make two calls of DELETE @@ -596,6 +601,90 @@ def process_data(self, payment, request, *args, **kwargs): "request not recognized by django-payments-payu provider", status=500 ) + def refund(self, payment, amount=None): + request_url = self._get_payu_api_order_url(payment.transaction_id) + "/refunds" + + request_data = { + "refund": { + "currencyCode": payment.currency, + "description": self.get_refund_description( + payment=payment, amount=amount + ), + } + } + if amount is not None: + request_data.setdefault("refund", {}).setdefault( + "amount", quantize_price(amount, payment.currency) + ) + ext_refund_id = self.get_refund_ext_id(payment=payment, amount=amount) + if ext_refund_id is not None: + request_data.setdefault("refund", {}).setdefault( + "extRefundId", ext_refund_id + ) + + response = self.post_request(request_url, data=json.dumps(request_data)) + + payment_extra_data = json.loads(payment.extra_data or "{}") + payment_extra_data_refund_responses = payment_extra_data.setdefault( + "refund_responses", [] + ) + payment_extra_data_refund_responses.append(response) + payment.extra_data = json.dumps(payment_extra_data, indent=2) + payment.save() + + try: + refund = response["refund"] + refund_id = refund["refundId"] + except Exception: + refund_id = None + + try: + response_status = dict(response["status"]) + response_status_code = response_status["statusCode"] + except Exception: + raise ValueError( + f"invalid response to refund {refund_id or '???'} of payment {payment.id}: {response}" + ) + if response_status_code != "SUCCESS": + raise ValueError( + f"refund {refund_id or '???'} of payment {payment.id} failed: " + f"code={response_status.get('code', '???')}, " + f"statusCode={response_status_code}, " + f"codeLiteral={response_status.get('codeLiteral', '???')}, " + f"statusDesc={response_status.get('statusDesc', '???')}" + ) + if refund_id is None: + raise ValueError( + f"invalid response to refund of payment {payment.id}: {response}" + ) + + try: + refund_order_id = response["orderId"] + refund_status = refund["status"] + refund_currency = refund["currencyCode"] + refund_amount = dequantize_price(refund["amount"], refund_currency) + except Exception: + raise ValueError( + f"invalid response to refund {refund_id} of payment {payment.id}: {response}" + ) + if refund_order_id != payment.transaction_id: + raise NotImplementedError( + f"response of refund {refund_id} of payment {payment.id} containing a different order_id " + f"not supported yet: {refund_order_id}" + ) + if refund_status == "CANCELED": + raise ValueError(f"refund {refund_id} of payment {payment.id} canceled") + elif refund_status not in {"PENDING", "FINALIZED"}: + raise ValueError( + f"invalid status of refund {refund_id} of payment {payment.id}" + ) + if refund_currency != payment.currency: + raise NotImplementedError( + f"refund {refund_id} of payment {payment.id} in different currency not supported yet: " + f"{refund_currency}" + ) + return refund_amount + class PaymentProcessor(object): "Payment processor" diff --git a/tests/test_payu.py b/tests/test_payu.py index fd9c879..d4d520c 100644 --- a/tests/test_payu.py +++ b/tests/test_payu.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals +import contextlib import json from decimal import Decimal from unittest import TestCase @@ -111,7 +112,13 @@ class TestPayuProvider(TestCase): def setUp(self): self.payment = Payment() - def set_up_provider(self, recurring, express): + def set_up_provider( + self, + recurring, + express, + get_refund_description=lambda payment, amount: "test", + **kwargs, + ): with patch("requests.post") as mocked_post: data = MagicMock() data = '{"access_token": "test_access_token"}' @@ -127,6 +134,8 @@ def set_up_provider(self, recurring, express): base_payu_url="http://mock.url/", recurring_payments=recurring, express_payments=express, + get_refund_description=get_refund_description, + **kwargs, ) def test_redirect_to_recurring_payment(self): @@ -1068,3 +1077,511 @@ def test_reject_order_error(self): }, ) self.assertEqual(self.payment.status, PaymentStatus.WAITING) + + def test_refund(self): + self.set_up_provider( + True, + True, + get_refund_description=lambda payment, amount: f"desc {payment.transaction_id} {amount}", + get_refund_ext_id=lambda payment, amount: f"ext {payment.transaction_id} {amount}", + ) + payment_extra_data_refund_response_previous = { + "orderId": "1234", + "refund": { + "refundId": "5000009986", + "extRefundId": "ext 1234 10", + "amount": "1000", + "currencyCode": "USD", + "description": "desc 1234 10", + "creationDateTime": "2020-07-02T08:19:03.896+02:00", + "status": "PENDING", + "statusDateTime": "2020-07-02T08:19:04.013+02:00", + }, + "status": { + "statusCode": "SUCCESS", + "statusDesc": "Refund queued for processing", + }, + } + self.payment.transaction_id = "1234" + self.payment.captured_amount = Decimal(210) + self.payment.extra_data = json.dumps( + {"refund_responses": [payment_extra_data_refund_response_previous]} + ) + self.payment.change_status(PaymentStatus.CONFIRMED) + self.payment.save() + refund_request_response_body = { + "orderId": "1234", + "refund": { + "refundId": "5000009987", + "extRefundId": "ext 1234 110", + "amount": "11000", + "currencyCode": "USD", + "description": "desc 1234 110", + "creationDateTime": "2020-07-02T09:19:03.896+02:00", + "status": "PENDING", + "statusDateTime": "2020-07-02T09:19:04.013+02:00", + }, + "status": { + "statusCode": "SUCCESS", + "statusDesc": "Refund queued for processing", + }, + } + refund_request_patch = self._patch_refund( + base_payu_url="http://mock.url", + order_id="1234", + access_token="test_access_token", + amount=11000, + currency_code="USD", + description="desc 1234 110", + ext_refund_id="ext 1234 110", + response_body=refund_request_response_body, + ) + + with refund_request_patch as refund_request_mock: + amount = self.provider.refund(self.payment, Decimal(110)) + + payment_extra_data_refund_responses = json.loads(self.payment.extra_data)[ + "refund_responses" + ] + self.assertEqual(refund_request_mock.call_count, 1) + self.assertEqual(amount, Decimal(110)) + self.assertEqual(self.payment.total, Decimal(220)) + self.assertEqual(self.payment.captured_amount, Decimal(210)) + self.assertEqual(self.payment.status, PaymentStatus.CONFIRMED) + self.assertEqual( + payment_extra_data_refund_responses, + [payment_extra_data_refund_response_previous, refund_request_response_body], + ) + + def test_refund_no_amount(self): + self.set_up_provider( + True, + True, + get_refund_description=lambda payment, amount: f"desc {payment.transaction_id} {amount}", + get_refund_ext_id=lambda payment, amount: f"ext {payment.transaction_id} {amount}", + ) + self.payment.transaction_id = "1234" + self.payment.captured_amount = self.payment.total + self.payment.change_status(PaymentStatus.CONFIRMED) + self.payment.save() + refund_request_response_body = { + "orderId": "1234", + "refund": { + "refundId": "5000009987", + "extRefundId": "ext 1234 None", + "amount": "22000", + "currencyCode": "USD", + "description": "desc 1234 None", + "creationDateTime": "2020-07-02T09:19:03.896+02:00", + "status": "PENDING", + "statusDateTime": "2020-07-02T09:19:04.013+02:00", + }, + "status": { + "statusCode": "SUCCESS", + "statusDesc": "Refund queued for processing", + }, + } + refund_request_patch = self._patch_refund( + base_payu_url="http://mock.url", + order_id="1234", + access_token="test_access_token", + amount=None, + currency_code="USD", + description="desc 1234 None", + ext_refund_id="ext 1234 None", + response_body=refund_request_response_body, + ) + + with refund_request_patch as refund_request_mock: + amount = self.provider.refund(self.payment) + + payment_extra_data_refund_responses = json.loads(self.payment.extra_data)[ + "refund_responses" + ] + self.assertEqual(refund_request_mock.call_count, 1) + self.assertEqual(amount, Decimal(220)) + self.assertEqual(self.payment.total, Decimal(220)) + self.assertEqual(self.payment.captured_amount, Decimal(220)) + self.assertEqual(self.payment.status, PaymentStatus.CONFIRMED) + self.assertEqual( + payment_extra_data_refund_responses, [refund_request_response_body] + ) + + def test_refund_no_get_refund_ext_id(self): + self.set_up_provider( + True, + True, + get_refund_description=lambda payment, amount: f"desc {payment.transaction_id} {amount}", + ) + self.payment.transaction_id = "1234" + self.payment.captured_amount = self.payment.total + self.payment.change_status(PaymentStatus.CONFIRMED) + self.payment.save() + refund_request_response_body = { + "orderId": "1234", + "refund": { + "refundId": "5000009987", + "extRefundId": "caf231c5-cbc1-4af3-96b7-95798b1cb846", + "amount": "11000", + "currencyCode": "USD", + "description": "desc 1234 110", + "creationDateTime": "2020-07-02T09:19:03.896+02:00", + "status": "PENDING", + "statusDateTime": "2020-07-02T09:19:04.013+02:00", + }, + "status": { + "statusCode": "SUCCESS", + "statusDesc": "Refund queued for processing", + }, + } + refund_request_patch = self._patch_refund( + base_payu_url="http://mock.url", + order_id="1234", + access_token="test_access_token", + amount=11000, + currency_code="USD", + description="desc 1234 110", + ext_refund_id="caf231c5-cbc1-4af3-96b7-95798b1cb846", + response_body=refund_request_response_body, + ) + + with refund_request_patch as refund_request_mock: + with patch( + "uuid.uuid4", return_value="caf231c5-cbc1-4af3-96b7-95798b1cb846" + ): + amount = self.provider.refund(self.payment, Decimal(110)) + + payment_extra_data_refund_responses = json.loads(self.payment.extra_data)[ + "refund_responses" + ] + self.assertEqual(refund_request_mock.call_count, 1) + self.assertEqual(amount, Decimal(110)) + self.assertEqual(self.payment.total, Decimal(220)) + self.assertEqual(self.payment.captured_amount, Decimal(220)) + self.assertEqual(self.payment.status, PaymentStatus.CONFIRMED) + self.assertEqual( + payment_extra_data_refund_responses, [refund_request_response_body] + ) + + def test_refund_no_ext_id(self): + self.set_up_provider( + True, + True, + get_refund_description=lambda payment, amount: f"desc {payment.transaction_id} {amount}", + get_refund_ext_id=lambda payment, amount: None, + ) + self.payment.transaction_id = "1234" + self.payment.captured_amount = self.payment.total + self.payment.change_status(PaymentStatus.CONFIRMED) + self.payment.save() + refund_request_response_body = { + "orderId": "1234", + "refund": { + "refundId": "5000009987", + "amount": "11000", + "currencyCode": "USD", + "description": "desc 1234 110", + "creationDateTime": "2020-07-02T09:19:03.896+02:00", + "status": "PENDING", + "statusDateTime": "2020-07-02T09:19:04.013+02:00", + }, + "status": { + "statusCode": "SUCCESS", + "statusDesc": "Refund queued for processing", + }, + } + refund_request_patch = self._patch_refund( + base_payu_url="http://mock.url", + order_id="1234", + access_token="test_access_token", + amount=11000, + currency_code="USD", + description="desc 1234 110", + ext_refund_id=None, + response_body=refund_request_response_body, + ) + + with refund_request_patch as refund_request_mock: + amount = self.provider.refund(self.payment, Decimal(110)) + + payment_extra_data_refund_responses = json.loads(self.payment.extra_data)[ + "refund_responses" + ] + self.assertEqual(refund_request_mock.call_count, 1) + self.assertEqual(amount, Decimal(110)) + self.assertEqual(self.payment.total, Decimal(220)) + self.assertEqual(self.payment.captured_amount, Decimal(220)) + self.assertEqual(self.payment.status, PaymentStatus.CONFIRMED) + self.assertEqual( + payment_extra_data_refund_responses, [refund_request_response_body] + ) + + def test_refund_no_ext_id_twice(self): + self.set_up_provider( + True, + True, + get_refund_description=lambda payment, amount: f"desc {payment.transaction_id} {amount}", + get_refund_ext_id=lambda payment, amount: None, + ) + self.payment.transaction_id = "1234" + self.payment.captured_amount = self.payment.total + self.payment.change_status(PaymentStatus.CONFIRMED) + self.payment.save() + refund_request_response_body = { + "orderId": "1234", + "refund": { + "refundId": "5000009987", + "amount": "20000", + "currencyCode": "USD", + "description": "desc 1234 200", + "creationDateTime": "2020-07-02T09:19:03.896+02:00", + "status": "PENDING", + "statusDateTime": "2020-07-02T09:19:04.013+02:00", + }, + "status": { + "statusCode": "SUCCESS", + "statusDesc": "Refund queued for processing", + }, + } + refund_request_patch = self._patch_refund( + base_payu_url="http://mock.url", + order_id="1234", + access_token="test_access_token", + amount=20000, + currency_code="USD", + description="desc 1234 200", + ext_refund_id=None, + response_body=refund_request_response_body, + ) + + with refund_request_patch as refund_request_mock: + amount1 = self.provider.refund(self.payment, Decimal(200)) + amount2 = self.provider.refund(self.payment, Decimal(200)) + + payment_extra_data_refund_responses = json.loads(self.payment.extra_data)[ + "refund_responses" + ] + self.assertEqual(refund_request_mock.call_count, 2) + self.assertEqual(amount2, amount1) + self.assertEqual(amount2, Decimal(200)) + self.assertEqual(self.payment.total, Decimal(220)) + self.assertEqual(self.payment.captured_amount, Decimal(220)) + self.assertEqual(self.payment.status, PaymentStatus.CONFIRMED) + self.assertEqual( + payment_extra_data_refund_responses, + [refund_request_response_body, refund_request_response_body], + ) + + def test_refund_finalized(self): + self.set_up_provider( + True, + True, + get_refund_description=lambda payment, amount: f"desc {payment.transaction_id} {amount}", + get_refund_ext_id=lambda payment, amount: f"ext {payment.transaction_id} {amount}", + ) + self.payment.transaction_id = "1234" + self.payment.captured_amount = self.payment.total + self.payment.change_status(PaymentStatus.CONFIRMED) + self.payment.save() + refund_request_response_body = { + "orderId": "1234", + "refund": { + "refundId": "5000009987", + "extRefundId": "ext 1234 110", + "amount": "11000", + "currencyCode": "USD", + "description": "desc 1234 110", + "creationDateTime": "2020-07-02T09:19:03.896+02:00", + "status": "FINALIZED", + "statusDateTime": "2020-07-02T09:19:04.013+02:00", + }, + "status": { + "statusCode": "SUCCESS", + "statusDesc": "Refund queued for processing", + }, + } + refund_request_patch = self._patch_refund( + base_payu_url="http://mock.url", + order_id="1234", + access_token="test_access_token", + amount=11000, + currency_code="USD", + description="desc 1234 110", + ext_refund_id="ext 1234 110", + response_body=refund_request_response_body, + ) + + with refund_request_patch as refund_request_mock: + amount = self.provider.refund(self.payment, Decimal(110)) + + payment_extra_data_refund_responses = json.loads(self.payment.extra_data)[ + "refund_responses" + ] + self.assertEqual(refund_request_mock.call_count, 1) + self.assertEqual(amount, Decimal(110)) + self.assertEqual(self.payment.total, Decimal(220)) + self.assertEqual(self.payment.captured_amount, Decimal(220)) + self.assertEqual(self.payment.status, PaymentStatus.CONFIRMED) + self.assertEqual( + payment_extra_data_refund_responses, [refund_request_response_body] + ) + + def test_refund_canceled(self): + self.set_up_provider( + True, + True, + get_refund_description=lambda payment, amount: f"desc {payment.transaction_id} {amount}", + get_refund_ext_id=lambda payment, amount: f"ext {payment.transaction_id} {amount}", + ) + self.payment.transaction_id = "1234" + self.payment.captured_amount = self.payment.total + self.payment.change_status(PaymentStatus.CONFIRMED) + self.payment.save() + refund_request_response_body = { + "orderId": "1234", + "refund": { + "refundId": "5000009987", + "extRefundId": "ext 1234 110", + "amount": "11000", + "currencyCode": "USD", + "description": "desc 1234 110", + "creationDateTime": "2020-07-02T09:19:03.896+02:00", + "status": "CANCELED", + "statusDateTime": "2020-07-02T09:19:04.013+02:00", + }, + "status": { + "statusCode": "SUCCESS", + "statusDesc": "Refund queued for processing", + }, + } + refund_request_patch = self._patch_refund( + base_payu_url="http://mock.url", + order_id="1234", + access_token="test_access_token", + amount=11000, + currency_code="USD", + description="desc 1234 110", + ext_refund_id="ext 1234 110", + response_body=refund_request_response_body, + ) + + with self.assertRaisesRegex( + ValueError, "refund 5000009987 of payment 1 canceled" + ): + with refund_request_patch as refund_request_mock: + self.provider.refund(self.payment, Decimal(110)) + + payment_extra_data_refund_responses = json.loads(self.payment.extra_data)[ + "refund_responses" + ] + self.assertEqual(refund_request_mock.call_count, 1) + self.assertEqual(self.payment.total, Decimal(220)) + self.assertEqual(self.payment.captured_amount, Decimal(220)) + self.assertEqual(self.payment.status, PaymentStatus.CONFIRMED) + self.assertEqual( + payment_extra_data_refund_responses, [refund_request_response_body] + ) + + def test_refund_error(self): + self.set_up_provider( + True, + True, + get_refund_description=lambda payment, amount: f"desc {payment.transaction_id} {amount}", + get_refund_ext_id=lambda payment, amount: f"ext {payment.transaction_id} {amount}", + ) + self.payment.transaction_id = "1234" + self.payment.captured_amount = self.payment.total + self.payment.change_status(PaymentStatus.CONFIRMED) + self.payment.save() + refund_request_response_body = { + "status": { + "statusCode": "OPENPAYU_BUSINESS_ERROR", + "severity": "ERROR", + "code": "9102", + "codeLiteral": "NO_BALANCE", + "statusDesc": "Lack of funds in account", + } + } + refund_request_patch = self._patch_refund( + base_payu_url="http://mock.url", + order_id="1234", + access_token="test_access_token", + amount=11000, + currency_code="USD", + description="desc 1234 110", + ext_refund_id="ext 1234 110", + response_body=refund_request_response_body, + ) + + with self.assertRaisesRegex( + ValueError, + r"refund \?\?\? of payment 1 failed: code=9102, " + r"statusCode=OPENPAYU_BUSINESS_ERROR, " + r"codeLiteral=NO_BALANCE, " + r"statusDesc=Lack of funds in account", + ): + with refund_request_patch as refund_request_mock: + self.provider.refund(self.payment, Decimal(110)) + + payment_extra_data_refund_responses = json.loads(self.payment.extra_data)[ + "refund_responses" + ] + self.assertEqual(refund_request_mock.call_count, 1) + self.assertEqual(self.payment.total, Decimal(220)) + self.assertEqual(self.payment.captured_amount, Decimal(220)) + self.assertEqual(self.payment.status, PaymentStatus.CONFIRMED) + self.assertEqual( + payment_extra_data_refund_responses, [refund_request_response_body] + ) + + @contextlib.contextmanager + def _patch_refund( + self, + base_payu_url, + order_id, + access_token, + currency_code, + description, + response_body, + amount=None, + ext_refund_id=None, + ): + requests_post_patch = patch( + "requests.post", + return_value=MagicMock(status_code=200, text=json.dumps(response_body)), + ) + with requests_post_patch as requests_post_mock: + yield requests_post_mock + for requests_post_mock_call in requests_post_mock.call_args_list: + requests_post_mock_call_data_actual_json = ( + requests_post_mock_call.kwargs.pop("data") + ) + self.assertEqual( + requests_post_mock_call.args, + (f"{base_payu_url}/api/v2_1/orders/{order_id}/refunds",), + ) + self.assertEqual( + requests_post_mock_call.kwargs, + { + "headers": { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + }, + ) + requests_post_mock_call_data_expected = { + "refund": { + "currencyCode": currency_code, + "description": description, + } + } + if amount is not None: + requests_post_mock_call_data_expected["refund"]["amount"] = amount + if ext_refund_id is not None: + requests_post_mock_call_data_expected["refund"][ + "extRefundId" + ] = ext_refund_id + self.assertEqual( + json.loads(requests_post_mock_call_data_actual_json), + requests_post_mock_call_data_expected, + ) From e6e66d5a626fbd5db5cc385655e88ab2ab5591a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Hol=C3=BD?= Date: Wed, 13 Mar 2024 14:07:02 +0100 Subject: [PATCH 4/5] Apply black's "fixes" --- payments_payu/provider.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/payments_payu/provider.py b/payments_payu/provider.py index f7396ed..da465df 100644 --- a/payments_payu/provider.py +++ b/payments_payu/provider.py @@ -163,9 +163,11 @@ def __init__(self, *args, **kwargs): self.payu_sandbox = kwargs.pop("sandbox", False) self.payu_base_url = kwargs.pop( "base_payu_url", - "https://secure.snd.payu.com/" - if self.payu_sandbox - else "https://secure.payu.com/", + ( + "https://secure.snd.payu.com/" + if self.payu_sandbox + else "https://secure.payu.com/" + ), ) self.payu_auth_url = kwargs.pop( "auth_url", urljoin(self.payu_base_url, "/pl/standard/user/oauth/authorize") From d9b8740b9c7b7df863c7ade7f9ede2490ba8c5c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Hol=C3=BD?= Date: Wed, 13 Mar 2024 13:52:43 +0100 Subject: [PATCH 5/5] Fix ModuleNotFoundError: No module named 'setuptools' --- .github/workflows/main.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 94b20e7..26b0bf0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -19,16 +19,16 @@ jobs: tests: # The type of runner that the job will run on runs-on: ubuntu-latest - + strategy: matrix: DJANGO_VERSION: [ '2.2.*', '3.0.*', '3.1.*', '3.2.*', '4.0.*', '4.1.*'] python-version: ['3.7', '3.8', '3.9', '3.10'] exclude: - DJANGO_VERSION: '4.1.*' - python-version: '3.7' + python-version: '3.7' - DJANGO_VERSION: '4.0.*' - python-version: '3.7' + python-version: '3.7' - DJANGO_VERSION: '3.1.*' python-version: '3.10' - DJANGO_VERSION: '3.0.*' @@ -52,7 +52,7 @@ jobs: steps: - uses: actions/checkout@v2 - + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: @@ -92,7 +92,7 @@ jobs: - name: Install run: | - pip install flake8 isort black mypy django-stubs dj_database_url types-six types-requests types-mock + pip install setuptools flake8 isort black mypy django-stubs dj_database_url types-six types-requests types-mock python setup.py develop pip install -e . pip install -r requirements.txt