diff --git a/.gitignore b/.gitignore index 1cfadd7..7e4efba 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ test.db .idea *.iml +# Code +.vscode/ diff --git a/README.md b/README.md index bc18ba6..7799264 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,13 @@ This module provides implementations for the following payment-gateways: [More Stripe information](docs/stripe.md) +### Netaxept +Implemented features: +- Authorization +- Capture +- Refund + +[More Netaxept information](docs/netaxept.md) ## The example project The source distribution includes an example project that lets one exercise @@ -75,7 +82,7 @@ Install the django-payment dependencies (the example project has identical depen Then point your browser to: - http://127.0.0.1:8000/admin + http://127.0.0.1:8000/admin/ Create a new payment (make sure the captured amount currency is the same as the total currency) @@ -94,7 +101,8 @@ To run unit tests: pip install pytest-django pytest -To lint, typecheck, test, and verify you didn't forget to create a migration: +To lint, typecheck, test on all supported versions of python and django. +Also to verify you didn't forget to create a migration: pip install tox tox diff --git a/devel-requirements.txt b/devel-requirements.txt new file mode 100644 index 0000000..3623ebe --- /dev/null +++ b/devel-requirements.txt @@ -0,0 +1,5 @@ +pytest-django +flake8 +mypy +python-language-server + diff --git a/docs/netaxept.md b/docs/netaxept.md new file mode 100644 index 0000000..906c0b3 --- /dev/null +++ b/docs/netaxept.md @@ -0,0 +1,27 @@ +# Netaxept + +## Configuration + +In the PAYMENT_GATEWAYS setting, configure the netaxept connection params: + +`merchant_id`, `secret`, `base_url`, and `after_terminal_url`. + +The production base_url is: + +`https://epayment.nets.eu/` + + +## Design + +Netaxept works by taking the user to a hosted page and then redirecting the user to the merchant in order to finish +processing the payment. +We chose not to provide such a view in the payment application (we do provide an example view in the example_project), +This means a project that uses netaxept payment will have to implement its own after_terminal view. + +- The first reason is that it's not possible to design a simple, generic response that we can show to users of the +application (because we don't know anything about the application) +- The second reason is that after a successful payment something more than just acknowledging the payment +usually needs to happen in the application (for instance setting the status of an order, sending an email, etc). + +It's not impossible to solve those two problems with configuration, application-provided functions, and signals +but it doesn't seem like all this complexity is worth it, compared to reimplementing a simple, straightforward webhook. diff --git a/example_project/settings.py b/example_project/settings.py index 8b26572..708f39b 100644 --- a/example_project/settings.py +++ b/example_project/settings.py @@ -59,6 +59,8 @@ def abspath(*args): ROOT_URLCONF = 'urls' +DATETIME_FORMAT = "Y-m-d @ H:i:s e" + TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', @@ -80,14 +82,16 @@ def abspath(*args): DATETIME_FORMAT = 'Y-m-d @ H:i:s e' # Enable specific currencies (djmoney) -CURRENCIES = ['USD', 'EUR', 'JPY', 'GBP', 'CAD', 'CHF'] +CURRENCIES = ['USD', 'EUR', 'JPY', 'GBP', 'CAD', 'CHF', 'NOK'] DUMMY = 'dummy' STRIPE = 'stripe' +NETAXEPT = 'netaxept' CHECKOUT_PAYMENT_GATEWAYS = { DUMMY: 'Dummy gateway', STRIPE: 'Stripe', + NETAXEPT: 'Netaxept', } PAYMENT_GATEWAYS = { @@ -117,4 +121,17 @@ def abspath(*args): }, }, }, + NETAXEPT: { + 'module': 'payment.gateways.netaxept', + 'config': { + 'auto_capture': True, + 'template_path': 'payment/netaxept.html', + 'connection_params': { + 'base_url': os.environ.get('NETAXEPT_BASE_URL') or 'https://test.epayment.nets.eu', + 'after_terminal_url': 'http://localhost:8000/example/netaxept/after_terminal', + 'merchant_id': os.environ.get('NETAXEPT_MERCHANT_ID'), + 'secret': os.environ.get('NETAXEPT_SECRET'), + } + } + }, } diff --git a/example_project/templates/operation_list.html b/example_project/templates/operation_list.html index 935aa2f..4206c31 100644 --- a/example_project/templates/operation_list.html +++ b/example_project/templates/operation_list.html @@ -12,7 +12,6 @@

