From 6cd431ef960f3d1d6c70df9f5a20bba6de24b9ca Mon Sep 17 00:00:00 2001 From: Shadi Naif Date: Tue, 23 Apr 2024 18:22:28 +0300 Subject: [PATCH] feat: Implement PayFort Payment Processor --- .github/workflows/ci.yml | 4 +- Makefile | 2 +- ecommerce_payfort/processors.py | 226 ++++++++++++++- .../templates/payment/payfort.html | 43 --- .../templates/payment/payment_form.html | 37 +++ .../payment/payment_handle_format_error.html | 23 ++ .../payment_handle_internal_error.html | 21 ++ .../payment/payment_wait_feedback.html | 66 +++++ .../tests/processors/test_payfort.py | 22 +- ecommerce_payfort/urls.py | 31 +- ecommerce_payfort/views.py | 267 ++++++++++++++++-- scripts/tox_install_ecommerce_run_pytest.sh | 5 +- settings/payfort.py | 25 ++ setup.py | 13 +- tox.ini | 2 +- tutor_plugin/ecommerce-config.yml | 10 +- 16 files changed, 688 insertions(+), 109 deletions(-) delete mode 100644 ecommerce_payfort/templates/payment/payfort.html create mode 100644 ecommerce_payfort/templates/payment/payment_form.html create mode 100644 ecommerce_payfort/templates/payment/payment_handle_format_error.html create mode 100644 ecommerce_payfort/templates/payment/payment_handle_internal_error.html create mode 100644 ecommerce_payfort/templates/payment/payment_wait_feedback.html diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 610c720..854605e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,8 +2,8 @@ name: ci on: push: - branches: [main] - pull_request: + branches: [xmain] # disabled for now +# pull_request: disabled for now jobs: build: diff --git a/Makefile b/Makefile index 643a748..f830a48 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ tests: ## Run unit and integration tests tox -e py38 unit_tests: ## Run unit tests - tox -e py38 -- tests/unit + tox -e py38 -- $(shell pwd)/ecommerce_payfort/tests/unit quality: ## Run code quality checks tox -e flake8 diff --git a/ecommerce_payfort/processors.py b/ecommerce_payfort/processors.py index 5fd8b3e..1b6b5ef 100644 --- a/ecommerce_payfort/processors.py +++ b/ecommerce_payfort/processors.py @@ -2,27 +2,38 @@ PayFort payment processor. """ +import hashlib import logging +import re +from urllib.parse import urljoin +from django.middleware.csrf import get_token +from django.urls import reverse from django.utils.translation import ugettext_lazy as _ +from ecommerce.extensions.payment.processors import BasePaymentProcessor, HandledProcessorResponse from oscar.apps.payment.exceptions import GatewayError -from ecommerce.extensions.payment.processors import BasePaymentProcessor - logger = logging.getLogger(__name__) -def format_price(price): - """ - Return the price in the expected format. - """ - return '{:0.2f}'.format(price) +def sanitize_text(text_to_sanitize, valid_pattern, max_length=None, replacement="_"): + """Sanitize the text by replacing invalid characters with the replacement character.""" + sanitized = re.sub(valid_pattern, replacement, text_to_sanitize) + if max_length is None: + return sanitized + + if len(sanitized) > max_length and re.fullmatch(valid_pattern, '.') is not None: + return sanitized[:max_length - 3] + "..." + return sanitized[:max_length] class PayFortException(GatewayError): - """ - An umbrella exception to catch all errors from PayFort. - """ + """PayFort exception.""" + pass # pylint: disable=unnecessary-pass + + +class PayFortBadSignatureException(PayFortException): + """PayFort bad signature exception.""" pass # pylint: disable=unnecessary-pass @@ -34,8 +45,201 @@ class PayFort(BasePaymentProcessor): Scroll through the page, it's a very long single-page documentation. """ - NAME = 'payfort' + NAME = "payfort" CHECKOUT_TEXT = _("Checkout with credit card") + PAYMENT_MODE = "CARD" + SUPPORTED_SHA_METHODS = { + "SHA-256": hashlib.sha256, + "SHA-512": hashlib.sha512, + } + VALID_PATTERNS = { + "order_description": r"[^A-Za-z0-9 /.'#:_\$-]", + "customer_name": r"[^A-Za-z0-9 ]", + } + SUCCESS_STATUS = "14" def __init__(self, site): + """Initialize the PayFort processor.""" + super(PayFort, self).__init__(site) self.site = site + configuration = self.configuration + + self.ACCESS_CODE = configuration.get("access_code") + self.MERCHANT_IDENTIFIER = configuration.get("merchant_identifier") + self.REQUESST_SHA_PHRASE = configuration.get("request_sha_phrase") + self.RESPONSE_SHA_PHRASE = configuration.get("response_sha_phrase") + self.SHA_METHOD = configuration.get("sha_method") + self.ECOMMERCE_URL_ROOT = configuration.get("ecommerce_url_root") + + self.MAX_ORDER_DESCRIPTION_LENGTH = 150 + + def _get_signature(self, transaction_parameters, response_sha=False): + """Return the signature for the given transaction parameters.""" + sorted_keys = sorted(transaction_parameters, key=lambda arg: arg.lower()) + sorted_dict = {key: transaction_parameters[key] for key in sorted_keys} + + result_string = ( + f"{self.RESPONSE_SHA_PHRASE if response_sha else self.REQUESST_SHA_PHRASE}" + f"{''.join(f'{key}={value}' for key, value in sorted_dict.items())}" + f"{self.RESPONSE_SHA_PHRASE if response_sha else self.REQUESST_SHA_PHRASE}" + ) + + return self.SUPPORTED_SHA_METHODS[self.SHA_METHOD](result_string.encode()).hexdigest() + + def _get_merchant_reference(self, basket): + """Return the merchant reference for the given basket.""" + return f"{self.site.id}-{basket.owner_id}-{basket.id}" + + def _get_amount(self, basket): + """Return the amount for the given basket.""" + return int(round(basket.total_incl_tax * 100, 0)) + + def _get_customer_email(self, basket): + """Return the customer email for the given basket.""" + return basket.owner.email + + def _get_order_description(self, basket): + """Return the order description for the given basket.""" + description = "" + for index, line in enumerate(basket.all_lines()): + description += f"{line.quantity} X {line.product.title or '-'}" + if index < len(basket.all_lines()) - 1: + description += "; " + + return sanitize_text( + description, + self.VALID_PATTERNS["order_description"], + max_length=self.MAX_ORDER_DESCRIPTION_LENGTH + ) + + def _get_customer_name(self, basket): + """Return the customer name for the given basket.""" + return sanitize_text( + basket.owner.get_full_name() or "Name not set", + self.VALID_PATTERNS["customer_name"], + max_length=50, + ) + + def _get_merchant_extra(self, basket): + """Return the merchant extra for the given basket.""" + return f"{self.site.id}-{basket.owner_id}-{basket.id}" + + def _get_currency(self, basket): + """Return the currency for the given basket.""" + for index, line in enumerate(basket.all_lines()): + if line.price_currency: + raise Exception(f"Currency not supported ({line.price_currency})") + + return "SAR" + + def get_transaction_parameters(self, basket, request=None, use_client_side_checkout=False, **kwargs): + """Return the transaction parameters needed for this processor.""" + + transaction_parameters = { + "command": "PURCHASE", + "access_code": self.ACCESS_CODE, + "merchant_identifier": self.MERCHANT_IDENTIFIER, + "language": request.LANGUAGE_CODE.split("-")[0], + "merchant_reference": self._get_merchant_reference(basket), + "amount": self._get_amount(basket), + "currency": "SAR", + "customer_email": self._get_customer_email(basket), + "order_description": self._get_order_description(basket), + "customer_name": self._get_customer_name(basket), + "return_url": urljoin( + self.ECOMMERCE_URL_ROOT, + reverse("payfort:response") + ), + } + signature = self._get_signature(transaction_parameters) + transaction_parameters.update({ + "signature": signature, + "payment_page_url": reverse("payfort:form"), + "csrfmiddlewaretoken": get_token(request), + }) + return transaction_parameters + + def verify_response_signature(self, response_data): + """Verify the response signature.""" + data = response_data.copy() + signature = data.pop("signature") + if signature is None: + raise PayFortBadSignatureException("Signature not found in the response") + + expected_signature = self._get_signature(data, response_sha=True) + if signature != expected_signature: + raise PayFortBadSignatureException( + f"Response signature mismatch. merchant_reference: {data.get('merchant_reference', 'none')}" + ) + + @staticmethod + def get_transaction_id(response_data): + """Return the transaction ID from the response data.""" + return f"{response_data.get('eci', 'none')}-{response_data.get('fort_id', 'none')}" + + @staticmethod + def verify_response_format(response_data): + """Verify the format of the response from PayFort.""" + minimum_mandatory_fields = [ + "merchant_reference", + "command", + "merchant_identifier", + "amount", + "currency", + "response_code", + "signature", + "status", + ] + for field in minimum_mandatory_fields: + if field not in response_data: + raise PayFortException(f"Missing field in response: {field}") + if not isinstance(response_data[field], str): + raise PayFortException(( + f"Invalid field type in response: {field}. " + f"Should be , but got <{type(response_data[field])}>" + )) + + try: + amount = int(response_data["amount"]) + except ValueError: + raise PayFortException("Invalid amount in response (not an integer): {response_data['amount']}") + + if amount <= 0: + raise PayFortException(f"Invalid amount in response: {response_data['amount']}") + + if response_data["currency"] != "SAR": + raise PayFortException(f"Invalid currency in response: {response_data['currency']}") + + if response_data["command"] != "PURCHASE": + raise PayFortException(f"Invalid command in response: {response_data['command']}") + + if re.fullmatch(r"\d+-\d+-\d+", response_data["merchant_reference"]) is None: + raise PayFortException(f"Invalid merchant_reference in response: {response_data['merchant_reference']}") + + if ( + (response_data.get("eci") is None or response_data.get("fort_id") is None) and + response_data['status'] == PayFort.SUCCESS_STATUS + ): + raise PayFortException( + f"Unexpected successful payment that lacks eci or fort_id: {response_data['merchant_reference']}" + ) + + def handle_processor_response(self, response, basket=None): + """Handle the payment processor response and record the relevant details.""" + currency = response["currency"] + total = int(response["amount"]) / 100 + transaction_id = self.get_transaction_id(response) + card_number = response.get("card_number") + card_type = response.get("payment_option") + + return HandledProcessorResponse( + transaction_id=transaction_id, + total=total, + currency=currency, + card_number=card_number, + card_type=card_type + ) + + def issue_credit(self, order_number, basket, reference_number, amount, currency): # pylint: disable=unused-argument + """Not available.""" + raise NotImplementedError("PayFort processor cannot issue credits or refunds from Open edX ecommerce.") diff --git a/ecommerce_payfort/templates/payment/payfort.html b/ecommerce_payfort/templates/payment/payfort.html deleted file mode 100644 index edc69ad..0000000 --- a/ecommerce_payfort/templates/payment/payfort.html +++ /dev/null @@ -1,43 +0,0 @@ -{% load i18n %} -{% load static %} -{% load compress %} - - - - - - {% trans "PayFort" %} - {{ payment_mode }} - {% compress css %} - {% if main_css %} - - {% else %} - - {% endif %} - {% endcompress %} - - {% compress css %} - {# This block is separated to better support browser caching. #} - {% block stylesheets %} - {% endblock %} - {% endcompress %} - - - - - - {# This adds the header for the page. #} - {% include 'edx/partials/_student_navbar.html' %} -
-
- - {% compress js %} - - - - Note: django-compressor does not recognize the data-main attribute. Load the main script separately. - - {% endcompress %} - - diff --git a/ecommerce_payfort/templates/payment/payment_form.html b/ecommerce_payfort/templates/payment/payment_form.html new file mode 100644 index 0000000..f3343f9 --- /dev/null +++ b/ecommerce_payfort/templates/payment/payment_form.html @@ -0,0 +1,37 @@ +{% extends "edx/base.html" %} +{% load i18n %} + +{% block content %} + +
+

