diff --git a/payment_sixpay/.flake8 b/payment_sixpay/.flake8 new file mode 100644 index 0000000..b7cdb63 --- /dev/null +++ b/payment_sixpay/.flake8 @@ -0,0 +1,29 @@ +[flake8] +max-line-length = 120 + +# colored output +format = ${cyan}%(path)s${reset}:${yellow_bold}%(row)d${reset}:${green_bold}%(col)d${reset}: ${red_bold}%(code)s${reset} %(text)s + +# decent quote styles +inline-quotes = single +multiline-quotes = single +docstring-quotes = double +avoid-escape = true + +extend-exclude = + build + dist + docs + htmlcov + *.egg-info + node_modules + .*/ + +ignore = + # allow omitting whitespace around arithmetic operators + E226 + # don't require specific wrapping before/after binary operators + W503 + W504 + # allow assigning lambdas (it's useful for single-line functions defined inside other functions) + E731 diff --git a/payment_sixpay/.gitignore b/payment_sixpay/.gitignore new file mode 100644 index 0000000..87f6d35 --- /dev/null +++ b/payment_sixpay/.gitignore @@ -0,0 +1,180 @@ + +### Linux template +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* +### macOS template +*.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 +.idea + +## File-based project format: +*.iws + +## Plugin-specific files: + +# IntelliJ +/out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties +### Windows template +# Windows image file caches +Thumbs.db +ehthumbs.db + +# Folder config file +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msm +*.msp + +# Windows shortcuts +*.lnk +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/build +docs/_build +docs/_templates +docs/_static + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# dotenv +.env + +# virtualenv +.venv/ +venv/ +ENV/ + +# Spyder project settings +.spyderproject + +# Rope project settings +.ropeproject + diff --git a/payment_sixpay/.isort.cfg b/payment_sixpay/.isort.cfg new file mode 100644 index 0000000..d780987 --- /dev/null +++ b/payment_sixpay/.isort.cfg @@ -0,0 +1,8 @@ +[isort] +line_length=120 +multi_line_output=0 +lines_after_imports=2 +sections=FUTURE,STDLIB,THIRDPARTY,INDICO,FIRSTPARTY,LOCALFOLDER +known_third_party=flask_multipass,flask_pluginengine +known_indico=indico +skip_glob=20??????????_*_*.py diff --git a/payment_sixpay/LICENSE b/payment_sixpay/LICENSE new file mode 100644 index 0000000..c4a98ae --- /dev/null +++ b/payment_sixpay/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) European Organization for Nuclear Research (CERN) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/payment_sixpay/MANIFEST.in b/payment_sixpay/MANIFEST.in new file mode 100644 index 0000000..a45de03 --- /dev/null +++ b/payment_sixpay/MANIFEST.in @@ -0,0 +1,4 @@ +graft indico_payment_sixpay/templates +graft indico_payment_sixpay/translations + +global-exclude *.pyc __pycache__ .keep diff --git a/payment_sixpay/README.md b/payment_sixpay/README.md new file mode 100644 index 0000000..10703d2 --- /dev/null +++ b/payment_sixpay/README.md @@ -0,0 +1,19 @@ +# SIXPay-Saferpay Payment Plugin + +This plugin provides a payment option for Indico's payment module using the +SIXPay Saferpay API. + +When used, the user will be sent to Saferpay to make the payment, and afterwards +they are automatically sent back to Indico. + +## Changelog + +### 3.0 + +- Initial release for Indico 3.0 + +## Credits + +Originally developed by Max Fischer for Indico 1.2 and 2.x. Updated to use the +latest SIXPay API by Martin Claus. Adapted to Indico 3.0 and Python 3 by the +CERN Indico Team. diff --git a/payment_sixpay/headers.yml b/payment_sixpay/headers.yml new file mode 100644 index 0000000..0b33f8f --- /dev/null +++ b/payment_sixpay/headers.yml @@ -0,0 +1,11 @@ +root: true +name: Max Fischer, Martin Claus, CERN +start_year: 2017 +header: |- + {comment_start} This file is part of the Indico plugins. + {comment_middle} Copyright (C) {dates} {name} + {comment_middle} + {comment_middle} The Indico plugins are free software; you can redistribute + {comment_middle} them and/or modify them under the terms of the MIT License; + {comment_middle} see the LICENSE file for more details. + {comment_end} diff --git a/payment_sixpay/indico_payment_sixpay/__init__.py b/payment_sixpay/indico_payment_sixpay/__init__.py new file mode 100644 index 0000000..7dda1a5 --- /dev/null +++ b/payment_sixpay/indico_payment_sixpay/__init__.py @@ -0,0 +1,11 @@ +# This file is part of the Indico plugins. +# Copyright (C) 2017 - 2021 Max Fischer, Martin Claus, CERN +# +# The Indico plugins are free software; you can redistribute +# them and/or modify them under the terms of the MIT License; +# see the LICENSE file for more details. + +from indico.util.i18n import make_bound_gettext + + +_ = make_bound_gettext('payment_sixpay') diff --git a/payment_sixpay/indico_payment_sixpay/blueprint.py b/payment_sixpay/indico_payment_sixpay/blueprint.py new file mode 100644 index 0000000..d65a91c --- /dev/null +++ b/payment_sixpay/indico_payment_sixpay/blueprint.py @@ -0,0 +1,23 @@ +# This file is part of the Indico plugins. +# Copyright (C) 2017 - 2021 Max Fischer, Martin Claus, CERN +# +# The Indico plugins are free software; you can redistribute +# them and/or modify them under the terms of the MIT License; +# see the LICENSE file for more details. + +from indico.core.plugins import IndicoPluginBlueprint + +from indico_payment_sixpay.controllers import (RHInitSixpayPayment, SixpayNotificationHandler, UserCancelHandler, + UserFailureHandler, UserSuccessHandler) + + +blueprint = IndicoPluginBlueprint( + 'payment_sixpay', __name__, + url_prefix='/event//registrations//payment/sixpay' +) + +blueprint.add_url_rule('/init', 'init', RHInitSixpayPayment, methods=('GET', 'POST')) +blueprint.add_url_rule('/failure', 'failure', UserCancelHandler, methods=('GET', 'POST')) +blueprint.add_url_rule('/cancel', 'cancel', UserFailureHandler, methods=('GET', 'POST')) +blueprint.add_url_rule('/success', 'success', UserSuccessHandler, methods=('GET', 'POST')) +blueprint.add_url_rule('/notify', 'notify', SixpayNotificationHandler, methods=('Get', 'POST')) diff --git a/payment_sixpay/indico_payment_sixpay/controllers.py b/payment_sixpay/indico_payment_sixpay/controllers.py new file mode 100644 index 0000000..97fdced --- /dev/null +++ b/payment_sixpay/indico_payment_sixpay/controllers.py @@ -0,0 +1,357 @@ +# This file is part of the Indico plugins. +# Copyright (C) 2017 - 2021 Max Fischer, Martin Claus, CERN +# +# The Indico plugins are free software; you can redistribute +# them and/or modify them under the terms of the MIT License; +# see the LICENSE file for more details. + +from urllib.parse import urljoin + +import requests +from flask import flash, redirect, request +from requests import RequestException +from werkzeug.exceptions import BadRequest, NotFound + +from indico.core.plugins import url_for_plugin +from indico.modules.events.payment.controllers import RHPaymentBase +from indico.modules.events.payment.models.transactions import TransactionAction +from indico.modules.events.payment.notifications import notify_amount_inconsistency +from indico.modules.events.payment.util import get_active_payment_plugins, register_transaction +from indico.modules.events.registration.models.registrations import Registration +from indico.web.flask.util import url_for +from indico.web.rh import RH + +from indico_payment_sixpay import _ +from indico_payment_sixpay.plugin import SixpayPaymentPlugin +from indico_payment_sixpay.util import (PROVIDER_SIXPAY, SIXPAY_JSON_API_SPEC, SIXPAY_PP_ASSERT_URL, + SIXPAY_PP_CANCEL_URL, SIXPAY_PP_CAPTURE_URL, SIXPAY_PP_INIT_URL, + get_request_header, get_setting, get_terminal_id, to_large_currency, + to_small_currency) + + +class TransactionFailure(Exception): + """A transaction with SIXPay failed. + + :param step: name of the step at which the transaction failed + :param details: verbose description of what went wrong + """ + + def __init__(self, step, details=None): + self.step = step + self.details = details + + +class RHSixpayBase(RH): + """Request Handler for asynchronous callbacks from SIXPay. + + These handlers are used either by + + - the user, when he is redirected from SIXPay back to Indico + - SIXPay, when it sends back the result of a transaction + """ + + CSRF_ENABLED = False + + def _process_args(self): + self.registration = Registration.query.filter_by(uuid=request.args['token']).first() + if not self.registration: + raise BadRequest + self.token = self.registration.transaction.data['Init_PP_response']['Token'] + + def _get_setting(self, setting): + return get_setting(setting, self.registration.event) + + +class RHInitSixpayPayment(RHPaymentBase): + def _get_transaction_parameters(self): + """Get parameters for creating a transaction request.""" + settings = SixpayPaymentPlugin.event_settings.get_all(self.event) + format_map = { + 'user_id': self.registration.user_id, + 'user_name': self.registration.full_name, + 'user_firstname': self.registration.first_name, + 'user_lastname': self.registration.last_name, + 'event_id': self.registration.event_id, + 'event_title': self.registration.event.title, + 'registration_id': self.registration.id, + 'regform_title': self.registration.registration_form.title + } + order_description = settings['order_description'].format(**format_map) + order_identifier = settings['order_identifier'].format(**format_map) + # see the SIXPay Manual + # https://saferpay.github.io/jsonapi/#Payment_v1_PaymentPage_Initialize + # on what these things mean + transaction_parameters = { + 'RequestHeader': get_request_header(SIXPAY_JSON_API_SPEC, settings['account_id']), + 'TerminalId': get_terminal_id(settings['account_id']), + 'Payment': { + 'Amount': { + # indico handles price as largest currency, but six expects + # smallest. E.g. EUR: indico uses 100.2 Euro, but six + # expects 10020 Cent + 'Value': str(to_small_currency(self.registration.price, self.registration.currency)), + 'CurrencyCode': self.registration.currency, + }, + 'OrderId': order_identifier[:80], + 'DESCRIPTION': order_description[:1000], + }, + # callbacks of the transaction - where to announce success etc., when redircting the user + 'ReturnUrls': { + 'Success': url_for_plugin('payment_sixpay.success', self.registration.locator.uuid, _external=True), + 'Fail': url_for_plugin('payment_sixpay.failure', self.registration.locator.uuid, _external=True), + 'Abort': url_for_plugin('payment_sixpay.cancel', self.registration.locator.uuid, _external=True) + }, + 'Notification': { + # where to asynchronously call back from SIXPay + 'NotifyUrl': url_for_plugin('payment_sixpay.notify', self.registration.locator.uuid, _external=True) + } + } + if settings['notification_mail']: + transaction_parameters['Notification']['MerchantEmails'] = [settings['notification_mail']] + return transaction_parameters + + def _init_payment_page(self, transaction_data): + """Initialize payment page.""" + endpoint = urljoin(SixpayPaymentPlugin.settings.get('url'), SIXPAY_PP_INIT_URL) + credentials = (SixpayPaymentPlugin.settings.get('username'), SixpayPaymentPlugin.settings.get('password')) + resp = requests.post(endpoint, json=transaction_data, auth=credentials) + try: + resp.raise_for_status() + except RequestException as exc: + self.logger.error('Could not initialize payment: %s', exc.response.text) + raise Exception('Could not initialize payment') + return resp.json() + + def _process_args(self): + RHPaymentBase._process_args(self) + if 'sixpay' not in get_active_payment_plugins(self.event): + raise NotFound + if not SixpayPaymentPlugin.instance.supports_currency(self.registration.currency): + raise BadRequest + + def _process(self): + transaction_params = self._get_transaction_parameters() + init_response = self._init_payment_page(transaction_params) + payment_url = init_response['RedirectUrl'] + + # create pending transaction and store Saferpay transaction token + new_indico_txn = register_transaction( + self.registration, + self.registration.price, + self.registration.currency, + TransactionAction.pending, + PROVIDER_SIXPAY, + {'Init_PP_response': init_response} + ) + if not new_indico_txn: + # set it on the current transaction if we could not create a next one + # this happens if we already have a pending transaction and it's incredibly + # ugly... + self.registration.transaction.data = {'Init_PP_response': init_response} + return redirect(payment_url) + + +class SixpayNotificationHandler(RHSixpayBase): + """Handler for notification from SIXPay service.""" + + def __init__(self): + """Initialize request handler.""" + super().__init__() + self.sixpay_url = None + + def _process_args(self): + super()._process_args() + self.sixpay_url = get_setting('url') + + def _process(self): + """Process the reply from SIXPay about the transaction.""" + try: + self._process_confirmation() + except TransactionFailure as exc: + SixpayPaymentPlugin.logger.warning('SIXPay transaction failed during %s: %s', exc.step, exc.details) + + def _process_confirmation(self): + """Process the confirmation response inside indico.""" + # assert transaction status from SIXPay + try: + assert_response = self._assert_payment() + if self._is_duplicate_transaction(assert_response): + # we have already handled the transaction + return True + if self._is_authorized(assert_response) and not self._is_captured(assert_response): + self._capture_transaction(assert_response) + self._verify_amount(assert_response) + self._register_payment(assert_response) + except TransactionFailure as exc: + SixpayPaymentPlugin.logger.warning('SIXPay transaction failed during %s: %s', exc.step, exc.details) + raise + return True + + def _perform_request(self, task, endpoint, data): + """Perform a request against SIXPay. + + :param task: description of the request, used for error handling + :param endpoint: the URL endpoint *relative* to the SIXPay base URL + :param **data: data passed during the request + + This will automatically raise any HTTP errors encountered during the + request. If the request itself fails, a :py:exc:`~.TransactionFailure` + is raised for ``task``. + """ + request_url = urljoin(self.sixpay_url, endpoint) + credentials = (get_setting('username'), get_setting('password')) + response = requests.post(request_url, json=data, auth=credentials) + try: + response.raise_for_status() + except requests.HTTPError: + raise TransactionFailure(step=task, details=response.text) + return response + + def _assert_payment(self): + """Check the status of the transaction with SIXPay. + + Returns transaction assert data. + """ + assert_response = self._perform_request( + 'assert', + SIXPAY_PP_ASSERT_URL, + { + 'RequestHeader': get_request_header(SIXPAY_JSON_API_SPEC, self._get_setting('account_id')), + 'Token': self.token, + } + ) + if assert_response.ok: + return assert_response.json() + + def _is_duplicate_transaction(self, transaction_data): + """Check if this transaction has already been recorded.""" + prev_transaction = self.registration.transaction + if ( + not prev_transaction or + prev_transaction.provider != PROVIDER_SIXPAY or + 'Transaction' not in prev_transaction.data + ): + return False + old = prev_transaction.data.get('Transaction') + new = transaction_data.get('Transaction') + return ( + old['OrderId'] == new['OrderId'] and + old['Type'] == new['Type'] and + old['Id'] == new['Id'] and + old['SixTransactionReference'] == new['SixTransactionReference'] and + old['Amount']['Value'] == new['Amount']['Value'] and + old['Amount']['CurrencyCode'] == new['Amount']['CurrencyCode'] + ) + + def _is_authorized(self, assert_data): + """Check if payment is authorized.""" + return assert_data['Transaction']['Status'] == 'AUTHORIZED' + + def _is_captured(self, assert_data): + """Check if payment is captured, i.e. the cash flow is triggered.""" + return assert_data['Transaction']['Status'] == 'CAPTURED' + + def _verify_amount(self, assert_data): + """Verify the amount and currency of the payment. + + Sends an email but still registers incorrect payments. + """ + expected_amount = float(self.registration.price) + expected_currency = self.registration.currency + amount = float(assert_data['Transaction']['Amount']['Value']) + currency = assert_data['Transaction']['Amount']['CurrencyCode'] + if to_small_currency(expected_amount, expected_currency) == amount and expected_currency == currency: + return True + SixpayPaymentPlugin.logger.warning("Payment doesn't match event's fee: %s %s != %s %s", + amount, currency, to_small_currency(expected_amount, expected_currency), + expected_currency) + notify_amount_inconsistency(self.registration, to_large_currency(amount, currency), currency) + return False + + def _capture_transaction(self, assert_data): + """Confirm to SIXPay that the transaction is accepted. + + On success returns the response JSON data. + """ + capture_data = { + 'RequestHeader': get_request_header(SIXPAY_JSON_API_SPEC, self._get_setting('account_id')), + 'TransactionReference': {'TransactionId': assert_data['Transaction']['Id']} + } + capture_response = self._perform_request('capture', SIXPAY_PP_CAPTURE_URL, capture_data) + return capture_response.json() + + def _cancel_transaction(self, assert_data): + """Inform Sixpay that the transaction is canceled. + + Cancel the transaction at Sixpay. This method is implemented but + not used and tested yet. + """ + cancel_data = { + 'RequestHeader': get_request_header( + SIXPAY_JSON_API_SPEC, self._get_setting('account_id') + ), + 'TransactionReference': { + 'TransactionId': assert_data['Transaction']['Id'] + } + } + cancel_response = self._perform_request( + 'cancel', SIXPAY_PP_CANCEL_URL, cancel_data + ) + return cancel_response.json() + + def _register_payment(self, assert_data): + """Register the transaction as paid.""" + register_transaction( + self.registration, + self.registration.transaction.amount, + self.registration.transaction.currency, + TransactionAction.complete, + PROVIDER_SIXPAY, + data={'Transaction': assert_data['Transaction']} + ) + + +class UserCancelHandler(RHSixpayBase): + """User redirect target in case of cancelled payment.""" + + def _process(self): + register_transaction( + self.registration, + self.registration.transaction.amount, + self.registration.transaction.currency, + # XXX: this is indeed reject and not cancel (cancel is "mark as unpaid" and + # only used for manual transactions) + TransactionAction.reject, + provider=PROVIDER_SIXPAY, + ) + flash(_('You cancelled the payment.'), 'info') + return redirect(url_for('event_registration.display_regform', self.registration.locator.registrant)) + + +class UserFailureHandler(RHSixpayBase): + """User redirect target in case of failed payment.""" + + def _process(self): + register_transaction( + self.registration, + self.registration.transaction.amount, + self.registration.transaction.currency, + TransactionAction.reject, + provider=PROVIDER_SIXPAY, + ) + flash(_('Your payment has failed.'), 'info') + return redirect(url_for('event_registration.display_regform', self.registration.locator.registrant)) + + +class UserSuccessHandler(SixpayNotificationHandler): + """User redirect target in case of successful payment.""" + + def _process(self): + try: + self._process_confirmation() + except TransactionFailure as exc: + SixpayPaymentPlugin.logger.warning('SIXPay transaction failed during %s: %s', exc.step, exc.details) + flash(_('Your payment could not be confirmed. Please contact the event organizers.'), 'warning') + else: + flash(_('Your payment has been confirmed.'), 'success') + return redirect(url_for('event_registration.display_regform', self.registration.locator.registrant)) diff --git a/payment_sixpay/indico_payment_sixpay/forms.py b/payment_sixpay/indico_payment_sixpay/forms.py new file mode 100644 index 0000000..293198d --- /dev/null +++ b/payment_sixpay/indico_payment_sixpay/forms.py @@ -0,0 +1,168 @@ +# This file is part of the Indico plugins. +# Copyright (C) 2017 - 2021 Max Fischer, Martin Claus, CERN +# +# The Indico plugins are free software; you can redistribute +# them and/or modify them under the terms of the MIT License; +# see the LICENSE file for more details. + +from wtforms.fields import StringField +from wtforms.fields.html5 import URLField +from wtforms.validators import DataRequired, Email, Length, Optional, ValidationError + +from indico.modules.events.payment import PaymentEventSettingsFormBase, PaymentPluginSettingsFormBase +from indico.web.forms.fields import IndicoPasswordField +from indico.web.forms.validators import IndicoRegexp + +from indico_payment_sixpay import _ + + +# XXX: Maybe this could be refactored to use the standard indico Placeholder system? +class FormatField: + """Validator for format fields, i.e. strings with ``{key}`` placeholders. + + :param max_length: optional maximum length, checked on a test formatting + :param field_map: keyword arguments to use for test formatting + + On validation, a test mapping is applied to the field. This ensures the + field has a valid ``str.format`` format, and does not use illegal keys + (as determined by ``default_field_map`` and ``field_map``). + The ``max_length`` is validated against the test-formatted field, which + is an estimate for an average sized input. + """ + + #: default placeholders to test length after formatting + default_field_map = { + 'user_id': 12345, + 'user_name': 'Jane Whiteacre', + 'user_firstname': 'Jane', + 'user_lastname': 'Whiteacre', + 'event_id': 12345, + 'event_title': 'Placeholder: The Event', + 'registration_id': 12345, + 'regform_title': 'EarlyBird Registration' + } + + def __init__(self, max_length=float('inf'), field_map=None): + """Format field validator, i.e. strings with ``{key}`` placeholders. + + :param max_length: optional maximum length, + checked on a test formatting + :param field_map: keyword arguments to use for test formatting + """ + self.max_length = max_length + self.field_map = self.default_field_map.copy() + if field_map is not None: + self.field_map.update(field_map) + + def __call__(self, form, field): + """Validate format field data. + + Returns true on successful validation, else an ValidationError is + thrown. + """ + if not field.data: + return True + try: + test_format = field.data.format(**self.field_map) + except KeyError as exc: + raise ValidationError(_('Invalid format string key: {}').format(exc)) + except ValueError as exc: + raise ValidationError(_('Malformed format string: {}').format(exc)) + if len(test_format) > self.max_length: + raise ValidationError( + _('Format string too long: shortest replacement with {len}, expected {max}') + .format(len=len(test_format), max=self.max_length) + ) + return True + + +class PluginSettingsForm(PaymentPluginSettingsFormBase): + """Configuration form for the Plugin across all events.""" + + url = URLField( + label=_('Saferpay JSON API URL'), + validators=[DataRequired()], + description=_('URL to contact the Saferpay JSON API'), + ) + username = StringField( + label=_('API username'), + validators=[DataRequired()], + description=_('The username to access the SaferPay JSON API') + ) + password = IndicoPasswordField( + label=_('API password'), + validators=[DataRequired()], + description=_('The password to access the SaferPay JSON API'), + toggle=True, + ) + account_id = StringField( + label=_('Account ID'), + validators=[ + Optional(), + IndicoRegexp(r'^[0-9-]{0,15}$') + ], + description=_( + 'Default Saferpay account ID, such as "123456-12345678". ' + 'Event managers will be able to override this.' + ) + ) + order_description = StringField( + label=_('Order Description'), + validators=[DataRequired(), FormatField(max_length=80)], + description=_( + 'The default description of each order in a human readable way. ' + 'It is presented to the registrant during the transaction with Saferpay. ' + 'Event managers will be able to override this.' + ) + ) + order_identifier = StringField( + label=_('Order Identifier'), + validators=[DataRequired(), FormatField(max_length=80)], + description=_( + 'The default identifier of each order for further processing. ' + 'Event managers will be able to override this.' + ) + ) + notification_mail = StringField( + label=_('Notification Email'), + validators=[Optional(), Email(), Length(0, 50)], + description=_( + 'Emmil address to receive notifications of transactions. ' + "This is independent of Indico's own payment notifications. " + 'Event managers will be able to override this.' + ) + ) + + +class EventSettingsForm(PaymentEventSettingsFormBase): + """Configuration form for the plugin for a specific event.""" + + account_id = StringField( + label=_('Account ID'), + validators=[ + DataRequired(), + IndicoRegexp(r'^[0-9-]{0,15}$') + ], + description=_('The Saferpay account ID, such as "123456-12345678".') + ) + order_description = StringField( + label=_('Order Description'), + validators=[DataRequired(), FormatField(max_length=80)], + description=_( + 'The description of each order in a human readable way. ' + 'It is presented to the registrant during the transaction with Saferpay.' + ) + ) + order_identifier = StringField( + label=_('Order Identifier'), + validators=[DataRequired(), FormatField(max_length=80)], + description=_('The default identifier of each order for further processing.') + ) + notification_mail = StringField( + label=_('Notification Email'), + validators=[DataRequired(), Email(), Length(0, 50)], + description=_( + 'Emmil address to receive notifications of transactions. ' + "This is independent of Indico's own payment notifications." + ) + ) diff --git a/payment_sixpay/indico_payment_sixpay/plugin.py b/payment_sixpay/indico_payment_sixpay/plugin.py new file mode 100644 index 0000000..a71d62b --- /dev/null +++ b/payment_sixpay/indico_payment_sixpay/plugin.py @@ -0,0 +1,49 @@ +# This file is part of the Indico plugins. +# Copyright (C) 2017 - 2021 Max Fischer, Martin Claus, CERN +# +# The Indico plugins are free software; you can redistribute +# them and/or modify them under the terms of the MIT License; +# see the LICENSE file for more details. + +from indico.core.plugins import IndicoPlugin +from indico.modules.events.payment import PaymentPluginMixin + +from indico_payment_sixpay.forms import EventSettingsForm, PluginSettingsForm + + +class SixpayPaymentPlugin(PaymentPluginMixin, IndicoPlugin): + """SIXPay Saferpay + + Provides a payment method using the SIXPay Saferpay API. + """ + + configurable = True + #: form for default configuration across events + settings_form = PluginSettingsForm + #: form for configuration for specific events + event_settings_form = EventSettingsForm + #: global default settings - should be a reasonable default + default_settings = { + 'method_name': 'SIXPay Saferpay', + 'url': 'https://www.saferpay.com/api/', + 'username': None, + 'password': None, + 'account_id': None, + 'order_description': '{event_title}, {regform_title}, {user_name}', + 'order_identifier': 'e{event_id}r{registration_id}', + 'notification_mail': None + } + #: per event default settings - use the global settings + default_event_settings = { + 'enabled': False, + 'method_name': None, + 'account_id': None, + 'order_description': None, + 'order_identifier': None, + 'notification_mail': None, + } + + def get_blueprints(self): + """Blueprint for URL endpoints with callbacks.""" + from indico_payment_sixpay.blueprint import blueprint + return blueprint diff --git a/payment_sixpay/indico_payment_sixpay/templates/event_payment_form.html b/payment_sixpay/indico_payment_sixpay/templates/event_payment_form.html new file mode 100644 index 0000000..8d04287 --- /dev/null +++ b/payment_sixpay/indico_payment_sixpay/templates/event_payment_form.html @@ -0,0 +1,19 @@ +{% trans %} + Clicking on the Pay Now button will redirect you + to the SIXPay Saferpay site in order to complete your payment. +{% endtrans %} + +
+
{% trans %}First name{% endtrans %}
+
{{ registration.first_name }}
+
{% trans %}Last name{% endtrans %}
+
{{ registration.last_name }}
+
{% trans %}Total amount{% endtrans %}
+
{{ format_currency(amount, currency, locale=session.lang) }}
+
+
+ + {%- trans %}Pay Now{% endtrans -%} + +
+
diff --git a/payment_sixpay/indico_payment_sixpay/templates/transaction_details.html b/payment_sixpay/indico_payment_sixpay/templates/transaction_details.html new file mode 100644 index 0000000..14c3c31 --- /dev/null +++ b/payment_sixpay/indico_payment_sixpay/templates/transaction_details.html @@ -0,0 +1,9 @@ +{% extends 'events/payment/transaction_details.html' %} +{% block details %} + {% if transaction.data.Transaction %} +
{% trans %}Transaction ID{% endtrans %}
+
{{ transaction.data.Transaction.Id }}
+
{% trans %}Order ID{% endtrans %}
+
{{ transaction.data.Transaction.OrderId }}
+ {% endif %} +{% endblock %} diff --git a/payment_sixpay/indico_payment_sixpay/util.py b/payment_sixpay/indico_payment_sixpay/util.py new file mode 100644 index 0000000..cc8a9d6 --- /dev/null +++ b/payment_sixpay/indico_payment_sixpay/util.py @@ -0,0 +1,104 @@ +# This file is part of the Indico plugins. +# Copyright (C) 2017 - 2021 Max Fischer, Martin Claus, CERN +# +# The Indico plugins are free software; you can redistribute +# them and/or modify them under the terms of the MIT License; +# see the LICENSE file for more details. + +import uuid + +import iso4217 +from werkzeug.exceptions import NotImplemented as HTTPNotImplemented + +from indico_payment_sixpay import _ + + +# Saferpay API details +SIXPAY_JSON_API_SPEC = '1.12' +SIXPAY_PP_INIT_URL = 'Payment/v1/PaymentPage/Initialize' +SIXPAY_PP_ASSERT_URL = 'Payment/v1/PaymentPage/Assert' +SIXPAY_PP_CAPTURE_URL = 'Payment/v1/Transaction/Capture' +SIXPAY_PP_CANCEL_URL = 'Payment/v1/Transaction/Cancel' + +# payment provider identifier +PROVIDER_SIXPAY = 'sixpay' + +# currencies for which the major to minor currency ratio +# is not a multiple of 10 +NON_DECIMAL_CURRENCY = {'MRU', 'MGA'} + + +def validate_currency(iso_code): + """Check whether the currency can be properly handled by this plugin. + + :param iso_code: an ISO4217 currency code, e.g. ``"EUR"`` + :raises: :py:exc:`~.HTTPNotImplemented` if the currency is not valid + """ + if iso_code in NON_DECIMAL_CURRENCY: + raise HTTPNotImplemented( + _("Unsupported currency '{}' for SIXPay. Please contact the organizers").format(iso_code) + ) + try: + iso4217.Currency(iso_code) + except ValueError: + raise HTTPNotImplemented( + _("Unknown currency '{}' for SIXPay. Please contact the organizers").format(iso_code) + ) + + +def to_small_currency(large_currency_amount, iso_code): + """Convert an amount from large currency to small currency. + + :param large_currency_amount: the amount in large currency, e.g. ``2.3`` + :param iso_code: the ISO currency code, e.g. ``"EUR"`` + :return: the amount in small currency, e.g. ``230`` + """ + validate_currency(iso_code) + exponent = iso4217.Currency(iso_code).exponent + if exponent == 0: + return large_currency_amount + return int(large_currency_amount * (10 ** exponent)) + + +def to_large_currency(small_currency_amount, iso_code): + """Inverse of :py:func:`to_small_currency`.""" + validate_currency(iso_code) + exponent = iso4217.Currency(iso_code).exponent + if exponent == 0: + return small_currency_amount + return small_currency_amount / (10 ** exponent) + + +def get_request_header(api_spec, account_id): + return { + 'SpecVersion': api_spec, + 'CustomerId': get_customer_id(account_id), + 'RequestId': str(uuid.uuid4()), + 'RetryIndicator': 0, + } + + +def get_customer_id(account_id): + """Extract customer ID from account ID. + + Customer ID is the first part (befor the hyphen) of the account ID. + """ + return account_id.split('-')[0] + + +def get_terminal_id(account_id): + """Extract the teminal ID from account ID. + + The Terminal ID is the second part (after the hyphen) of the + account ID. + """ + return account_id.split('-')[1] + + +def get_setting(setting, event=None): + """Return a configuration setting of the plugin.""" + from indico_payment_sixpay.plugin import SixpayPaymentPlugin + if event: + return SixpayPaymentPlugin.event_settings.get(event, setting) + else: + return SixpayPaymentPlugin.settings.get(setting) diff --git a/payment_sixpay/setup.cfg b/payment_sixpay/setup.cfg new file mode 100644 index 0000000..e2745ec --- /dev/null +++ b/payment_sixpay/setup.cfg @@ -0,0 +1,33 @@ +[metadata] +name = indico-plugin-payment-sixpay +version = 3.0 +description = SIXPay/Saferpay payments for Indico event registration fees +long_description = file: README.md +long_description_content_type = text/markdown; charset=UTF-8; variant=GFM +url = https://github.com/indico/indico-plugins +license = MIT +author = Max Fischer, Martin Claus and Indico Team (CERN) +author_email = indico-team@cern.ch +classifiers = + Environment :: Plugins + Environment :: Web Environment + License :: OSI Approved :: MIT License + Programming Language :: Python :: 3.9 + +[options] +packages = find: +zip_safe = false +include_package_data = true +python_requires = ~=3.9.0 +install_requires = + indico>=3.0 + iso4217==1.6.20180829 + +[options.entry_points] +indico.plugins = + payment_sixpay = indico_payment_sixpay.plugin:SixpayPaymentPlugin + + + +[pydocstyle] +ignore = D100,D101,D102,D103,D104,D105,D107,D203,D213 diff --git a/payment_sixpay/setup.py b/payment_sixpay/setup.py new file mode 100644 index 0000000..1cb2c6f --- /dev/null +++ b/payment_sixpay/setup.py @@ -0,0 +1,11 @@ +# This file is part of the Indico plugins. +# Copyright (C) 2017 - 2021 Max Fischer, Martin Claus, CERN +# +# The Indico plugins are free software; you can redistribute +# them and/or modify them under the terms of the MIT License; +# see the LICENSE file for more details. + +from setuptools import setup + + +setup()