Payment

  • captured amount: {{ payment.captured_amount }}
  • -See payment in admin

    Operations

    diff --git a/example_project/urls.py b/example_project/urls.py index ad6593b..c6d182b 100644 --- a/example_project/urls.py +++ b/example_project/urls.py @@ -2,12 +2,13 @@ from django.urls import path, include import views +from views import netaxept from views import stripe example_urlpatterns = [ path('', views.view_payment, name='view_payment'), - path('/capture', views.capture, name='capture'), path('stripe/', include(stripe.urls)), + path('netaxept/', include(netaxept.urls)), ] urlpatterns = [ diff --git a/example_project/views/__init__.py b/example_project/views/__init__.py index 92c0353..176d3de 100644 --- a/example_project/views/__init__.py +++ b/example_project/views/__init__.py @@ -1,10 +1,9 @@ from django.http import HttpRequest, HttpResponse -from django.shortcuts import get_object_or_404, redirect +from django.shortcuts import get_object_or_404 from django.template.response import TemplateResponse from structlog import get_logger from payment.models import Payment -from payment.utils import gateway_capture logger = get_logger() @@ -12,10 +11,3 @@ def view_payment(request: HttpRequest, payment_id: int) -> HttpResponse: payment = get_object_or_404(Payment, id=payment_id) return TemplateResponse(request, 'operation_list.html', {'payment': payment}) - - -def capture(request: HttpRequest, payment_id: int) -> HttpResponse: - payment = get_object_or_404(Payment, id=payment_id) - capture_result = gateway_capture(payment=payment) - logger.info('capture', payment=payment, capture_result=capture_result) - return redirect('view_payment', payment_id=payment_id) diff --git a/example_project/views/netaxept.py b/example_project/views/netaxept.py new file mode 100644 index 0000000..a48e715 --- /dev/null +++ b/example_project/views/netaxept.py @@ -0,0 +1,66 @@ +""" +Example views for interactive testing of payment with netaxept. +""" +from django.http import HttpRequest +from django.http import HttpResponse +from django.shortcuts import redirect, get_object_or_404 +from django.urls import path +from django.views.decorators.http import require_GET +from structlog import get_logger + +from payment import get_payment_gateway +from payment.gateways.netaxept import actions +from payment.gateways.netaxept import gateway_to_netaxept_config +from payment.gateways.netaxept.netaxept_protocol import get_payment_terminal_url +from payment.models import Payment + +logger = get_logger() + + +@require_GET +def register_and_authorize(request: HttpRequest, payment_id: int) -> HttpResponse: + """ + Register the payment with netaxept, and take the user to the terminal page for payment authorization. + """ + logger.info('netaxept-register-and-authorize', payment_id=payment_id) + + transaction_id = actions.register_payment(payment_id) + + payment = get_object_or_404(Payment, id=payment_id) + payment_gateway, gateway_config = get_payment_gateway(payment.gateway) + netaxept_config = gateway_to_netaxept_config(gateway_config) + return redirect(get_payment_terminal_url(config=netaxept_config, transaction_id=transaction_id)) + + +@require_GET +def after_terminal(request): + """ + The browser gets redirected here when the user finishes interacting with the netaxept terminal pages. + We expect query-string parameters: transactionId and responseCode. + See: https://shop.nets.eu/web/partners/response-codes + + We know we opened the terminal with AutoAuth set to True, so we interpret this callback to mean that an + AUTH operation was performed. Netaxept does not provide any way to authenticate that the callback really comes + from netaxept (other than them sending us a valid hard to guess 32 character long transaction_id), so we cannot + be 100% sure of the information received. + We decide to store the authorization operation nonetheless. If by any chance the information was faked we will + detect it in the next step, when we try to capture the money. + """ + transaction_id = request.GET['transactionId'] + response_code = request.GET['responseCode'] + logger.info('netaxept-webhook', transaction_id=transaction_id, response_code=response_code) + + success = (response_code == 'OK') + + actions.create_auth_transaction(transaction_id=transaction_id, success=success) + + if success: + return HttpResponse('ok') + elif response_code: + return HttpResponse('response code {}'.format(response_code)) + + +urls = [ + path('register_and_authorize/', register_and_authorize, name='netaxept_register_and_authorize'), + path('after_terminal', after_terminal, name='netaxept_after_terminal'), +] diff --git a/payment/__init__.py b/payment/__init__.py index b9089f1..22360f9 100644 --- a/payment/__init__.py +++ b/payment/__init__.py @@ -51,6 +51,7 @@ class TransactionKind: """Represents the type of a transaction. The following transactions types are possible: + - REGISTER - Some gateways require an initial register transaction, before authorizing. - AUTH - an amount reserved against the customer's funding source. Money does not change hands until the authorization is captured. - VOID - a cancellation of a pending authorization or capture. @@ -59,6 +60,7 @@ class TransactionKind: - REFUND - full or partial return of captured funds to the customer. """ + REGISTER = "register" AUTH = "auth" CAPTURE = "capture" VOID = "void" @@ -67,6 +69,7 @@ class TransactionKind: # Which were authorized, but needs to be confirmed manually by staff # eg. Braintree with "submit_for_settlement" enabled CHOICES = [ + (REGISTER, pgettext_lazy("transaction kind", "Registration")), (AUTH, pgettext_lazy("transaction kind", "Authorization")), (REFUND, pgettext_lazy("transaction kind", "Refund")), (CAPTURE, pgettext_lazy("transaction kind", "Capture")), diff --git a/payment/admin.py b/payment/admin.py index 8efd6fb..59f68e8 100644 --- a/payment/admin.py +++ b/payment/admin.py @@ -5,6 +5,7 @@ from django.shortcuts import get_object_or_404, render from django.urls import reverse from django.utils.html import format_html +from django.utils.safestring import mark_safe from django.utils.translation import ugettext_lazy as _ from djmoney.forms import MoneyField from import_export.admin import ExportMixin @@ -13,7 +14,7 @@ from .export import PaymentResource from .models import Payment, Transaction -from .utils import gateway_refund +from .utils import gateway_refund, gateway_void, gateway_capture ############################################################## @@ -77,6 +78,31 @@ def has_module_permission(self, request): ############################################################## # Payments +class CapturePaymentForm(forms.Form): + amount = MoneyField(min_value=0) + + +def capture_payment_form(request, payment_id): + payment = get_object_or_404(Payment, pk=payment_id) + if request.method == 'POST': + form = CapturePaymentForm(request.POST) + if form.is_valid(): + gateway_capture(payment=payment, amount=form.cleaned_data['amount']) + # As confirmation we take the user to the payment page + return HttpResponseRedirect(reverse('admin:payment_payment_change', args=[payment_id])) + else: + form = CapturePaymentForm(initial={'amount': payment.get_charge_amount()}) + + return render( + request, + 'admin/payment/form.html', + { + 'title': 'Capture in {}, for payment with total: {}'.format(payment.gateway, payment.total), + 'form': form, + 'opts': Payment._meta, # Used to setup the navigation / breadcrumbs of the page + } + ) + class RefundPaymentForm(forms.Form): amount = MoneyField(min_value=0) @@ -87,9 +113,7 @@ def refund_payment_form(request, payment_id): if request.method == 'POST': form = RefundPaymentForm(request.POST) if form.is_valid(): - gateway_refund( - payment=payment, - amount=form.cleaned_data['amount']) + gateway_refund(payment=payment, amount=form.cleaned_data['amount']) # As confirmation we take the user to the payment page return HttpResponseRedirect(reverse('admin:payment_payment_change', args=[payment_id])) else: @@ -106,6 +130,32 @@ def refund_payment_form(request, payment_id): ) +class VoidPaymentForm(forms.Form): + pass + + +def void_payment_form(request, payment_id): + payment = get_object_or_404(Payment, pk=payment_id) + if request.method == 'POST': + form = VoidPaymentForm(request.POST) + if form.is_valid(): + gateway_void(payment=payment) + # As confirmation we take the user to the payment page + return HttpResponseRedirect(reverse('admin:payment_payment_change', args=[payment_id])) + else: + form = VoidPaymentForm() + + return render( + request, + 'admin/payment/form.html', + { + 'title': 'Void in {}, for payment with total: {}'.format(payment.gateway, payment.total), + 'form': form, + 'opts': Payment._meta, # Used to setup the navigation / breadcrumbs of the page + } + ) + + @admin.register(Payment) class PaymentAdmin(ExportMixin, admin.ModelAdmin): date_hierarchy = 'created' @@ -115,7 +165,7 @@ class PaymentAdmin(ExportMixin, admin.ModelAdmin): 'customer_email'] search_fields = ['customer_email', 'token', 'total', 'id'] - readonly_fields = ['created', 'modified', 'refund_button'] + readonly_fields = ['created', 'modified', 'operation_button'] inlines = [TransactionInline] resource_class = PaymentResource @@ -135,20 +185,36 @@ def formatted_captured_amount(self, obj): def get_urls(self): urls = super().get_urls() my_urls = [ - url( - r'^(?P[0-9a-f-]+)/refund/$', + url(r'^(?P[0-9a-f-]+)/refund/$', self.admin_site.admin_view(refund_payment_form), - name='payment_refund', - ), + name='payment_refund'), + url(r'^(?P[0-9a-f-]+)/void/$', + self.admin_site.admin_view(void_payment_form), + name='payment_void'), + url(r'^(?P[0-9a-f-]+)/capture/$', + self.admin_site.admin_view(capture_payment_form), + name='payment_capture'), ] return my_urls + urls - def refund_button(self, payment): + def operation_button(self, payment): + buttons = [] + if payment.can_capture(): + buttons.append(format_html( + '{}', + reverse('admin:payment_capture', args=[payment.pk]), + _('Capture'))) if payment.can_refund(): - return format_html('{}', - reverse('admin:payment_refund', args=[payment.pk]), - _('Refund')) - else: - return '-' - - refund_button.short_description = _('Refund') # type: ignore + buttons.append(format_html( + '{}', + reverse('admin:payment_refund', args=[payment.pk]), + _('Refund'))) + if payment.can_void(): + buttons.append(format_html( + '{}', + reverse('admin:payment_void', args=[payment.pk]), + _('Void'))) + + return mark_safe('  '.join(buttons)) if buttons else '-' + + operation_button.short_description = _('Operation') # type: ignore diff --git a/payment/gateways/netaxept/__init__.py b/payment/gateways/netaxept/__init__.py new file mode 100644 index 0000000..a1857c9 --- /dev/null +++ b/payment/gateways/netaxept/__init__.py @@ -0,0 +1,72 @@ +from .netaxept_protocol import NetaxeptConfig, NetaxeptOperation, process, NetaxeptProtocolError +from ... import OperationType +from ...interface import GatewayConfig, GatewayResponse, PaymentData + + +def get_client_token(**_): + """ Not implemented for netaxept gateway. """ + pass + + +def authorize(payment_information: PaymentData, + config: GatewayConfig, + should_capture: bool = False) -> GatewayResponse: + raise NotImplementedError() + + +def process_payment(payment_information: PaymentData, config: GatewayConfig) -> GatewayResponse: + raise NotImplementedError() + + +def capture(payment_information: PaymentData, config: GatewayConfig) -> GatewayResponse: + return _op(payment_information, config, OperationType.CAPTURE) + + +def refund(payment_information: PaymentData, config: GatewayConfig) -> GatewayResponse: + return _op(payment_information, config, OperationType.REFUND) + + +def void(payment_information: PaymentData, config: GatewayConfig) -> GatewayResponse: + return _op(payment_information, config, OperationType.VOID) + + +def gateway_to_netaxept_config(gateway_config: GatewayConfig) -> NetaxeptConfig: + return NetaxeptConfig(**gateway_config.connection_params) + + +def _op(payment_information: PaymentData, config: GatewayConfig, operation_type: OperationType) -> GatewayResponse: + try: + process_result = process( + config=gateway_to_netaxept_config(config), + transaction_id=payment_information.token, + operation=_operation_type_to_netaxept_op[operation_type], + amount=payment_information.amount) + # We don't need to introspect anything inside the process_result: If no exception was thrown we immediately + # know process ran successfully + return GatewayResponse( + is_success=True, + kind=operation_type.value, + amount=payment_information.amount, + currency=payment_information.currency, + transaction_id=payment_information.token, + error=None, + raw_response=process_result.raw_response + ) + except NetaxeptProtocolError as exception: + return GatewayResponse( + is_success=False, + kind=operation_type.value, + amount=payment_information.amount, + currency=payment_information.currency, + transaction_id=payment_information.token, + error=exception.error, + raw_response=exception.raw_response + ) + + +_operation_type_to_netaxept_op = { + OperationType.AUTH: NetaxeptOperation.AUTH, + OperationType.CAPTURE: NetaxeptOperation.CAPTURE, + OperationType.VOID: NetaxeptOperation.ANNUL, + OperationType.REFUND: NetaxeptOperation.CREDIT, +} diff --git a/payment/gateways/netaxept/actions.py b/payment/gateways/netaxept/actions.py new file mode 100644 index 0000000..4259e59 --- /dev/null +++ b/payment/gateways/netaxept/actions.py @@ -0,0 +1,81 @@ +from django.db import transaction +from django.shortcuts import get_object_or_404 + +from payment import get_payment_gateway, TransactionKind +from payment.gateways.netaxept import NetaxeptProtocolError +from payment.gateways.netaxept import netaxept_protocol, gateway_to_netaxept_config +from payment.models import Payment, Transaction + + +class NetaxeptException(Exception): + def __str__(self): + return repr(self.msg) + + +class PaymentAlreadyRegistered(NetaxeptException): + msg = 'Payment already registered' + + +def register_payment(payment_id: int) -> str: + """ + - Registers the payment with netaxept. + - Records a Transaction representing the registration. + - Stores the newly created netaxept transaction_id in the Payment. + + :param payment_id: The id of a Payment object. + :return: The newly created netaxept transaction_id + :raises NetaxeptException: If the registration fails + """ + payment = get_object_or_404(Payment, id=payment_id) + + if payment.token != '': + raise PaymentAlreadyRegistered() + + _payment_gateway, gateway_config = get_payment_gateway(payment.gateway) + netaxept_config = gateway_to_netaxept_config(gateway_config) + + try: + register_response = netaxept_protocol.register( + config=netaxept_config, + order_number=payment_id, + amount=payment.total, + language='en') + except NetaxeptProtocolError as exception: + Transaction.objects.create( + payment=payment, + kind=TransactionKind.REGISTER, + token='', + is_success=False, + amount=payment.total, + error=exception.error, + gateway_response=exception.raw_response) + raise NetaxeptException(exception.error) + + with transaction.atomic(): + Transaction.objects.create( + payment=payment, + kind=TransactionKind.REGISTER, + token=register_response.transaction_id, + is_success=True, + amount=payment.total, + error=None, + gateway_response=register_response.raw_response) + + payment.token = register_response.transaction_id + payment.save() + + return register_response.transaction_id + + +def create_auth_transaction(transaction_id: str, success: bool) -> None: + """ Record the outcome of a netaxept auth transaction. """ + payment = Payment.objects.get(token=transaction_id) + + Transaction.objects.create( + payment=payment, + kind=TransactionKind.AUTH, + token=transaction_id, + is_success=success, + amount=payment.total, + error=None, + gateway_response={}) diff --git a/payment/gateways/netaxept/netaxept_protocol.py b/payment/gateways/netaxept/netaxept_protocol.py new file mode 100644 index 0000000..c5dd4be --- /dev/null +++ b/payment/gateways/netaxept/netaxept_protocol.py @@ -0,0 +1,198 @@ +""" +Low-level communications with netaxept. + +To avoid overcustomization this library makes a few choices on behalf of the library user: +- AutoAuth is turned on. +- We always redirect after the terminal (after_terminal_url must be configured) +- The terminal is displayed as a single page. + +Netaxept reference: +------------------- +https://shop.nets.eu/web/partners/home +Read this first: https://shop.nets.eu/web/partners/flow-outline +Terminal details: https://shop.nets.eu/web/partners/terminal-options +API details: https://shop.nets.eu/web/partners/appi +Test card numbers: https://shop.nets.eu/web/partners/test-cards +""" +from decimal import Decimal +from enum import Enum +from urllib.parse import urlencode, urljoin + +import requests +import xmltodict +from dataclasses import dataclass +from moneyed import Money +from structlog import get_logger +from typing import Optional, Union, Dict + +logger = get_logger() + + +@dataclass +class NetaxeptConfig: + merchant_id: str + secret: str + base_url: str + after_terminal_url: str + + +class NetaxeptOperation(Enum): + AUTH = 'AUTH' + VERIFY = 'VERIFY' + SALE = 'SALE' + CAPTURE = 'CAPTURE' + CREDIT = 'CREDIT' + ANNUL = 'ANNUL' + + +class NetaxeptProtocolError(Exception): + def __init__(self, error: str, raw_response: Dict[str, str]): + self.error = error + self.raw_response = raw_response + + +@dataclass +class RegisterResponse: + transaction_id: str + raw_response: Dict[str, str] + + +def register(config: NetaxeptConfig, amount: Money, order_number: Union[str, int], + language: Optional[str] = None, description: Optional[str] = None) -> RegisterResponse: + """ + Registering a payment is the first step for netaxept, before taking the user to the netaxept + terminal hosted page. + + See: https://shop.nets.eu/web/partners/register + + :param config: The netaxept configuration + :param amount: The amount of the payment. + :param order_number: An alphanumerical string identifying the payment. 32 chars max (letters and numbers) + :param language: The iso639-1 code of the language in which the terminal should be displayed. + :param description: A text that will be displayed in the netaxept admin (but not to the user). + :return: a RegisterResponse + :raises: NetaxeptProtocolError + """ + + logger.info('netaxept-register', amount=amount, order_number=order_number, language=language, + description=description) + + params = { + 'merchantId': config.merchant_id, + 'token': config.secret, + 'description': description, + + # Order + 'orderNumber': order_number, + 'amount': _money_to_netaxept_amount(amount), + 'currencyCode': _money_to_netaxept_currency(amount), + + # Terminal + 'autoAuth': True, + 'terminalSinglePage': True, + 'language': _iso6391_to_netaxept_language(language), + 'redirectUrl': config.after_terminal_url + } + + response = requests.post(url=urljoin(config.base_url, 'Netaxept/Register.aspx'), + data=params) + raw_response = _build_raw_response(response) + + logger.info('netaxept-register', amount=amount, order_number=order_number, language=language, + description=description, raw_response=raw_response) + + if response.status_code == requests.codes.ok: + d = xmltodict.parse(response.text) + if 'RegisterResponse' in d: + return RegisterResponse( + transaction_id=d['RegisterResponse']['TransactionId'], + raw_response=raw_response) + elif 'Exception' in d: + raise NetaxeptProtocolError(d['Exception']['Error']['Message'], raw_response) + raise NetaxeptProtocolError(response.reason, raw_response) + + +@dataclass +class ProcessResponse: + response_code: str + raw_response: Dict[str, str] + + +def process(config: NetaxeptConfig, transaction_id: str, operation: NetaxeptOperation, + amount: Decimal) -> ProcessResponse: + """ + :param config: The netaxept config + :param transaction_id: The id of the transaction, should match the transaction id of the register call. + :param operation: The type of operation to perform + :param amount: The amount to process (only applies to Capture and Refund. + :return: ProcessResponse + :raises: NetaxeptProtocolError + """ + logger.info('netaxept-process', transaction_id=transaction_id, operation=operation.value, amount=amount) + + params = { + 'merchantId': config.merchant_id, + 'token': config.secret, + 'operation': operation.value, + 'transactionId': transaction_id, + 'transactionAmount': _decimal_to_netaxept_amount(amount), + } + + response = requests.post(url=urljoin(config.base_url, 'Netaxept/Process.aspx'), + data=params) + + raw_response = _build_raw_response(response) + + logger.info('netaxept-process-response', transaction_id=transaction_id, operation=operation.value, + amount=amount, raw_response=raw_response) + + if response.status_code == requests.codes.ok: + d = xmltodict.parse(response.text) + if 'ProcessResponse' in d: + return ProcessResponse( + response_code=d['ProcessResponse']['ResponseCode'], + raw_response=raw_response) + elif 'Exception' in d: + raise NetaxeptProtocolError(d['Exception']['Error']['Message'], raw_response) + raise NetaxeptProtocolError(response.reason, raw_response) + + +def get_payment_terminal_url(config: NetaxeptConfig, transaction_id: str) -> str: + qs = urlencode({'merchantId': config.merchant_id, 'transactionId': transaction_id}) + return '{}?{}'.format(urljoin(config.base_url, 'Terminal/default.aspx'), qs) + + +def _decimal_to_netaxept_amount(decimal_amount: Decimal) -> int: + """ Return the netaxept representation of the decimal representation of the amount. """ + return int((decimal_amount * 100).to_integral_value()) + + +def _money_to_netaxept_amount(money: Money) -> int: + """ Return the netaxept representation of the money's amount. """ + return _decimal_to_netaxept_amount(money.amount) + + +def _money_to_netaxept_currency(money: Money) -> str: + """ Return the netaxept representation of the money's currency. """ + return money.currency.code + + +_netaxept_language_codes = ['no_NO', 'sv_SE', 'da_DK', 'fi_FI ', 'en_GB', 'de_DE', 'fr_FR', 'ru_RU ', 'pl_PL', + 'nl_NL', 'es_ES', 'it_IT', 'pt_PT', 'et_EE', 'lv_LV', 'lt_LT'] + +_netaxept_language_codes_by_prefix = {l[:2]: l for l in _netaxept_language_codes} + + +def _iso6391_to_netaxept_language(iso6391_language: Optional[str]) -> Optional[str]: + """ Return the netaxept representation of the language. """ + return _netaxept_language_codes_by_prefix.get(iso6391_language) # type:ignore + + +def _build_raw_response(response: requests.Response): + return { + 'status_code': response.status_code, + 'url': response.url, + 'encoding': response.encoding, + 'reason': response.reason, + 'text': response.text, + } diff --git a/payment/gateways/stripe/__init__.py b/payment/gateways/stripe/__init__.py index a5528b7..5b7e8c8 100644 --- a/payment/gateways/stripe/__init__.py +++ b/payment/gateways/stripe/__init__.py @@ -1,5 +1,5 @@ import stripe -from typing import Dict +from typing import Dict, Optional from . import connect from .forms import StripePaymentModalForm @@ -183,7 +183,7 @@ def _create_stripe_charge(client, payment_information, should_capture: bool): def _create_response( - payment_information: PaymentData, kind: str, response: Dict, error: str + payment_information: PaymentData, kind: str, response: Dict, error: Optional[str] ) -> GatewayResponse: # Get currency from response or payment currency = get_currency_from_stripe( diff --git a/payment/interface.py b/payment/interface.py index 9ba92b3..4be9b9c 100644 --- a/payment/interface.py +++ b/payment/interface.py @@ -1,6 +1,5 @@ -from decimal import Decimal - from dataclasses import dataclass +from decimal import Decimal from typing import Any, Dict, Optional @@ -14,7 +13,7 @@ class GatewayResponse: kind: str amount: Decimal currency: str - transaction_id: str + transaction_id: Optional[str] error: Optional[str] raw_response: Optional[Dict[str, str]] = None diff --git a/payment/locale/fr/LC_MESSAGES/django.mo b/payment/locale/fr/LC_MESSAGES/django.mo index e11dc14..49635e6 100644 Binary files a/payment/locale/fr/LC_MESSAGES/django.mo and b/payment/locale/fr/LC_MESSAGES/django.mo differ diff --git a/payment/locale/fr/LC_MESSAGES/django.po b/payment/locale/fr/LC_MESSAGES/django.po index 8833941..21bc847 100644 --- a/payment/locale/fr/LC_MESSAGES/django.po +++ b/payment/locale/fr/LC_MESSAGES/django.po @@ -8,8 +8,8 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2019-09-12 09:24-0500\n" -"PO-Revision-Date: 2019-09-12 14:25+0000\n" +"POT-Creation-Date: 2019-09-16 16:06+0200\n" +"PO-Revision-Date: 2019-09-16 14:06+0000\n" "Last-Translator: b' '\n" "Language-Team: LANGUAGE \n" "Language: \n" @@ -24,77 +24,98 @@ msgctxt "Custom payment choice type" msgid "Manual" msgstr "Manuel" -#: __init__.py:70 +#: __init__.py:72 +#| msgid "transaction" +msgctxt "transaction kind" +msgid "Registration" +msgstr "Enregistrement" + +#: __init__.py:73 msgctxt "transaction kind" msgid "Authorization" msgstr "Autorisation" -#: __init__.py:71 +#: __init__.py:74 msgctxt "transaction kind" msgid "Refund" msgstr "Remboursement" -#: __init__.py:72 +#: __init__.py:75 msgctxt "transaction kind" msgid "Capture" msgstr "Capture" -#: __init__.py:73 +#: __init__.py:76 msgctxt "transaction kind" msgid "Void" msgstr "Annulation" -#: __init__.py:97 +#: __init__.py:100 msgctxt "payment status" msgid "Not charged" msgstr "Non payé" -#: __init__.py:98 +#: __init__.py:101 msgctxt "payment status" msgid "Partially charged" msgstr "Payé en partie" -#: __init__.py:99 +#: __init__.py:102 msgctxt "payment status" msgid "Fully charged" msgstr "Payé" -#: __init__.py:100 +#: __init__.py:103 msgctxt "payment status" msgid "Partially refunded" msgstr "Remboursé en partie " -#: __init__.py:101 +#: __init__.py:104 msgctxt "payment status" msgid "Fully refunded" msgstr "Remboursé" -#: admin.py:28 models.py:168 +#: admin.py:29 models.py:168 msgid "amount" msgstr "montant" -#: admin.py:36 models.py:37 models.py:162 +#: admin.py:37 models.py:37 models.py:162 msgid "created" msgstr "créé" -#: admin.py:44 models.py:38 +#: admin.py:45 models.py:38 msgid "modified" msgstr "modifié" -#: admin.py:127 models.py:42 +#: admin.py:179 models.py:42 msgid "total" msgstr "total" -#: admin.py:133 models.py:43 +#: admin.py:185 models.py:43 msgid "captured amount" msgstr "montant capturé" -#: admin.py:150 admin.py:154 +#: admin.py:208 #| msgctxt "transaction kind" -#| msgid "Refund" +#| msgid "Capture" +msgid "Capture" +msgstr "Capture" + +#: admin.py:213 msgid "Refund" msgstr "Remboursement" +#: admin.py:218 +#| msgctxt "transaction kind" +#| msgid "Void" +msgid "Void" +msgstr "Annulation" + +#: admin.py:222 +#| msgid "transaction" +msgid "Operation" +msgstr "Operation" + #: apps.py:7 msgid "Payment" msgstr "Paiement" @@ -184,7 +205,6 @@ msgid "payments" msgstr "paiements" #: models.py:65 -#| msgid "Payment %s (%s)" msgid "Payment {} ({})" msgstr "Paiement {} ({})" @@ -211,3 +231,7 @@ msgstr "transaction" #: models.py:174 msgid "transactions" msgstr "transactions" + +#: templates/admin/payment/form.html:8 +msgid "Home" +msgstr "Accueil" diff --git a/payment/migrations/0003_index_token_and_add_transaction_kind.py b/payment/migrations/0003_index_token_and_add_transaction_kind.py new file mode 100644 index 0000000..89e0825 --- /dev/null +++ b/payment/migrations/0003_index_token_and_add_transaction_kind.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.4 on 2019-09-11 09:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payment', '0002_cascade_delete_transaction'), + ] + + operations = [ + migrations.AlterField( + model_name='payment', + name='token', + field=models.CharField(blank=True, db_index=True, default='', max_length=128, verbose_name='token'), + ), + migrations.AlterField( + model_name='transaction', + name='kind', + field=models.CharField(choices=[('register', 'Registration'), ('auth', 'Authorization'), ('refund', 'Refund'), ('capture', 'Capture'), ('void', 'Void')], max_length=10, verbose_name='kind'), + ), + ] diff --git a/payment/models.py b/payment/models.py index 0a2f644..cfea249 100644 --- a/payment/models.py +++ b/payment/models.py @@ -38,7 +38,7 @@ class Payment(models.Model): modified = models.DateTimeField(_('modified'), auto_now=True) charge_status = models.CharField(_('charge status'), max_length=20, choices=ChargeStatus.CHOICES, default=ChargeStatus.NOT_CHARGED) - token = models.CharField(_('token'), max_length=128, blank=True, default="") + token = models.CharField(_('token'), max_length=128, blank=True, default="", db_index=True) total = MoneyField(_('total'), max_digits=12, decimal_places=2) captured_amount = MoneyField(_('captured amount'), max_digits=12, decimal_places=2) @@ -113,7 +113,7 @@ def can_authorize(self): return self.is_active and self.not_charged def can_capture(self): - if not (self.is_active and self.not_charged): + if not (self.is_active and self.charge_status in [ChargeStatus.NOT_CHARGED, ChargeStatus.PARTIALLY_CHARGED]): return False _, gateway_config = get_payment_gateway(self.gateway) diff --git a/payment/templates/admin/payment/form.html b/payment/templates/admin/payment/form.html index c75afde..e144de4 100644 --- a/payment/templates/admin/payment/form.html +++ b/payment/templates/admin/payment/form.html @@ -17,6 +17,6 @@
    {% csrf_token %} {{ form.as_p }} - +
    {% endblock %} \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index be7361a..91e8ab2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,11 +1,15 @@ [flake8] exclude = .git, .tox, .direnv, */migrations/* -max-line-length = 119 +max-line-length = 120 + +[pycodestyle] +max-line-length = 120 [tool:pytest] DJANGO_SETTINGS_MODULE = tests.settings markers = integration +python_functions = test_* it_* [mypy] ignore_missing_imports = True @@ -21,4 +25,4 @@ omit = [coverage:report] omit = */management/* - */admin.py \ No newline at end of file + */admin.py diff --git a/setup.py b/setup.py index 0f040c5..d71e12e 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ setup( name='django-payment', - version='1.1', + version='1.2', description='', long_description='', author='Nicholas Wolff', @@ -16,6 +16,7 @@ 'payment.migrations', 'payment.gateways.dummy', 'payment.gateways.stripe', + 'payment.gateways.netaxept', ], package_data={ 'payment': [ @@ -32,6 +33,8 @@ 'django-countries', 'dataclasses', 'django-import-export', + 'requests', + 'xmltodict', ], license='MIT', classifiers=[ diff --git a/tests/gateways/test_netaxept.py b/tests/gateways/test_netaxept.py new file mode 100644 index 0000000..fde536e --- /dev/null +++ b/tests/gateways/test_netaxept.py @@ -0,0 +1,283 @@ +# flake8: noqa +from dataclasses import dataclass, asdict +from decimal import Decimal +from unittest.mock import patch + +import pytest +from moneyed import Money +from pytest import raises + +from payment import GatewayConfig, ChargeStatus +from payment.gateways.netaxept import gateway_to_netaxept_config, capture, refund +from payment.gateways.netaxept.netaxept_protocol import NetaxeptConfig, get_payment_terminal_url, \ + _iso6391_to_netaxept_language, _money_to_netaxept_amount, _money_to_netaxept_currency, register, RegisterResponse, \ + NetaxeptProtocolError, process, ProcessResponse, NetaxeptOperation +from payment.interface import GatewayResponse +from payment.utils import create_payment_information + +_gateway_config = GatewayConfig( + auto_capture=True, + template_path="template.html", + connection_params={ + 'merchant_id': '123456', + 'secret': 'supersekret', + 'base_url': 'https://test.epayment.nets.eu', + 'after_terminal_url': 'http://localhost', + }, +) + +_netaxept_config = NetaxeptConfig( + merchant_id='123456', + secret='supersekret', + base_url='https://test.epayment.nets.eu', + after_terminal_url='http://localhost') + + +############################################################################## +# Utility tests + +def it_should_return_netaxept_language(): + assert _iso6391_to_netaxept_language('fr') == 'fr_FR' + + +def it_should_return_none_netaxept_language_when_given_none(): + assert _iso6391_to_netaxept_language(None) is None + + +def it_should_transform_money_to_netaxept_representation(): + money = Money(10, 'NOK') + assert _money_to_netaxept_amount(money) == 1000 + assert _money_to_netaxept_currency(money) == 'NOK' + + +def it_should_build_terminal_url(): + assert get_payment_terminal_url(_netaxept_config, transaction_id='11111') == \ + 'https://test.epayment.nets.eu/Terminal/default.aspx?merchantId=123456&transactionId=11111' + + +############################################################################## +# Protocol tests + +@dataclass +class MockResponse: + status_code: int + url: str + encoding: str + reason: str + text: str + + +@patch('requests.post') +def it_should_register(requests_post): + mock_response = MockResponse( + status_code=200, + url='https://test.epayment.nets.eu/Netaxept/Register.aspx', + encoding='ISO-8859-1', + reason='OK', + text=""" + + 7624b99699f344e3b6da9884d20f0b27 + """) + requests_post.return_value = mock_response + register_response = register(_netaxept_config, amount=Money(10, 'CHF'), order_number='123') + assert register_response == RegisterResponse( + transaction_id='7624b99699f344e3b6da9884d20f0b27', + raw_response=asdict(mock_response)) + requests_post.assert_called_with( + url='https://test.epayment.nets.eu/Netaxept/Register.aspx', + data={'merchantId': '123456', 'token': 'supersekret', 'description': None, 'orderNumber': '123', + 'amount': 1000, 'currencyCode': 'CHF', 'autoAuth': True, 'terminalSinglePage': True, + 'language': None, 'redirectUrl': 'http://localhost'}) + + +@patch('requests.post') +def it_should_handle_registration_failure(requests_post): + mock_response = MockResponse( + status_code=200, + url='https://test.epayment.nets.eu/Netaxept/Register.aspx', + encoding='ISO-8859-1', + reason='OK', + text=""" + + + Unable to translate supermerchant to submerchant, please check currency code and merchant ID + + """) + requests_post.return_value = mock_response + with raises(NetaxeptProtocolError) as excinfo: + register(_netaxept_config, amount=Money(10, 'CAD'), order_number='123') + assert excinfo.value == NetaxeptProtocolError( + error='Unable to translate supermerchant to submerchant, please check currency code and merchant ID', + raw_response=asdict(mock_response)) + requests_post.assert_called_with( + url='https://test.epayment.nets.eu/Netaxept/Register.aspx', + data={'merchantId': '123456', 'token': 'supersekret', 'description': None, 'orderNumber': '123', + 'amount': 1000, 'currencyCode': 'CAD', 'autoAuth': True, 'terminalSinglePage': True, + 'language': None, 'redirectUrl': 'http://localhost'}) + + +@patch('requests.post') +def it_should_process(requests_post): + mock_response = MockResponse( + status_code=200, + url='https://test.epayment.nets.eu/Netaxept/Register.aspx', + encoding='ISO-8859-1', + reason='OK', + text=""" + + 675 + 2019-09-16T17:31:00.7593672+02:00 + 123456 + CAPTURE + OK + 1111111111114cf693a1cf86123e0d8f + """) + requests_post.return_value = mock_response + process_response = process( + config=_netaxept_config, + transaction_id='1111111111114cf693a1cf86123e0d8f', + operation=NetaxeptOperation.CAPTURE, + amount=Decimal(10)) + assert process_response == ProcessResponse( + response_code='OK', + raw_response=asdict(mock_response)) + requests_post.assert_called_with( + url='https://test.epayment.nets.eu/Netaxept/Process.aspx', + data={'merchantId': '123456', 'token': 'supersekret', 'operation': 'CAPTURE', + 'transactionId': '1111111111114cf693a1cf86123e0d8f', 'transactionAmount': 1000}) + + +@patch('requests.post') +def it_should_handle_process_failure(requests_post): + mock_response = MockResponse( + status_code=200, + url='https://test.epayment.nets.eu/Netaxept/Register.aspx', + encoding='ISO-8859-1', + reason='OK', + text=""" + + + Unable to translate supermerchant to submerchant, please check currency code and merchant ID + + """) + requests_post.return_value = mock_response + with raises(NetaxeptProtocolError) as excinfo: + process( + config=_netaxept_config, + transaction_id='1111111111114cf693a1cf86123e0d8f', + operation=NetaxeptOperation.CAPTURE, + amount=Decimal(10)) + assert excinfo.value == NetaxeptProtocolError( + error='Unable to translate supermerchant to submerchant, please check currency code and merchant ID', + raw_response=asdict(mock_response)) + requests_post.assert_called_with( + url='https://test.epayment.nets.eu/Netaxept/Process.aspx', + data={'merchantId': '123456', 'token': 'supersekret', 'operation': 'CAPTURE', + 'transactionId': '1111111111114cf693a1cf86123e0d8f', 'transactionAmount': 1000}) + + +############################################################################## +# SPI tests + +@pytest.fixture() +def netaxept_authorized_payment(payment_dummy): + payment_dummy.charge_status = ChargeStatus.NOT_CHARGED + payment_dummy.save() + return payment_dummy + + +def it_builds_netaxept_config(): + assert gateway_to_netaxept_config(_gateway_config) == _netaxept_config + + +@patch('payment.gateways.netaxept.process') +def it_should_capture(process, netaxept_authorized_payment): + mock_process_response = ProcessResponse( + response_code='OK', + raw_response={'status_code': 200, 'url': 'https://test.epayment.nets.eu/Netaxept/Register.aspx', + 'encoding': 'ISO-8859-1', 'reason': 'OK', + 'text': '\n \n 675\n 2019-09-16T17:31:00.7593672+02:00\n 123456\n CAPTURE\n OK\n 1111111111114cf693a1cf86123e0d8f\n '}) + process.return_value = mock_process_response + payment_info = create_payment_information( + payment=netaxept_authorized_payment, + payment_token='1111111111114cf693a1cf86123e0d8f', + amount=Money(10, 'CHF')) + capture_result = capture(config=_gateway_config, payment_information=payment_info) + assert capture_result == GatewayResponse( + is_success=True, + kind='capture', + amount=Decimal('10'), + currency='CHF', + transaction_id='1111111111114cf693a1cf86123e0d8f', + error=None, + raw_response=mock_process_response.raw_response) + process.assert_called_with( + config=NetaxeptConfig( + merchant_id='123456', + secret='supersekret', + base_url='https://test.epayment.nets.eu', + after_terminal_url='http://localhost'), + amount=Decimal('10'), + transaction_id='1111111111114cf693a1cf86123e0d8f', + operation=NetaxeptOperation.CAPTURE) + + +@patch('payment.gateways.netaxept.process') +def it_should_not_capture_when_protocol_error(process, netaxept_authorized_payment): + process.side_effect = NetaxeptProtocolError(error='some error', raw_response={}) + payment_info = create_payment_information( + payment=netaxept_authorized_payment, + payment_token='1111111111114cf693a1cf86123e0d8f', + amount=Money(10, 'CHF')) + capture_result = capture(config=_gateway_config, payment_information=payment_info) + assert capture_result == GatewayResponse( + is_success=False, + kind='capture', + amount=Decimal('10'), + currency='CHF', + transaction_id='1111111111114cf693a1cf86123e0d8f', + error='some error', + raw_response={}) + process.assert_called_with( + config=NetaxeptConfig( + merchant_id='123456', + secret='supersekret', + base_url='https://test.epayment.nets.eu', + after_terminal_url='http://localhost'), + amount=Decimal('10'), + transaction_id='1111111111114cf693a1cf86123e0d8f', + operation=NetaxeptOperation.CAPTURE) + + +@patch('payment.gateways.netaxept.process') +def it_should_refund(process, netaxept_authorized_payment): + mock_process_response = ProcessResponse( + response_code='OK', + raw_response={ + 'status_code': 200, + 'url': 'https://test.epayment.nets.eu/Netaxept/Register.aspx', + 'encoding': 'ISO-8859-1', 'reason': 'OK', + 'text': '\n \n 675\n 2019-09-16T17:31:00.7593672+02:00\n 123456\n REFUND\n OK\n 1111111111114cf693a1cf86123e0d8f\n '}) + process.return_value = mock_process_response + payment_info = create_payment_information( + payment=netaxept_authorized_payment, + payment_token='1111111111114cf693a1cf86123e0d8f', + amount=Money(10, 'CHF')) + capture_result = refund(config=_gateway_config, payment_information=payment_info) + assert capture_result == GatewayResponse( + is_success=True, + kind='refund', + amount=Decimal('10'), + currency='CHF', + transaction_id='1111111111114cf693a1cf86123e0d8f', + error=None, + raw_response=mock_process_response.raw_response) + process.assert_called_with( + config=NetaxeptConfig( + merchant_id='123456', + secret='supersekret', + base_url='https://test.epayment.nets.eu', + after_terminal_url='http://localhost'), + amount=Decimal('10'), + transaction_id='1111111111114cf693a1cf86123e0d8f', + operation=NetaxeptOperation.CREDIT) diff --git a/tests/settings.py b/tests/settings.py index 2a9624e..dfc2b73 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -59,10 +59,12 @@ DUMMY = 'dummy' STRIPE = 'stripe' +NETAXEPT = 'netaxept' CHECKOUT_PAYMENT_GATEWAYS = { DUMMY: 'Dummy gateway', STRIPE: 'Stripe', + NETAXEPT: 'Netaxept', } PAYMENT_GATEWAYS = { @@ -92,4 +94,17 @@ } } }, + NETAXEPT: { + 'module': 'payment.gateways.netaxept', + 'config': { + 'auto_capture': True, + 'template_path': 'payment/netaxept.html', + 'connection_params': { + 'merchant_id': os.environ.get('NETAXEPT_MERCHANT_ID'), + 'token': os.environ.get('NETAXEPT_TOKEN'), + 'base_url': os.environ.get('NETAXEPT_BASE_URL') or 'https://test.epayment.nets.eu', + 'after_terminal_url': os.environ.get('NETAXEPT_AFTER_TERMINAL_URL'), + } + } + }, } diff --git a/tox.ini b/tox.ini index 6606d1f..fb04ca7 100644 --- a/tox.ini +++ b/tox.ini @@ -21,6 +21,8 @@ deps = django-countries dataclasses django-import-export + requests + xmltodict pytest-django pytest-cov flake8