From f202141e8bf64c140d921eeb8b9e0dca227ea965 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Hol=C3=BD?= Date: Thu, 4 Apr 2024 18:35:36 +0200 Subject: [PATCH] add a use_renewal_triggered_by parameter --- AUTHORS.rst | 2 +- HISTORY.rst | 5 ++ README.rst | 28 +++++---- payments_payu/provider.py | 22 ++++++- tests/test_payu.py | 125 ++++++++++++++++++++++++++++---------- 5 files changed, 134 insertions(+), 48 deletions(-) diff --git a/AUTHORS.rst b/AUTHORS.rst index 661c530..123b311 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -10,4 +10,4 @@ Development Lead Contributors ------------ -* Radek HolĂ˝ +* BlenderKit diff --git a/HISTORY.rst b/HISTORY.rst index 7504961..206c780 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,11 @@ History ------- +Unreleased +********** +* add a use_renewal_triggered_by parameter +* deprecate False value of the use_renewal_triggered_by parameter; migrate to AbstractRecurringUserPlan.renewal_triggered_by and set use_renewal_triggered_by=True instead + 1.3.1 (2024-03-19) ****************** * Fix description on PyPI diff --git a/README.rst b/README.rst index 2033abb..796f66b 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, 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]) +.. class:: payments_payu.provider.PayuProvider(client_secret, second_key, pos_id, get_refund_description, [sandbox=False, endpoint="https://secure.payu.com/", recurring_payments=False, use_renewal_triggered_by=False, express_payments=False, widget_branding=False, get_refund_ext_id=_DEFAULT_GET_REFUND_EXT_ID]) This backend implements payments using `PayU.com `_. @@ -42,24 +42,26 @@ Example:: 'client_secret': 'peopleiseedead', 'sandbox': True, 'capture': False, + 'use_renewal_triggered_by': True, '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 - :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. + :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 + :use_renewal_triggered_by: (default: False) Pass the ``renewal_triggered_by`` argument instead of the ``automatic_renewal`` argument to ``Payment.set_renew_token()`` + :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 da465df..a3ac709 100644 --- a/payments_payu/provider.py +++ b/payments_payu/provider.py @@ -2,6 +2,7 @@ import json import logging import uuid +import warnings from decimal import ROUND_HALF_UP, Decimal from urllib.parse import urljoin @@ -157,7 +158,14 @@ class PayuApiError(Exception): class PayuProvider(BasicProvider): - def __init__(self, *args, **kwargs): + def __init__(self, *args, use_renewal_triggered_by=False, **kwargs): + # TODO: use_renewal_triggered_by=False deprecated. Remove in the next major release. + if not use_renewal_triggered_by: + warnings.warn( + "use_renewal_triggered_by=False is deprecated. Migrate to " + "AbstractRecurringUserPlan.renewal_triggered_by and set use_renewal_triggered_by=True instead.", + DeprecationWarning, + ) self.client_secret = kwargs.pop("client_secret") self.second_key = kwargs.pop("second_key") self.payu_sandbox = kwargs.pop("sandbox", False) @@ -185,6 +193,7 @@ def __init__(self, *args, **kwargs): 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.__use_renewal_triggered_by = use_renewal_triggered_by 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()) @@ -420,6 +429,15 @@ def create_order(self, payment, payment_processor, auto_renew=False): payment.transaction_id = response_dict["orderId"] if "payMethods" in response_dict: + set_renew_token_kwargs = {} + if self.__use_renewal_triggered_by: + set_renew_token_kwargs["renewal_triggered_by"] = ( + "task" if self.recurring_payments else "user" + ) + else: + set_renew_token_kwargs["automatic_renewal"] = ( + self.recurring_payments + ) payment.set_renew_token( response_dict["payMethods"]["payMethod"]["value"], card_expire_year=response_dict["payMethods"]["payMethod"]["card"][ @@ -431,7 +449,7 @@ def create_order(self, payment, payment_processor, auto_renew=False): card_masked_number=response_dict["payMethods"]["payMethod"]["card"][ "number" ], - automatic_renewal=self.recurring_payments, + **set_renew_token_kwargs, ) add_extra_data(payment, {"card_response": response_dict}) diff --git a/tests/test_payu.py b/tests/test_payu.py index d4d520c..6e6b07c 100644 --- a/tests/test_payu.py +++ b/tests/test_payu.py @@ -2,6 +2,7 @@ import contextlib import json +import warnings from decimal import Decimal from unittest import TestCase @@ -33,6 +34,8 @@ def __eq__(self, other): class Payment(Mock): + UNSET = object() + id = 1 description = "payment" currency = "USD" @@ -97,13 +100,15 @@ def set_renew_token( card_expire_year=None, card_expire_month=None, card_masked_number=None, - automatic_renewal=None, + automatic_renewal=UNSET, + renewal_triggered_by=UNSET, ): self.token = token self.card_expire_year = card_expire_year self.card_expire_month = card_expire_month self.card_masked_number = card_masked_number self.automatic_renewal = automatic_renewal + self.renewal_triggered_by = renewal_triggered_by class TestPayuProvider(TestCase): @@ -116,6 +121,7 @@ def set_up_provider( self, recurring, express, + use_renewal_triggered_by=False, get_refund_description=lambda payment, amount: "test", **kwargs, ): @@ -133,6 +139,7 @@ def set_up_provider( pos_id=POS_ID, base_payu_url="http://mock.url/", recurring_payments=recurring, + use_renewal_triggered_by=use_renewal_triggered_by, express_payments=express, get_refund_description=get_refund_description, **kwargs, @@ -140,7 +147,7 @@ def set_up_provider( def test_redirect_to_recurring_payment(self): """Test that if the payment recurrence is set, the user is redirected to renew payment form""" - self.set_up_provider(True, True) + self.set_up_provider(True, True, use_renewal_triggered_by=True) form = self.provider.get_form(payment=self.payment) self.assertEqual(form.__class__.__name__, "RenewPaymentForm") self.assertEqual(form.action, "https://example.com/process_url/token") @@ -148,7 +155,7 @@ def test_redirect_to_recurring_payment(self): self.assertEqual(self.payment.captured_amount, Decimal("0")) def test_redirect_payu(self): - self.set_up_provider(True, False) + self.set_up_provider(True, False, use_renewal_triggered_by=True) with patch("requests.post") as mocked_post: post = MagicMock() post.text = '{"redirectUri": "test_redirect_uri", "status": {"statusCode": "SUCCESS"}, "orderId": 123}' @@ -159,7 +166,45 @@ def test_redirect_payu(self): self.assertEqual(context.exception.args[0], "test_redirect_uri") def test_redirect_payu_store_token(self): - self.set_up_provider(True, False) + with warnings.catch_warnings(record=True) as caught_warnings: + warnings.simplefilter("always") + self.set_up_provider(True, False, use_renewal_triggered_by=True) + with patch("requests.post") as mocked_post: + post = MagicMock() + post.text = json.dumps( + { + "redirectUri": "test_redirect_uri", + "status": {"statusCode": "SUCCESS"}, + "orderId": 123, + "payMethods": { + "payMethod": { + "value": 1211, + "card": { + "expirationYear": 2021, + "expirationMonth": 1, + "number": "1234xxx", + }, + } + }, + } + ) + post.status_code = 200 + mocked_post.return_value = post + with self.assertRaises(RedirectNeeded) as context: + self.provider.get_form(payment=self.payment) + self.assertEqual(context.exception.args[0], "test_redirect_uri") + self.assertEqual(self.payment.token, 1211) + self.assertEqual(self.payment.card_expire_year, 2021) + self.assertEqual(self.payment.card_expire_month, 1) + self.assertEqual(self.payment.card_masked_number, "1234xxx") + self.assertEqual(self.payment.automatic_renewal, Payment.UNSET) + self.assertEqual(self.payment.renewal_triggered_by, "task") + self.assertFalse(caught_warnings) + + def test_redirect_payu_store_token_deprecated(self): + with warnings.catch_warnings(record=True) as caught_warnings: + warnings.simplefilter("always") + self.set_up_provider(True, False) with patch("requests.post") as mocked_post: post = MagicMock() post.text = json.dumps( @@ -189,9 +234,17 @@ def test_redirect_payu_store_token(self): self.assertEqual(self.payment.card_expire_month, 1) self.assertEqual(self.payment.card_masked_number, "1234xxx") self.assertEqual(self.payment.automatic_renewal, True) + self.assertEqual(self.payment.renewal_triggered_by, Payment.UNSET) + self.assertEqual(len(caught_warnings), 1) + self.assertTrue(issubclass(caught_warnings[0].category, DeprecationWarning)) + self.assertEqual( + str(caught_warnings[0].message), + "use_renewal_triggered_by=False is deprecated. Migrate to " + "AbstractRecurringUserPlan.renewal_triggered_by and set use_renewal_triggered_by=True instead.", + ) def test_redirect_payu_unknown_status(self): - self.set_up_provider(True, False) + self.set_up_provider(True, False, use_renewal_triggered_by=True) with patch("requests.post") as mocked_post: post = MagicMock() post_text = { @@ -244,7 +297,7 @@ def test_redirect_payu_unknown_status(self): ) def test_redirect_payu_bussiness_error(self): - self.set_up_provider(True, False) + self.set_up_provider(True, False, use_renewal_triggered_by=True) with patch("requests.post") as mocked_post: post = MagicMock() post_text = { @@ -261,7 +314,7 @@ def test_redirect_payu_bussiness_error(self): self.assertEqual(self.payment.fraud_status, FraudStatus.REJECT) def test_redirect_payu_duplicate_order(self): - self.set_up_provider(True, False) + self.set_up_provider(True, False, use_renewal_triggered_by=True) self.payment.status = PaymentStatus.CONFIRMED self.payment.save() with patch("requests.post") as mocked_post: @@ -282,7 +335,7 @@ def test_redirect_payu_duplicate_order(self): self.assertEqual(context.exception.args[0], "") def test_redirect_payu_no_status_code(self): - self.set_up_provider(True, False) + self.set_up_provider(True, False, use_renewal_triggered_by=True) with patch("requests.post") as mocked_post: post = MagicMock() post_text = { @@ -334,7 +387,7 @@ def test_redirect_payu_no_status_code(self): ) def test_redirect_payu_unauthorized_status(self): - self.set_up_provider(True, False) + self.set_up_provider(True, False, use_renewal_triggered_by=True) with patch("requests.post") as mocked_post: post = MagicMock() post.text = json.dumps( @@ -365,7 +418,7 @@ def test_redirect_payu_unauthorized_status(self): ) def test_get_access_token_trusted_merchant(self): - self.set_up_provider(True, False) + self.set_up_provider(True, False, use_renewal_triggered_by=True) with patch("requests.post") as mocked_post: post = MagicMock() post.text = json.dumps( @@ -396,7 +449,7 @@ def test_get_access_token_trusted_merchant(self): def test_redirect_cvv_form(self): """Test redirection to CVV form if requested by PayU""" - self.set_up_provider(True, True) + self.set_up_provider(True, True, use_renewal_triggered_by=True) with patch("requests.post") as mocked_post: post = MagicMock() post.text = json.dumps( @@ -455,7 +508,7 @@ def test_redirect_cvv_form(self): def test_showing_cvv_form(self): """Test redirection to CVV form if requested by PayU""" - self.set_up_provider(True, True) + self.set_up_provider(True, True, use_renewal_triggered_by=True) self.payment.extra_data = json.dumps({"cvv_url": "foo_url"}) with patch("requests.post") as mocked_post: post = MagicMock() @@ -483,7 +536,7 @@ def test_showing_cvv_form(self): def test_redirect_3ds_form(self): """Test redirection to 3DS page if requested by PayU""" - self.set_up_provider(True, False) + self.set_up_provider(True, False, use_renewal_triggered_by=True) with patch("requests.post") as mocked_post: post = MagicMock() post.text = json.dumps( @@ -537,7 +590,7 @@ def test_redirect_3ds_form(self): def test_payu_renew_form(self): """Test showing PayU card form""" - self.set_up_provider(True, True) + self.set_up_provider(True, True, use_renewal_triggered_by=True) transaction_id = "1234" data = MagicMock() data.return_value = { @@ -557,7 +610,7 @@ def test_payu_renew_form(self): def test_payu_widget_form(self): """Test showing PayU card widget""" - self.set_up_provider(True, True) + self.set_up_provider(True, True, use_renewal_triggered_by=True) self.payment.token = None transaction_id = "1234" data = MagicMock() @@ -582,7 +635,7 @@ def test_payu_widget_form(self): def test_process_notification(self): """Test processing PayU notification""" - self.set_up_provider(True, True) + self.set_up_provider(True, True, use_renewal_triggered_by=True) mocked_request = MagicMock() mocked_request.body = json.dumps({"order": {"status": "COMPLETED"}}).encode( "utf8" @@ -603,7 +656,7 @@ def test_process_notification(self): def test_process_notification_cancelled(self): """Test processing PayU cancelled notification""" - self.set_up_provider(True, True) + self.set_up_provider(True, True, use_renewal_triggered_by=True) self.payment.transaction_id = "123" self.payment.save() mocked_request = MagicMock() @@ -639,7 +692,7 @@ def test_process_notification_refund(self): self.payment.change_status(PaymentStatus.CONFIRMED) self.payment.save() - self.set_up_provider(True, True) + self.set_up_provider(True, True, use_renewal_triggered_by=True) mocked_request = MagicMock() mocked_request.body = json.dumps( { @@ -675,7 +728,7 @@ def test_process_notification_partial_refund(self): self.payment.save() self.payment.refresh_from_db() - self.set_up_provider(True, True) + self.set_up_provider(True, True, use_renewal_triggered_by=True) mocked_request = MagicMock() mocked_request.body = json.dumps( { @@ -706,7 +759,7 @@ def test_process_notification_partial_refund(self): def test_process_notification_refund_not_finalized(self): """Test processing PayU partial refund notification""" - self.set_up_provider(True, True) + self.set_up_provider(True, True, use_renewal_triggered_by=True) mocked_request = MagicMock() mocked_request.body = json.dumps( { @@ -729,7 +782,7 @@ def test_process_notification_refund_not_finalized(self): def test_process_notification_total_amount(self): """Test processing PayU notification if it captures correct amount""" - self.set_up_provider(True, True) + self.set_up_provider(True, True, use_renewal_triggered_by=True) mocked_request = MagicMock() mocked_request.body = json.dumps( { @@ -756,7 +809,7 @@ def test_process_notification_total_amount(self): def test_process_notification_error(self): """Test processing PayU notification with wrong signature""" - self.set_up_provider(True, True) + self.set_up_provider(True, True, use_renewal_triggered_by=True) mocked_request = MagicMock() mocked_request.body = b"{}" mocked_request.META = { @@ -774,7 +827,7 @@ def test_process_notification_error(self): def test_process_notification_error_malformed_post(self): """Test processing PayU notification with malformed POST""" - self.set_up_provider(True, True) + self.set_up_provider(True, True, use_renewal_triggered_by=True) mocked_request = MagicMock() mocked_request.body = b"{}" mocked_request.META = {"CONTENT_TYPE": "application/json"} @@ -784,7 +837,7 @@ def test_process_notification_error_malformed_post(self): def test_process_first_renew(self): """Test processing first renew""" - self.set_up_provider(True, True) + self.set_up_provider(True, True, use_renewal_triggered_by=True) self.payment.token = None with patch("requests.post") as mocked_post: post = MagicMock() @@ -843,7 +896,7 @@ def test_process_first_renew(self): def test_process_renew(self): """Test processing renew""" - self.set_up_provider(True, True) + self.set_up_provider(True, True, use_renewal_triggered_by=True) with patch("requests.post") as mocked_post: post = MagicMock() post.text = json.dumps( @@ -905,7 +958,7 @@ def test_process_renew(self): def test_process_renew_card_on_file(self): """Test processing renew""" - self.set_up_provider(True, True) + self.set_up_provider(True, True, use_renewal_triggered_by=True) self.provider.card_on_file = True with patch("requests.post") as mocked_post: post = MagicMock() @@ -968,7 +1021,7 @@ def test_process_renew_card_on_file(self): def test_auto_complete_recurring(self): """Test processing renew. The function should return 'success' string, if nothing is required from user.""" - self.set_up_provider(True, True) + self.set_up_provider(True, True, use_renewal_triggered_by=True) with patch("requests.post") as mocked_post: post = MagicMock() post.text = '{"status": {"statusCode": "SUCCESS"}, "orderId": 123}' @@ -981,7 +1034,7 @@ def test_auto_complete_recurring(self): def test_auto_complete_recurring_cvv2(self): """Test processing renew when cvv2 form is required - it should return the payment processing URL""" - self.set_up_provider(True, True) + self.set_up_provider(True, True, use_renewal_triggered_by=True) with patch("requests.post") as mocked_post: post = MagicMock() post.text = json.dumps( @@ -1000,7 +1053,7 @@ def test_auto_complete_recurring_cvv2(self): def test_delete_card_token(self): """Test delete_card_token()""" - self.set_up_provider(True, True) + self.set_up_provider(True, True, use_renewal_triggered_by=True) self.payment.transaction_id = "1234" with patch("requests.delete") as mocked_post: post = MagicMock() @@ -1019,7 +1072,7 @@ def test_delete_card_token(self): def test_get_paymethod_tokens(self): """Test delete_card_token()""" - self.set_up_provider(True, True) + self.set_up_provider(True, True, use_renewal_triggered_by=True) self.payment.transaction_id = "1234" with patch("requests.get") as mocked_post: post = MagicMock() @@ -1040,7 +1093,7 @@ def test_get_paymethod_tokens(self): def test_reject_order(self): """Test processing renew""" - self.set_up_provider(True, True) + self.set_up_provider(True, True, use_renewal_triggered_by=True) self.payment.transaction_id = "1234" with patch("requests.delete") as mocked_post: post = MagicMock() @@ -1060,7 +1113,7 @@ def test_reject_order(self): def test_reject_order_error(self): """Test processing renew""" - self.set_up_provider(True, True) + self.set_up_provider(True, True, use_renewal_triggered_by=True) self.payment.transaction_id = "1234" with patch("requests.delete") as mocked_post: post = MagicMock() @@ -1082,6 +1135,7 @@ def test_refund(self): self.set_up_provider( True, True, + use_renewal_triggered_by=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}", ) @@ -1157,6 +1211,7 @@ def test_refund_no_amount(self): self.set_up_provider( True, True, + use_renewal_triggered_by=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}", ) @@ -1211,6 +1266,7 @@ def test_refund_no_get_refund_ext_id(self): self.set_up_provider( True, True, + use_renewal_triggered_by=True, get_refund_description=lambda payment, amount: f"desc {payment.transaction_id} {amount}", ) self.payment.transaction_id = "1234" @@ -1267,6 +1323,7 @@ def test_refund_no_ext_id(self): self.set_up_provider( True, True, + use_renewal_triggered_by=True, get_refund_description=lambda payment, amount: f"desc {payment.transaction_id} {amount}", get_refund_ext_id=lambda payment, amount: None, ) @@ -1320,6 +1377,7 @@ def test_refund_no_ext_id_twice(self): self.set_up_provider( True, True, + use_renewal_triggered_by=True, get_refund_description=lambda payment, amount: f"desc {payment.transaction_id} {amount}", get_refund_ext_id=lambda payment, amount: None, ) @@ -1376,6 +1434,7 @@ def test_refund_finalized(self): self.set_up_provider( True, True, + use_renewal_triggered_by=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}", ) @@ -1430,6 +1489,7 @@ def test_refund_canceled(self): self.set_up_provider( True, True, + use_renewal_triggered_by=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}", ) @@ -1486,6 +1546,7 @@ def test_refund_error(self): self.set_up_provider( True, True, + use_renewal_triggered_by=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}", )