Skip to content

Commit

Permalink
feat: Implement PayFort Payment Processor
Browse files Browse the repository at this point in the history
  • Loading branch information
shadinaif committed May 9, 2024
1 parent dc42625 commit 6cd431e
Show file tree
Hide file tree
Showing 16 changed files with 688 additions and 109 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ name: ci

on:
push:
branches: [main]
pull_request:
branches: [xmain] # disabled for now
# pull_request: disabled for now

jobs:
build:
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
226 changes: 215 additions & 11 deletions ecommerce_payfort/processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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 <str>, 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.")
43 changes: 0 additions & 43 deletions ecommerce_payfort/templates/payment/payfort.html

This file was deleted.

37 changes: 37 additions & 0 deletions ecommerce_payfort/templates/payment/payment_form.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{% extends "edx/base.html" %}
{% load i18n %}

{% block content %}
<style>
.centered-content {
text-align: center;
margin-top: 2em;
}
</style>
<div class="centered-content">
<h1>{% trans "Redirecting to the Payment Gateway..." %}</h1>
</div>

<form action="https://sbcheckout.payfort.com/FortAPI/paymentPage" method="post" name="payment_form">
<input type="hidden" name="command" value={{ command }}>
<input type="hidden" name="access_code" value="{{ access_code }}">
<input type="hidden" name="merchant_identifier" value="{{ merchant_identifier }}">
<input type="hidden" name="merchant_reference" value="{{ merchant_reference }}">
<input type="hidden" name="amount" value="{{ amount }}">
<input type="hidden" name="currency" value="{{ currency }}">
<input type="hidden" name="language" value="{{ language }}">
<input type="hidden" name="customer_email" value="{{ customer_email }}">
<input type="hidden" name="order_description" value="{{ order_description }}">
<input type="hidden" name="signature" value="{{ signature }}">
<input type="hidden" name="customer_name" value="{{ customer_name }}">
<input type="hidden" name="return_url" value="{{ return_url }}">
</form>
{% endblock %}

{% block javascript %}
<script type="text/javascript">
window.onload = function() {
document.payment_form.submit();
};
</script>
{% endblock %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{% extends "edx/base.html" %}
{% load i18n %}

{% block content %}
<style>
.centered-content {
text-align: center;
margin-top: 2em;
}
</style>
<div class="centered-content">
<h1>{% trans "This is unfortunate and not expected!" %}</h1>

<p>{% 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" %}</p>

<p><strong>{% trans "Please do not submit a purchase again before contacting the administrator" %}</strong></p>

{% if reference != "none" %}
<p>{% trans "For your reference, the payment ID is:" %} <strong>{{ reference }}</strong></p>
{% endif %}
</div>

{% endblock %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{% extends "edx/base.html" %}
{% load i18n %}

{% block content %}
<style>
.centered-content {
text-align: center;
margin-top: 2em;
}
</style>
<div class="centered-content">
<h1>{% trans "This is unfortunate and not expected!" %}</h1>

<p>{% 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." %}</p>

<p><strong>{% trans "Please do not submit a purchase again before contacting the administrator" %}</strong></p>

<p>{% trans "For your reference, the payment ID is:" %} <strong>{{ merchant_reference }}</strong></p>
</div>

{% endblock %}
Loading

0 comments on commit 6cd431e

Please sign in to comment.