Skip to content

Commit

Permalink
Payment/CERN: Add support for PostFinance-Checkout
Browse files Browse the repository at this point in the history
  • Loading branch information
ThiefMaster committed Dec 21, 2023
1 parent 3f50f8a commit 0ec250f
Show file tree
Hide file tree
Showing 10 changed files with 472 additions and 31 deletions.
19 changes: 13 additions & 6 deletions payment_cern/indico_payment_cern/blueprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,26 @@
from indico.core.plugins import IndicoPluginBlueprint

from indico_payment_cern.controllers import (RHPaymentCancel, RHPaymentCancelBackground, RHPaymentDecline,
RHPaymentSuccess, RHPaymentSuccessBackground, RHPaymentUncertain)
RHPaymentSuccess, RHPaymentSuccessBackground, RHPaymentUncertain,
RHPostFinanceCheckIndicoTransaction, RHPostFinanceInitPayment,
RHPostFinanceReturn, RHPostFinanceWebhook)


blueprint = IndicoPluginBlueprint(
'payment_cern', __name__,
url_prefix='/event/<int:event_id>/registrations/<int:reg_form_id>/payment/response/cern'
url_prefix='/event/<int:event_id>/registrations/<int:reg_form_id>/payment'
)
blueprint.add_url_rule('/cancel', 'cancel', RHPaymentCancel, methods=('GET', 'POST'))
blueprint.add_url_rule('/decline', 'decline', RHPaymentDecline, methods=('GET', 'POST'))
blueprint.add_url_rule('/uncertain', 'uncertain', RHPaymentUncertain, methods=('GET', 'POST'))
blueprint.add_url_rule('/success', 'success', RHPaymentSuccess, methods=('GET', 'POST'))
blueprint.add_url_rule('/response/cern/cancel', 'cancel', RHPaymentCancel, methods=('GET', 'POST'))
blueprint.add_url_rule('/response/cern/decline', 'decline', RHPaymentDecline, methods=('GET', 'POST'))
blueprint.add_url_rule('/response/cern/uncertain', 'uncertain', RHPaymentUncertain, methods=('GET', 'POST'))
blueprint.add_url_rule('/response/cern/success', 'success', RHPaymentSuccess, methods=('GET', 'POST'))
# ID-less URL for the callback where we cannot customize anything besides a single variable
blueprint.add_url_rule('!/payment/cern/success', 'background-success', RHPaymentSuccessBackground,
methods=('GET', 'POST'))
blueprint.add_url_rule('!/payment/cern/cancel', 'background-cancel', RHPaymentCancelBackground,
methods=('GET', 'POST'))
# New system
blueprint.add_url_rule('/cern/init', 'init', RHPostFinanceInitPayment, methods=('POST',))
blueprint.add_url_rule('/cern/return', 'return', RHPostFinanceReturn)
blueprint.add_url_rule('/cern/check-transaction', 'check_transaction', RHPostFinanceCheckIndicoTransaction)
blueprint.add_url_rule('!/payment/cern/postfinance-webhook', 'pf_webhook', RHPostFinanceWebhook, methods=('POST',))
159 changes: 154 additions & 5 deletions payment_cern/indico_payment_cern/controllers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,31 @@
# them and/or modify them under the terms of the MIT License; see
# the LICENSE file for more details.

import json
import re
from datetime import datetime

from flask import flash, redirect, request
from flask import flash, jsonify, redirect, request
from flask_pluginengine import current_plugin, render_plugin_template
from markupsafe import Markup
from werkzeug.exceptions import BadRequest
from marshmallow import fields
from postfinancecheckout.models import TransactionState as PostFinanceTransactionState
from werkzeug.exceptions import BadRequest, Unauthorized