{% trans "Redirecting to the Payment Gateway..." %}

+
+ +
+ + + + + + + + + + + + +
+{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/ecommerce_payfort/templates/payment/payment_handle_format_error.html b/ecommerce_payfort/templates/payment/payment_handle_format_error.html new file mode 100644 index 0000000..6b41c9f --- /dev/null +++ b/ecommerce_payfort/templates/payment/payment_handle_format_error.html @@ -0,0 +1,23 @@ +{% extends "edx/base.html" %} +{% load i18n %} + +{% block content %} + +
+

{% trans "This is unfortunate and not expected!" %}

+ +

{% trans "The response came from the payment gateway is malformed but flagged as succeeded! We're are not sure if your account has been charged or not! The administrator has been notified and will investigate the issue shortly" %}

+ +

{% trans "Please do not submit a purchase again before contacting the administrator" %}

+ + {% if reference != "none" %} +

{% trans "For your reference, the payment ID is:" %} {{ reference }}

+ {% endif %} +
+ +{% endblock %} diff --git a/ecommerce_payfort/templates/payment/payment_handle_internal_error.html b/ecommerce_payfort/templates/payment/payment_handle_internal_error.html new file mode 100644 index 0000000..e164f36 --- /dev/null +++ b/ecommerce_payfort/templates/payment/payment_handle_internal_error.html @@ -0,0 +1,21 @@ +{% extends "edx/base.html" %} +{% load i18n %} + +{% block content %} + +
+

{% trans "This is unfortunate and not expected!" %}

+ +

{% trans "The payment has been processed successfully, but we had an unexpected error while submitting the payment information to the server. The administrator has been notified and will investigate the issue." %}

+ +

{% trans "Please do not submit a purchase again before contacting the administrator" %}

+ +

{% trans "For your reference, the payment ID is:" %} {{ merchant_reference }}

+
+ +{% endblock %} diff --git a/ecommerce_payfort/templates/payment/payment_wait_feedback.html b/ecommerce_payfort/templates/payment/payment_wait_feedback.html new file mode 100644 index 0000000..87e431b --- /dev/null +++ b/ecommerce_payfort/templates/payment/payment_wait_feedback.html @@ -0,0 +1,66 @@ +{% extends "edx/base.html" %} +{% load i18n %} + +{% block content %} + +
+

{% trans "Payment succeeded. Processing enrollment.. please wait.." %}

+ +

{% trans "For your reference, the payment ID is:" %} {{ ecommerce_transaction_id }}

+
+{% endblock %} + + +{% block javascript %} + +{% endblock %} diff --git a/ecommerce_payfort/tests/processors/test_payfort.py b/ecommerce_payfort/tests/processors/test_payfort.py index b0a3390..095281f 100644 --- a/ecommerce_payfort/tests/processors/test_payfort.py +++ b/ecommerce_payfort/tests/processors/test_payfort.py @@ -4,9 +4,27 @@ from ecommerce.extensions.payment.tests.processors.mixins import PaymentProcessorTestCaseMixin from ecommerce.tests.testcases import TestCase from ecommerce_payfort.tests.mixins import PayFortMixin +from ecommerce_payfort.processors import PayFort @ddt.ddt -@pytest.mark.xfail +# @pytest.mark.xfail class PayFortTests(PayFortMixin, PaymentProcessorTestCaseMixin, TestCase): - pass + processor_name = 'payfort' + processor_class = PayFort + + def test_handle_processor_response(self): + """ Verify that the processor creates the appropriate PaymentEvent and Source objects. """ + raise NotImplementedError + + def test_get_transaction_parameters(self): + """ Verify the processor returns the appropriate parameters required to complete a transaction. """ + raise NotImplementedError + + def test_issue_credit_error(self): + """ Verify the payment processor responds appropriately if the payment gateway cannot issue a credit/refund. """ + raise NotImplementedError + + def test_issue_credit(self): + """ Verify the payment processor responds appropriately to requests to issue credit/refund. """ + raise NotImplementedError diff --git a/ecommerce_payfort/urls.py b/ecommerce_payfort/urls.py index a4a9078..c60d8b9 100644 --- a/ecommerce_payfort/urls.py +++ b/ecommerce_payfort/urls.py @@ -1,16 +1,31 @@ """ Defines the URL routes for the payfort app. """ -from django.conf.urls import url +from django.urls import re_path -from .views import PayFortPaymentPageView, PayFortResponseView +from .views import ( + PayFortFeedbackView, + PayFortPaymentHandleFormatErrorView, + PayFortPaymentHandleInternalErrorView, + PayFortPaymentRedirectView, + PayFortRedirectionResponseView, + PayFortStatusView, +) urlpatterns = [ - url(r'^payment/payfort/pay/$', PayFortPaymentPageView.as_view(), name='payment-form'), - url(r'^payment/payfort/submit/$', PayFortResponseView.as_view(), name='submit'), - url( - r'^payment/payfort/status/(?P.+)/$', - PayFortResponseView.as_view(), - name='status-check' + re_path(r'^pay/$', PayFortPaymentRedirectView.as_view(), name='form'), + re_path(r'^response/$', PayFortRedirectionResponseView.as_view(), name='response'), + re_path(r'^feedback/$', PayFortFeedbackView.as_view(), name='feedback'), + re_path(r'^status/$', PayFortStatusView.as_view(), name='status'), + + re_path( + r'^handle_internal_error/(.+)/$', + PayFortPaymentHandleInternalErrorView.as_view(), + name='handle-internal-error' + ), + re_path( + r'^handle_format_error/(.+)/$', + PayFortPaymentHandleFormatErrorView.as_view(), + name='handle-format-error' ), ] diff --git a/ecommerce_payfort/views.py b/ecommerce_payfort/views.py index c340351..3ca3cde 100644 --- a/ecommerce_payfort/views.py +++ b/ecommerce_payfort/views.py @@ -1,50 +1,259 @@ -""" -Views related to the PayFort payment processor. -""" +"""Views related to the PayFort payment processor.""" +import json import logging +from django.contrib.auth.mixins import LoginRequiredMixin +from django.core.exceptions import ObjectDoesNotExist from django.db import transaction -from django.shortcuts import render +from django.http import Http404, HttpResponse +from django.shortcuts import redirect, render +from django.urls import reverse from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt -from django.views.generic import View -from oscar.core.loading import get_class, get_model - +from django.views.generic import TemplateView, View from ecommerce.extensions.checkout.mixins import EdxOrderPlacementMixin +from ecommerce.extensions.checkout.utils import get_receipt_page_url +from oscar.apps.partner import strategy +from oscar.core.loading import get_class, get_model +from django.utils import timezone -from .processors import PayFort +from ecommerce_payfort.processors import PayFort, PayFortException, PayFortBadSignatureException logger = logging.getLogger(__name__) -Applicator = get_class('offer.applicator', 'Applicator') -Basket = get_model('basket', 'Basket') -OrderNumberGenerator = get_class('order.utils', 'OrderNumberGenerator') +Applicator = get_class("offer.applicator", "Applicator") +Basket = get_model("basket", "Basket") +OrderNumberGenerator = get_class("order.utils", "OrderNumberGenerator") -class PayFortPaymentPageView(View): - """ - Render the template which loads the PayFort payment form via JavaScript - """ - template_name = 'payment/payfort.html' +class PayFortPaymentRedirectView(LoginRequiredMixin, TemplateView): + """Render the template which loads the PayFort payment form via JavaScript""" + template_name = "payment/payment_form.html" def post(self, request): - """ - Handles the POST request. - """ - return render(request, self.template_name, request.POST.dict()) + """Handles the POST request.""" + return render(request=request, template_name=self.template_name, context=request.POST.dict()) -class PayFortResponseView(EdxOrderPlacementMixin, View): - """ - Handle the response from PayFort after processing the payment. - """ - - @property - def payment_processor(self): - return PayFort(self.request.site) +class PayFortCallBaseView(EdxOrderPlacementMixin, View): + """Base class for the PayFort views.""" + def __init__(self, *args, **kwargs): + """Initialize the PayFortCallBaseView.""" + super(PayFortCallBaseView, self).__init__(*args, **kwargs) + self.payment_processor = None + self.request = None + self._basket = None @method_decorator(transaction.non_atomic_requests) @method_decorator(csrf_exempt) def dispatch(self, request, *args, **kwargs): - return super(PayFortResponseView, self).dispatch(request, *args, **kwargs) + """Dispatch the request to the appropriate handler.""" + return super(PayFortCallBaseView, self).dispatch(request, *args, **kwargs) + + @property + def basket(self): + """Retrieve the basket from the database.""" + if self._basket is not None: + return self._basket + + if not self.request: + return None + + merchant_reference = self.request.POST.get("merchant_reference", "") + + try: + basket_id = int(merchant_reference.split('-')[-1]) + basket = Basket.objects.get(id=basket_id) + basket.strategy = strategy.Default() + Applicator().apply(basket, basket.owner, self.request) + + self._basket = basket + except (ValueError, ObjectDoesNotExist): + return None + + return self._basket + + def log_error(self, message): + """Log the error message.""" + logger.error(f"{self.__class__.__name__}: {message}") + + def save_payment_processor_response(self, response_data): + """Save the payment processor response to the database.""" + try: + return self.payment_processor.record_processor_response( + response={ + "view": self.__class__.__name__, + "response": response_data + }, + transaction_id=self.payment_processor.get_transaction_id(response_data), + basket=self.basket + ) + except Exception as exc: # pylint:disable=broad-except + self.log_error( + f"Recording payment processor response failed! " + f"merchant_reference: {response_data.get('merchant_reference', 'none')}. " + f"Exception: {str(exc)}" + ) + raise Http404 + + def validate_response(self, response_data): + """Validate the response from PayFort.""" + try: + self.payment_processor.verify_response_signature(response_data) + except PayFortBadSignatureException as e: + self.log_error(str(e)) + raise + + success = response_data.get("status", "") == self.payment_processor.SUCCESS_STATUS + + try: + self.payment_processor.verify_response_format(response_data) + + if not self.basket: + raise PayFortException(f"Basket not found! merchant_reference: {response_data['merchant_reference']}") + + except PayFortException as e: + self.log_error(str(e)) + if success: + reference = response_data.get("fort_id", "none") + self.log_error( + f"Bad response format for a successful payment! reference: {reference}, " + f"merchant_reference: {response_data.get('merchant_reference', 'none')}" + ) + raise + raise Http404 + + +class PayFortRedirectionResponseView(PayFortCallBaseView): + """Handle the response from PayFort sent to customer after processing the payment.""" + template_name = "payment/payment_wait_feedback.html" + MAX_ATTEMPTS = 24 + WAIT_TIME = 5000 + + def post(self, request): + """Handle the POST request from PayFort after processing the payment.""" + data = request.POST.dict() + self.payment_processor = PayFort(request.site) + self.request = request + + try: + self.validate_response(data) + except PayFortBadSignatureException: + raise Http404 + except PayFortException: + self.save_payment_processor_response(data) + return redirect(reverse( + 'payfort:handle-internal-error', + args=[self.payment_processor.get_transaction_id(data)] + )) + except Http404: + self.save_payment_processor_response(data) + raise + + payment_processor_response = self.save_payment_processor_response(data) + if data["status"] == self.payment_processor.SUCCESS_STATUS: + data["ecommerce_transaction_id"] = payment_processor_response.transaction_id + data["ecommerce_error_url"] = reverse( + 'payfort:handle-internal-error', + args=[self.payment_processor.get_transaction_id(data)] + ) + data["ecommerce_status_url"] = reverse("payfort:status") + data["ecommerce_max_attempts"] = self.MAX_ATTEMPTS + data["ecommerce_wait_time"] = self.WAIT_TIME + return render(request=request, template_name=self.template_name, context=data) + + self.log_error( + f"Payfort payment failed! merchant_reference: {data['merchant_reference']}. " + f"response_code: {data['response_code']}" + ) + return redirect(reverse("payment_error")) + + +class PayFortStatusView(PayFortCallBaseView): + """Handle the status request from PayFort.""" + def post(self, request): + """Handle the POST request from PayFort.""" + if not self.basket: + return HttpResponse(status=404) + + if self.basket.status == Basket.FROZEN: + return HttpResponse(status=204) + elif self.basket.status == Basket.SUBMITTED: + return HttpResponse( + status=200, + content=json.dumps({ + "receipt_url": get_receipt_page_url( + request=request, + site_configuration=self.basket.site.siteconfiguration, + order_number=self.basket.order_number, + ) + }), + content_type='application/json' + ) + else: + return HttpResponse(status=404) + + +class PayFortFeedbackView(PayFortCallBaseView): + """Handle the response from PayFort sent to customer after processing the payment.""" + def post(self, request): + """Handle the POST request from PayFort after processing the payment.""" + data = request.POST.dict() + self.payment_processor = PayFort(request.site) + self.request = request + + try: + self.validate_response(data) + except PayFortBadSignatureException: + raise Http404 + except (Http404, PayFortException): + self.save_payment_processor_response(data) + raise Http404 + + payment_processor_response = self.save_payment_processor_response(data) + if data["status"] != self.payment_processor.SUCCESS_STATUS: + self.log_error( + f"Payfort payment failed! merchant_reference: {data['merchant_reference']}. " + f"response_code: {data['response_code']}" + ) + return HttpResponse(status=200) + + try: + with transaction.atomic(): + self.handle_payment(data, self.basket) + self.create_order(request, self.basket) + except Exception as exc: # pylint:disable=broad-except + logger.exception( + f"Processing payment for basket [{self.basket.id}] failed! " + "The payment was successfully processed by PayFort. Response was recorded in entry no. " + f"({payment_processor_response.id}). " + f"Exception: {exc.__class__.__name__}: {exc}" + ) + return HttpResponse(status=422) + + return HttpResponse(status=200) + + +class PayFortPaymentHandleInternalErrorView(TemplateView): + """Render the template that shows the error message to the user when the payment handling is failed.""" + template_name = "payment/payment_handle_internal_error.html" + + def get(self, request, merchant_reference): + """Handles the GET request.""" + context = { + "merchant_reference": merchant_reference, + } + return render(request, self.template_name, context) + + +class PayFortPaymentHandleFormatErrorView(TemplateView): + """Render the template that shows the error message to the user when the payment response is in wrong format.""" + template_name = "payment/payment_handle_format_error.html" + + def get(self, request, reference): + """Handles the GET request.""" + context = { + "reference": reference, + } + return render(request, self.template_name, context) diff --git a/scripts/tox_install_ecommerce_run_pytest.sh b/scripts/tox_install_ecommerce_run_pytest.sh index c5c187f..1603584 100644 --- a/scripts/tox_install_ecommerce_run_pytest.sh +++ b/scripts/tox_install_ecommerce_run_pytest.sh @@ -24,7 +24,8 @@ fi rm -rf .tox/ecommerce-maple.master/ecommerce_payfort cat settings/payfort.py > .tox/ecommerce-maple.master/ecommerce/settings/payfort.py -cd .tox/ecommerce-maple.master/ecommerce +export tests_root_dir=$(pwd) +cd .tox/ecommerce-maple.master/ecommerce -"$@" # Arguments passed to this script +pytest "$tests_root_dir/$@" # Arguments passed to this script diff --git a/settings/payfort.py b/settings/payfort.py index 4669f30..952c2e3 100644 --- a/settings/payfort.py +++ b/settings/payfort.py @@ -1,3 +1,28 @@ from ecommerce.settings.test import * INSTALLED_APPS += ['ecommerce_payfort'] + +PAYMENT_PROCESSOR_CONFIG = { + 'edx': { + 'payfort': { + # 'access_token': '1234', + # 'entity_id': 'abcd', + # 'currency': 'SAR', + # 'payfort_base_api_url': 'https://test.example.com', + # 'return_url': '/payment/payfort/submit/', + # 'encryption_key': 'test-key', + # 'salt': 'test-salt', + }, + }, + 'other': { + 'payfort': { + # 'access_token': '1234', + # 'entity_id': 'abcd', + # 'currency': 'SAR', + # 'payfort_base_api_url': 'https://test.example.com', + # 'return_url': '/payment/payfort/submit/', + # 'encryption_key': 'test-key', + # 'salt': 'test-salt', + }, + }, +} diff --git a/setup.py b/setup.py index ea30069..e2feac8 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ from pathlib import Path -from setuptools import setup +from setuptools import find_packages, setup README = open(Path(__file__).parent / 'README.rst').read() CHANGELOG = open(Path(__file__).parent / 'CHANGELOG.rst').read() @@ -53,12 +53,15 @@ def package_data(pkg, roots): 'Django~=3.2', ], package_data=package_data('ecommerce_payfort', ['locale']), - packages=[ - 'ecommerce_payfort', - ], + packages=find_packages( + include=[ + 'ecommerce_payfort', 'ecommerce_payfort.*', + ], + exclude=["*tests"], + ), entry_points={ 'ecommerce': [ - 'ecommerce_payfort = payfort.apps:PayFortConfig', + 'ecommerce_payfort = ecommerce_payfort.apps:PayFortConfig', ], }, ) diff --git a/tox.ini b/tox.ini index 4e13a65..8684458 100644 --- a/tox.ini +++ b/tox.ini @@ -20,7 +20,7 @@ deps = commands = # Clone the openedx/ecommerce and install the ecommerce-payfort package and run tests - bash ./scripts/tox_install_ecommerce_run_pytest.sh pytest {toxinidir}/ecommerce_payfort/{posargs} + bash ./scripts/tox_install_ecommerce_run_pytest.sh {posargs} [testenv:flake8] deps = flake8 diff --git a/tutor_plugin/ecommerce-config.yml b/tutor_plugin/ecommerce-config.yml index 3a930fd..98a8421 100644 --- a/tutor_plugin/ecommerce-config.yml +++ b/tutor_plugin/ecommerce-config.yml @@ -1,9 +1,9 @@ cybersource: - merchant_id: SET-ME-PLEASE - flex_shared_secret_key_id: SET-ME-PLEASE - flex_shared_secret_key: SET-ME-PLEASE - soap_api_url: https://ics2wstest.ic3.com/commerce/1.x/transactionProcessor/CyberSourceTransaction_1.140.wsdl - transaction_key: SET-ME-PLEASE + merchant_id: SET-ME-PLEASE + flex_shared_secret_key_id: SET-ME-PLEASE + flex_shared_secret_key: SET-ME-PLEASE + soap_api_url: https://ics2wstest.ic3.com/commerce/1.x/transactionProcessor/CyberSourceTransaction_1.140.wsdl + transaction_key: SET-ME-PLEASE paypal: cancel_checkout_path: /checkout/cancel-checkout/ client_id: SET-ME-PLEASE