Only create pending txn when proceeding to payment

Otherwise someone may go to the checkout page not proceed, and then no
longer see the checkout link due to the pending transaction. While this
can still happen now if the user simply interrupts the payment process
instead of finishing or cancelling, it's much less likely to happen (and
an event manager can always "mark unpaid" to reset the pending
transaction in case a user ends up in this state)
This commit is contained in:
Adrian Moennich 2021-07-29 11:31:29 +02:00
parent 2565ad5f78
commit 613e2cc50a
5 changed files with 114 additions and 125 deletions

View File

@ -7,16 +7,17 @@
from indico.core.plugins import IndicoPluginBlueprint
from indico_payment_sixpay.controllers import (SixPayNotificationHandler, UserCancelHandler, UserFailureHandler,
UserSuccessHandler)
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/response/sixpay'
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('/ipn', 'notify', SixPayNotificationHandler, methods=('Get', 'POST'))
blueprint.add_url_rule('/notify', 'notify', SixPayNotificationHandler, methods=('Get', 'POST'))

View File

@ -9,11 +9,14 @@ from urllib.parse import urljoin
import requests
from flask import flash, redirect, request
from werkzeug.exceptions import BadRequest
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 register_transaction
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
@ -21,8 +24,9 @@ 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, get_request_header, get_setting,
to_large_currency, to_small_currency)
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):
@ -58,6 +62,95 @@ class RHSixpayBase(RH):
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."""

View File

@ -5,19 +5,10 @@
# 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 requests import RequestException
from indico.core.plugins import IndicoPlugin, url_for_plugin
from indico.core.plugins import IndicoPlugin
from indico.modules.events.payment import PaymentPluginMixin
from indico.modules.events.payment.models.transactions import TransactionAction
from indico.modules.events.payment.util import register_transaction
from indico_payment_sixpay.forms import EventSettingsForm, PluginSettingsForm
from indico_payment_sixpay.util import (PROVIDER_SIXPAY, SIXPAY_JSON_API_SPEC, SIXPAY_PP_INIT_URL, get_request_header,
get_terminal_id, to_small_currency)
class SixpayPaymentPlugin(PaymentPluginMixin, IndicoPlugin):
@ -56,108 +47,3 @@ class SixpayPaymentPlugin(PaymentPluginMixin, IndicoPlugin):
"""Blueprint for URL endpoints with callbacks."""
from indico_payment_sixpay.blueprint import blueprint
return blueprint
# Dear future Maintainer,
# - the business logic is here!
# - see PaymentPluginMixin.render_payment_form for what `data` provides
# - What happens here
# - We add `success`, `cancel` and `failure` for *sixpay* to redirect the
# user back to us AFTER his request
# - We add `notify` for *sixpay* to inform us asynchronously about
# the result
# - We send a request to initialize the pyment page to SixPay to get a
# request url for this transaction
# - We put the payment page URL and token we got into `data`
# - Return uses `indico_payment_sixpay/templates/event_payment_form.html`,
# presenting a trigger button to the user
def adjust_payment_form_data(self, data):
"""Prepare the payment form shown to registrants."""
global_settings = data['settings']
transaction = self._get_transaction_parameters(data)
init_response = self._init_payment_page(
sixpay_url=global_settings['url'],
transaction_data=transaction,
credentials=(global_settings['username'], global_settings['password'])
)
data['payment_url'] = init_response['RedirectUrl']
# create pending transaction and store Saferpay transaction token
new_indico_txn = register_transaction(
data['registration'],
data['amount'],
data['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...
data['registration'].transaction.data = {'Init_PP_response': init_response}
return data
@staticmethod
def get_field_format_map(registration):
"""Generate dict which provides registration information."""
return {
'user_id': registration.user_id,
'user_name': registration.full_name,
'user_firstname': registration.first_name,
'user_lastname': registration.last_name,
'event_id': registration.event_id,
'event_title': registration.event.title,
'registration_id': registration.id,
'regform_title': registration.registration_form.title
}
def _get_transaction_parameters(self, payment_data):
"""Parameters for formulating a transaction request."""
settings = payment_data['event_settings']
registration = payment_data['registration']
format_map = self.get_field_format_map(registration)
for format_field in ('order_description', 'order_identifier'):
payment_data[format_field] = settings[format_field].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(payment_data['amount'], payment_data['currency'])),
'CurrencyCode': payment_data['currency'],
},
'OrderId': payment_data['order_identifier'][:80],
'DESCRIPTION': payment_data['order_description'][:1000],
},
# callbacks of the transaction - where to announce success etc., when redircting the user
'ReturnUrls': {
'Success': url_for_plugin('payment_sixpay.success', registration.locator.uuid, _external=True),
'Fail': url_for_plugin('payment_sixpay.failure', registration.locator.uuid, _external=True),
'Abort': url_for_plugin('payment_sixpay.cancel', registration.locator.uuid, _external=True)
},
'Notification': {
# where to asynchronously call back from SixPay
'NotifyUrl': url_for_plugin('payment_sixpay.notify', registration.locator.uuid, _external=True)
}
}
if settings['notification_mail']:
transaction_parameters['Notification']['MerchantEmails'] = [settings['notification_mail']]
return transaction_parameters
def _init_payment_page(self, sixpay_url, transaction_data, credentials):
"""Initialize payment page."""
endpoint = urljoin(sixpay_url, SIXPAY_PP_INIT_URL)
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()

View File

@ -1,4 +1,7 @@
Clicking on the <strong>{% trans %}Pay Now{% endtrans %}</strong> button you will get redirected to the SixPay site in order to complete your transaction.
{% 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>
@ -8,5 +11,9 @@ Clicking on the <strong>{% trans %}Pay Now{% endtrans %}</strong> button you wil
<dt>{% trans %}Total amount{% endtrans %}</dt>
<dd>{{ format_currency(amount, currency, locale=session.lang) }}</dd>
<dt></dt>
<dd><a class="i-button" href="{{ payment_url }}">{% trans %}Pay Now{% endtrans %}</a></dd>
<dd>
<a class="i-button" href="{{ url_for_plugin('payment_sixpay.init', registration.locator.registrant) }}">
{%- trans %}Pay Now{% endtrans -%}
</a>
</dd>
</dl>

View File

@ -1,6 +1,8 @@
{% 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 %}