from indico.modules.events.payment.models.transactions import TransactionAction
from indico.core.errors import UserValueError
from indico.modules.events.payment.controllers import RHPaymentBase
from indico.modules.events.payment.models.transactions import TransactionAction, TransactionStatus
from indico.modules.events.payment.util import register_transaction
from indico.modules.events.registration.controllers.display import RHRegistrationFormRegistrationBase
from indico.modules.events.registration.models.registrations import Registration, RegistrationState
from indico.web.args import use_kwargs
from indico.web.flask.util import url_for
from indico.web.rh import RH
from indico.web.rh import RH, custom_auth

from indico_payment_cern import _
from indico_payment_cern.util import create_hash
from indico_payment_cern.postfinance import create_pf_transaction, get_pf_transaction
from indico_payment_cern.util import create_hash, get_payment_method
from indico_payment_cern.views import WPPaymentEventCERN


class RHPaymentAbortedBase(RHRegistrationFormRegistrationBase):
Expand Down Expand Up @@ -125,3 +134,143 @@ def _process(self):
# We don't do anything here since we don't have anything stored locally
# for a transaction that was not successful.
pass


class RHPostFinanceInitPayment(RHPaymentBase):
"""Initialize the new PostFinance Checkout payment flow."""

@use_kwargs({
'postfinance_method': fields.String(required=True),
})
def _process(self, postfinance_method):
method = get_payment_method(self.event, self.registration.currency, postfinance_method)
if method is None:
raise UserValueError(_('Invalid currency'))
payment_page_url = create_pf_transaction(self.registration, method)
return redirect(payment_page_url)


class RHPostFinanceReturn(RHPaymentBase):
"""Show a waiting page after being returned from the payment page."""

def _process(self):
if not (txn := self.registration.transaction):
# Likely the user cancelled straight away, in that case no transaction is created on the Indico side
return redirect(url_for('event_registration.display_regform', self.registration.locator.registrant))

if txn.status == TransactionStatus.pending:
return WPPaymentEventCERN.render_template('postfinance_return.html', self.event,
regform=self.regform, registration=self.registration)

if txn.status == TransactionStatus.successful:
flash(_('Your payment has been processed.'), 'success')
elif txn.status == TransactionStatus.rejected:
flash(_('Your payment was unsuccessful. Please retry or get in touch with the event organizers.'), 'error')

return redirect(url_for('event_registration.display_regform', self.registration.locator.registrant))


class RHPostFinanceCheckIndicoTransaction(RHPaymentBase):
"""Check if the registration's transaction is still pending.
This is used on the return page to poll whether to send the user back to the
registration page or keep checking.
"""

def _process(self):
txn = self.registration.transaction
is_pending = txn is not None and txn.status == TransactionStatus.pending
return jsonify(pending=is_pending)


@custom_auth
class RHPostFinanceWebhook(RH):
"""Webhook sent by the postfinance backend.
The webhook should be set to include the following states:
- Fulfill
- Failed
- Processing
- Decline
- Voided
Other states should NOT be included, because PostFinance only lets you query the current
state of the transaction, and by the time a webhook arrives it may have progressed to a
later state. This causes nasty SQL deadlock warnings so by only subscribing to the most
important events we can avoid that.
"""

CSRF_ENABLED = False

def _check_access(self):
if (secret := current_plugin.settings.get('postfinance_webhook_secret')) and request.bearer_token != secret:
current_plugin.logger.warning('Received postfinance webhook without a valid bearer token')
raise Unauthorized('Invalid bearer token')

@use_kwargs({
'entity_name': fields.String(data_key='listenerEntityTechnicalName', required=True),
'entity_id': fields.Integer(data_key='entityId', required=True),
})
def _process(self, entity_name, entity_id):
if entity_name != 'Transaction':
raise BadRequest('Unexpected entity name')

pf_txn = get_pf_transaction(entity_id)
if pf_txn.state == PostFinanceTransactionState.PROCESSING:
self._register_processing(pf_txn)
elif pf_txn.state == PostFinanceTransactionState.FULFILL:
self._register_success(pf_txn)
elif pf_txn.state in {PostFinanceTransactionState.FAILED, PostFinanceTransactionState.DECLINE,
PostFinanceTransactionState.VOIDED}:
# Note: Unlike documented (https://checkout.postfinance.ch/en-us/doc/payment/transaction-process#_failed),
# the "failed" state is NOT final. It happens when 3D-Secure fails, but the user can retry (e.g. with a
# different card) and still cause a successful transaction.
self._register_failure(pf_txn)

