Skip to content

Commit

Permalink
feat:add tests
Browse files Browse the repository at this point in the history
  • Loading branch information
shadinaif committed May 14, 2024
1 parent 6cd431e commit 574f9c8
Show file tree
Hide file tree
Showing 26 changed files with 1,170 additions and 541 deletions.
20 changes: 10 additions & 10 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: [xmain] # disabled for now
# pull_request: disabled for now
branches: [main]
pull_request:

jobs:
build:
Expand All @@ -13,9 +13,9 @@ jobs:
matrix:
include:
- python-version: 3.8
tox-env: py38
tox-env: py38-tests
- python-version: 3.8
tox-env: flake8
tox-env: py38-quality

name: "Python ${{ matrix.python-version }} - ${{ matrix.tox-env }}"
steps:
Expand All @@ -26,12 +26,12 @@ jobs:
with:
python-version: ${{ matrix.python-version }}

- name: Cache tox environments
uses: actions/cache@v3
with:
path: .tox
# Refresh the cache if the following files change
key: "tox-${{ matrix.python-version }}-${{ matrix.tox-env }}-${{ hashFiles('tox.ini', 'setup.py', 'scripts/tox_install_ecommerce_run_pytest.sh', 'requirements/ecommerce-maple.master.txt', 'payfort-test.txt') }}"
# - name: Cache tox environments
# uses: actions/cache@v3
# with:
# path: .tox
# # Refresh the cache if the following files change
# key: "tox-${{ matrix.python-version }}-${{ matrix.tox-env }}-${{ hashFiles('tox.ini', 'setup.py', 'scripts/tox_install_ecommerce_run_pytest.sh', 'requirements/ecommerce-palm.master.txt', 'payfort-test.txt') }}"

- name: Install Dependencies
run: |
Expand Down
2 changes: 1 addition & 1 deletion ecommerce_payfort/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""
The PayFort payment processor pluggable application for Open edX ecommerce.
"""
default_app_config = 'ecommerce_payfort.apps.PayFortConfig'
default_app_config = 'ecommerce_payfort.apps.PayFortConfig' # pylint: disable=invalid-name
4 changes: 1 addition & 3 deletions ecommerce_payfort/apps.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
"""
PayFort payment processor Django application initialization.
"""
"""PayFort payment processor Django application initialization."""
from django.apps import AppConfig


Expand Down
220 changes: 32 additions & 188 deletions ecommerce_payfort/processors.py
Original file line number Diff line number Diff line change
@@ -1,40 +1,15 @@
"""
PayFort payment processor.
"""

import hashlib
"""PayFort payment processor."""
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

logger = logging.getLogger(__name__)


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]

from ecommerce_payfort import utils

class PayFortException(GatewayError):
"""PayFort exception."""
pass # pylint: disable=unnecessary-pass


class PayFortBadSignatureException(PayFortException):
"""PayFort bad signature exception."""
pass # pylint: disable=unnecessary-pass
logger = logging.getLogger(__name__)


class PayFort(BasePaymentProcessor):
Expand All @@ -44,191 +19,58 @@ class PayFort(BasePaymentProcessor):
For reference, see https://paymentservices-reference.payfort.com/docs/api/build/index.html
Scroll through the page, it's a very long single-page documentation.
"""

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"
NAME = "payfort"

def __init__(self, site):
"""Initialize the PayFort processor."""
super(PayFort, self).__init__(site)
super().__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"
self.access_code = self.configuration.get("access_code")
self.merchant_identifier = self.configuration.get("merchant_identifier")
self.request_sha_phrase = self.configuration.get("request_sha_phrase")
self.response_sha_phrase = self.configuration.get("response_sha_phrase")
self.sha_method = self.configuration.get("sha_method")
self.ecommerce_url_root = self.configuration.get("ecommerce_url_root")

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),
"access_code": self.access_code,
"merchant_identifier": self.merchant_identifier,
"language": utils.get_language(request),
"merchant_reference": utils.get_merchant_reference(self.site.id, basket),
"amount": utils.get_amount(basket),
"currency": utils.get_currency(basket),
"customer_email": utils.get_customer_email(basket),
"order_description": utils.get_order_description(basket),
"customer_name": utils.get_customer_name(basket),
"return_url": urljoin(
self.ECOMMERCE_URL_ROOT,
self.ecommerce_url_root,
reverse("payfort:response")
),
}
signature = self._get_signature(transaction_parameters)

signature = utils.get_signature(
self.request_sha_phrase,
self.sha_method,
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']}"
)
return transaction_parameters

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)
transaction_id = utils.get_transaction_id(response)
card_number = response.get("card_number")
card_type = response.get("payment_option")

Expand All @@ -240,6 +82,8 @@ def handle_processor_response(self, response, basket=None):
card_type=card_type
)

def issue_credit(self, order_number, basket, reference_number, amount, currency): # pylint: disable=unused-argument
def issue_credit(
self, order_number, basket, reference_number, amount, currency
): # pylint: disable=too-many-arguments
"""Not available."""
raise NotImplementedError("PayFort processor cannot issue credits or refunds from Open edX ecommerce.")
30 changes: 0 additions & 30 deletions ecommerce_payfort/tests/mixins.py

This file was deleted.

Empty file.
30 changes: 0 additions & 30 deletions ecommerce_payfort/tests/processors/test_payfort.py

This file was deleted.

Loading

0 comments on commit 574f9c8

Please sign in to comment.