From af0a140a182adaae4788df95a062c8468ca73e20 Mon Sep 17 00:00:00 2001 From: Domenico DiNicola Date: Tue, 17 Sep 2024 16:39:32 +0200 Subject: [PATCH] moneygram --- src/hope_payment_gateway/apps/core/admin.py | 3 + .../apps/fsp/moneygram/client.py | 138 ++++++------ .../apps/fsp/moneygram/handlers.py | 15 ++ .../apps/fsp/moneygram/views.py | 41 ++++ .../apps/gateway/admin.py | 95 +++++--- tests/factories.py | 8 + tests/test_admin_smoke.py | 203 ++++++++++++++++++ 7 files changed, 412 insertions(+), 91 deletions(-) create mode 100644 src/hope_payment_gateway/apps/fsp/moneygram/handlers.py create mode 100644 src/hope_payment_gateway/apps/fsp/moneygram/views.py create mode 100644 tests/test_admin_smoke.py diff --git a/src/hope_payment_gateway/apps/core/admin.py b/src/hope_payment_gateway/apps/core/admin.py index a1f604b..f598fee 100644 --- a/src/hope_payment_gateway/apps/core/admin.py +++ b/src/hope_payment_gateway/apps/core/admin.py @@ -7,6 +7,9 @@ admin.site.site_header = "Payment Gateway" +admin.site.site_header = "Payment Gateway" + + @admin.register(User) class UserAdminPlus(UserAdminPlus): pass diff --git a/src/hope_payment_gateway/apps/fsp/moneygram/client.py b/src/hope_payment_gateway/apps/fsp/moneygram/client.py index b388c23..9acd0d8 100644 --- a/src/hope_payment_gateway/apps/fsp/moneygram/client.py +++ b/src/hope_payment_gateway/apps/fsp/moneygram/client.py @@ -7,11 +7,9 @@ import phonenumbers import requests from phonenumbers import NumberParseException -from urllib3.connectionpool import HTTPSConnectionPool +from requests.exceptions import ConnectionError from hope_payment_gateway.apps.core.models import Singleton -from hope_payment_gateway.apps.gateway.flows import PaymentRecordFlow -from hope_payment_gateway.apps.gateway.models import PaymentRecord logger = logging.getLogger(__name__) @@ -20,6 +18,13 @@ "WILL_CALL": "WILL_CALL", "DIRECT_TO_ACCT": "DIRECT_TO_ACCT", "BANK_DEPOSIT": "DIRECT_TO_ACCT", + "WILLCALL_TO": "WILLCALL_TO", + "2_HOUR": "2_HOUR", + "OVERNIGHT": "OVERNIGHT", + "OVERNIGHT2ANY": "OVERNIGHT2ANY", + "24_HOUR": "24_HOUR", + "CARD_DEPOSIT": "CARD_DEPOSIT", + "HOME_DELIVERY": "HOME_DELIVERY", } @@ -27,15 +32,18 @@ class PayloadMissingKey(Exception): pass +class InvalidToken(Exception): + pass + + class MoneyGramClient(metaclass=Singleton): token = "" expires_in = None - token_response = None def __init__(self): - self.get_token() + self.set_token() - def get_token(self): + def set_token(self): url = settings.MONEYGRAM_HOST + "/oauth/accesstoken?grant_type=client_credentials" credentials = f"{settings.MONEYGRAM_CLIENT_ID}:{settings.MONEYGRAM_CLIENT_SECRET}" encoded_credentials = base64.b64encode(credentials.encode("utf-8")).decode("utf-8") @@ -43,21 +51,36 @@ def get_token(self): try: response = requests.get(url, headers=headers) - except HTTPSConnectionPool: + parsed_response = json.loads(response.text) + except ConnectionError: self.token = None - self.token_response = None else: if response.status_code == 200: - parsed_response = json.loads(response.text) self.token = parsed_response["access_token"] self.expires_in = parsed_response["expires_in"] else: logger.warning("Invalid token") self.token = None - self.token_response = response + error = parsed_response["error"] + raise InvalidToken(f"{error['category']}: {error['message']} [{error['code']}]") - def prepare_transaction(self, hope_payload): + def get_headers(self, request_id): + return { + "Content-Type": "application/json", + "X-MG-ClientRequestId": request_id, + "content-type": "application/json", + "Authorization": "Bearer " + self.token, + } + @staticmethod + def get_basic_payload(): + return { + "targetAudience": "AGENT_FACING", + "agentPartnerId": settings.MONEYGRAM_PARTNER_ID, + "userLanguage": "en-US", + } + + def prepare_transaction(self, hope_payload): raw_phone_no = hope_payload.get("phone_no", "N/A") try: phone_no = phonenumbers.parse(raw_phone_no, None) @@ -77,8 +100,8 @@ def prepare_transaction(self, hope_payload): ]: if not (key in hope_payload.keys() and hope_payload[key]): raise PayloadMissingKey("InvalidPayload: {} is missing in the payload".format(key)) - - return { + transaction_id = hope_payload["payment_record_code"] + payload = { "targetAudience": "AGENT_FACING", "agentPartnerId": settings.MONEYGRAM_PARTNER_ID, "userLanguage": "en-US", @@ -124,58 +147,53 @@ def prepare_transaction(self, hope_payload): } }, } + return transaction_id, payload def create_transaction(self, hope_payload): - if self.token: + endpoint = "/disbursement/v1/transactions" + transaction_id, payload = self.prepare_transaction(hope_payload) + return self.perform_request(endpoint, transaction_id, payload) - url = settings.MONEYGRAM_HOST + "/disbursement/v1/transactions" - payload = self.prepare_transaction(hope_payload) - headers = { - "Content-Type": "application/json", - "X-MG-ClientRequestId": hope_payload["payment_record_code"], - "Authorization": "Bearer " + self.token, - } - - response = self.perform_request(url, headers, payload) - self.transaction_callback(hope_payload, response.json()) - return response - - else: - return self.token_response - - def perform_request(self, url, headers, payload=None): - try: - response = requests.post(url, json=payload, headers=headers) - - if response.status_code == 200: - parsed_response = json.dumps(json.loads(response.text), indent=2) - print(parsed_response) - else: - print("Request failed with status code:", response.status_code) - print(json.dumps(json.loads(response.text), indent=2)) + def prepare_quote(self, hope_payload: dict): - except (requests.exceptions.RequestException, requests.exceptions.MissingSchema) as e: - print("An error occurred:", e) - response = dict - - return response - - def transaction_callback(self, hope_payload, response): - record_code = hope_payload["payment_record_code"] - pr = PaymentRecord.objects.get(record_code=record_code) - pr.fsp_code = response["referenceNumber"] - pr.success = True - pr.payout_amount = response["receiveAmount"]["amount"]["value"] - pr.extra_data.update( + transaction_id = hope_payload["payment_record_code"] + payload = self.get_basic_payload() + payload.update( { - "fee": response["receiveAmount"]["fees"]["value"], - "fee_currency": response["receiveAmount"]["fees"]["currencyCode"], - "taxes": response["receiveAmount"]["taxes"]["value"], - "taxes_currency": response["receiveAmount"]["taxes"]["currencyCode"], - "expectedPayoutDate": response["expectedPayoutDate"], - "transactionId": response["transactionId"], + "destinationCountryCode": hope_payload["destination_country"], + "serviceOptionCode": hope_payload.get("delivery_services_code", None), + "beneficiaryTypeCode": "Consumer", + "sendAmount": {"currencyCode": hope_payload["origination_currency"], "value": hope_payload["amount"]}, } ) - flow = PaymentRecordFlow(pr) - flow.store() + return transaction_id, payload + + def quote(self, hope_payload): + + endpoint = "/disbursement/v1/transactions/quote" + transaction_id, payload = self.prepare_quote(hope_payload) + return self.perform_request(endpoint, transaction_id, payload) + + def perform_request(self, endpoint, transaction_id, payload): + url = settings.MONEYGRAM_HOST + endpoint + headers = self.get_headers(transaction_id) + for _ in range(2): + try: + response = requests.post(url, json=payload, headers=headers) + break + except (requests.exceptions.RequestException, requests.exceptions.MissingSchema) as e: + print("An error occurred:", e) + response = dict + break + except Exception as e: + print("Token Expired:", e) + self.set_token() + + if response.status_code == 200: + parsed_response = json.dumps(json.loads(response.text), indent=2) + print(parsed_response) + else: + print("Request failed with status code:", response.status_code) + print(json.dumps(json.loads(response.text), indent=2)) + return response diff --git a/src/hope_payment_gateway/apps/fsp/moneygram/handlers.py b/src/hope_payment_gateway/apps/fsp/moneygram/handlers.py new file mode 100644 index 0000000..ff28dc1 --- /dev/null +++ b/src/hope_payment_gateway/apps/fsp/moneygram/handlers.py @@ -0,0 +1,15 @@ +from hope_payment_gateway.apps.gateway.models import FinancialServiceProvider, FinancialServiceProviderConfig +from hope_payment_gateway.apps.gateway.registry import FSPProcessor + + +class WesternUnionHandler(FSPProcessor): + + def get_configuration(self, config_key, delivery_mechanism): + wu = FinancialServiceProvider.objects.get(vision_vendor_number="1900723202") + try: + config = FinancialServiceProviderConfig.objects.get( + key=config_key, fsp=wu, delivery_mechanism__code=delivery_mechanism + ).configuration + except FinancialServiceProviderConfig.DoesNotExist: + config = wu.configuration + return config diff --git a/src/hope_payment_gateway/apps/fsp/moneygram/views.py b/src/hope_payment_gateway/apps/fsp/moneygram/views.py new file mode 100644 index 0000000..05b155e --- /dev/null +++ b/src/hope_payment_gateway/apps/fsp/moneygram/views.py @@ -0,0 +1,41 @@ +from rest_framework.response import Response +from rest_framework.status import HTTP_400_BAD_REQUEST +from viewflow.fsm import TransitionNotAllowed + +from hope_payment_gateway.apps.fsp.moneygram.client import MoneyGramClient +from hope_payment_gateway.apps.gateway.flows import PaymentRecordFlow +from hope_payment_gateway.apps.gateway.models import PaymentRecord + + +def quote_transaction(payload): + client = MoneyGramClient() + response = client.quote(payload) + return response + + +def create_transaction(payload): + client = MoneyGramClient() + response = client.create_transaction(payload) + if response: + body = response.json() + record_code = payload["payment_record_code"] + pr = PaymentRecord.objects.get(record_code=record_code) + pr.fsp_code = body["referenceNumber"] + pr.success = True + pr.payout_amount = body["receiveAmount"]["amount"]["value"] + pr.extra_data.update( + { + "fee": body["receiveAmount"]["fees"]["value"], + "fee_currency": body["receiveAmount"]["fees"]["currencyCode"], + "taxes": body["receiveAmount"]["taxes"]["value"], + "taxes_currency": body["receiveAmount"]["taxes"]["currencyCode"], + "expectedPayoutDate": body["expectedPayoutDate"], + "transactionId": body["transactionId"], + } + ) + try: + flow = PaymentRecordFlow(pr) + flow.store() + except TransitionNotAllowed as e: + response = Response({"transition_not_allowed": str(e)}, status=HTTP_400_BAD_REQUEST) + return response diff --git a/src/hope_payment_gateway/apps/gateway/admin.py b/src/hope_payment_gateway/apps/gateway/admin.py index fb9c074..68b68b1 100644 --- a/src/hope_payment_gateway/apps/gateway/admin.py +++ b/src/hope_payment_gateway/apps/gateway/admin.py @@ -16,7 +16,8 @@ from adminfilters.autocomplete import AutoCompleteFilter from adminfilters.mixin import AdminFiltersMixin -from hope_payment_gateway.apps.fsp.moneygram.client import MoneyGramClient, PayloadMissingKey +from hope_payment_gateway.apps.fsp.moneygram.client import InvalidToken, MoneyGramClient, PayloadMissingKey +from hope_payment_gateway.apps.fsp.moneygram.views import create_transaction, quote_transaction from hope_payment_gateway.apps.fsp.western_union.endpoints.cancel import cancel, search_request from hope_payment_gateway.apps.fsp.western_union.endpoints.client import WesternUnionClient from hope_payment_gateway.apps.fsp.western_union.endpoints.send_money import ( @@ -93,9 +94,10 @@ def wu_prepare_payload(self, request, pk) -> TemplateResponse: payload = create_validation_payload(payload) client = WesternUnionClient("SendMoneyValidation_Service_H2HService.wsdl") _, data = client.prepare("sendmoneyValidation", payload) + context["title"] = "Western Union Payload" context["content"] = data - return TemplateResponse(request, "western_union.html", context) + return TemplateResponse(request, "request.html", context) except (PayloadException, InvalidCorridor, PayloadMissingKey) as e: messages.add_message(request, messages.ERROR, str(e)) @@ -110,7 +112,7 @@ def wu_send_money_validation(self, request, pk) -> TemplateResponse: try: payload = create_validation_payload(payload) context.update(send_money_validation(payload)) - return TemplateResponse(request, "western_union.html", context) + return TemplateResponse(request, "request.html", context) except (PayloadException, InvalidCorridor) as e: messages.add_message(request, messages.ERROR, str(e)) return obj @@ -133,7 +135,7 @@ def wu_search_request(self, request, pk) -> TemplateResponse: context["msg"] = f"Search request through MTCN \n" f"PARAM: mtcn {mtcn}" frm = obj.extra_data.get("foreign_remote_system", None) context.update(search_request(frm, mtcn)) - return TemplateResponse(request, "western_union.html", context) + return TemplateResponse(request, "request.html", context) messages.warning(request, "Missing MTCN") @view(html_attrs={"style": "background-color:#88FF88;color:black"}, label="Cancel") @@ -153,48 +155,44 @@ def mg_prepare_payload(self, request, pk) -> TemplateResponse: try: client = MoneyGramClient() context["title"] = "Moneygram Payload" + context["format"] = "json" context["content"] = client.prepare_transaction(obj.get_payload()) - return TemplateResponse(request, "western_union.html", context) + return TemplateResponse(request, "request.html", context) except (PayloadException, InvalidCorridor, PayloadMissingKey) as e: messages.add_message(request, messages.ERROR, str(e)) return obj + except InvalidToken as e: + logger.error(e) + self.message_user(request, str(e), messages.ERROR) @view(html_attrs={"style": "background-color:#88FF88;color:black"}, label="Create Transaction") def mg_create_transaction(self, request, pk) -> TemplateResponse: - obj = PaymentRecord.objects.get(pk=pk) - - client = MoneyGramClient() - resp = client.create_transaction(obj.get_payload()) - data = resp.json() - msgs = [] - if resp.status_code == 200: - loglevel = messages.SUCCESS - elif 400 <= resp.status_code < 500: - loglevel = messages.WARNING - if "errors" in resp.json(): - for error in data["errors"]: - msgs.append(f"{error['message']} ({error['code']})") - elif "error" in resp.json(): - msgs.append(resp.json()["error"]["message"]) - else: - msgs = [ - "Error", - ] - else: - loglevel = messages.ERROR - for error in data["errors"]: - msgs.append(f"{error['message']} ({error['code']})") - - for msg in msgs: - messages.add_message(request, loglevel, msg) + obj = PaymentRecord.objects.get(pk=pk) + try: + resp = create_transaction(obj.get_payload()) + return self.handle_mg_response(request, resp, pk) + except InvalidToken as e: + logger.error(e) + self.message_user(request, str(e), messages.ERROR) + + @view(html_attrs={"style": "background-color:#88FF88;color:black"}, label="Quote") + def mg_quote_transaction(self, request, pk) -> TemplateResponse: + obj = PaymentRecord.objects.get(pk=pk) + try: + resp = quote_transaction(obj.get_payload()) + return self.handle_mg_response(request, resp, pk) + except InvalidToken as e: + logger.error(e) + self.message_user(request, str(e), messages.ERROR) @choice(change_list=False) def moneygram(self, button): button.choices = [ self.mg_prepare_payload, self.mg_create_transaction, + self.mg_quote_transaction, ] return button @@ -208,6 +206,41 @@ def instruction(self, button: button) -> Optional[str]: button.visible = False return None + def handle_mg_response(self, request, resp, pk): + if resp: + data = resp.json() + msgs = [] + if resp.status_code == 200: + context = self.get_common_context(request, pk) + context["title"] = "Moneygram Quote" + context["format"] = "json" + context["content"] = resp.json() + return TemplateResponse(request, "request.html", context) + + elif 400 <= resp.status_code < 500: + loglevel = messages.WARNING + if "errors" in data: + for error in data["errors"]: + msgs.append(f"{error['message']} ({error['code']})") + if "offendingFields" in error: + for field in error["offendingFields"]: + msgs.append(f"Field: {field['field']}") + elif "error" in data: + msgs.append(data["error"]["message"]) + else: + msgs = [ + "Error", + ] + else: + loglevel = messages.ERROR + for error in data["errors"]: + msgs.append(f"{error['message']} ({error['code']})") + + for msg in msgs: + messages.add_message(request, loglevel, msg) + else: + messages.add_message(request, messages.ERROR, "Connection Error") + @admin.register(PaymentInstruction) class PaymentInstructionAdmin(ExtraButtonsMixin, admin.ModelAdmin): diff --git a/tests/factories.py b/tests/factories.py index ba3b2f6..fee8e2f 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -41,6 +41,14 @@ class AdminFactory(UserFactory): is_superuser = True +class SuperUserFactory(UserFactory): + username = factory.Sequence(lambda n: "superuser%03d@example.com" % n) + email = factory.Sequence(lambda n: "superuser%03d@example.com" % n) + is_superuser = True + is_staff = True + is_active = True + + class AnonUserFactory(UserFactory): username = "anonymous" diff --git a/tests/test_admin_smoke.py b/tests/test_admin_smoke.py new file mode 100644 index 0000000..80ad4d1 --- /dev/null +++ b/tests/test_admin_smoke.py @@ -0,0 +1,203 @@ +# from unittest.mock import Mock +# +# from django.contrib.admin.sites import site +# from django.contrib.admin.templatetags.admin_urls import admin_urlname +# from django.db.models.options import Options +# from django.urls import reverse +# +# import pytest +# from admin_extra_buttons.handlers import ChoiceHandler +# from django_regex.utils import RegexList as _RegexList +# +# from factories import SuperUserFactory +# +# pytestmark = [pytest.mark.admin, pytest.mark.smoke, pytest.mark.django_db] +# +# +# class RegexList(_RegexList): +# def extend(self, __iterable) -> None: +# for e in __iterable: +# self.append(e) +# +# +# GLOBAL_EXCLUDED_MODELS = RegexList( +# [ +# r"django_celery_beat\.ClockedSchedule", +# r"contenttypes\.ContentType", +# r"faces\.DummyModel", +# "authtoken", +# "social_django", +# "depot", +# ] +# ) +# +# GLOBAL_EXCLUDED_BUTTONS = RegexList( +# [ +# r"social.SocialProviderAdmin:test", +# ] +# ) +# +# KWARGS = {} +# +# +# def log_submit_error(res): +# try: +# return f"Submit failed with: {repr(res.context['form'].errors)}" +# except KeyError: +# return "Submit failed" +# +# +# def pytest_generate_tests(metafunc): +# import django +# +# markers = metafunc.definition.own_markers +# excluded_models = RegexList(GLOBAL_EXCLUDED_MODELS) +# excluded_buttons = RegexList(GLOBAL_EXCLUDED_BUTTONS) +# if "skip_models" in [m.name for m in markers]: +# skip_rule = list(filter(lambda m: m.name == "skip_models", markers))[0] +# excluded_models.extend(skip_rule.args) +# if "skip_buttons" in [m.name for m in markers]: +# skip_rule = list(filter(lambda m: m.name == "skip_buttons", markers))[0] +# excluded_buttons.extend(skip_rule.args) +# django.setup() +# if "button_handler" in metafunc.fixturenames: +# m = [] +# ids = [] +# for model, admin in site._registry.items(): +# if hasattr(admin, "get_changelist_buttons"): +# name = model._meta.object_name +# assert admin.urls # we need to force this call +# # admin.get_urls() # we need to force this call +# buttons = admin.extra_button_handlers.values() +# full_name = f"{model._meta.app_label}.{name}" +# admin_name = f"{model._meta.app_label}.{admin.__class__.__name__}" +# if not (full_name in excluded_models): +# for btn in buttons: +# tid = f"{admin_name}:{btn.name}" +# if tid not in excluded_buttons: +# m.append([admin, btn]) +# ids.append(tid) +# metafunc.parametrize("modeladmin,button_handler", m, ids=ids) +# elif "modeladmin" in metafunc.fixturenames: +# m = [] +# ids = [] +# for model, admin in site._registry.items(): +# name = model._meta.object_name +# full_name = f"{model._meta.app_label}.{name}" +# if not (full_name in excluded_models): +# m.append(admin) +# ids.append(f"{admin.__class__.__name__}:{full_name}") +# metafunc.parametrize("modeladmin", m, ids=ids) +# +# +# @pytest.fixture() +# def record(db, request): +# from testutils.factories import get_factory_for_model +# +# modeladmin = request.getfixturevalue("modeladmin") +# instance = modeladmin.model.objects.first() +# if not instance: +# full_name = ( +# f"{modeladmin.model._meta.app_label}.{modeladmin.model._meta.object_name}" +# ) +# factory = get_factory_for_model(modeladmin.model) +# try: +# instance = factory(**KWARGS.get(full_name, {})) +# except Exception as e: +# raise Exception( +# f"Error creating fixture for {factory} using {KWARGS}" +# ) from e +# return instance +# +# +# @pytest.fixture() +# def app(django_app_factory, mocked_responses): +# +# django_app = django_app_factory(csrf_checks=False) +# admin_user = SuperUserFactory(username="superuser") +# django_app.set_user(admin_user) +# django_app._user = admin_user +# return django_app +# +# +# def test_admin_index(app): +# url = reverse("admin:index") +# +# res = app.get(url) +# assert res.status_code == 200 +# +# +# @pytest.mark.skip_models( +# "constance.Config", +# ) +# def test_admin_changelist(app, modeladmin, record): +# url = reverse(admin_urlname(modeladmin.model._meta, "changelist")) +# opts: Options = modeladmin.model._meta +# res = app.get(url) +# assert res.status_code == 200, res.location +# assert str(opts.app_config.verbose_name) in str(res.content) +# if modeladmin.has_change_permission(Mock(user=app._user)): +# assert f"/{record.pk}/change/" in res.body.decode() +# +# +# def show_error(res): +# errors = [] +# for k, v in dict(res.context["adminform"].form.errors).items(): +# errors.append(f'{k}: {"".join(v)}') +# return (f"Form submitting failed: {res.status_code}: {errors}",) +# +# +# @pytest.mark.skip_models("constance.Config", "advanced_filters.AdvancedFilter") +# def test_admin_changeform(app, modeladmin, record): +# opts: Options = modeladmin.model._meta +# url = reverse(admin_urlname(opts, "change"), args=[record.pk]) +# +# res = app.get(url) +# assert str(opts.app_config.verbose_name) in res.body.decode() +# if modeladmin.has_change_permission(Mock(user=app._user)): +# res = res.forms[1].submit() +# assert res.status_code in [302, 200] +# +# +# @pytest.mark.skip_models("constance.Config") +# def test_admin_add(app, modeladmin): +# url = reverse(admin_urlname(modeladmin.model._meta, "add")) +# if modeladmin.has_add_permission(Mock(user=app._user)): +# res = app.get(url) +# res = res.forms[1].submit() +# assert res.status_code in [200, 302], log_submit_error(res) +# else: +# pytest.skip("No 'add' permission") +# +# +# @pytest.mark.skip_models( +# "constance.Config", +# "hope", +# ) +# def test_admin_delete(app, modeladmin, record, monkeypatch): +# url = reverse(admin_urlname(modeladmin.model._meta, "delete"), args=[record.pk]) +# if modeladmin.has_delete_permission(Mock(user=app._user)): +# res = app.get(url) +# res.forms[1].submit() +# assert res.status_code in [200, 302] +# else: +# pytest.skip("No 'delete' permission") +# +# +# @pytest.mark.skip_buttons("security.UserAdmin:link_user_data") +# def test_admin_buttons(app, modeladmin, button_handler, record, monkeypatch): +# from admin_extra_buttons.handlers import LinkHandler +# +# if isinstance(button_handler, ChoiceHandler): +# pass +# elif isinstance(button_handler, LinkHandler): +# btn = button_handler.get_button({"original": record}) +# button_handler.func(None, btn) +# else: +# if len(button_handler.sig.parameters) == 2: +# url = reverse(f"admin:{button_handler.url_name}") +# else: +# url = reverse(f"admin:{button_handler.url_name}", args=[record.pk]) +# +# res = app.get(url) +# assert res.status_code in [200, 302]