Add plugin: payment_sixpay

This commit is contained in:
Adrian Moennich 2021-07-29 11:49:13 +02:00
commit 93f3298b09
17 changed files with 1056 additions and 0 deletions

29
payment_sixpay/.flake8 Normal file
View File

@ -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

180
payment_sixpay/.gitignore vendored Normal file
View File

@ -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

View File

@ -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

21
payment_sixpay/LICENSE Normal file
View File

@ -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.

View File

@ -0,0 +1,4 @@
graft indico_payment_sixpay/templates
graft indico_payment_sixpay/translations
global-exclude *.pyc __pycache__ .keep

19
payment_sixpay/README.md Normal file
View File

@ -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.

View File

@ -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}

View File

@ -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')

View File

@ -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/<int:event_id>/registrations/<int:reg_form_id>/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'))

View File

@ -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))

View File

@ -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."
)
)

View File

@ -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

View File

@ -0,0 +1,19 @@
{% trans %}
Clicking on the <strong>Pay Now</strong> button will redirect you
to the SIXPay Saferpay site in order to complete your payment.
{% endtrans %}
<dl class="i-data-list">
<dt>{% trans %}First name{% endtrans %}</dt>
<dd>{{ registration.first_name }}</dd>
<dt>{% trans %}Last name{% endtrans %}</dt>
<dd>{{ registration.last_name }}</dd>
<dt>{% trans %}Total amount{% endtrans %}</dt>
<dd>{{ format_currency(amount, currency, locale=session.lang) }}</dd>
<dt></dt>
<dd>
<a class="i-button" href="{{ url_for_plugin('payment_sixpay.init', registration.locator.registrant) }}">
{%- trans %}Pay Now{% endtrans -%}
</a>
</dd>
</dl>

View File

@ -0,0 +1,9 @@
{% extends 'events/payment/transaction_details.html' %}
{% block details %}
{% if transaction.data.Transaction %}
<dt>{% trans %}Transaction ID{% endtrans %}</dt>
<dd>{{ transaction.data.Transaction.Id }}</dd>
<dt>{% trans %}Order ID{% endtrans %}</dt>
<dd>{{ transaction.data.Transaction.OrderId }}</dd>
{% endif %}
{% endblock %}

View File

@ -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)

33
payment_sixpay/setup.cfg Normal file
View File

@ -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

11
payment_sixpay/setup.py Normal file
View File

@ -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()