eopayment/eopayment/systempayv2.py

537 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
# eopayment - online payment library
# Copyright (C) 2011-2020 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import base64
import pytz
import datetime as dt
import hashlib
import hmac
import random
import re
import string
import six
from six.moves.urllib import parse as urlparse
import warnings
from gettext import gettext as _
from .common import (PaymentCommon, PaymentResponse, PAID, DENIED, CANCELLED,
ERROR, FORM, Form, ResponseError, force_text, force_byte)
from .cb import translate_cb_error_code
__all__ = ['Payment']
VADS_TRANS_DATE = 'vads_trans_date'
VADS_AUTH_NUMBER = 'vads_auth_number'
VADS_AUTH_RESULT = 'vads_auth_result'
VADS_RESULT = 'vads_result'
VADS_EXTRA_RESULT = 'vads_extra_result'
VADS_CUST_EMAIL = 'vads_cust_email'
VADS_CUST_NAME = 'vads_cust_name'
VADS_CUST_PHONE = 'vads_cust_phone'
VADS_CUST_INFO1 = 'vads_order_info'
VADS_CUST_INFO2 = 'vads_order_info2'
VADS_CUST_INFO3 = 'vads_order_info3'
VADS_CUST_FIRST_NAME = 'vads_cust_first_name'
VADS_CUST_LAST_NAME = 'vads_cust_last_name'
VADS_URL_RETURN = 'vads_url_return'
VADS_AMOUNT = 'vads_amount'
VADS_SITE_ID = 'vads_site_id'
VADS_TRANS_ID = 'vads_trans_id'
SIGNATURE = 'signature'
VADS_CTX_MODE = 'vads_ctx_mode'
VADS_EFFECTIVE_CREATION_DATE = 'vads_effective_creation_date'
VADS_EOPAYMENT_TRANS_ID = 'vads_ext_info_eopayment_trans_id'
def isonow():
return dt.datetime.utcnow().isoformat('T').replace('-', '') \
.replace('T', '').replace(':', '')[:14]
def parse_utc(value):
try:
naive_dt = dt.datetime.strptime(value, '%Y%m%d%H%M%S')
except ValueError:
return None
return pytz.utc.localize(naive_dt)
class Parameter:
def __init__(self, name, ptype, code, max_length=None, length=None,
needed=False, default=None, choices=None, description=None,
help_text=None):
self.name = name
self.ptype = ptype
self.code = code
self.max_length = max_length
self.length = length
self.needed = needed
self.default = default
self.choices = choices
self.description = description
self.help_text = help_text
def check_value(self, value):
if self.length and len(value) != self.length:
return False
if self.max_length and len(value) > self.max_length:
return False
if self.choices and value not in self.choices:
return False
if value == '':
return True
value = value.replace('.', '')
if self.ptype == 'n':
return value.isdigit()
elif self.ptype == 'an':
return value.isalnum()
elif self.ptype == 'an-':
return value.replace('-', '').isalnum()
elif self.ptype == 'an;':
return value.replace(';', '').isalnum()
return True
PARAMETERS = [
# amount as euro cents
Parameter('vads_action_mode', None, 47, needed=True, default='INTERACTIVE',
choices=('SILENT', 'INTERACTIVE')),
Parameter('vads_amount', 'n', 9, max_length=12, needed=True),
Parameter('vads_capture_delay', 'n', 6, max_length=3, default=''),
# Same as 'vads_capture_delay' but matches other backend naming for
# deferred payment
Parameter('capture_day', 'n', 6, max_length=3, default=''),
Parameter('vads_contrib', 'ans', 31, max_length=255, default='eopayment'),
# defaut currency = EURO, norme ISO4217
Parameter('vads_currency', 'n', 10, length=3, default='978', needed=True),
Parameter('vads_cust_address', 'an', 19, max_length=255),
# code ISO 3166
Parameter('vads_cust_country', 'a', 22, length=2, default='FR'),
Parameter('vads_cust_email', 'an@', 15, max_length=127),
Parameter('vads_cust_id', 'an', 16, max_length=63),
Parameter('vads_cust_name', 'ans', 18, max_length=127),
Parameter('vads_cust_phone', 'an', 23, max_length=63),
Parameter('vads_cust_title', 'an', 17, max_length=63),
Parameter('vads_cust_city', 'an', 21, max_length=63),
Parameter('vads_cust_zip', 'an', 20, max_length=63),
Parameter('vads_ctx_mode', 'a', 11, needed=True, choices=('TEST',
'PRODUCTION'),
default='TEST'),
# ISO 639 code
Parameter('vads_language', 'a', 12, length=2, default='fr'),
Parameter('vads_order_id', 'an-', 13, max_length=32),
Parameter('vads_order_info', 'an', 14, max_length=255,
description=_(u"Complément d'information 1")),
Parameter('vads_order_info2', 'an', 14, max_length=255,
description=_(u"Complément d'information 2")),
Parameter('vads_order_info3', 'an', 14, max_length=255,
description=_(u"Complément d'information 3")),
Parameter('vads_page_action', None, 46, needed=True, default='PAYMENT',
choices=('PAYMENT',)),
Parameter('vads_payment_cards', 'an;', 8, max_length=127, default='',
description=_(u'Liste des cartes de paiement acceptées'),
help_text=_(u'vide ou des valeurs sépareés par un point-virgule '
'parmi AMEX, AURORE-MULTI, BUYSTER, CB, COFINOGA, '
'E-CARTEBLEUE, MASTERCARD, JCB, MAESTRO, ONEY, '
'ONEY_SANDBOX, PAYPAL, PAYPAL_SB, PAYSAFECARD, '
'VISA')),
# must be SINGLE or MULTI with parameters
Parameter('vads_payment_config', '', 7, default='SINGLE',
choices=('SINGLE', 'MULTI'), needed=True),
Parameter('vads_return_mode', None, 48, default='GET',
choices=('', 'NONE', 'POST', 'GET')),
Parameter('signature', 'an', None, length=40),
Parameter('vads_site_id', 'n', 2, length=8, needed=True,
description=_(u'Identifiant de la boutique')),
Parameter('vads_theme_config', 'ans', 32, max_length=255),
Parameter(VADS_TRANS_DATE, 'n', 4, length=14, needed=True,
default=isonow),
# https://paiement.systempay.fr/doc/fr-FR/form-payment/reference/vads-trans-id.html
Parameter('vads_trans_id', 'an', 3, length=6, needed=True),
Parameter('vads_validation_mode', 'n', 5, max_length=1, choices=('', '0', '1'),
default=''),
Parameter('vads_version', 'an', 1, default='V2', needed=True,
choices=('V2',)),
Parameter('vads_url_success', 'ans', 24, max_length=1024),
Parameter('vads_url_referral', 'ans', 26, max_length=127),
Parameter('vads_url_refused', 'ans', 25, max_length=1024),
Parameter('vads_url_cancel', 'ans', 27, max_length=1024),
Parameter('vads_url_error', 'ans', 29, max_length=1024),
Parameter('vads_url_return', 'ans', 28, max_length=1024),
Parameter('vads_user_info', 'ans', 61, max_length=255),
Parameter('vads_contracts', 'ans', 62, max_length=255),
Parameter(VADS_CUST_FIRST_NAME, 'ans', 104, max_length=63),
Parameter(VADS_CUST_LAST_NAME, 'ans', 104, max_length=63),
]
PARAMETER_MAP = dict(((parameter.name,
parameter) for parameter in PARAMETERS))
def add_vads(kwargs):
new_vargs = {}
for k, v in kwargs.items():
if k.startswith('vads_'):
new_vargs[k] = v
else:
new_vargs['vads_' + k] = v
return new_vargs
def check_vads(kwargs, exclude=[]):
for parameter in PARAMETERS:
name = parameter.name
if name not in kwargs and name not in exclude and parameter.needed:
raise ValueError('parameter %s must be defined' % name)
if name in kwargs and not parameter.check_value(kwargs[name]):
raise ValueError('parameter %s value %s is not of the type %s' % (
name, kwargs[name],
parameter.ptype))
class Payment(PaymentCommon):
'''
Produce request for and verify response from the SystemPay payment
gateway.
>>> gw =Payment(dict(secret_test='xxx', secret_production='yyyy',
site_id=123, ctx_mode='PRODUCTION'))
>>> print gw.request(100)
('20120525093304_188620',
'https://paiement.systempay.fr/vads-payment/?vads_url_return=http%3A%2F%2Furl.de.retour%2Fretour.php&vads_cust_country=FR&vads_site_id=&vads_payment_config=SINGLE&vads_trans_id=188620&vads_action_mode=INTERACTIVE&vads_contrib=eopayment&vads_page_action=PAYMENT&vads_trans_date=20120525093304&vads_ctx_mode=TEST&vads_validation_mode=&vads_version=V2&vads_payment_cards=&signature=5d412498ab523627ec5730a09118f75afa602af5&vads_language=fr&vads_capture_delay=&vads_currency=978&vads_amount=100&vads_return_mode=NONE',
{'vads_url_return': 'http://url.de.retour/retour.php',
'vads_cust_country': 'FR', 'vads_site_id': '',
'vads_payment_config': 'SINGLE', 'vads_trans_id': '188620',
'vads_action_mode': 'INTERACTIVE', 'vads_contrib': 'eopayment',
'vads_page_action': 'PAYMENT', 'vads_trans_date': '20120525093304',
'vads_ctx_mode': 'TEST', 'vads_validation_mode': '',
'vads_version': 'V2', 'vads_payment_cards': '', 'signature':
'5d412498ab523627ec5730a09118f75afa602af5', 'vads_language': 'fr',
'vads_capture_delay': '', 'vads_currency': '978', 'vads_amount': 100,
'vads_return_mode': 'NONE'})
'''
has_free_transaction_id = True
service_url = "https://paiement.systempay.fr/vads-payment/"
signature_algo = 'sha1'
description = {
'caption': 'SystemPay, système de paiment du groupe BPCE',
'parameters': [
{
'name': 'normal_return_url',
'caption': _('Normal return URL'),
'default': '',
'required': True,
},
{
'name': 'automatic_return_url',
'caption': _('Automatic return URL (ignored, must be set in Payzen/SystemPay backoffice)'),
'required': False,
},
{'name': 'service_url',
'default': service_url,
'caption': _(u'URL du service de paiment'),
'help_text': _(u'ne pas modifier si vous ne savez pas'),
'validation': lambda x: x.startswith('http'),
'required': True, },
{'name': 'secret_test',
'caption': _(u'Secret pour la configuration de TEST'),
'validation': lambda value: str.isalnum(value),
'required': True, },
{'name': 'secret_production',
'caption': _(u'Secret pour la configuration de PRODUCTION'),
'validation': lambda value: str.isalnum(value), },
{'name': 'signature_algo',
'caption': _(u'Algorithme de signature'),
'default': 'sha1',
'choices': (
('sha1', 'SHA-1'),
('hmac_sha256', 'HMAC-SHA-256'),
)},
{
'name': 'manual_validation',
'caption': 'Validation manuelle',
'type': bool,
'default': False,
'scope': 'transaction'
}
]
}
for name in ('vads_ctx_mode', VADS_SITE_ID, 'vads_order_info',
'vads_order_info2', 'vads_order_info3',
'vads_payment_cards', 'vads_payment_config', 'capture_day'):
parameter = PARAMETER_MAP[name]
def check_value(parameter):
def validate(value):
return parameter.check_value(value)
return validate
x = {'name': name,
'caption': parameter.description or name,
'validation': check_value(parameter),
'default': parameter.default,
'required': parameter.needed,
'help_text': parameter.help_text,
'max_length': parameter.max_length}
description['parameters'].append(x)
def __init__(self, options, logger=None):
super(Payment, self).__init__(options, logger=logger)
options = add_vads(options)
self.options = options
def make_vads_trans_id(self):
# vads_trans_id must be 6 alphanumeric characters,
# trans_id starting with 9 are reserved for the systempay backoffice
# https://paiement.systempay.fr/doc/fr-FR/form-payment/reference/vads-trans-id.html
gen = random.SystemRandom()
if six.PY3:
alphabet = string.ascii_letters + string.digits
else:
alphabet = string.letters + string.digits
first_letter_alphabet = alphabet.replace('9', '')
vads_trans_id = (
gen.choice(first_letter_alphabet)
+ ''.join(gen.choice(alphabet) for i in range(5))
)
return vads_trans_id
def request(self, amount, name=None, first_name=None, last_name=None,
address=None, email=None, phone=None, orderid=None, info1=None,
info2=None, info3=None, next_url=None, manual_validation=None,
transaction_id=None, **kwargs):
'''
Create the URL string to send a request to SystemPay
'''
self.logger.debug('%s amount %s name %s address %s email %s phone %s '
'next_url %s info1 %s info2 %s info3 %s kwargs: %s',
__name__, amount, name, address, email, phone, info1,
info2, info3, next_url, kwargs)
# amount unit is cents
amount = '%.0f' % (100 * amount)
kwargs.update(add_vads({'amount': force_text(amount)}))
if int(amount) < 0:
raise ValueError('amount must be an integer >= 0')
normal_return_url = self.normal_return_url
if next_url:
warnings.warn("passing next_url to request() is deprecated, "
"set normal_return_url in options", DeprecationWarning)
normal_return_url = next_url
if normal_return_url:
kwargs[VADS_URL_RETURN] = force_text(normal_return_url)
if name is not None:
kwargs['vads_cust_name'] = force_text(name)
if first_name is not None:
kwargs[VADS_CUST_FIRST_NAME] = force_text(first_name)
if last_name is not None:
kwargs[VADS_CUST_LAST_NAME] = force_text(last_name)
if address is not None:
kwargs['vads_cust_address'] = force_text(address)
if email is not None:
kwargs['vads_cust_email'] = force_text(email)
if phone is not None:
kwargs['vads_cust_phone'] = force_text(phone)
if info1 is not None:
kwargs['vads_order_info'] = force_text(info1)
if info2 is not None:
kwargs['vads_order_info2'] = force_text(info2)
if info3 is not None:
kwargs['vads_order_info3'] = force_text(info3)
if orderid is not None:
# check orderid format first
name = 'vads_order_id'
orderid = force_text(orderid)
ptype = 'an-'
p = Parameter(name, ptype, 13, max_length=32)
if not p.check_value(orderid):
raise ValueError(
'%s value %s is not of the type %s' % (name, orderid, ptype))
kwargs[name] = orderid
vads_trans_id = self.make_vads_trans_id()
assert re.match(r'^[0-9a-zA-Z]{6}$', vads_trans_id)
kwargs[VADS_TRANS_ID] = vads_trans_id
fields = kwargs
for parameter in PARAMETERS:
name = parameter.name
# import default parameters from configuration
if name not in fields \
and name in self.options:
fields[name] = force_text(self.options[name])
# import default parameters from module
if name not in fields and parameter.default is not None:
if callable(parameter.default):
fields[name] = parameter.default()
else:
fields[name] = parameter.default
capture_day = fields.pop('capture_day')
if capture_day:
fields['vads_capture_delay'] = capture_day
if manual_validation:
fields['vads_validation_mode'] = '1'
check_vads(fields)
if transaction_id:
fields[VADS_EOPAYMENT_TRANS_ID] = transaction_id
else:
transaction_id = '%s_%s' % (fields[VADS_TRANS_DATE], vads_trans_id)
fields[SIGNATURE] = force_text(self.signature(fields))
self.logger.debug('%s request contains fields: %s', __name__, fields)
self.logger.debug('%s transaction id: %s', __name__, transaction_id)
form = Form(
url=self.service_url,
method='POST',
fields=[
{
'type': u'hidden',
'name': force_text(field_name),
'value': force_text(field_value),
}
for field_name, field_value in fields.items()])
return transaction_id, FORM, form
RESULT_MAP = {
'00': {'message': 'Paiement réalisé avec succés.', 'result': PAID},
'02': {'message': 'Le commerçant doit contacter la banque du porteur.'},
'05': {'message': 'Paiement refusé.', 'result': DENIED},
'17': {'message': 'Annulation client.', 'result': CANCELLED},
'30': {'message': 'Erreur de format.'},
'96': {'message': 'Erreur technique lors du paiement.'},
}
EXTRA_RESULT_MAP = {
'': {'message': 'Pas de contrôle effectué.'},
'00': {'message': 'Tous les contrôles se sont déroulés avec succés.'},
'02': {'message': 'La carte a dépassé l\'encours autorisé.'},
'03': {'message': 'La carte appartient à la liste grise du commerçant.'},
'04': {'messaǵe': 'Le pays d\'émission de la carte appartient à la liste grise du '
'commerçant ou le pays d\'émission de la carte n\'appartient pas à la '
'liste blanche du commerçant.'},
'05': {'message': 'Ladresse IP appartient à la liste grise du marchand.'},
'06': {'message': 'Le code bin appartient à la liste grise du marchand.'},
'07': {'message': 'Détection dune e-carte bleue.'},
'08': {'message': 'Détection dune carte commerciale nationale.'},
'09': {'message': 'Détection dune carte commerciale étrangère.'},
'14': {'message': 'Détection dune carte à autorisation systématique.'},
'30': {'message': 'Le pays de ladresse IP appartient à la liste grise.'},
'99': {'message': 'Problème technique recontré par le serveur lors du traitement '
'd\'un des contrôles locauxi.'},
}
@classmethod
def make_eopayment_result(cls, fields):
# https://paiement.systempay.fr/doc/fr-FR/payment-file/oneclick-payment/vads-result.html
# https://paiement.systempay.fr/doc/fr-FR/payment-file/oneclick-payment/vads-auth-result.html
# https://paiement.systempay.fr/doc/fr-FR/payment-file/oneclick-payment/vads-extra-result.html
vads_result = fields.get(VADS_RESULT)
vads_auth_result = fields.get(VADS_AUTH_RESULT)
vads_extra_result = fields.get(VADS_EXTRA_RESULT)
# map to human messages and update return
vads_result_message = cls.RESULT_MAP.get(vads_result, {}).get('message')
if vads_result_message:
fields[VADS_RESULT + '_message'] = vads_result_message
vads_extra_result_message = cls.EXTRA_RESULT_MAP.get(vads_extra_result, {}).get('message')
if vads_extra_result_message:
fields[VADS_EXTRA_RESULT + '_message'] = vads_extra_result_message
vads_auth_result_message, auth_eopayment_result = translate_cb_error_code(vads_auth_result)
if vads_auth_result_message:
fields[VADS_AUTH_RESULT + '_message'] = vads_auth_result_message
# now build eopayment resume
if vads_result is None:
return ERROR, 'absence de champ vads_result'
if vads_result_message is None:
return ERROR, 'valeur vads_result inconnue'
result = cls.RESULT_MAP[vads_result].get('result', ERROR)
message = vads_result_message
if vads_auth_result_message and (vads_result != '00' or vads_result != vads_auth_result):
message += ' ' + vads_auth_result_message
if vads_result in ('00', '05', '30') and vads_extra_result_message and vads_extra_result != '':
message += ' ' + vads_extra_result_message
if result == ERROR and auth_eopayment_result not in (PAID, ERROR, None):
result = auth_eopayment_result
return result, message
def response(self, query_string, **kwargs):
fields = urlparse.parse_qs(query_string, True)
if not set(fields) >= set([SIGNATURE, VADS_CTX_MODE, VADS_AUTH_RESULT]):
raise ResponseError('missing %s, %s or %s' % (SIGNATURE, VADS_CTX_MODE,
VADS_AUTH_RESULT))
for key, value in fields.items():
fields[key] = value[0]
copy = fields.copy()
signature = self.signature(fields)
result, message = self.make_eopayment_result(copy)
self.logger.debug('checking systempay response on: %r', copy)
signature_result = signature == fields[SIGNATURE]
if not signature_result:
self.logger.debug('signature check: %s <!> %s', signature,
fields[SIGNATURE])
if not signature_result:
message += ' signature invalide.'
test = fields[VADS_CTX_MODE] == 'TEST'
if VADS_EOPAYMENT_TRANS_ID in fields:
transaction_id = fields[VADS_EOPAYMENT_TRANS_ID]
else:
transaction_id = '%s_%s' % (copy[VADS_TRANS_DATE], copy[VADS_TRANS_ID])
# the VADS_AUTH_NUMBER is the number to match payment in bank logs
copy[self.BANK_ID] = copy.get(VADS_AUTH_NUMBER, '')
transaction_date = None
if VADS_EFFECTIVE_CREATION_DATE in fields:
transaction_date = parse_utc(fields[VADS_EFFECTIVE_CREATION_DATE])
response = PaymentResponse(
result=result,
signed=signature_result,
bank_data=copy,
order_id=transaction_id,
transaction_id=transaction_id,
bank_status=message,
transaction_date=transaction_date,
test=test)
return response
def sha1_sign(self, secret, signed_data):
return hashlib.sha1(signed_data).hexdigest()
def hmac_sha256_sign(self, secret, signed_data):
digest = hmac.HMAC(secret, digestmod=hashlib.sha256, msg=signed_data).digest()
return base64.b64encode(digest)
def signature(self, fields):
self.logger.debug('got fields %s to sign' % fields)
ordered_keys = sorted(
[key for key in fields.keys() if key.startswith('vads_')])
self.logger.debug('ordered keys %s' % ordered_keys)
ordered_fields = [force_byte(fields[key]) for key in ordered_keys]
secret = force_byte(getattr(self, 'secret_%s' % fields['vads_ctx_mode'].lower()))
signed_data = b'+'.join(ordered_fields)
signed_data = b'%s+%s' % (signed_data, secret)
self.logger.debug(u'generating signature on «%s»', signed_data)
sign_method = getattr(self, '%s_sign' % self.signature_algo)
sign = sign_method(secret, signed_data)
self.logger.debug(u'signature «%s»', sign)
return force_text(sign)