return '', 204

def _get_registration(self, pf_txn):
return Registration.query.get_or_404(int(pf_txn.meta_data['registration_id']))

def _fix_datetimes(self, data):
def _default(o):
if isinstance(o, datetime):
return o.isoformat()
raise TypeError(f'Object of type {o.__class__.__name__} is not JSON serializable')
return json.loads(json.dumps(data, default=_default))

def _register_processing(self, pf_txn):
registration = self._get_registration(pf_txn)
if registration.state != RegistrationState.complete:
register_transaction(registration=registration,
amount=pf_txn.authorization_amount,
currency=pf_txn.currency,
action=TransactionAction.pending,
provider='cern',
data=self._fix_datetimes(pf_txn.to_dict()))

def _register_failure(self, pf_txn):
registration = self._get_registration(pf_txn)
if registration.state != RegistrationState.complete:
register_transaction(registration=registration,
amount=pf_txn.authorization_amount,
currency=pf_txn.currency,
action=TransactionAction.reject,
provider='cern',
data=self._fix_datetimes(pf_txn.to_dict()))

def _register_success(self, pf_txn):
registration = self._get_registration(pf_txn)
transaction = registration.transaction
pf_txn_data = self._fix_datetimes(pf_txn.to_dict())
if transaction and transaction.data == pf_txn_data:
current_plugin.logger.warning('Ignoring duplicate webhook call with same data')
return
if registration.state != RegistrationState.complete:
register_transaction(registration=registration,
amount=pf_txn.completed_amount,
currency=pf_txn.currency,
action=TransactionAction.complete,
provider='cern',
data=pf_txn_data)
62 changes: 57 additions & 5 deletions payment_cern/indico_payment_cern/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from flask import request, session
from flask_pluginengine import render_plugin_template
from postfinancecheckout.models import TransactionEnvironmentSelectionStrategy
from wtforms.fields import BooleanField, EmailField, StringField, URLField
from wtforms.validators import DataRequired

Expand All @@ -19,9 +20,12 @@
from indico.core.plugins import IndicoPlugin, url_for_plugin
from indico.modules.events.payment import (PaymentEventSettingsFormBase, PaymentPluginMixin,
PaymentPluginSettingsFormBase)
from indico.modules.users import EnumConverter
from indico.util.string import remove_accents, str_to_ascii
from indico.web.flask.util import url_for
from indico.web.forms.fields import MultipleItemsField, OverrideMultipleItemsField, PrincipalListField
from indico.web.forms.fields import (IndicoEnumSelectField, MultipleItemsField, OverrideMultipleItemsField,
PrincipalListField)
from indico.web.forms.widgets import SwitchWidget

from indico_payment_cern import _
from indico_payment_cern.blueprint import blueprint
Expand All @@ -35,12 +39,40 @@
{'id': 'disabled_currencies', 'caption': _('Disabled currencies'), 'required': False}]


PF_ENV_STRATEGIES = {
TransactionEnvironmentSelectionStrategy.FORCE_TEST_ENVIRONMENT: _('Force test'),
TransactionEnvironmentSelectionStrategy.FORCE_PRODUCTION_ENVIRONMENT: _('Force production'),
TransactionEnvironmentSelectionStrategy.USE_CONFIGURATION: _('Use configuration'),
}


class PluginSettingsForm(PaymentPluginSettingsFormBase):
_fieldsets = [
(_('General'), [
'authorized_users', 'fp_email_address', 'fp_department_name', 'order_id_prefix', 'payment_methods',
'use_new_system',
]),
(_('Legacy system'), [
'payment_url', 'shop_id_chf', 'shop_id_eur', 'hash_seed_chf', 'hash_seed_eur', 'hash_seed_out_chf',
'hash_seed_out_eur', 'server_url_suffix',
]),
(_('New system'), [
'postfinance_space_id', 'postfinance_user_id', 'postfinance_api_secret', 'postfinance_webhook_secret',
'postfinance_env_strategy',
]),
]

