mirror of
https://github.com/lucaspalomodevelop/indico-plugins.git
synced 2026-03-13 07:29:39 +00:00
Add plugin: payment_sixpay
This commit is contained in:
commit
93f3298b09
29
payment_sixpay/.flake8
Normal file
29
payment_sixpay/.flake8
Normal 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
180
payment_sixpay/.gitignore
vendored
Normal 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
|
||||
|
||||
8
payment_sixpay/.isort.cfg
Normal file
8
payment_sixpay/.isort.cfg
Normal 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
21
payment_sixpay/LICENSE
Normal 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.
|
||||
4
payment_sixpay/MANIFEST.in
Normal file
4
payment_sixpay/MANIFEST.in
Normal 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
19
payment_sixpay/README.md
Normal 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.
|
||||
11
payment_sixpay/headers.yml
Normal file
11
payment_sixpay/headers.yml
Normal 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}
|
||||
11
payment_sixpay/indico_payment_sixpay/__init__.py
Normal file
11
payment_sixpay/indico_payment_sixpay/__init__.py
Normal 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')
|
||||
23
payment_sixpay/indico_payment_sixpay/blueprint.py
Normal file
23
payment_sixpay/indico_payment_sixpay/blueprint.py
Normal 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'))
|
||||
357
payment_sixpay/indico_payment_sixpay/controllers.py
Normal file
357
payment_sixpay/indico_payment_sixpay/controllers.py
Normal 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))
|
||||
168
payment_sixpay/indico_payment_sixpay/forms.py
Normal file
168
payment_sixpay/indico_payment_sixpay/forms.py
Normal 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."
|
||||
)
|
||||
)
|
||||
49
payment_sixpay/indico_payment_sixpay/plugin.py
Normal file
49
payment_sixpay/indico_payment_sixpay/plugin.py
Normal 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
|
||||
@ -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>
|
||||
@ -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 %}
|
||||
104
payment_sixpay/indico_payment_sixpay/util.py
Normal file
104
payment_sixpay/indico_payment_sixpay/util.py
Normal 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
33
payment_sixpay/setup.cfg
Normal 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
11
payment_sixpay/setup.py
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user