# General
authorized_users = PrincipalListField(_('Authorized users'), allow_groups=True,
description=_('List of users/groups who are authorized to configure the CERN '
'Payment module for any event.'))
fp_email_address = EmailField(_('FP email adress'), [DataRequired()], description=_('Email address to contact FP.'))
fp_department_name = StringField(_('FP department name'), [DataRequired()])
order_id_prefix = StringField(_('Order ID Prefix'))
payment_methods = MultipleItemsField(_('Payment Methods'), fields=PAYMENT_METHODS_FIELDS, unique_field='name')
use_new_system = BooleanField(_('Use new system'), widget=SwitchWidget(),
description=_('Use the new Postfinance payment system'))
# Legacy
payment_url = URLField(_('Payment URL'), [DataRequired()], description=_('URL used for the epayment'))
shop_id_chf = StringField(_('Shop ID (CHF)'), [DataRequired()])
shop_id_eur = StringField(_('Shop ID (EUR)'), [DataRequired()])
Expand All @@ -49,8 +81,16 @@ class PluginSettingsForm(PaymentPluginSettingsFormBase):
hash_seed_out_chf = StringField(_('Hash seed out (CHF)'), [DataRequired()])
hash_seed_out_eur = StringField(_('Hash seed out (EUR)'), [DataRequired()])
server_url_suffix = StringField(_('Server URL Suffix'), description='Server URL Suffix (indico[suffix].cern.ch)')
order_id_prefix = StringField(_('Order ID Prefix'))
payment_methods = MultipleItemsField(_('Payment Methods'), fields=PAYMENT_METHODS_FIELDS, unique_field='name')
# New
postfinance_space_id = StringField(_('PostFinance space ID'), [DataRequired()])
postfinance_user_id = StringField(_('PostFinance user ID'), [DataRequired()])
postfinance_api_secret = StringField(_('PostFinance API secret'), [DataRequired()])
postfinance_webhook_secret = StringField(_('Webhook secret'),
description=_('If set, the webhook URL on postfinance must be configured '
'to send "Bearer YOUR SECRET" as the Authorization header'))
postfinance_env_strategy = IndicoEnumSelectField(_('Environment strategy'), [DataRequired()],
enum=TransactionEnvironmentSelectionStrategy,
titles=PF_ENV_STRATEGIES)


class EventSettingsForm(PaymentEventSettingsFormBase):
Expand All @@ -59,6 +99,8 @@ class EventSettingsForm(PaymentEventSettingsFormBase):
edit_fields=['fee'],
description=_('Here the fees of the various payment methods can be '
'overridden.'))
force_test_mode = BooleanField(_('Force test mode'),
description=_("Uses Postfinance's test mode (no real money involved)"))

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
Expand Down Expand Up @@ -87,12 +129,22 @@ class CERNPaymentPlugin(PaymentPluginMixin, IndicoPlugin):
'hash_seed_out_eur': '',
'server_url_suffix': '',
'order_id_prefix': '',
'payment_methods': []}
'payment_methods': [],
'use_new_system': False,
'postfinance_space_id': '',
'postfinance_user_id': '',
'postfinance_api_secret': '',
'postfinance_webhook_secret': '',
'postfinance_env_strategy': TransactionEnvironmentSelectionStrategy.USE_CONFIGURATION}
acl_settings = {'authorized_users'}
settings_converters = {
'postfinance_env_strategy': EnumConverter(TransactionEnvironmentSelectionStrategy),
}
default_event_settings = {'enabled': False,
'method_name': None,
'apply_fees': True,
'custom_fees': {}}
'custom_fees': {},
'force_test_mode': False}
valid_currencies = {'EUR', 'CHF'}

def init(self):
Expand Down
Loading

0 comments on commit 0ec250f

Please sign in to comment.