Compare commits

..

No commits in common. "main" and "wip/48694-jenkins-coverage-move" have entirely different histories.

43 changed files with 1430 additions and 2339 deletions

View File

@ -1,6 +1,2 @@
[run]
dynamic_context = test_function
omit = */.tox/*
[html]
show_contexts = True

View File

@ -1,8 +0,0 @@
# trivial: apply pyupgrade (#58937)
caa40e7e771b41bd1d164341a2e7f40689cbe4ba
# trivial: apply black (#58937)
3ee72e5336d03526c7ab297a5cf09057a6d5d1c2
# trivial: apply isort (#58937)
8bf4ab81c5483af946365645d15708796c832d7e
# misc: apply double-quote-string-fixer (#79788)
02781300dc176ec56da26763b69cb6b4ada965f1

View File

@ -1,22 +0,0 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: double-quote-string-fixer
- repo: https://github.com/psf/black
rev: 22.3.0
hooks:
- id: black
args: ['--target-version', 'py37', '--skip-string-normalization', '--line-length', '110']
- repo: https://github.com/PyCQA/isort
rev: 5.12.0
hooks:
- id: isort
args: ['--profile', 'black', '--line-length', '110']
- repo: https://github.com/asottile/pyupgrade
rev: v3.1.0
hooks:
- id: pyupgrade
args: ['--keep-percent-format', '--py37-plus']

16
Jenkinsfile vendored
View File

@ -1,4 +1,4 @@
@Library('eo-jenkins-lib@main') import eo.Utils
@Library('eo-jenkins-lib@master') import eo.Utils
pipeline {
agent any
@ -21,18 +21,10 @@ pipeline {
stage('Packaging') {
steps {
script {
env.SHORT_JOB_NAME=sh(
returnStdout: true,
// given JOB_NAME=gitea/project/PR-46, returns project
// given JOB_NAME=project/main, returns project
script: '''
echo "${JOB_NAME}" | sed "s/gitea\\///" | awk -F/ '{print $1}'
'''
).trim()
if (env.GIT_BRANCH == 'main' || env.GIT_BRANCH == 'origin/main') {
sh "sudo -H -u eobuilder /usr/local/bin/eobuilder -d bullseye,bookworm ${SHORT_JOB_NAME}"
if (env.JOB_NAME == 'eopayment' && env.GIT_BRANCH == 'origin/master') {
sh 'sudo -H -u eobuilder /usr/local/bin/eobuilder eopayment'
} else if (env.GIT_BRANCH.startsWith('hotfix/')) {
sh "sudo -H -u eobuilder /usr/local/bin/eobuilder -d bullseye,bookworm --branch ${env.GIT_BRANCH} --hotfix ${SHORT_JOB_NAME}"
sh "sudo -H -u eobuilder /usr/local/bin/eobuilder --branch ${env.GIT_BRANCH} --hotfix eopayment"
}
}
}

View File

@ -4,6 +4,7 @@ recursive-include eopayment/locale *.po *.mo
include COPYING
include VERSION
include README.txt
include eopayment/request
include eopayment/response
include eopayment/resource/PaiementSecuriseService.wsdl
include eopayment/resource/PaiementSecuriseService1.xsd

View File

@ -26,8 +26,8 @@ from those services, reporting whether the transaction was successful and which
one it was. The full content (which is specific to the service) is also
reported for logging purpose.
The paybox module also depend upon the python Cryptodome library for RSA
signature validation on the responses.
The spplus and paybox module also depend upon the python Crypto library for DES
decoding of the merchant key and RSA signature validation on the responses.
Some backends allow to specify the order and transaction ids in different
fields, in order to allow to match them in payment system backoffice. They are:
@ -179,25 +179,3 @@ You can put some configuration in ~/.config/eopayment.init ::
order_id : 20201029093825_Vlco55
test : True
transaction_date : 2020-10-29 09:38:26+00:00
Code Style
==========
black is used to format the code, using thoses parameters:
black --target-version py35 --skip-string-normalization --line-length 110
isort is used to format the imports, using those parameters:
isort --profile black --line-length 110
pyupgrade is used to automatically upgrade syntax, using those parameters:
pyupgrade --keep-percent-format --py37-plus
There is .pre-commit-config.yaml to use pre-commit to automatically run black,
isort and pyupgrade before commits. (execute `pre-commit install` to install
the git hook.)

1
debian/compat vendored Normal file
View File

@ -0,0 +1 @@
9

22
debian/control vendored
View File

@ -2,23 +2,41 @@ Source: eopayment
Section: python
Priority: optional
Maintainer: Frederic Peters <fpeters@debian.org>
Build-Depends: debhelper-compat (= 12),
Build-Depends: debhelper (>= 9),
python-all (>= 2.6),
python-crypto,
python-requests,
python-setuptools (>= 0.6b3),
python-six,
python-tz,
python3-all,
python3-crypto,
python3-django,
python3-requests,
python3-setuptools,
python3-six,
python3-tz,
dh-python,
git
Standards-Version: 3.9.1
X-Python-Version: >= 2.7
X-Python3-Version: >= 3.4
Homepage: http://dev.entrouvert.org/projects/eopayment/
Package: python-eopayment
Architecture: all
Depends: ${python:Depends},
python-zeep (>= 2.5),
python-click
Description: common API to access online payment services
eopayment is a Python module to interface with French's bank credit
card online payment services. Supported services are ATOS/SIP, SystemPay,
and SPPLUS.
Package: python3-eopayment
Architecture: all
Depends: ${python3:Depends},
python3-zeep (>= 2.5),
python3-pycryptodome,
python3-click
Description: common API to access online payment services (Python 3)
eopayment is a Python module to interface with French's bank credit

1
debian/python-eopayment.docs vendored Normal file
View File

@ -0,0 +1 @@
README.txt

2
debian/rules vendored
View File

@ -4,5 +4,5 @@ export PYBUILD_NAME=eopayment
export PYBUILD_DISABLE=test
%:
dh $@ --with python3 --buildsystem=pybuild
dh $@ --with python2,python3 --buildsystem=pybuild

View File

@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# eopayment - online payment library
# Copyright (C) 2011-2020 Entr'ouvert
#
@ -17,53 +18,23 @@
import datetime
import importlib
import logging
import pytz
import six
from .common import ( # noqa: F401
ACCEPTED,
CANCELED,
CANCELLED,
DENIED,
ERROR,
EXPIRED,
FORM,
HTML,
PAID,
RECEIVED,
URL,
WAITING,
BackendNotFound,
PaymentException,
ResponseError,
force_text,
URL, HTML, FORM, RECEIVED, ACCEPTED, PAID, DENIED,
CANCELED, CANCELLED, ERROR, WAITING, EXPIRED, force_text,
ResponseError, PaymentException,
)
__all__ = [
'Payment',
'URL',
'HTML',
'FORM',
'SIPS',
'SYSTEMPAY',
'TIPI',
'DUMMY',
'get_backend',
'RECEIVED',
'ACCEPTED',
'PAID',
'DENIED',
'CANCELED',
'CANCELLED',
'ERROR',
'WAITING',
'EXPIRED',
'get_backends',
'PAYFIP_WS',
'SAGA',
'KEYWARE',
'MOLLIE',
]
__all__ = ['Payment', 'URL', 'HTML', 'FORM', 'SIPS', 'SYSTEMPAY',
'TIPI', 'DUMMY', 'get_backend', 'RECEIVED', 'ACCEPTED', 'PAID',
'DENIED', 'CANCELED', 'CANCELLED', 'ERROR', 'WAITING',
'EXPIRED', 'get_backends', 'PAYFIP_WS', 'SAGA']
if six.PY3:
__all__.extend(['KEYWARE', 'MOLLIE'])
SIPS = 'sips'
SIPS2 = 'sips2'
@ -86,87 +57,87 @@ def get_backend(kind):
module = importlib.import_module('.' + kind, package='eopayment')
return module.Payment
__BACKENDS = [DUMMY, SIPS2, SYSTEMPAY, OGONE, PAYBOX, PAYZEN, TIPI, PAYFIP_WS, KEYWARE, MOLLIE, SAGA]
__BACKENDS = [DUMMY, SIPS, SIPS2, SYSTEMPAY, OGONE, PAYBOX, PAYZEN,
TIPI, PAYFIP_WS, KEYWARE, MOLLIE, SAGA]
def get_backends():
"""Return a dictionnary mapping existing eopayment backends name to their
description.
'''Return a dictionnary mapping existing eopayment backends name to their
description.
>>> get_backends()['dummy'].description['caption']
'Dummy payment backend'
>>> get_backends()['dummy'].description['caption']
'Dummy payment backend'
"""
return {backend: get_backend(backend) for backend in __BACKENDS}
'''
return dict((backend, get_backend(backend)) for backend in __BACKENDS)
class Payment:
"""
Interface to credit card online payment servers of French banks. The
only use case supported for now is a unique automatic payment.
class Payment(object):
'''
Interface to credit card online payment servers of French banks. The
only use case supported for now is a unique automatic payment.
>>> options = {
'numcli': '12345',
}
>>> p = Payment(kind=TIPI, options=options)
>>> transaction_id, kind, data = p.request('10.00', email='bob@example.com')
>>> print (transaction_id, kind, data) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
('...', 1, 'https://www.payfip.gov.fr/tpa/paiement.web?...')
>>> options = {
'numcli': '12345',
}
>>> p = Payment(kind=TIPI, options=options)
>>> transaction_id, kind, data = p.request('10.00', email='bob@example.com')
>>> print (transaction_id, kind, data) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
('...', 1, 'https://www.payfip.gov.fr/tpa/paiement.web?...')
Supported backend of French banks are:
Supported backend of French banks are:
- TIPI/PayFiP
- SIPS 2.0, for BNP, Banque Populaire (before 2010), CCF, HSBC, Crédit
Agricole, La Banque Postale, LCL, Société Générale and Crédit du
Nord.
- SystemPay v2/Payzen for Banque Populaire and Caise d'Epargne (Natixis, after 2010)
- Ogone
- Paybox
- Mollie (Belgium)
- Keyware (Belgium)
- TIPI/PayFiP
- SIPS 2.0, for BNP, Banque Populaire (before 2010), CCF, HSBC, Crédit
Agricole, La Banque Postale, LCL, Société Générale and Crédit du
Nord.
- SystemPay v2/Payzen for Banque Populaire and Caise d'Epargne (Natixis, after 2010)
- Ogone
- Paybox
- Mollie (Belgium)
- Keyware (Belgium)
For SIPs you also need the bank provided middleware especially the two
executables, request and response, as the protocol from ATOS/SIPS is not
documented. For the other backends the modules are autonomous.
For SIPs you also need the bank provided middleware especially the two
executables, request and response, as the protocol from ATOS/SIPS is not
documented. For the other backends the modules are autonomous.
Each backend need some configuration parameters to be used, the
description of the backend list those parameters. The description
dictionary can be used to generate configuration forms.
Each backend need some configuration parameters to be used, the
description of the backend list those parameters. The description
dictionary can be used to generate configuration forms.
>>> d = get_backend(SPPLUS).description
>>> print d['caption']
SPPlus payment service of French bank Caisse d'epargne
>>> print [p['name'] for p in d['parameters']] # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
['cle', ..., 'moyen']
>>> print d['parameters'][0]['caption']
Secret key, a 40 digits hexadecimal number
>>> d = get_backend(SPPLUS).description
>>> print d['caption']
SPPlus payment service of French bank Caisse d'epargne
>>> print [p['name'] for p in d['parameters']] # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
['cle', ..., 'moyen']
>>> print d['parameters'][0]['caption']
Secret key, a 40 digits hexadecimal number
"""
'''
def __init__(self, kind, options, logger=None):
self.kind = kind
self.backend = get_backend(kind)(options, logger=logger)
def request(self, amount, **kwargs):
"""Request a payment to the payment backend.
'''Request a payment to the payment backend.
Arguments:
amount -- the amount of money to ask
email -- the email of the customer (optional)
usually redundant with the hardwired settings in the bank
configuration panel. At this url you must use the Payment.response
method to analyze the bank returned values.
Arguments:
amount -- the amount of money to ask
email -- the email of the customer (optional)
usually redundant with the hardwired settings in the bank
configuration panel. At this url you must use the Payment.response
method to analyze the bank returned values.
It returns a triple of values, (transaction_id, kind, data):
- the first gives a string value to later match the payment with
the invoice,
- kind gives the type of the third value, payment.URL or
payment.HTML or payment.FORM,
- the third is the URL or the HTML form to contact the payment
server, which must be sent to the customer browser.
"""
logger.debug('%r' % kwargs)
It returns a triple of values, (transaction_id, kind, data):
- the first gives a string value to later match the payment with
the invoice,
- kind gives the type of the third value, payment.URL or
payment.HTML or payment.FORM,
- the third is the URL or the HTML form to contact the payment
server, which must be sent to the customer browser.
'''
logger.debug(u'%r' % kwargs)
if 'capture_date' in kwargs:
capture_date = kwargs.pop('capture_date')
@ -186,68 +157,69 @@ class Payment:
# backend timezone should come from some backend configuration
backend_tz = pytz.timezone('Europe/Paris')
utc_tz = pytz.timezone('Etc/UTC')
backend_trans_date = utc_tz.localize(datetime.datetime.utcnow()).astimezone(backend_tz)
backend_trans_date = utc_tz.localize(
datetime.datetime.utcnow()).astimezone(backend_tz)
capture_day = (capture_date - backend_trans_date.date()).days
if capture_day <= 0:
raise ValueError('capture_date needs to be superior to the transaction date.')
raise ValueError("capture_date needs to be superior to the transaction date.")
kwargs['capture_day'] = force_text(capture_day)
return self.backend.request(amount, **kwargs)
def response(self, query_string, **kwargs):
"""
Process a response from the Bank API. It must be used on the URL
where the user browser of the payment server is going to post the
result of the payment. Beware it can happen multiple times for the
same payment, so you MUST support multiple notification of the same
event, i.e. it should be idempotent. For example if you already
validated some invoice, receiving a new payment notification for the
same invoice should alter this state change.
'''
Process a response from the Bank API. It must be used on the URL
where the user browser of the payment server is going to post the
result of the payment. Beware it can happen multiple times for the
same payment, so you MUST support multiple notification of the same
event, i.e. it should be idempotent. For example if you already
validated some invoice, receiving a new payment notification for the
same invoice should alter this state change.
Beware that when notified directly by the bank (and not through the
customer browser) no applicative session will exist, so you should
not depend on it in your handler.
Beware that when notified directly by the bank (and not through the
customer browser) no applicative session will exist, so you should
not depend on it in your handler.
Arguments:
query_string -- the URL encoded form-data from a GET or a POST
Arguments:
query_string -- the URL encoded form-data from a GET or a POST
It returns a quadruplet of values:
It returns a quadruplet of values:
(result, transaction_id, bank_data, return_content)
(result, transaction_id, bank_data, return_content)
- result is a boolean stating whether the transaction worked, use it
to decide whether to act on a valid payment,
- the transaction_id return the same id than returned by request
when requesting for the payment, use it to find the invoice or
transaction which is linked to the payment,
- bank_data is a dictionnary of the data sent by the bank, it should
be logged for security reasons,
- return_content, if not None you must return this content as the
result of the HTTP request, it's used when the bank is calling
your site as a web service.
- result is a boolean stating whether the transaction worked, use it
to decide whether to act on a valid payment,
- the transaction_id return the same id than returned by request
when requesting for the payment, use it to find the invoice or
transaction which is linked to the payment,
- bank_data is a dictionnary of the data sent by the bank, it should
be logged for security reasons,
- return_content, if not None you must return this content as the
result of the HTTP request, it's used when the bank is calling
your site as a web service.
"""
'''
return self.backend.response(query_string, **kwargs)
def cancel(self, amount, bank_data, **kwargs):
"""
Cancel or edit the amount of a transaction sent to the bank.
'''
Cancel or edit the amount of a transaction sent to the bank.
Arguments:
- amount -- the amount of money to cancel
- bank_data -- the transaction dictionary received from the bank
"""
Arguments:
- amount -- the amount of money to cancel
- bank_data -- the transaction dictionary received from the bank
'''
return self.backend.cancel(amount, bank_data, **kwargs)
def validate(self, amount, bank_data, **kwargs):
"""
Validate and trigger the transmission of a transaction to the bank.
'''
Validate and trigger the transmission of a transaction to the bank.
Arguments:
- amount -- the amount of money
- bank_data -- the transaction dictionary received from the bank
"""
Arguments:
- amount -- the amount of money
- bank_data -- the transaction dictionary received from the bank
'''
return self.backend.validate(amount, bank_data, **kwargs)
def get_parameters(self, scope='global'):
@ -258,65 +230,11 @@ class Payment:
res.append(param)
return res
def get_min_time_between_transactions(self):
if hasattr(self.backend, 'min_time_between_transactions'):
return self.backend.min_time_between_transactions
return 0
@property
def has_free_transaction_id(self):
return self.backend.has_free_transaction_id
@property
def has_empty_response(self):
return self.backend.has_free_transaction_id
def payment_status(self, transaction_id, **kwargs):
if not self.backend.payment_status:
raise NotImplementedError('payment_status is not implemented on this backend')
return self.backend.payment_status(transaction_id=transaction_id, **kwargs)
@property
def has_payment_status(self):
return hasattr(self.backend, 'payment_status')
def get_minimal_amount(self):
return getattr(self.backend, 'minimal_amount', None)
def get_maximal_amount(self):
return getattr(self.backend, 'maximal_amount', None)
@property
def has_guess(self):
return hasattr(self.backend.__class__, 'guess')
@classmethod
def guess(self, *, method=None, query_string=None, body=None, headers=None, backends=(), **kwargs):
'''Try to guess the kind of backend and the transaction_id given part of an HTTP response.
method CAN be GET or POST.
query_string is the URL encoded query-string as a regular string.
body is the bytes content of the response.
headers can eventually give access to the response headers.
backends is to limit the accepted kind of backends if the possible backends are known.
'''
last_exception = None
for kind, backend in get_backends().items():
if not hasattr(backend, 'guess'):
continue
if backends and kind not in backends:
continue
try:
transaction_id = backend.guess(
method=method, query_string=query_string, body=body, headers=headers
)
except Exception as e:
last_exception = e
continue
if transaction_id:
return kind, transaction_id
if last_exception is not None:
raise last_exception
raise BackendNotFound

View File

@ -14,6 +14,7 @@
# 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/>.
from __future__ import print_function
import configparser
import decimal
@ -25,7 +26,7 @@ import tempfile
import click
from . import FORM, URL, Payment
from . import Payment, FORM, URL
def option(value):
@ -67,7 +68,7 @@ def main(ctx, backend, debug, option, name):
backend = config_backend
load = True
elif name and backend:
load = config_backend == backend and config_name == name
load = (config_backend == backend and config_name == name)
elif name:
load = config_name == name
elif backend:
@ -123,5 +124,4 @@ def response(backend, query_string, param):
for line in formatted_value.splitlines(False):
print(' ', line)
main()

View File

@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# eopayment - online payment library
# Copyright (C) 2011-2020 Entr'ouvert
#
@ -16,7 +17,8 @@
'''Responses codes emitted by EMV Card or 'Carte Bleu' in France'''
from . import CANCELLED, DENIED, ERROR, PAID
from . import PAID, CANCELLED, ERROR, DENIED
CB_RESPONSE_CODES = {
'00': {'message': 'Transaction approuvée ou traitée avec succès', 'result': PAID},

View File

@ -14,24 +14,33 @@
# 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 html
import logging
import os
import os.path
import os
import random
import sys
import logging
from datetime import date
from decimal import ROUND_DOWN, Decimal
import six
if six.PY3:
import html
else:
import cgi
from gettext import gettext as _
try:
if 'django' in sys.modules:
from django.utils.translation import gettext_lazy as _
from django.utils.translation import ugettext_lazy as _
except ImportError:
pass
__all__ = ['PaymentCommon', 'URL', 'HTML', 'RANDOM', 'RECEIVED', 'ACCEPTED', 'PAID', 'ERROR', 'WAITING']
__all__ = ['PaymentCommon', 'URL', 'HTML', 'RANDOM', 'RECEIVED', 'ACCEPTED',
'PAID', 'ERROR', 'WAITING']
RANDOM = random.SystemRandom()
@ -55,18 +64,23 @@ ORDERID_TRANSACTION_SEPARATOR = '!'
def force_text(s, encoding='utf-8'):
if issubclass(type(s), str):
if issubclass(type(s), six.text_type):
return s
try:
if not issubclass(type(s), str):
if isinstance(s, bytes):
s = str(s, encoding)
if not issubclass(type(s), six.string_types):
if six.PY3:
if isinstance(s, bytes):
s = six.text_type(s, encoding)
else:
s = six.text_type(s)
elif hasattr(s, '__unicode__'):
s = six.text_type(s)
else:
s = str(s)
s = six.text_type(bytes(s), encoding)
else:
s = s.decode(encoding)
except UnicodeDecodeError:
return str(s, encoding, 'ignore')
return six.text_type(s, encoding, 'ignore')
return s
@ -87,43 +101,30 @@ class ResponseError(PaymentException):
pass
class BackendNotFound(PaymentException):
pass
class PaymentResponse(object):
'''Holds a generic view on the result of payment transaction response.
result -- holds the declarative result of the transaction, does not use
it to validate the payment in your backoffice, it's just for informing
the user that all is well.
test -- indicates if the transaction was a test
signed -- holds whether the message was signed
bank_data -- a dictionnary containing some data depending on the bank,
you have to log it for audit purpose.
return_content -- when handling a response in a callback endpoint, i.e.
a response transmitted directly from the bank to the merchant website,
you usually have to confirm good reception of the message by returning a
properly formatted response, this is it.
bank_status -- if result is False, it contains the reason
order_id -- the id given by the merchant in the payment request
transaction_id -- the id assigned by the bank to this transaction, it
could be the one sent by the merchant in the request, but it is usually
an identifier internal to the bank.
'''
class PaymentResponse:
"""Holds a generic view on the result of payment transaction response.
result -- holds the declarative result of the transaction, does not use
it to validate the payment in your backoffice, it's just for informing
the user that all is well.
test -- indicates if the transaction was a test
signed -- holds whether the message was signed
bank_data -- a dictionnary containing some data depending on the bank,
you have to log it for audit purpose.
return_content -- when handling a response in a callback endpoint, i.e.
a response transmitted directly from the bank to the merchant website,
you usually have to confirm good reception of the message by returning a
properly formatted response, this is it.
bank_status -- if result is False, it contains the reason
order_id -- the id given by the merchant in the payment request
transaction_id -- the id assigned by the bank to this transaction, it
could be the one sent by the merchant in the request, but it is usually
an identifier internal to the bank.
"""
def __init__(
self,
result=None,
signed=None,
bank_data=dict(),
return_content=None,
bank_status='',
transaction_id='',
order_id='',
test=False,
transaction_date=None,
):
def __init__(self, result=None, signed=None, bank_data=dict(),
return_content=None, bank_status='', transaction_id='',
order_id='', test=False, transaction_date=None):
self.result = result
self.signed = signed
self.bank_data = bank_data
@ -150,9 +151,8 @@ class PaymentResponse:
return self.result == ERROR
class PaymentCommon:
class PaymentCommon(object):
has_free_transaction_id = False
has_empty_response = False
PATH = '/tmp'
BANK_ID = '__bank_id'
@ -171,9 +171,11 @@ class PaymentCommon:
while True:
parts = [RANDOM.choice(choices) for x in range(length)]
id = ''.join(parts)
name = '%s_%s_%s' % (str(date.today()), '-'.join(prefixes), str(id))
name = '%s_%s_%s' % (str(date.today()),
'-'.join(prefixes), str(id))
try:
fd = os.open(os.path.join(self.PATH, name), os.O_CREAT | os.O_EXCL)
fd = os.open(os.path.join(self.PATH, name),
os.O_CREAT | os.O_EXCL)
except Exception:
raise
else:
@ -185,21 +187,21 @@ class PaymentCommon:
try:
amount = Decimal(amount)
except ValueError:
raise ValueError(
'invalid amount %s: it must be a decimal integer with two digits '
'at most after the decimal point',
amount,
)
if int(amount) < min_amount or (max_amount and int(amount) > max_amount):
raise ValueError('invalid amount %s: it must be a decimal integer with two digits '
'at most after the decimal point', amount)
if int(amount) < min_amount or (max_amount and int(amount) > max_amount):
raise ValueError('amount %s is not in range [%s, %s]' % (amount, min_amount, max_amount))
if cents:
amount *= Decimal('100') # convert to cents
amount = amount.to_integral_value(ROUND_DOWN)
return str(amount)
payment_status = None
class Form:
def __init__(self, url, method, fields, encoding='utf-8', submit_name='Submit', submit_value='Submit'):
class Form(object):
def __init__(self, url, method, fields, encoding='utf-8',
submit_name='Submit', submit_value='Submit'):
self.url = url
self.method = method
self.fields = fields
@ -217,16 +219,17 @@ class Form:
return s
def escape(self, s):
return html.escape(force_text(s, self.encoding))
if six.PY3:
return html.escape(force_text(s, self.encoding))
else:
return cgi.escape(force_text(s, self.encoding)).encode(self.encoding)
def __str__(self):
s = '<form method="%s" action="%s">' % (self.method, self.url)
for field in self.fields:
s += '\n <input type="%s" name="%s" value="%s"/>' % (
self.escape(field['type']),
self.escape(field['name']),
self.escape(field['value']),
)
s += '\n <input type="%s" name="%s" value="%s"/>' % (self.escape(field['type']),
self.escape(field['name']),
self.escape(field['value']))
s += '\n <input type="submit"'
if self.submit_name:
s += ' name="%s"' % self.escape(self.submit_name)

View File

@ -17,38 +17,46 @@
import logging
import uuid
import warnings
from urllib.parse import parse_qs, urlencode
from .common import ERROR, PAID, URL, WAITING, PaymentCommon, PaymentResponse, ResponseError, _, force_text
from six.moves.urllib.parse import parse_qs, urlencode
from .common import (
PaymentCommon,
PaymentResponse,
ResponseError,
URL,
PAID, ERROR, WAITING,
force_text,
_
)
__all__ = ['Payment']
SERVICE_URL = 'https://dummy-payment.entrouvert.com/'
SERVICE_URL = 'http://dummy-payment.demo.entrouvert.com/'
LOGGER = logging.getLogger(__name__)
class Payment(PaymentCommon):
"""
Dummy implementation of the payment interface.
'''
Dummy implementation of the payment interface.
It is used with a dummy implementation of a bank payment service that
you can find on:
It is used with a dummy implementation of a bank payment service that
you can find on:
https://dummy-payment.entrouvert.com/
You must pass the following keys inside the options dictionnary:
- dummy_service_url, the URL of the dummy payment service, it defaults
to the one operated by Entr'ouvert.
- automatic_return_url: where to POST to notify the service of a
payment
- origin: a human string to display to the user about the origin of
the request.
- siret: an identifier for the eCommerce site, fake.
- normal_return_url: the return URL for the user (can be overriden on a
per request basis).
"""
http://dummy-payment.demo.entrouvert.com/
You must pass the following keys inside the options dictionnary:
- dummy_service_url, the URL of the dummy payment service, it defaults
to the one operated by Entr'ouvert.
- automatic_return_url: where to POST to notify the service of a
payment
- origin: a human string to display to the user about the origin of
the request.
- siret: an identifier for the eCommerce site, fake.
- normal_return_url: the return URL for the user (can be overriden on a
per request basis).
'''
description = {
'caption': 'Dummy payment backend',
'parameters': [
@ -71,7 +79,8 @@ class Payment(PaymentCommon):
},
{
'name': 'origin',
'caption': _('name of the requesting service, ' 'to present in the user interface'),
'caption': _('name of the requesting service, '
'to present in the user interface'),
'type': str,
'default': 'origin',
},
@ -120,51 +129,23 @@ class Payment(PaymentCommon):
],
}
def request(
self,
amount,
name=None,
address=None,
email=None,
phone=None,
orderid=None,
info1=None,
info2=None,
info3=None,
next_url=None,
capture_day=None,
subject=None,
**kwargs,
):
def request(self, amount, name=None, address=None, email=None, phone=None,
orderid=None, info1=None, info2=None, info3=None,
next_url=None, capture_day=None, subject=None, **kwargs):
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,
)
__name__, amount, name, address, email, phone, info1, info2, info3, next_url, kwargs)
transaction_id = str(uuid.uuid4().hex)
normal_return_url = self.normal_return_url
if next_url and not normal_return_url:
warnings.warn(
'passing next_url to request() is deprecated, ' 'set normal_return_url in options',
DeprecationWarning,
)
warnings.warn("passing next_url to request() is deprecated, "
"set normal_return_url in options", DeprecationWarning)
normal_return_url = next_url
automatic_return_url = self.automatic_return_url
if self.direct_notification_url and not automatic_return_url:
warnings.warn(
'direct_notification_url option is deprecated, ' 'use automatic_return_url',
DeprecationWarning,
)
warnings.warn("direct_notification_url option is deprecated, "
"use automatic_return_url", DeprecationWarning)
automatic_return_url = self.direct_notification_url
query = {
'transaction_id': transaction_id,
@ -172,20 +153,11 @@ class Payment(PaymentCommon):
'email': email,
'return_url': normal_return_url or '',
'direct_notification_url': automatic_return_url or '',
'origin': self.origin,
'origin': self.origin
}
query.update(
dict(
name=name,
address=address,
email=email,
phone=phone,
orderid=orderid,
info1=info1,
info2=info2,
info3=info3,
)
)
dict(name=name, address=address, email=email, phone=phone,
orderid=orderid, info1=info1, info2=info2, info3=info3))
if capture_day is not None:
query['capture_day'] = str(capture_day)
if subject is not None:
@ -221,8 +193,7 @@ class Payment(PaymentCommon):
order_id=transaction_id,
transaction_id=transaction_id,
bank_status=form.get('reason'),
test=True,
)
test=True)
return response
def validate(self, amount, bank_data, **kwargs):
@ -230,9 +201,3 @@ class Payment(PaymentCommon):
def cancel(self, amount, bank_data, **kwargs):
return {}
@classmethod
def guess(self, *, method=None, query_string=None, body=None, headers=None, backends=(), **kwargs):
qs = parse_qs(force_text(query_string))
if set(qs.keys()) >= {'transaction_id', 'signed'}:
return qs['transaction_id'][0]

View File

@ -14,29 +14,17 @@
# 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/>.
from urllib.parse import parse_qs, urljoin
import requests
from six.moves.urllib.parse import parse_qs, urljoin
from .common import (
CANCELLED,
ERROR,
PAID,
URL,
WAITING,
PaymentCommon,
PaymentException,
PaymentResponse,
ResponseError,
_,
)
from .common import (CANCELLED, ERROR, PAID, URL, WAITING, PaymentCommon,
PaymentException, PaymentResponse, ResponseError, _)
__all__ = ['Payment']
class Payment(PaymentCommon):
'''Implements EMS API, see https://dev.online.emspay.eu/.'''
service_url = 'https://api.online.emspay.eu/v1/'
description = {
@ -79,7 +67,7 @@ class Payment(PaymentCommon):
'email_address': email,
'first_name': first_name,
'last_name': last_name,
},
}
}
resp = self.call_endpoint('POST', 'orders', data=body)
@ -110,7 +98,7 @@ class Payment(PaymentCommon):
order_id=order_id,
transaction_id=order_id,
bank_status=status,
test=bool('is-test' in resp.get('flags', [])),
test=bool('is-test' in resp.get('flags', []))
)
return response
@ -132,12 +120,12 @@ class Payment(PaymentCommon):
result = response.json()
except ValueError:
self.logger.debug('received invalid json %r', response.text)
raise PaymentException(
'%s on endpoint "%s" returned invalid JSON: %s' % (method, endpoint, response.text)
)
raise PaymentException('%s on endpoint "%s" returned invalid JSON: %s' %
(method, endpoint, response.text))
self.logger.debug('received "%s" with status %s', result, response.status_code)
try:
response.raise_for_status()
except requests.exceptions.HTTPError as e:
raise PaymentException('%s error on endpoint "%s": %s "%s"' % (method, endpoint, e, result))
raise PaymentException(
'%s error on endpoint "%s": %s "%s"' % (method, endpoint, e, result))
return result

View File

@ -6,8 +6,8 @@ msgid ""
msgstr ""
"Project-Id-Version: eopayment 0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-02-20 14:06+0100\n"
"PO-Revision-Date: 2021-05-06 17:12+0200\n"
"POT-Creation-Date: 2020-11-18 20:15+0100\n"
"PO-Revision-Date: 2020-11-18 21:12+0100\n"
"Last-Translator: Frederic Peters <fpeters@entrouvert.com>\n"
"Language: French\n"
"MIME-Version: 1.0\n"
@ -15,164 +15,192 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: eopayment/dummy.py eopayment/keyware.py eopayment/mollie.py
#: eopayment/ogone.py eopayment/paybox.py eopayment/sips2.py
#: eopayment/systempayv2.py
#: eopayment/dummy.py:65 eopayment/keyware.py:35 eopayment/mollie.py:38
#: eopayment/ogone.py:438 eopayment/paybox.py:205 eopayment/sips2.py:123
#: eopayment/systempayv2.py:235
msgid "Normal return URL"
msgstr "URL de retour normal"
#: eopayment/dummy.py eopayment/paybox.py eopayment/sips2.py eopayment/tipi.py
#: eopayment/dummy.py:71 eopayment/paybox.py:211 eopayment/sips2.py:129
#: eopayment/tipi.py:67
msgid "Automatic return URL"
msgstr "URL de retour automatique"
#: eopayment/dummy.py
#: eopayment/dummy.py:76
msgid "URL of the dummy payment service"
msgstr "URL du service de paiement bouchon"
#: eopayment/dummy.py
#: eopayment/dummy.py:82
msgid "name of the requesting service, to present in the user interface"
msgstr "nom du service appelant qui sera affiché dans linterface utilisateur"
#: eopayment/dummy.py
#: eopayment/dummy.py:90
msgid ""
"All response will be considered as signed (to test payment locally for "
"example, as you cannot received the signed callback)"
msgstr ""
"Toutes les réponses seront considérées comme signées (utile pour tester le "
"paiement en local où le retour signé ne peut pas être obtenu)"
"Toutes les réponses seront considérées comme signées (utile pour tester "
"le paiement en local où le retour signé ne peut pas être obtenu)"
#: eopayment/dummy.py
#: eopayment/dummy.py:115
msgid "direct notification url (replaced by automatic_return_url)"
msgstr "URL de notification directe (remplacée par automatic_return_url)"
#: eopayment/dummy.py
#: eopayment/dummy.py:121
msgid "Return URL for the user"
msgstr "URL de retour pour lusager"
#: eopayment/keyware.py
#: eopayment/keyware.py:31
msgid "Keyware payment backend"
msgstr "Service de paiement Keyware"
#: eopayment/keyware.py eopayment/mollie.py
#: eopayment/keyware.py:40 eopayment/mollie.py:43
msgid "Asychronous return URL"
msgstr "URL de retour asynchrone"
#: eopayment/keyware.py eopayment/mollie.py
#: eopayment/keyware.py:45 eopayment/mollie.py:48
msgid "URL of the payment service"
msgstr "URL du service de paiement"
#: eopayment/keyware.py eopayment/mollie.py
#: eopayment/keyware.py:52 eopayment/mollie.py:55
msgid "API key"
msgstr "Clé dAPI"
#: eopayment/mollie.py
#: eopayment/mollie.py:61
msgid "General description that will be displayed for all payments"
msgstr "Description générale qui sera affichée pour tous les paiements"
#: eopayment/ogone.py
#: eopayment/ogone.py:434
msgid "Ogone / Ingenico Payment System e-Commerce"
msgstr "Système de paiement Ogone/Ingenico"
#: eopayment/ogone.py
#: eopayment/ogone.py:444
msgid "Automatic return URL (ignored, must be set in Ogone backoffice)"
msgstr ""
"URL de retour automatique (ignorée, doit être posée dans le backoffice Ogone)"
msgstr "URL de retour automatique (ignorée, doit être posée dans le backoffice Ogone)"
#: eopayment/ogone.py
#: eopayment/ogone.py:460
msgid "Language"
msgstr "Langue"
#: eopayment/ogone.py
msgid "Characters encoding"
msgstr "Table d'encodage des caractères (Latin1 ou UTF-8)"
#: eopayment/paybox.py
#: eopayment/paybox.py:201
msgid "Paybox"
msgstr "Paybox"
#: eopayment/paybox.py
#: eopayment/paybox.py:233
msgid "Site key"
msgstr "Clé de site"
#: eopayment/paybox.py:268
msgid "Callback URL"
msgstr "URL de retour"
#: eopayment/paybox.py eopayment/sips2.py
#: eopayment/paybox.py:281 eopayment/sips2.py:141
msgid "Capture Mode"
msgstr "Mode de capture"
#: eopayment/paybox.py
#: eopayment/paybox.py:295
msgid "Default Timezone"
msgstr "Fuseau horaire par défaut"
#: eopayment/payfip_ws.py eopayment/tipi.py
#: eopayment/payfip_ws.py:156 eopayment/tipi.py:47
msgid "Client number"
msgstr "Numéro de clients"
#: eopayment/payfip_ws.py eopayment/tipi.py
#: eopayment/payfip_ws.py:157 eopayment/tipi.py:48
msgid "6 digits number provided by DGFIP"
msgstr "6 chiffres fournis par la DGFIP"
#: eopayment/payfip_ws.py eopayment/tipi.py
#: eopayment/payfip_ws.py:164
msgid "PayFIP WS service URL"
msgstr "URL du service PayFIP WS"
#: eopayment/payfip_ws.py:165 eopayment/payfip_ws.py:172
#: eopayment/payfip_ws.py:179 eopayment/tipi.py:56
msgid "do not modify if you do not know"
msgstr "ne pas modifier si vous ne savez pas"
#: eopayment/payfip_ws.py:171
msgid "PayFIP WS WSDL URL"
msgstr "URL vers le WSDL de PayFIP WS"
#: eopayment/payfip_ws.py:178
msgid "PayFiP payment URL"
msgstr "URL du paiement PayFiP"
#: eopayment/payfip_ws.py:184 eopayment/tipi.py:72
msgid "Payment type"
msgstr "Type de paiement"
#: eopayment/payfip_ws.py eopayment/tipi.py
#: eopayment/payfip_ws.py:187 eopayment/tipi.py:76
msgid "test"
msgstr "test"
#: eopayment/payfip_ws.py eopayment/tipi.py
#: eopayment/payfip_ws.py:188
msgid "activation"
msgstr "activation"
#: eopayment/payfip_ws.py eopayment/tipi.py
#: eopayment/payfip_ws.py:189 eopayment/tipi.py:77
msgid "production"
msgstr "production"
#: eopayment/payfip_ws.py
#: eopayment/payfip_ws.py:194
msgid "User return URL"
msgstr "URL de retour usager"
#: eopayment/payfip_ws.py
#: eopayment/payfip_ws.py:199
msgid "Asynchronous return URL"
msgstr "URL de retour asynchrone"
#: eopayment/sips2.py
#: eopayment/sips2.py:98
msgid "Platform"
msgstr "Plateforme"
#: eopayment/sips2.py
#: eopayment/sips2.py:105
msgid "Merchant ID"
msgstr "Identifiant de marchand"
#: eopayment/sips2.py
#: eopayment/sips2.py:111
msgid "Secret Key"
msgstr "Clé secrète"
#: eopayment/sips2.py
#: eopayment/sips2.py:117
msgid "Key Version"
msgstr "Version de clé"
#: eopayment/sips2.py
#: eopayment/sips2.py:134
msgid "Currency code"
msgstr "Code de la devise"
#: eopayment/sips2.py
#: eopayment/sips2.py:148
msgid "Capture Day"
msgstr "Jour de capture"
#: eopayment/sips2.py
#: eopayment/sips2.py:153
msgid "Payment Means"
msgstr "Méthodes de paiement"
#: eopayment/sips2.py
#: eopayment/sips2.py:158
msgid "SIPS server Timezone"
msgstr "Fuseau horaire du serveur SIPS"
#: eopayment/systempayv2.py
#: eopayment/systempayv2.py:241
msgid ""
"Automatic return URL (ignored, must be set in Payzen/SystemPay backoffice)"
msgstr ""
"URL de retour automatique (ignorée, doit être posée dans le backoffice "
"Payzen/SystemPay)"
"URL de retour automatique (ignorée, doit être posée dans le backoffice Payzn/SystemPay)"
#: eopayment/tipi.py
#: eopayment/tipi.py:55
msgid "TIPI service URL"
msgstr "URL de service TIPI"
#: eopayment/tipi.py:62
msgid "Normal return URL (unused by TIPI)"
msgstr "URL de retour normal (pas utilisée par TIPI)"
#: eopayment/tipi.py:78
msgid "with user account"
msgstr "avec compte usager"
#: eopayment/tipi.py:79
msgid "manual entry"
msgstr "saisie manuelle"

View File

@ -14,32 +14,20 @@
# 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/>.
from urllib.parse import parse_qs, urljoin
from .common import _
import requests
from six.moves.urllib.parse import parse_qs, urljoin
from .common import (
ACCEPTED,
CANCELLED,
ERROR,
PAID,
URL,
WAITING,
PaymentCommon,
PaymentException,
PaymentResponse,
ResponseError,
_,
)
from .common import (CANCELLED, ERROR, PAID, URL, PaymentCommon,
PaymentException, PaymentResponse, ResponseError, WAITING,
ACCEPTED)
__all__ = ['Payment']
class Payment(PaymentCommon):
'''Implements Mollie API, see https://docs.mollie.com/reference/v2/.'''
has_empty_response = True
service_url = 'https://api.mollie.com/v2/'
description = {
@ -78,12 +66,9 @@ class Payment(PaymentCommon):
def request(self, amount, **kwargs):
amount = self.clean_amount(amount, cents=False)
orderid = kwargs.pop('orderid', None)
subject = kwargs.pop('subject', None)
metadata = {
k: v for k, v in kwargs.items() if k in ('email', 'first_name', 'last_name') and v is not None
}
metadata = {k: v for k, v in kwargs.items()
if k in ('email', 'first_name', 'last_name') and v is not None}
body = {
'amount': {
'value': amount,
@ -94,17 +79,13 @@ class Payment(PaymentCommon):
'metadata': metadata,
'description': self.description_text,
}
if orderid is not None:
body['description'] = orderid
metadata['orderid'] = orderid
if subject is not None:
body['description'] = subject
resp = self.call_endpoint('POST', 'payments', data=body)
return resp['id'], URL, resp['_links']['checkout']['href']
def response(self, query_string, redirect=False, order_id_hint=None, order_status_hint=None, **kwargs):
def response(self, query_string, redirect=False, order_id_hint=None,
order_status_hint=None, **kwargs):
if redirect:
if order_status_hint in (PAID, CANCELLED, ERROR):
return PaymentResponse(order_id=order_id_hint, result=order_status_hint)
@ -137,7 +118,7 @@ class Payment(PaymentCommon):
order_id=payment_id,
transaction_id=payment_id,
bank_status=status,
test=resp['mode'] == 'test',
test=resp['mode'] == 'test'
)
return response
@ -152,28 +133,13 @@ class Payment(PaymentCommon):
result = response.json()
except ValueError:
self.logger.debug('received invalid json %r', response.text)
raise PaymentException(
'%s on endpoint "%s" returned invalid JSON: %s' % (method, endpoint, response.text)
)
raise PaymentException('%s on endpoint "%s" returned invalid JSON: %s' %
(method, endpoint, response.text))
self.logger.debug('received "%s" with status %s', result, response.status_code)
try:
response.raise_for_status()
except requests.exceptions.HTTPError as e:
raise PaymentException(
'%s error on endpoint "%s": %s "%s"' % (method, endpoint, e, result.get('detail', result))
)
'%s error on endpoint "%s": %s "%s"' % (method, endpoint, e,
result.get('detail', result)))
return result
@classmethod
def guess(self, *, method=None, query_string=None, body=None, headers=None, backends=(), **kwargs):
for content in [query_string, body]:
if isinstance(content, bytes):
try:
content = content.decode()
except UnicodeDecodeError:
pass
if isinstance(content, str):
fields = parse_qs(content)
if set(fields) == {'id'}:
return fields['id'][0]
return None

View File

@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# eopayment - online payment library
# Copyright (C) 2011-2020 Entr'ouvert
#
@ -15,28 +16,18 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import hashlib
import uuid
from decimal import ROUND_HALF_UP, Decimal
from urllib import parse as urlparse
import string
import six
from six.moves.urllib import parse as urlparse
from decimal import Decimal, ROUND_HALF_UP
from .common import (
ACCEPTED,
CANCELLED,
DENIED,
ERROR,
FORM,
ORDERID_TRANSACTION_SEPARATOR,
PAID,
WAITING,
Form,
PaymentCommon,
PaymentResponse,
ResponseError,
_,
force_byte,
force_text,
PaymentCommon, PaymentResponse, FORM, CANCELLED, PAID,
ERROR, Form, DENIED, ACCEPTED, ORDERID_TRANSACTION_SEPARATOR,
WAITING, ResponseError, force_byte, force_text, _
)
ENVIRONMENT_TEST = 'TEST'
ENVIRONMENT_TEST_URL = 'https://secure.ogone.com/ncol/test/orderstandard.asp'
ENVIRONMENT_PROD = 'PROD'
@ -466,19 +457,10 @@ class Payment(PaymentCommon):
},
{
'name': 'language',
'caption': _('Language'),
'caption': _(u'Language'),
'default': 'fr_FR',
'choices': (('fr_FR', 'français'),),
},
{
'name': 'encoding',
'caption': _('Characters encoding'),
'default': 'utf-8',
'choices': [
('iso-8859-1', 'Latin1 (ISO-8859-1)'),
('utf-8', 'Unicode (UTF-8)'),
],
},
{
'name': 'hash_algorithm',
'caption': 'Algorithme de hachage',
@ -500,31 +482,26 @@ class Payment(PaymentCommon):
'default': 'EUR',
'choices': ('EUR',),
},
],
]
}
def __init__(self, options, logger=None):
# retro-compatibility with old default of latin1
options.setdefault('encoding', 'iso-8859-1')
super().__init__(options, logger=logger)
def sha_sign(self, algo, key, params, keep, encoding='iso-8859-1'):
def sha_sign(self, algo, key, params, keep):
'''Ogone signature algorithm of query string'''
values = params.items()
values = [(a.upper(), b) for a, b in values]
values = sorted(values)
values = ['%s=%s' % (a, b) for a, b in values if a in keep and b]
values = [u'%s=%s' % (a, b) for a, b in values if a in keep and b]
tosign = key.join(values)
tosign += key
tosign = force_byte(tosign, encoding=encoding)
tosign = force_byte(tosign, encoding='iso-8859-1')
hashing = getattr(hashlib, algo)
return hashing(tosign).hexdigest().upper()
def sha_sign_in(self, params, encoding='iso-8859-1'):
return self.sha_sign(self.hash_algorithm, self.sha_in, params, SHA_IN_PARAMS, encoding=encoding)
def sha_sign_in(self, params):
return self.sha_sign(self.hash_algorithm, self.sha_in, params, SHA_IN_PARAMS)
def sha_sign_out(self, params, encoding='iso-8859-1'):
return self.sha_sign(self.hash_algorithm, self.sha_out, params, SHA_OUT_PARAMS, encoding=encoding)
def sha_sign_out(self, params):
return self.sha_sign(self.hash_algorithm, self.sha_out, params, SHA_OUT_PARAMS)
def get_request_url(self):
if self.environment == ENVIRONMENT_TEST:
@ -533,27 +510,20 @@ class Payment(PaymentCommon):
return ENVIRONMENT_PROD_URL
raise NotImplementedError('unknown environment %s' % self.environment)
def request(
self,
amount,
orderid=None,
name=None,
email=None,
language=None,
description=None,
transaction_id=None,
**kwargs,
):
# use complus for transmitting and receiving the transaction_id see
# https://epayments-support.ingenico.com/fr/integration/all-sales-channels/integrate-with-e-commerce/guide#variable-feedback-parameters
# orderid is now only used for unicity of payments check (it's
# garanteed that no payment for the same ORDERID can happen during a 45
# days window, see
# https://epayments-support.ingenico.com/fr/integration/all-sales-channels/integrate-with-e-commerce/guide#form-parameters)
complus = transaction_id or uuid.uuid4().hex
if not orderid:
orderid = complus
def request(self, amount, orderid=None, name=None, email=None,
language=None, description=None, **kwargs):
reference = self.transaction_id(20, string.digits + string.ascii_letters)
# prepend order id in payment reference
if orderid:
if len(orderid) > 24:
raise ValueError('orderid length exceeds 25 characters')
reference = (
orderid
+ ORDERID_TRANSACTION_SEPARATOR
+ self.transaction_id(29 - len(orderid),
string.digits + string.ascii_letters))
language = language or self.language
# convertir en centimes
amount = Decimal(amount) * 100
@ -561,11 +531,10 @@ class Payment(PaymentCommon):
amount = amount.quantize(Decimal('1.'), rounding=ROUND_HALF_UP)
params = {
'AMOUNT': force_text(amount),
'ORDERID': orderid,
'ORDERID': reference,
'PSPID': self.pspid,
'LANGUAGE': language,
'CURRENCY': self.currency,
'COMPLUS': complus,
}
if self.normal_return_url:
params['ACCEPTURL'] = self.normal_return_url
@ -589,33 +558,32 @@ class Payment(PaymentCommon):
form = Form(
url=url,
method='POST',
fields=[{'type': 'hidden', 'name': key, 'value': params[key]} for key in params],
)
return complus, FORM, form
fields=[{'type': 'hidden',
'name': key,
'value': params[key]} for key in params])
return reference, FORM, form
def response(self, query_string, **kwargs):
params = urlparse.parse_qs(query_string, True, encoding=self.encoding)
params = {key.upper(): params[key][0] for key in params}
if not set(params) >= {'ORDERID', 'PAYID', 'STATUS', 'NCERROR'}:
if six.PY3:
params = urlparse.parse_qs(query_string, True, encoding='iso-8859-1')
else:
params = urlparse.parse_qs(query_string, True)
params = dict((key.upper(), params[key][0]) for key in params)
if not set(params) >= set(['ORDERID', 'PAYID', 'STATUS', 'NCERROR']):
raise ResponseError('missing ORDERID, PAYID, STATUS or NCERROR')
# py2: decode binary strings in query-string
# uniformize iso-8859-1 encoded values
for key in params:
params[key] = force_text(params[key], self.encoding)
orderid = params['ORDERID']
complus = params.get('COMPLUS')
params[key] = force_text(params[key], 'iso-8859-1')
reference = params['ORDERID']
transaction_id = params['PAYID']
status = params['STATUS']
error = params['NCERROR']
signed = False
if self.sha_in:
signature = params.get('SHASIGN')
# check signature against both encoding
for encoding in ('iso-8859-1', 'utf-8'):
expected_signature = self.sha_sign_out(params, encoding=encoding)
signed = signature == expected_signature
if signed:
break
expected_signature = self.sha_sign_out(params)
signed = signature == expected_signature
if status == '1':
result = CANCELLED
elif status == '2':
@ -631,35 +599,15 @@ class Payment(PaymentCommon):
# status 91: payment waiting/pending)
result = WAITING
else:
self.logger.error(
'response STATUS=%s NCERROR=%s NCERRORPLUS=%s', status, error, params.get('NCERRORPLUS', '')
)
self.logger.error('response STATUS=%s NCERROR=%s NCERRORPLUS=%s',
status, error, params.get('NCERRORPLUS', ''))
result = ERROR
# extract reference from received order id
if ORDERID_TRANSACTION_SEPARATOR in reference:
reference, transaction_id = reference.split(ORDERID_TRANSACTION_SEPARATOR, 1)
return PaymentResponse(
result=result,
signed=signed,
bank_data=params,
order_id=complus or orderid,
transaction_id=transaction_id,
)
@classmethod
def guess(self, *, method=None, query_string=None, body=None, headers=None, backends=(), **kwargs):
for content in [query_string, body]:
if isinstance(content, bytes):
try:
content = content.decode()
except UnicodeDecodeError:
pass
if isinstance(content, str):
fields = {key.upper(): values for key, values in urlparse.parse_qs(content).items()}
if not set(fields) >= {'ORDERID', 'PAYID', 'STATUS', 'NCERROR'}:
continue
orderid = fields.get('ORDERID')
complus = fields.get('COMPLUS')
if complus:
return complus[0]
return orderid[0]
return None
order_id=reference,
transaction_id=transaction_id)

View File

@ -1,3 +1,4 @@
# -*- coding: utf-8
# eopayment - online payment library
# Copyright (C) 2011-2020 Entr'ouvert
#
@ -14,42 +15,33 @@
# 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 codecs
from collections import OrderedDict
import datetime
import logging
import hashlib
import hmac
import logging
import random
import re
import requests
import uuid
import warnings
from collections import OrderedDict
from urllib import parse as urllib
from urllib import parse as urlparse
from xml.sax.saxutils import escape as xml_escape
import pytz
import requests
from Cryptodome.Hash import SHA
from Cryptodome.PublicKey import RSA
from Cryptodome.Signature import PKCS1_v1_5
from Crypto.Signature import PKCS1_v1_5
from Crypto.PublicKey import RSA
from Crypto.Hash import SHA
import six
from six.moves.urllib import parse as urlparse
from six.moves.urllib import parse as urllib
import base64
import warnings
from .common import (PaymentCommon, PaymentResponse, FORM, PAID, CANCELLED,
DENIED, ERROR, Form, ResponseError, force_text,
force_byte, _)
from . import cb
from .common import (
CANCELLED,
DENIED,
ERROR,
FORM,
PAID,
Form,
PaymentCommon,
PaymentResponse,
ResponseError,
_,
force_byte,
force_text,
)
__all__ = ['sign', 'Payment']
@ -120,7 +112,9 @@ PAYBOX_ERROR_CODES = {
'message': 'Opération sans authentification 3-DSecure, bloquée par le filtre.',
'result': DENIED,
},
'99999': {'message': 'Opération en attente de validation par lémetteur du moyen de paiement.'},
'99999': {
'message': 'Opération en attente de validation par lémetteur du moyen de paiement.'
},
}
ALGOS = {
@ -131,15 +125,18 @@ ALGOS = {
}
URLS = {
'test': 'https://preprod-tpeweb.paybox.com/cgi/MYchoix_pagepaiement.cgi',
'prod': 'https://tpeweb.paybox.com/cgi/MYchoix_pagepaiement.cgi',
'backup': 'https://tpeweb1.paybox.com/cgi/MYchoix_pagepaiement.cgi',
'test':
'https://preprod-tpeweb.paybox.com/cgi/MYchoix_pagepaiement.cgi',
'prod':
'https://tpeweb.paybox.com/cgi/MYchoix_pagepaiement.cgi',
'backup':
'https://tpeweb1.paybox.com/cgi/MYchoix_pagepaiement.cgi',
}
PAYBOX_DIRECT_URLS = {
'test': 'https://preprod-ppps.paybox.com/PPPS.php',
'prod': 'https://ppps.paybox.com/PPPS.php',
'backup': 'https://ppps1.paybox.com/PPPS.php',
'backup': 'https://ppps1.paybox.com/PPPS.php'
}
PAYBOX_DIRECT_CANCEL_OPERATION = '00055'
@ -150,13 +147,14 @@ PAYBOX_DIRECT_VERSION_NUMBER = '00103'
PAYBOX_DIRECT_SUCCESS_RESPONSE_CODE = '00000'
# payment modes
PAYMENT_MODES = {'AUTHOR_CAPTURE': 'O', 'IMMEDIATE': 'N'}
PAYMENT_MODES = {'AUTHOR_CAPTURE': 'O',
'IMMEDIATE': 'N'}
def sign(data, key):
"""Take a list of tuple key, value and sign it by building a string to
sign.
"""
'''Take a list of tuple key, value and sign it by building a string to
sign.
'''
logger = logging.getLogger(__name__)
algo = None
logger.debug('signature key %r', key)
@ -183,21 +181,20 @@ def verify(data, signature, key=PAYBOX_KEY):
class Payment(PaymentCommon):
"""Paybox backend for eopayment.
'''Paybox backend for eopayment.
If you want to handle Instant Payment Notification, you must pass
provide a automatic_return_url option specifying the URL of the
callback endpoint.
If you want to handle Instant Payment Notification, you must pass
provide a automatic_return_url option specifying the URL of the
callback endpoint.
Email is mandatory to emit payment requests with paybox.
IP adresses to authorize:
IN OUT
test 195.101.99.73 195.101.99.76
production 194.2.160.66 194.2.122.158
backup 195.25.7.146 195.25.7.166
"""
Email is mandatory to emit payment requests with paybox.
IP adresses to authorize:
IN OUT
test 195.101.99.73 195.101.99.76
production 194.2.160.66 194.2.122.158
backup 195.25.7.146 195.25.7.166
'''
callback = None
description = {
@ -222,46 +219,49 @@ class Payment(PaymentCommon):
('test', 'Test'),
('backup', 'Backup'),
('prod', 'Production'),
),
)
},
{
'name': 'site',
'caption': 'Numéro de site',
'required': True,
'validation': lambda x: isinstance(x, str) and x.isdigit() and len(x) == 7,
'validation': lambda x: isinstance(x, six.string_types)
and x.isdigit() and len(x) == 7,
},
{
'name': 'cle',
'caption': 'Clé',
'help_text': 'Uniquement nécessaire pour l\'annulation / remboursement / encaissement (PayBox Direct)',
'caption': _('Site key'),
'required': False,
'validation': lambda x: isinstance(x, str),
'validation': lambda x: isinstance(x, six.string_types),
},
{
'name': 'rang',
'caption': 'Numéro de rang',
'required': True,
'validation': lambda x: isinstance(x, str) and x.isdigit() and (len(x) in (2, 3)),
'validation': lambda x: isinstance(x, six.string_types)
and x.isdigit() and len(x) == 3,
},
{
'name': 'identifiant',
'caption': 'Identifiant',
'required': True,
'validation': lambda x: isinstance(x, str) and x.isdigit() and (0 < len(x) < 10),
'validation': lambda x: isinstance(x, six.string_types)
and x.isdigit() and (0 < len(x) < 10),
},
{
'name': 'shared_secret',
'caption': 'Secret partagé (clé HMAC)',
'validation': lambda x: isinstance(x, str)
and all(a.lower() in '0123456789abcdef' for a in x)
and (len(x) % 2 == 0),
and all(a.lower() in '0123456789abcdef' for a in x),
'required': True,
},
{
'name': 'devise',
'caption': 'Devise',
'default': '978',
'choices': (('978', 'Euro'),),
'choices': (
('978', 'Euro'),
),
},
{
'name': 'callback',
@ -273,29 +273,30 @@ class Payment(PaymentCommon):
'caption': 'Nombre de jours pour un paiement différé',
'default': '',
'required': False,
'validation': lambda x: isinstance(x, str) and x.isdigit() and (1 <= len(x) <= 2),
'validation': lambda x: isinstance(x, six.string_types)
and x.isdigit() and (1 <= len(x) <= 2)
},
{
'name': 'capture_mode',
'caption': _('Capture Mode'),
'default': 'IMMEDIATE',
'required': False,
'choices': list(PAYMENT_MODES),
'choices': list(PAYMENT_MODES)
},
{
'name': 'manual_validation',
'caption': 'Validation manuelle',
'type': bool,
'default': False,
'scope': 'transaction',
'scope': 'transaction'
},
{
'name': 'timezone',
'caption': _('Default Timezone'),
'default': 'Europe/Paris',
'required': False,
},
],
}
]
}
def make_pbx_cmd(self, guid, orderid=None, transaction_id=None):
@ -307,36 +308,17 @@ class Payment(PaymentCommon):
pbx_cmd += '!' + orderid
return pbx_cmd
def make_pbx_archivage(self):
return ''.join(random.choices('ABCDEFGHJKMNPQRSTUVWXYZ246789', k=12))
def request(
self,
amount,
email,
name=None,
orderid=None,
manual_validation=None,
# 3DSv2 informations
total_quantity=None,
first_name=None,
last_name=None,
address1=None,
zipcode=None,
city=None,
country_code=None,
**kwargs,
):
def request(self, amount, email, name=None, orderid=None, manual_validation=None, **kwargs):
d = OrderedDict()
d['PBX_SITE'] = force_text(self.site)
d['PBX_RANG'] = force_text(self.rang).strip()[-3:]
d['PBX_IDENTIFIANT'] = force_text(self.identifiant)
d['PBX_TOTAL'] = self.clean_amount(amount)
d['PBX_DEVISE'] = force_text(self.devise)
pbx_archivage = self.make_pbx_archivage()
transaction_id = d['PBX_CMD'] = self.make_pbx_cmd(
guid=pbx_archivage, transaction_id=kwargs.get('transaction_id'), orderid=orderid
)
guid = str(uuid.uuid4().hex)
transaction_id = d['PBX_CMD'] = self.make_pbx_cmd(guid=guid,
transaction_id=kwargs.get('transaction_id'),
orderid=orderid)
d['PBX_PORTEUR'] = force_text(email)
d['PBX_RETOUR'] = (
'montant:M;reference:R;code_autorisation:A;erreur:E;numero_appel:T;'
@ -346,9 +328,9 @@ class Payment(PaymentCommon):
)
d['PBX_HASH'] = 'SHA512'
d['PBX_TIME'] = kwargs.get('time') or (
force_text(datetime.datetime.utcnow().isoformat('T')).split('.')[0] + '+00:00'
)
d['PBX_ARCHIVAGE'] = pbx_archivage
force_text(datetime.datetime.utcnow().isoformat('T')).split('.')[0]
+ '+00:00')
d['PBX_ARCHIVAGE'] = orderid or guid
if self.normal_return_url:
d['PBX_EFFECTUE'] = self.normal_return_url
d['PBX_REFUSE'] = self.normal_return_url
@ -356,7 +338,8 @@ class Payment(PaymentCommon):
d['PBX_ATTENTE'] = self.normal_return_url
automatic_return_url = self.automatic_return_url
if not automatic_return_url and self.callback:
warnings.warn('callback option is deprecated, ' 'use automatic_return_url', DeprecationWarning)
warnings.warn("callback option is deprecated, "
"use automatic_return_url", DeprecationWarning)
automatic_return_url = self.callback
capture_day = capture_day = kwargs.get('capture_day', self.capture_day)
if capture_day:
@ -366,69 +349,38 @@ class Payment(PaymentCommon):
d['PBX_AUTOSEULE'] = PAYMENT_MODES['AUTHOR_CAPTURE']
if automatic_return_url:
d['PBX_REPONDRE_A'] = force_text(automatic_return_url)
# PBX_SHOPPINGCART and PBX_BILLING: 3DSv2 new informations
if total_quantity:
total_quantity = xml_escape('%s' % total_quantity)
d['PBX_SHOPPINGCART'] = (
'<?xml version="1.0" encoding="utf-8"?><shoppingcart><total>'
'<totalQuantity>%s</totalQuantity></total></shoppingcart>' % total_quantity
)
if first_name or last_name or address1 or zipcode or city or country_code:
pbx_billing = '<?xml version="1.0" encoding="utf-8"?><Billing><Address>'
if first_name:
pbx_billing += '<FirstName>%s</FirstName>' % xml_escape(first_name)
if last_name:
pbx_billing += '<LastName>%s</LastName>' % xml_escape(last_name)
if address1:
pbx_billing += '<Address1>%s</Address1>' % xml_escape(address1)
if zipcode:
pbx_billing += '<ZipCode>%s</ZipCode>' % xml_escape('%s' % zipcode)
if city:
pbx_billing += '<City>%s</City>' % xml_escape(city)
if country_code:
pbx_billing += '<CountryCode>%s</CountryCode>' % xml_escape('%s' % country_code)
pbx_billing += '</Address></Billing>'
d['PBX_BILLING'] = force_text(pbx_billing)
d = d.items()
shared_secret = codecs.decode(bytes(self.shared_secret, 'ascii'), 'hex')
if six.PY3:
shared_secret = codecs.decode(bytes(self.shared_secret, 'ascii'), 'hex')
else:
shared_secret = codecs.decode(bytes(self.shared_secret), 'hex')
d = sign(d, shared_secret)
url = URLS[self.platform]
fields = []
for k, v in d:
fields.append(
{
'type': 'hidden',
'name': force_text(k),
'value': force_text(v),
}
)
form = Form(url, 'POST', fields, submit_name=None, submit_value='Envoyer', encoding='utf-8')
fields.append({
'type': u'hidden',
'name': force_text(k),
'value': force_text(v),
})
form = Form(url, 'POST', fields, submit_name=None,
submit_value=u'Envoyer', encoding='utf-8')
return transaction_id, FORM, form
def response(self, query_string, callback=False, **kwargs):
d = urlparse.parse_qs(query_string, True, False)
if not set(d) >= {'erreur', 'reference'}:
if not set(d) >= set(['erreur', 'reference']):
raise ResponseError('missing erreur or reference')
signed = False
if 'signature' in d:
sig = d['signature'][0]
try:
sig = base64.b64decode(sig)
except (TypeError, ValueError):
raise ResponseError('invalid signature')
sig = base64.b64decode(sig)
data = []
if callback:
for key in (
'montant',
'reference',
'code_autorisation',
'erreur',
'numero_appel',
'numero_transaction',
'date_transaction',
'heure_transaction',
):
for key in ('montant', 'reference', 'code_autorisation',
'erreur', 'numero_appel', 'numero_transaction',
'date_transaction', 'heure_transaction'):
data.append('%s=%s' % (key, urllib.quote(d[key][0])))
else:
for key, value in urlparse.parse_qsl(query_string, True, True):
@ -466,8 +418,7 @@ class Payment(PaymentCommon):
bank_data=d,
result=result,
bank_status=message,
transaction_date=transaction_date,
)
transaction_date=transaction_date)
def perform(self, amount, bank_data, operation):
logger = logging.getLogger(__name__)
@ -491,6 +442,8 @@ class Payment(PaymentCommon):
logger.debug('received %r', response.text)
data = dict(urlparse.parse_qsl(response.text, True, True))
if data.get('CODEREPONSE') != PAYBOX_DIRECT_SUCCESS_RESPONSE_CODE:
if six.PY2:
raise ResponseError(data['COMMENTAIRE'].encode('utf-8'))
raise ResponseError(data['COMMENTAIRE'])
return data
@ -499,17 +452,3 @@ class Payment(PaymentCommon):
def cancel(self, amount, bank_data, **kwargs):
return self.perform(amount, bank_data, PAYBOX_DIRECT_CANCEL_OPERATION)
@classmethod
def guess(self, *, method=None, query_string=None, body=None, headers=None, backends=(), **kwargs):
for content in [query_string, body]:
if isinstance(content, bytes):
try:
content = content.decode()
except UnicodeDecodeError:
pass
if isinstance(content, str):
fields = urlparse.parse_qs(content)
if 'erreur' in fields and 'reference' in fields:
return fields['reference'][0]
return None

View File

@ -14,48 +14,35 @@
# 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/>.
from __future__ import print_function, unicode_literals
import copy
import datetime
import decimal
import functools
import os
import random
import re
import unicodedata
import xml.etree.ElementTree as ET
from urllib.parse import parse_qs
import pytz
import requests
import six
from six.moves.urllib.parse import parse_qs
import zeep
import zeep.exceptions
from .common import (
CANCELLED,
DENIED,
ERROR,
EXPIRED,
PAID,
URL,
WAITING,
PaymentCommon,
PaymentException,
PaymentResponse,
ResponseError,
_,
force_text,
)
from .systempayv2 import isonow
from .common import (PaymentCommon, PaymentResponse, URL, PAID, DENIED,
CANCELLED, ERROR, ResponseError, PaymentException,
WAITING, EXPIRED, force_text, _)
# The URL of the WSDL published in the documentation is still wrong, it
# references XSD files which are not resolvable :/ we must use this other URL
# where the XSD files are resolvable. To not depend too much on those files, we
# provide copy in eopayment and we patch them to fix the service binding URL
# and the path of XSD files. The PayFiP development team is full of morons.
WSDL_URL = 'https://www.payfip.gouv.fr/tpa/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService?wsdl' # noqa: E501
SERVICE_URL = 'https://www.payfip.gouv.fr/tpa/services/securite' # noqa: E501
PAYMENT_URL = 'https://www.payfip.gouv.fr/tpa/paiementws.web'
WSDL_URL = 'https://www.tipi.budget.gouv.fr/tpa/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService?wsdl' # noqa: E501
SERVICE_URL = 'https://www.tipi.budget.gouv.fr/tpa/services/securite' # noqa: E501
PAYMENT_URL = 'https://www.tipi.budget.gouv.fr/tpa/paiementws.web'
REFDET_RE = re.compile(r'^[A-Za-z0-9]{1,30}$')
@ -63,7 +50,7 @@ REFDET_RE = re.compile(r'^[A-Za-z0-9]{1,30}$')
def clear_namespace(element):
def helper(element):
if element.tag.startswith('{'):
element.tag = element.tag[element.tag.index('}') + 1 :]
element.tag = element.tag[element.tag.index('}') + 1:]
for subelement in element:
helper(subelement)
@ -93,37 +80,19 @@ class PayFiPError(PaymentException):
args = [code, message]
if origin:
args.append(origin)
super().__init__(*args)
super(PayFiPError, self).__init__(*args)
class PayFiP:
class PayFiP(object):
'''Encapsulate SOAP web-services of PayFiP'''
def __init__(self, wsdl_url=None, service_url=None, zeep_client_kwargs=None, use_local_wsdl=True):
# use cached WSDL
if (not wsdl_url or (wsdl_url == WSDL_URL)) and use_local_wsdl:
base_path = os.path.join(os.path.dirname(__file__), 'resource', 'PaiementSecuriseService.wsdl')
wsdl_url = 'file://%s' % base_path
self.wsdl_url = wsdl_url
self.service_url = service_url
self.zeep_client_kwargs = zeep_client_kwargs
@property
def client(self):
if not hasattr(self, '_client'):
try:
self._client = zeep.Client(self.wsdl_url or WSDL_URL, **(self.zeep_client_kwargs or {}))
# distribued WSDL is wrong :/
self._client.service._binding_options['address'] = self.service_url or SERVICE_URL
except Exception as e:
raise PayFiPError('Cound not initialize the SOAP client', e)
return self._client
def __init__(self, wsdl_url=None, service_url=None, zeep_client_kwargs=None):
self.client = zeep.Client(wsdl_url or WSDL_URL, **(zeep_client_kwargs or {}))
# distribued WSDL is wrong :/
self.client.service._binding_options['address'] = service_url or SERVICE_URL
def fault_to_exception(self, fault):
if (
fault.message != 'fr.gouv.finances.cp.tpa.webservice.exceptions.FonctionnelleErreur'
or fault.detail is None
):
if fault.message != 'fr.gouv.finances.cp.tpa.webservice.exceptions.FonctionnelleErreur' or fault.detail is None:
return
detail = clear_namespace(fault.detail)
code = detail.find('FonctionnelleErreur/code')
@ -135,8 +104,7 @@ class PayFiP:
code=code.text,
message=(descriptif is not None and descriptif.text)
or (libelle is not None and libelle.text)
or '',
)
or '')
def _perform(self, request_qname, operation, **kwargs):
RequestType = self.client.get_type(request_qname) # noqa: E501
@ -145,22 +113,15 @@ class PayFiP:
except zeep.exceptions.Fault as fault:
raise self.fault_to_exception(fault) or PayFiPError('unknown', fault.message, fault)
except zeep.exceptions.Error as zeep_error:
raise PayFiPError('SOAP error', str(zeep_error), zeep_error)
except requests.RequestException as e:
raise PayFiPError('HTTP error', e)
except Exception as e:
raise PayFiPError('Unexpected error', e)
raise PayFiPError('erreur-soap', str(zeep_error), zeep_error)
def get_info_client(self, numcli):
return self._perform(
'{http://securite.service.tpa.cp.finances.gouv.fr/reponse}RecupererDetailClientRequest',
'recupererDetailClient',
numCli=numcli,
)
numCli=numcli)
def get_idop(
self, numcli, saisie, exer, refdet, montant, mel, url_notification, url_redirect, objet=None
):
def get_idop(self, numcli, saisie, exer, refdet, montant, mel, url_notification, url_redirect, objet=None):
return self._perform(
'{http://securite.service.tpa.cp.finances.gouv.fr/requete}CreerPaiementSecuriseRequest',
'creerPaiementSecurise',
@ -172,33 +133,52 @@ class PayFiP:
mel=mel,
urlnotif=url_notification,
urlredirect=url_redirect,
objet=objet,
)
objet=objet)
def get_info_paiement(self, idop):
return self._perform(
'{http://securite.service.tpa.cp.finances.gouv.fr/reponse}RecupererDetailPaiementSecuriseRequest',
'recupererDetailPaiementSecurise',
idOp=idop,
)
idOp=idop)
class Payment(PaymentCommon):
"""Produce requests for and verify response from the TIPI online payment
'''Produce requests for and verify response from the TIPI online payment
processor from the French Finance Ministry.
"""
'''
description = {
'caption': 'TIPI, Titres Payables par Internet',
'parameters': [
{
'name': 'numcli',
'caption': _('Client number'),
'help_text': _('6 digits number provided by DGFIP'),
'caption': _(u'Client number'),
'help_text': _(u'6 digits number provided by DGFIP'),
'validation': lambda s: str.isdigit(s) and len(s) == 6,
'required': True,
},
{
'name': 'service_url',
'default': SERVICE_URL,
'caption': _(u'PayFIP WS service URL'),
'help_text': _(u'do not modify if you do not know'),
'validation': lambda x: x.startswith('http'),
},
{
'name': 'wsdl_url',
'default': WSDL_URL,
'caption': _(u'PayFIP WS WSDL URL'),
'help_text': _(u'do not modify if you do not know'),
'validation': lambda x: x.startswith('http'),
},
{
'name': 'payment_url',
'default': PAYMENT_URL,
'caption': _(u'PayFiP payment URL'),
'help_text': _(u'do not modify if you do not know'),
'validation': lambda x: x.startswith('http'),
},
{
'name': 'saisie',
'caption': _('Payment type'),
@ -222,21 +202,20 @@ class Payment(PaymentCommon):
],
}
min_time_between_transactions = 60 * 20 # 20 minutes
minimal_amount = decimal.Decimal('1.0')
maximal_amount = decimal.Decimal('100000.0')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.payfip = PayFiP()
super(Payment, self).__init__(*args, **kwargs)
wsdl_url = self.wsdl_url
# use cached WSDL
if wsdl_url == WSDL_URL:
base_path = os.path.join(os.path.dirname(__file__), 'resource', 'PaiementSecuriseService.wsdl')
wsdl_url = 'file://%s' % base_path
self.payfip = PayFiP(wsdl_url=wsdl_url, service_url=self.service_url)
def _generate_refdet(self):
return '%s%010d' % (isonow(), random.randint(1, 1000000000))
def request(
self, amount, email, refdet=None, exer=None, orderid=None, subject=None, transaction_id=None, **kwargs
):
def request(self, amount, email, refdet=None, exer=None, orderid=None,
subject=None, transaction_id=None, **kwargs):
montant = self.clean_amount(amount, max_amount=100000)
numcli = self.numcli
@ -282,19 +261,13 @@ class Payment(PaymentCommon):
if saisie not in ('T', 'X', 'W'):
raise ValueError('SAISIE invalid format, %r, must be M, T, X or A' % saisie)
idop = self.payfip.get_idop(
numcli=numcli,
saisie=saisie,
exer=exer,
refdet=refdet,
montant=montant,
mel=mel,
objet=objet or None,
url_notification=urlnotif,
url_redirect=urlredirect,
)
idop = self.payfip.get_idop(numcli=numcli, saisie=saisie, exer=exer,
refdet=refdet, montant=montant, mel=mel,
objet=objet or None,
url_notification=urlnotif,
url_redirect=urlredirect)
return str(idop), URL, PAYMENT_URL + '?idop=%s' % idop
return str(idop), URL, self.payment_url + '?idop=%s' % idop
def payment_status(self, transaction_id, transaction_date=None, **kwargs):
# idop are valid for 15 minutes after their generation
@ -322,19 +295,19 @@ class Payment(PaymentCommon):
try:
response = self.payfip.get_info_paiement(idop)
except PayFiPError as e:
if e.code == 'P1' or (e.code == 'P5' and delta >= threshold):
return PaymentResponse(result=EXPIRED, signed=True, order_id=transaction_id)
if e.code == 'P1' or (
e.code == 'P5' and delta >= threshold):
return PaymentResponse(
result=EXPIRED,
signed=True,
order_id=transaction_id)
if e.code == 'P5' and delta < threshold:
return PaymentResponse(result=WAITING, signed=True, order_id=transaction_id)
return PaymentResponse(
result=WAITING,
signed=True,
order_id=transaction_id)
raise e
eopayment_response = self.payfip_response_to_eopayment_response(idop, response)
# convert CANCELLED to WAITING during the first 20 minutes
if eopayment_response.result == CANCELLED and delta < threshold:
eopayment_response.result = WAITING
eopayment_response.bank_status = (
'%s - still waiting as idop is still active' % eopayment_response.bank_status
)
return eopayment_response
return self.payfip_response_to_eopayment_response(idop, response)
def response(self, query_string, **kwargs):
fields = parse_qs(query_string, True)
@ -378,22 +351,7 @@ class Payment(PaymentCommon):
bank_data={k: response[k] for k in response},
order_id=idop,
transaction_id=transaction_id,
test=response.saisie == 'T',
)
@classmethod
def guess(self, *, method=None, query_string=None, body=None, headers=None, backends=(), **kwargs):
for content in [query_string, body]:
if isinstance(content, bytes):
try:
content = content.decode()
except UnicodeDecodeError:
pass
if isinstance(content, str):
fields = parse_qs(content)
if set(fields) == {'idOp'}:
return fields['idOp'][0]
return None
test=response.saisie == 'T')
if __name__ == '__main__':
@ -406,7 +364,6 @@ if __name__ == '__main__':
return func(*args, **kwargs)
except PayFiPError as e:
click.echo(click.style('PayFiP ERROR : %s "%s"' % (e.code, e.message), fg='red'))
return f
@click.group()
@ -415,7 +372,6 @@ if __name__ == '__main__':
@click.pass_context
def main(ctx, wsdl_url, service_url):
import logging
logging.basicConfig(level=logging.INFO)
# hide warning from zeep
logging.getLogger('zeep.wsdl.bindings.soap').level = logging.ERROR
@ -423,7 +379,7 @@ if __name__ == '__main__':
ctx.obj = PayFiP(wsdl_url=wsdl_url, service_url=service_url)
def numcli(ctx, param, value):
if not isinstance(value, str) or len(value) != 6 or not value.isdigit():
if not isinstance(value, six.string_types) or len(value) != 6 or not value.isdigit():
raise click.BadParameter('numcli must a 6 digits number')
return value
@ -449,17 +405,10 @@ if __name__ == '__main__':
@click.pass_obj
@show_payfip_error
def get_idop(payfip, numcli, saisie, exer, montant, refdet, mel, objet, url_notification, url_redirect):
idop = payfip.get_idop(
numcli=numcli,
saisie=saisie,
exer=exer,
montant=montant,
refdet=refdet,
mel=mel,
objet=objet,
url_notification=url_notification,
url_redirect=url_redirect,
)
idop = payfip.get_idop(numcli=numcli, saisie=saisie, exer=exer,
montant=montant, refdet=refdet, mel=mel,
objet=objet, url_notification=url_notification,
url_redirect=url_redirect)
print('idOp:', idop)
print(PAYMENT_URL + '?idop=%s' % idop)
@ -471,3 +420,6 @@ if __name__ == '__main__':
print(payfip.get_info_paiement(idop))
main()

2
eopayment/request Executable file
View File

@ -0,0 +1,2 @@
#!/bin/bash
echo -ne 0!!coin

View File

@ -134,7 +134,7 @@
</binding>
<service name="PaiementSecuriseService">
<port name="PaiementSecuriseServicePort" binding="tns:PaiementSecuriseServicePortBinding">
<soap:address location="https://www.payfip.gouv.fr/tpa/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService"/>
<soap:address location="http://www.tipi.budget.gouv.fr:80/tpa/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService"/>
</port>
</service>
</definitions>

View File

@ -11,8 +11,6 @@
<xs:element name="saisie" type="xs:string" minOccurs="0"/>
<xs:element name="urlnotif" type="xs:string" minOccurs="0"/>
<xs:element name="urlredirect" type="xs:string" minOccurs="0"/>
<xs:element name="typeAuthentification" type="xs:string" minOccurs="0"/>
<xs:element name="typeUsager" type="xs:string" minOccurs="0"/>
</xs:sequence>
</xs:complexType>
</xs:schema>

View File

@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# eopayment - online payment library
# Copyright (C) 2011-2020 Entr'ouvert
#
@ -14,25 +15,18 @@
# 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/>.
from __future__ import unicode_literals, print_function
import functools
from urllib.parse import parse_qs, urljoin
from six.moves.urllib.parse import urljoin, parse_qs
import lxml.etree as ET
import zeep
import zeep.exceptions
from .common import (
CANCELLED,
DENIED,
ERROR,
PAID,
URL,
PaymentCommon,
PaymentException,
PaymentResponse,
ResponseError,
)
from .common import (PaymentException, PaymentCommon, ResponseError, URL, PAID,
DENIED, CANCELLED, ERROR, PaymentResponse)
_zeep_transport = None
@ -41,7 +35,7 @@ class SagaError(PaymentException):
pass
class Saga:
class Saga(object):
def __init__(self, wsdl_url, service_url=None, zeep_client_kwargs=None):
self.wsdl_url = wsdl_url
kwargs = (zeep_client_kwargs or {}).copy()
@ -68,17 +62,8 @@ class Saga:
raise SagaError('Invalid SAGA response "%s"' % content[:1024])
return tree
def transaction(
self,
num_service,
id_tiers,
compte,
lib_ecriture,
montant,
urlretour_asynchrone,
email,
urlretour_synchrone,
):
def transaction(self, num_service, id_tiers, compte, lib_ecriture, montant,
urlretour_asynchrone, email, urlretour_synchrone):
tree = self.soap_call(
'Transaction',
'url',
@ -89,13 +74,15 @@ class Saga:
montant=montant,
urlretour_asynchrone=urlretour_asynchrone,
email=email,
urlretour_synchrone=urlretour_synchrone,
)
urlretour_synchrone=urlretour_synchrone)
# tree == <url>...</url>
return tree.text
def page_retour(self, operation, idop):
tree = self.soap_call(operation, 'ok', idop=idop)
tree = self.soap_call(
operation,
'ok',
idop=idop)
# tree == <ok id_tiers="A1"
# etat="paye" email="albert,dupond@monsite.com" num_service="222222"
# montant="100.00" compte="708"
@ -140,12 +127,13 @@ class Payment(PaymentCommon):
'caption': 'URL de notification',
'required': False,
},
],
]
}
@property
def saga(self):
return Saga(wsdl_url=urljoin(self.base_url, 'paiement_internet_ws_ministere?wsdl'))
return Saga(
wsdl_url=urljoin(self.base_url, 'paiement_internet_ws_ministere?wsdl'))
def request(self, amount, email, subject, orderid=None, **kwargs):
num_service = self.num_service
@ -164,8 +152,7 @@ class Payment(PaymentCommon):
montant=montant,
urlretour_asynchrone=urlretour_asynchrone,
email=email,
urlretour_synchrone=urlretour_synchrone,
)
urlretour_synchrone=urlretour_synchrone)
try:
idop = parse_qs(url.split('?', 1)[-1])['idop'][0]
@ -209,8 +196,7 @@ class Payment(PaymentCommon):
bank_data=dict(response),
order_id=idop,
transaction_id=idop,
test=False,
)
test=False)
if __name__ == '__main__':
@ -223,7 +209,6 @@ if __name__ == '__main__':
return func(*args, **kwargs)
except SagaError as e:
click.echo(click.style('SAGA ERROR : %s' % e, fg='red'))
return f
@click.group()
@ -232,7 +217,6 @@ if __name__ == '__main__':
@click.pass_context
def main(ctx, wsdl_url, service_url):
import logging
logging.basicConfig(level=logging.INFO)
# hide warning from zeep
logging.getLogger('zeep.wsdl.bindings.soap').level = logging.ERROR
@ -250,17 +234,8 @@ if __name__ == '__main__':
@click.option('--urlretour-synchrone', type=str, required=True)
@click.pass_obj
@show_payfip_error
def transaction(
saga,
num_service,
id_tiers,
compte,
lib_ecriture,
montant,
urlretour_asynchrone,
email,
urlretour_synchrone,
):
def transaction(saga, num_service, id_tiers, compte, lib_ecriture, montant,
urlretour_asynchrone, email, urlretour_synchrone):
url = saga.transaction(
num_service=num_service,
id_tiers=id_tiers,
@ -269,8 +244,7 @@ if __name__ == '__main__':
montant=montant,
urlretour_asynchrone=urlretour_asynchrone,
email=email,
urlretour_synchrone=urlretour_synchrone,
)
urlretour_synchrone=urlretour_synchrone)
print('url:', url)
main()

View File

@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# eopayment - online payment library
# Copyright (C) 2011-2020 Entr'ouvert
#
@ -14,36 +15,28 @@
# 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 collections
import datetime
import collections
import hashlib
import hmac
import json
import uuid
import warnings
from urllib import parse as urlparse
import pytz
from six.moves.urllib import parse as urlparse
import requests
from .common import (
CANCELED,
ERROR,
FORM,
PAID,
Form,
PaymentCommon,
PaymentResponse,
ResponseError,
_,
force_text,
)
import pytz
from .common import (PaymentCommon, FORM, Form, PaymentResponse, PAID, ERROR,
CANCELED, ResponseError, force_text, _)
__all__ = ['Payment']
class Payment(PaymentCommon):
"""
'''
Payment backend module for the ATOS/SIPS system used by many French banks.
The necessary options are:
@ -56,44 +49,42 @@ class Payment(PaymentCommon):
Worldline Benelux_Sips_Technical_integration_guide_Version_1.5.pdf
"""
has_free_transaction_id = True
'''
URL = {
'test': 'https://payment-webinit.simu.sips-services.com/paymentInit',
'prod': 'https://payment-webinit.sips-services.com/paymentInit',
'test': 'https://payment-webinit.simu.sips-atos.com/paymentInit',
'prod': 'https://payment-webinit.sips-atos.com/paymentInit',
}
WS_URL = {
'test': 'https://office-server.test.sips-services.com',
'prod': 'https://office-server.sips-services.com',
'test': 'https://office-server.test.sips-atos.com',
'prod': 'https://office-server.sips-atos.com',
}
INTERFACE_VERSION = 'HP_2.3'
RESPONSE_CODES = {
'00': 'Authorisation accepted',
'02': 'Authorisation request to be performed via telephone with the issuer, as the '
'card authorisation threshold has been exceeded, if the forcing is authorised for '
'the merchant',
'card authorisation threshold has been exceeded, if the forcing is authorised for '
'the merchant',
'03': 'Invalid distance selling contract',
'05': 'Authorisation refused',
'12': 'Invalid transaction, verify the parameters transferred in the request.',
'14': 'Invalid bank details or card security code',
'17': 'Buyer cancellation',
'24': 'Operation impossible. The operation the merchant wishes to perform is not '
'compatible with the status of the transaction.',
'compatible with the status of the transaction.',
'25': 'Transaction not found in the Sips database',
'30': 'Format error',
'34': 'Suspicion of fraud',
'40': 'Function not supported: the operation that the merchant would like to perform '
'is not part of the list of operations for which the merchant is authorised',
'is not part of the list of operations for which the merchant is authorised',
'51': 'Amount too high',
'54': 'Card is past expiry date',
'60': 'Transaction pending',
'63': 'Security rules not observed, transaction stopped',
'75': 'Number of attempts at entering the card number exceeded',
'90': 'Service temporarily unavailable',
'94': 'Duplicated transaction: for a given day, the TransactionReference has already been ' 'used',
'94': 'Duplicated transaction: for a given day, the TransactionReference has already been '
'used',
'97': 'Timeframe exceeded, transaction refused',
'99': 'Temporary problem at the Sips Office Server level',
}
@ -157,18 +148,23 @@ class Payment(PaymentCommon):
'caption': _('Capture Day'),
'required': False,
},
{'name': 'payment_means', 'caption': _('Payment Means'), 'required': False},
{
'name': 'payment_means',
'caption': _('Payment Means'),
'required': False
},
{
'name': 'timezone',
'caption': _('SIPS server Timezone'),
'default': 'Europe/Paris',
'required': False,
},
}
],
}
def encode_data(self, data):
return '|'.join('%s=%s' % (force_text(key), force_text(value)) for key, value in data.items())
return u'|'.join(u'%s=%s' % (force_text(key), force_text(value))
for key, value in data.items())
def seal_data(self, data):
s = self.encode_data(data)
@ -195,23 +191,9 @@ class Payment(PaymentCommon):
def get_url(self):
return self.URL[self.platform]
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,
transaction_id=None,
**kwargs,
):
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, transaction_id=None, **kwargs):
data = self.get_data()
# documentation:
# https://documentation.sips.worldline.com/fr/WLSIPS.801-MG-Presentation-generale-de-la-migration-vers-Sips-2.0.html#ariaid-title20
@ -229,10 +211,8 @@ class Payment(PaymentCommon):
data['captureDay'] = kwargs.get('capture_day')
normal_return_url = self.normal_return_url
if next_url and not normal_return_url:
warnings.warn(
'passing next_url to request() is deprecated, ' 'set normal_return_url in options',
DeprecationWarning,
)
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:
data['normalReturnUrl'] = normal_return_url
@ -240,7 +220,11 @@ class Payment(PaymentCommon):
url=self.get_url(),
method='POST',
fields=[
{'type': 'hidden', 'name': 'Data', 'value': self.encode_data(data)},
{
'type': 'hidden',
'name': 'Data',
'value': self.encode_data(data)
},
{
'type': 'hidden',
'name': 'Seal',
@ -251,13 +235,11 @@ class Payment(PaymentCommon):
'name': 'InterfaceVersion',
'value': self.INTERFACE_VERSION,
},
],
)
])
self.logger.debug('emitting request %r', data)
return transactionReference, FORM, form
@classmethod
def decode_data(cls, data):
def decode_data(self, data):
data = data.split('|')
data = [map(force_text, p.split('=', 1)) for p in data]
return collections.OrderedDict(data)
@ -272,7 +254,7 @@ class Payment(PaymentCommon):
def response(self, query_string, **kwargs):
form = urlparse.parse_qs(query_string)
if not set(form) >= {'Data', 'Seal', 'InterfaceVersion'}:
if not set(form) >= set(['Data', 'Seal', 'InterfaceVersion']):
raise ResponseError('missing Data, Seal or InterfaceVersion')
self.logger.debug('received query string %r', form)
data = self.decode_data(form['Data'][0])
@ -287,9 +269,7 @@ class Payment(PaymentCommon):
transaction_date = None
if 'transactionDateTime' in data:
try:
transaction_date = datetime.datetime.strptime(
data['transactionDateTime'], '%Y-%m-%d %H:%M:%S'
)
transaction_date = datetime.datetime.strptime(data['transactionDateTime'], '%Y-%m-%d %H:%M:%S')
except (ValueError, TypeError):
pass
else:
@ -301,10 +281,9 @@ class Payment(PaymentCommon):
bank_data=data,
order_id=transaction_id,
transaction_id=data.get('authorisationId'),
bank_status=self.RESPONSE_CODES.get(response_code, 'unknown code - ' + response_code),
bank_status=self.RESPONSE_CODES.get(response_code, u'unknown code - ' + response_code),
test=test,
transaction_date=transaction_date,
)
transaction_date=transaction_date)
def get_seal_for_json_ws_data(self, data):
data_to_send = []
@ -312,10 +291,8 @@ class Payment(PaymentCommon):
if key in ('keyVersion', 'sealAlgorithm', 'seal'):
continue
data_to_send.append(force_text(data[key]))
data_to_send_str = ''.join(data_to_send).encode('utf-8')
return hmac.new(
force_text(self.secret_key).encode('utf-8'), data_to_send_str, hashlib.sha256
).hexdigest()
data_to_send_str = u''.join(data_to_send).encode('utf-8')
return hmac.new(force_text(self.secret_key).encode('utf-8'), data_to_send_str, hashlib.sha256).hexdigest()
def perform_cash_management_operation(self, endpoint, data):
data['merchantId'] = self.merchant_id
@ -328,8 +305,10 @@ class Payment(PaymentCommon):
response = requests.post(
url,
data=json.dumps(data),
headers={'content-type': 'application/json', 'accept': 'application/json'},
)
headers={
'content-type': 'application/json',
'accept': 'application/json'
})
self.logger.debug('received %r', response.content)
response.raise_for_status()
json_response = response.json()
@ -368,25 +347,7 @@ class Payment(PaymentCommon):
headers={
'content-type': 'application/json',
'accept': 'application/json',
},
)
})
self.logger.debug('received %r', response.content)
response.raise_for_status()
return response.json()
@classmethod
def guess(self, *, method=None, query_string=None, body=None, headers=None, backends=(), **kwargs):
for content in [query_string, body]:
if isinstance(content, bytes):
try:
content = content.decode()
except UnicodeDecodeError:
pass
if isinstance(content, str):
fields = urlparse.parse_qs(content)
if not set(fields) >= {'Data', 'Seal', 'InterfaceVersion'}:
continue
data = self.decode_data(fields['Data'][0])
if 'transactionReference' in data:
return data['transactionReference']
return None

View File

@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# eopayment - online payment library
# Copyright (C) 2011-2020 Entr'ouvert
#
@ -15,32 +16,20 @@
# 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 urllib import parse as urlparse
import pytz
from .common import (PaymentCommon, PaymentResponse, PAID, DENIED, CANCELLED,
ERROR, FORM, Form, ResponseError, force_text, force_byte, _)
from .cb import translate_cb_error_code
from .common import (
CANCELLED,
DENIED,
ERROR,
FORM,
PAID,
Form,
PaymentCommon,
PaymentResponse,
ResponseError,
_,
force_byte,
force_text,
)
__all__ = ['Payment']
@ -68,7 +57,8 @@ VADS_EOPAYMENT_TRANS_ID = 'vads_ext_info_eopayment_trans_id'
def isonow():
return dt.datetime.utcnow().isoformat('T').replace('-', '').replace('T', '').replace(':', '')[:14]
return dt.datetime.utcnow().isoformat('T').replace('-', '') \
.replace('T', '').replace(':', '')[:14]
def parse_utc(value):
@ -80,20 +70,9 @@ def parse_utc(value):
class Parameter:
def __init__(
self,
name,
ptype,
code,
max_length=None,
length=None,
needed=False,
default=None,
choices=None,
description=None,
help_text=None,
scope='global',
):
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
@ -104,7 +83,6 @@ class Parameter:
self.choices = choices
self.description = description
self.help_text = help_text
self.scope = scope
def check_value(self, value):
if self.length and len(value) != self.length:
@ -129,9 +107,8 @@ class Parameter:
PARAMETERS = [
# amount as euro cents
Parameter(
'vads_action_mode', None, 47, needed=True, default='INTERACTIVE', choices=('SILENT', 'INTERACTIVE')
),
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
@ -150,60 +127,44 @@ PARAMETERS = [
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'),
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="Complément d'information 1",
scope='transaction',
),
Parameter(
'vads_order_info2',
'an',
14,
max_length=255,
description="Complément d'information 2",
scope='transaction',
),
Parameter(
'vads_order_info3',
'an',
14,
max_length=255,
description="Complément d'information 3",
scope='transaction',
),
Parameter('vads_page_action', None, 46, needed=True, default='PAYMENT', choices=('PAYMENT',)),
Parameter(
'vads_payment_cards',
'an;',
8,
max_length=127,
default='',
description='Liste des cartes de paiement acceptées',
help_text='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',
scope='transaction',
),
Parameter('vads_order_info', 'an', 14, max_length=255,
description="Complément d'information 1"),
Parameter('vads_order_info2', 'an', 14, max_length=255,
description="Complément d'information 2"),
Parameter('vads_order_info3', 'an', 14, max_length=255,
description="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='Liste des cartes de paiement acceptées',
help_text='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('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='Identifiant de la boutique'),
Parameter('vads_site_id', 'n', 2, length=8, needed=True,
description='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),
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_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),
@ -215,7 +176,8 @@ PARAMETERS = [
Parameter(VADS_CUST_FIRST_NAME, 'ans', 104, max_length=63),
Parameter(VADS_CUST_LAST_NAME, 'ans', 104, max_length=63),
]
PARAMETER_MAP = {parameter.name: parameter for parameter in PARAMETERS}
PARAMETER_MAP = dict(((parameter.name,
parameter) for parameter in PARAMETERS))
def add_vads(kwargs):
@ -234,36 +196,35 @@ def check_vads(kwargs, exclude=[]):
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)
)
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.
'''
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'})
"""
>>> 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/'
service_url = "https://paiement.systempay.fr/vads-payment/"
signature_algo = 'sha1'
description = {
@ -280,76 +241,57 @@ class Payment(PaymentCommon):
'caption': _('Automatic return URL (ignored, must be set in Payzen/SystemPay backoffice)'),
'required': False,
},
{
'name': 'service_url',
{'name': 'service_url',
'default': service_url,
'caption': 'URL du service de paiement',
'help_text': 'ne pas modifier si vous ne savez pas',
'validation': lambda x: x.startswith('http'),
'required': True,
},
{
'name': 'secret_test',
'required': True, },
{'name': 'secret_test',
'caption': 'Secret pour la configuration de TEST',
'validation': lambda value: str.isalnum(value),
'required': True,
},
{
'name': 'secret_production',
'required': True, },
{'name': 'secret_production',
'caption': 'Secret pour la configuration de PRODUCTION',
'validation': lambda value: str.isalnum(value),
},
{
'name': 'signature_algo',
'validation': lambda value: str.isalnum(value), },
{'name': 'signature_algo',
'caption': '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',
},
],
'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',
):
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,
'scope': parameter.scope,
}
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().__init__(options, logger=logger)
super(Payment, self).__init__(options, logger=logger)
options = add_vads(options)
self.options = options
@ -358,48 +300,28 @@ class Payment(PaymentCommon):
# 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()
alphabet = string.ascii_letters + string.digits
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))
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,
)
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)}))
@ -407,10 +329,8 @@ class Payment(PaymentCommon):
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,
)
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)
@ -440,7 +360,8 @@ class Payment(PaymentCommon):
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))
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()
@ -451,7 +372,8 @@ class Payment(PaymentCommon):
for parameter in PARAMETERS:
name = parameter.name
# import default parameters from configuration
if name not in fields and name in self.options:
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:
@ -477,13 +399,11 @@ class Payment(PaymentCommon):
method='POST',
fields=[
{
'type': 'hidden',
'type': u'hidden',
'name': force_text(field_name),
'value': force_text(field_value),
}
for field_name, field_value in fields.items()
],
)
for field_name, field_value in fields.items()])
return transaction_id, FORM, form
RESULT_MAP = {
@ -500,11 +420,9 @@ class Payment(PaymentCommon):
'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.'
},
'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.'},
@ -512,10 +430,8 @@ class Payment(PaymentCommon):
'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.'
},
'99': {'message': 'Problème technique recontré par le serveur lors du traitement '
'd\'un des contrôles locauxi.'},
}
@classmethod
@ -558,8 +474,9 @@ class Payment(PaymentCommon):
def response(self, query_string, **kwargs):
fields = urlparse.parse_qs(query_string, True)
if not set(fields) >= {SIGNATURE, VADS_CTX_MODE, VADS_AUTH_RESULT}:
raise ResponseError('missing %s, %s or %s' % (SIGNATURE, VADS_CTX_MODE, VADS_AUTH_RESULT))
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()
@ -568,21 +485,17 @@ class Payment(PaymentCommon):
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])
self.logger.debug('signature check: %s <!> %s', signature,
fields[SIGNATURE])
if not signature_result:
message += ' signature invalide.'
test = fields[VADS_CTX_MODE] == 'TEST'
vads_eopayment_trans_id = fields.get(VADS_EOPAYMENT_TRANS_ID)
vads_trans_date = fields.get(VADS_TRANS_DATE)
vads_trans_id = fields.get(VADS_TRANS_ID)
if vads_eopayment_trans_id:
transaction_id = vads_eopayment_trans_id
elif vads_trans_date and vads_trans_id:
transaction_id = vads_trans_date + '_' + vads_trans_id
if VADS_EOPAYMENT_TRANS_ID in fields:
transaction_id = fields[VADS_EOPAYMENT_TRANS_ID]
else:
raise ResponseError('backend error', message)
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
@ -596,8 +509,7 @@ class Payment(PaymentCommon):
transaction_id=transaction_id,
bank_status=message,
transaction_date=transaction_date,
test=test,
)
test=test)
return response
def sha1_sign(self, secret, signed_data):
@ -609,35 +521,15 @@ class Payment(PaymentCommon):
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_'))
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('generating signature on «%s»', signed_data)
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('signature «%s»', sign)
self.logger.debug(u'signature «%s»', sign)
return force_text(sign)
@classmethod
def guess(self, *, method=None, query_string=None, body=None, headers=None, backends=(), **kwargs):
for content in [query_string, body]:
if isinstance(content, bytes):
try:
content = content.decode()
except UnicodeDecodeError:
pass
if isinstance(content, str):
fields = urlparse.parse_qs(content)
if not set(fields) >= {SIGNATURE, VADS_CTX_MODE, VADS_AUTH_RESULT}:
continue
vads_eopayment_trans_id = fields.get(VADS_EOPAYMENT_TRANS_ID)
vads_trans_date = fields.get(VADS_TRANS_DATE)
vads_trans_id = fields.get(VADS_TRANS_ID)
if vads_eopayment_trans_id:
return vads_eopayment_trans_id[0]
elif vads_trans_date and vads_trans_id:
return vads_trans_date[0] + '_' + vads_trans_id[0]
return None

View File

@ -15,39 +15,48 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import datetime
import decimal
import logging
import random
import re
import warnings
from urllib.parse import parse_qs, urlencode
import random
import pytz
from .common import CANCELLED, DENIED, ERROR, PAID, URL, PaymentCommon, PaymentResponse, ResponseError, _
from .common import (PaymentCommon, PaymentResponse, URL, PAID, DENIED,
CANCELLED, ERROR, ResponseError, _)
from six.moves.urllib.parse import urlencode, parse_qs
import logging
import warnings
__all__ = ['Payment']
TIPI_URL = 'https://www.payfip.gouv.fr/tpa/paiement.web'
TIPI_URL = 'https://www.tipi.budget.gouv.fr/tpa/paiement.web'
LOGGER = logging.getLogger(__name__)
class Payment(PaymentCommon):
"""Produce requests for and verify response from the TIPI online payment
'''Produce requests for and verify response from the TIPI online payment
processor from the French Finance Ministry.
"""
'''
description = {
'caption': 'TIPI, Titres Payables par Internet',
'parameters': [
{
'name': 'numcli',
'caption': _('Client number'),
'help_text': _('6 digits number provided by DGFIP'),
'caption': _(u'Client number'),
'help_text': _(u'6 digits number provided by DGFIP'),
'validation': lambda s: str.isdigit(s) and (0 < int(s) < 1000000),
'required': True,
},
{
'name': 'service_url',
'default': TIPI_URL,
'caption': _(u'TIPI service URL'),
'help_text': _(u'do not modify if you do not know'),
'validation': lambda x: x.startswith('http'),
'required': True,
},
{
'name': 'normal_return_url',
'caption': _('Normal return URL (unused by TIPI)'),
@ -65,8 +74,9 @@ class Payment(PaymentCommon):
'default': 'T',
'choices': [
('T', _('test')),
('X', _('activation')),
('A', _('production')),
('X', _('production')),
('A', _('with user account')),
('M', _('manual entry')),
],
},
],
@ -74,38 +84,22 @@ class Payment(PaymentCommon):
REFDET_RE = re.compile('^[a-zA-Z0-9]{6,30}$')
minimal_amount = decimal.Decimal('1.0')
maximal_amount = decimal.Decimal('100000.0')
def _generate_refdet(self):
return '%s%010d' % (
datetime.datetime.now(pytz.timezone('Europe/Paris')).strftime('%Y%m%d%H%M%S'),
random.randint(1, 1000000000),
)
return '%s%010d' % (datetime.datetime.now(pytz.timezone('Europe/Paris')).strftime('%Y%m%d%H%M%S'),
random.randint(1, 1000000000))
def request(
self,
amount,
email,
next_url=None,
exer=None,
orderid=None,
refdet=None,
objet=None,
saisie=None,
**kwargs,
):
def request(self, amount, email, next_url=None, exer=None, orderid=None,
refdet=None, objet=None, saisie=None, **kwargs):
montant = self.clean_amount(amount, max_amount=9999.99)
automatic_return_url = self.automatic_return_url
if next_url and not automatic_return_url:
warnings.warn(
'passing next_url to request() is deprecated, ' 'set automatic_return_url in options',
DeprecationWarning,
)
warnings.warn("passing next_url to request() is deprecated, "
"set automatic_return_url in options", DeprecationWarning)
automatic_return_url = next_url
if automatic_return_url is not None:
if not isinstance(automatic_return_url, str) or not automatic_return_url.startswith('http'):
if (not isinstance(automatic_return_url, str)
or not automatic_return_url.startswith('http')):
raise ValueError('URLCL invalid URL format')
try:
if exer is not None:
@ -163,12 +157,12 @@ class Payment(PaymentCommon):
params['objet'] = objet
if automatic_return_url:
params['urlcl'] = automatic_return_url
url = '%s?%s' % (TIPI_URL, urlencode(params))
url = '%s?%s' % (self.service_url, urlencode(params))
return transaction_id, URL, url
def response(self, query_string, **kwargs):
fields = parse_qs(query_string, True)
if not set(fields) >= {'refdet', 'resultrans'}:
if not set(fields) >= set(['refdet', 'resultrans']):
raise ResponseError('missing refdet or resultrans')
for key, value in fields.items():
fields[key] = value[0]
@ -199,19 +193,4 @@ class Payment(PaymentCommon):
bank_data=fields,
order_id=refdet,
transaction_id=refdet,
test=test,
)
@classmethod
def guess(self, *, method=None, query_string=None, body=None, headers=None, backends=(), **kwargs):
for content in [query_string, body]:
if isinstance(content, bytes):
try:
content = content.decode()
except UnicodeDecodeError:
pass
if isinstance(content, str):
fields = parse_qs(content)
if 'refdet' in fields and 'resultrans' in fields:
return fields['refdet'][0]
return None
test=test)

View File

@ -4,24 +4,24 @@
Setup script for eopayment
'''
import io
import subprocess
import distutils
import distutils.core
import doctest
import io
import os
import subprocess
import sys
from distutils.cmd import Command
from distutils.command.build import build as _build
from glob import glob
from os.path import basename
from os.path import join as pjoin
from os.path import splitext
from unittest import TestLoader, TextTestRunner
from distutils.cmd import Command
from distutils.spawn import find_executable
import setuptools
from setuptools.command.install_lib import install_lib as _install_lib
from setuptools.command.sdist import sdist
from setuptools.command.install_lib import install_lib as _install_lib
from glob import glob
from os.path import splitext, basename, join as pjoin
import os
from unittest import TextTestRunner, TestLoader
import doctest
class TestCommand(distutils.core.Command):
@ -34,25 +34,27 @@ class TestCommand(distutils.core.Command):
pass
def run(self):
"""
'''
Finds all the tests modules in tests/, and runs them.
"""
'''
testfiles = []
for t in glob(pjoin(self._dir, 'tests', '*.py')):
if not t.endswith('__init__.py'):
testfiles.append('.'.join(['tests', splitext(basename(t))[0]]))
testfiles.append('.'.join(
['tests', splitext(basename(t))[0]])
)
tests = TestLoader().loadTestsFromNames(testfiles)
import eopayment
tests.addTests(doctest.DocTestSuite(eopayment))
t = TextTestRunner(verbosity=4)
t.run(tests)
class eo_sdist(sdist):
def run(self):
print('creating VERSION file')
print("creating VERSION file")
if os.path.exists('VERSION'):
os.remove('VERSION')
version = get_version()
@ -60,22 +62,22 @@ class eo_sdist(sdist):
version_file.write(version)
version_file.close()
sdist.run(self)
print('removing VERSION file')
print("removing VERSION file")
if os.path.exists('VERSION'):
os.remove('VERSION')
def get_version():
"""Use the VERSION, if absent generates a version with git describe, if not
tag exists, take 0.0.0- and add the length of the commit log.
"""
'''Use the VERSION, if absent generates a version with git describe, if not
tag exists, take 0.0.0- and add the length of the commit log.
'''
if os.path.exists('VERSION'):
with open('VERSION') as v:
with open('VERSION', 'r') as v:
return v.read()
if os.path.exists('.git'):
p = subprocess.Popen(
['git', 'describe', '--dirty', '--match=v*'], stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
p = subprocess.Popen(['git', 'describe', '--dirty',
'--match=v*'], stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
result = p.communicate()[0]
if p.returncode == 0:
result = result.decode('ascii').strip()[1:] # strip spaces/newlines and initial v
@ -87,10 +89,12 @@ def get_version():
commit_count = 0
version = '%s.post%s+%s' % (real_number, commit_count, commit_hash)
else:
version = result.replace('.dirty', '+dirty')
version = result
return version
else:
return '0.0.post%s' % len(subprocess.check_output(['git', 'rev-list', 'HEAD']).splitlines())
return '0.0.post%s' % len(
subprocess.check_output(
['git', 'rev-list', 'HEAD']).splitlines())
return '0.0.0'
@ -106,19 +110,9 @@ class compile_translations(Command):
pass
def run(self):
curdir = os.getcwd()
try:
from django.core.management import call_command
for path, dirs, files in os.walk('eopayment'):
if 'locale' not in dirs:
continue
os.chdir(os.path.realpath(path))
call_command('compilemessages')
except ImportError:
sys.stderr.write('!!! Please install Django >= 3.2 to build translations\n')
finally:
os.chdir(curdir)
django_admin = find_executable('django-admin')
if django_admin:
subprocess.check_call([django_admin, 'compilemessages'])
class build(_build):
@ -135,15 +129,19 @@ setuptools.setup(
name='eopayment',
version=get_version(),
license='GPLv3 or later',
description='Common API to use all French online payment credit card ' 'processing services',
description='Common API to use all French online payment credit card '
'processing services',
include_package_data=True,
long_description=open(os.path.join(os.path.dirname(__file__), 'README.txt'), encoding='utf-8').read(),
long_description=io.open(
os.path.join(
os.path.dirname(__file__),
'README.txt'), encoding='utf-8').read(),
long_description_content_type='text/plain',
url='http://dev.entrouvert.org/projects/eopayment/',
author="Entr'ouvert",
author_email='info@entrouvert.com',
maintainer='Benjamin Dauvergne',
maintainer_email='bdauvergne@entrouvert.com',
author_email="info@entrouvert.com",
maintainer="Benjamin Dauvergne",
maintainer_email="bdauvergne@entrouvert.com",
classifiers=[
'Development Status :: 5 - Production/Stable',
'Environment :: Web Environment',
@ -151,13 +149,15 @@ setuptools.setup(
'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)',
'Operating System :: POSIX',
'Programming Language :: Python',
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 3',
],
packages=['eopayment'],
install_requires=[
'pycryptodomex',
'pycrypto >= 2.5',
'pytz',
'requests',
'six',
'click',
'zeep >= 2.5',
],
@ -166,5 +166,5 @@ setuptools.setup(
'compile_translations': compile_translations,
'install_lib': install_lib,
'sdist': eo_sdist,
},
}
)

View File

@ -16,25 +16,27 @@
import json
import pytest
import httmock
import lxml.etree as ET
import pytest
from requests import Session
from requests.adapters import HTTPAdapter
from requests import Session
def pytest_addoption(parser):
parser.addoption('--save-http-session', action='store_true', help='save HTTP session')
parser.addoption('--target-url', help='target URL')
parser.addoption("--save-http-session", action="store_true", help="save HTTP session")
parser.addoption("--target-url", help="target URL")
class LoggingAdapter(HTTPAdapter):
def __init__(self, *args, **kwargs):
self.history = []
super().__init__(*args, **kwargs)
super(LoggingAdapter, self).__init__(*args, **kwargs)
def send(self, request, *args, **kwargs):
response = super().send(request, *args, **kwargs)
response = super(LoggingAdapter, self).send(request, *args, **kwargs)
self.history.append((request, response))
return response
@ -69,8 +71,8 @@ def record_http_session(request):
history = []
for request, response in adapter.history:
request_content = request.body or b''
response_content = response.content or b''
request_content = (request.body or b'')
response_content = (response.content or b'')
if is_xml_content_type(request):
request_content = xmlindent(request_content)
@ -101,7 +103,6 @@ def record_http_session(request):
request_content = request_content.decode('utf-8')
assert request_content == expected_request_content
return response_content
with httmock.HTTMock(Mocker().mock):
yield None

View File

@ -15,8 +15,8 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from datetime import date, datetime, timedelta
from unittest import mock
import mock
import pytest
import eopayment
@ -37,22 +37,22 @@ def do_mock_backend(monkeypatch):
'caption': 'Validation manuelle',
'type': bool,
'default': False,
'scope': 'transaction',
'scope': 'transaction'
},
{
'name': 'global_param',
'caption': 'Global Param',
'type': bool,
'default': False,
'scope': 'global',
'scope': 'global'
},
]
}
def get_backend(*args, **kwargs):
def backend(*args, **kwargs):
return MockBackend
return backend
monkeypatch.setattr(eopayment, 'get_backend', get_backend)
@ -62,13 +62,14 @@ def do_mock_backend(monkeypatch):
def test_deferred_payment(monkeypatch):
mock_backend, payment = do_mock_backend(monkeypatch)
capture_date = datetime.now().date() + timedelta(days=3)
capture_date = (datetime.now().date() + timedelta(days=3))
payment.request(amount=12.2, capture_date=capture_date)
mock_backend.request.assert_called_with(12.2, **{'capture_day': '3'})
mock_backend.request.assert_called_with(12.2, **{'capture_day': u'3'})
# capture date can't be inferior to the transaction date
capture_date = datetime.now().date() - timedelta(days=3)
with pytest.raises(ValueError, match='capture_date needs to be superior to the transaction date.'):
capture_date = (datetime.now().date() - timedelta(days=3))
with pytest.raises(
ValueError, match='capture_date needs to be superior to the transaction date.'):
payment.request(amount=12.2, capture_date=capture_date)
# capture date should be a date object
@ -77,7 +78,7 @@ def test_deferred_payment(monkeypatch):
payment.request(amount=12.2, capture_date=capture_date)
# using capture date on a backend that does not support it raise an error
capture_date = datetime.now().date() + timedelta(days=3)
capture_date = (datetime.now().date() + timedelta(days=3))
mock_backend.description['parameters'] = []
with pytest.raises(ValueError, match='capture_date is not supported by the backend.'):
payment.request(amount=12.2, capture_date=capture_date)
@ -88,7 +89,8 @@ def test_paris_timezone(freezer, monkeypatch):
_, payment = do_mock_backend(monkeypatch)
capture_date = date(year=2018, month=10, day=3)
with pytest.raises(ValueError, match='capture_date needs to be superior to the transaction date'):
with pytest.raises(
ValueError, match='capture_date needs to be superior to the transaction date'):
# utcnow will return 2018-10-02 23:50:00,
# converted to Europe/Paris it is already 2018-10-03
# so 2018-10-03 for capture_date is invalid
@ -110,19 +112,6 @@ def test_get_parameters(monkeypatch):
def test_payment_status(monkeypatch):
_, payment = do_mock_backend(monkeypatch)
assert not payment.has_payment_status
def test_get_min_time_between_transaction(monkeypatch):
_, payment = do_mock_backend(monkeypatch)
assert payment.get_min_time_between_transactions() == 0
def test_get_minimal_amount(monkeypatch):
_, payment = do_mock_backend(monkeypatch)
assert payment.get_minimal_amount() is None
def test_get_maximal_amount(monkeypatch):
_, payment = do_mock_backend(monkeypatch)
assert payment.get_maximal_amount() is None
with pytest.raises(NotImplementedError):
payment.payment_status('whatever')

View File

@ -14,19 +14,18 @@
# 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/>.
from six.moves.urllib.parse import urlparse, parse_qs
import datetime
from urllib.parse import parse_qs, urlparse
import pytest
import eopayment
import pytest
@pytest.fixture
def backend():
options = {
'automatic_notification_url': 'http://example.com/direct_notification_url',
'origin': 'Mairie de Perpette-les-oies',
'origin': 'Mairie de Perpette-les-oies'
}
return eopayment.Payment('dummy', options)
@ -34,13 +33,12 @@ def backend():
def test_request(backend, freezer):
freezer.move_to('2020-01-01 00:00:00+01:00')
transaction_id, method, raw_url = backend.request(
'10.10', capture_date=datetime.date(2020, 1, 7), subject='Repas pour 4 personnes'
)
'10.10', capture_date=datetime.date(2020, 1, 7), subject='Repas pour 4 personnes')
assert transaction_id
assert method == 1
url = urlparse(raw_url)
assert url.scheme == 'https'
assert url.netloc == 'dummy-payment.entrouvert.com'
assert url.scheme == 'http'
assert url.netloc == 'dummy-payment.demo.entrouvert.com'
assert url.path == '/'
assert url.fragment == ''
qs = {k: v[0] for k, v in parse_qs(url.query).items()}

View File

@ -18,78 +18,85 @@ import json
import pytest
import requests
from httmock import HTTMock, all_requests, remember_called, response, urlmatch, with_httmock
import six
from httmock import response, urlmatch, HTTMock, with_httmock, all_requests, remember_called
import eopayment
from eopayment.keyware import Payment
pytestmark = pytest.mark.skipif(six.PY2, reason='this payment module only supports python3')
WEBHOOK_URL = 'https://callback.example.com'
RETURN_URL = 'https://return.example.com'
API_KEY = 'test'
ORDER_ID = '1c969951-f5f1-4290-ae41-6177961fb3cb'
ORDER_ID = "1c969951-f5f1-4290-ae41-6177961fb3cb"
QUERY_STRING = 'order_id=' + ORDER_ID
POST_ORDER_RESPONSE = {
'amount': 995,
'client': {'user_agent': 'Testing API'},
'created': '2016-07-04T11:41:57.121017+00:00',
'currency': 'EUR',
'description': 'Example description',
'id': ORDER_ID,
'merchant_id': '7131b462-1b7d-489f-aba9-de2f0eadc9dc',
'modified': '2016-07-04T11:41:57.183822+00:00',
'order_url': 'https://api.online.emspay.eu/pay/1c969951-f5f1-4290-ae41-6177961fb3cb/',
'project_id': '1ef558ed-d77d-470d-b43b-c0f4a131bcef',
'status': 'new',
"amount": 995,
"client": {
"user_agent": "Testing API"
},
"created": "2016-07-04T11:41:57.121017+00:00",
"currency": "EUR",
"description": "Example description",
"id": ORDER_ID,
"merchant_id": "7131b462-1b7d-489f-aba9-de2f0eadc9dc",
"modified": "2016-07-04T11:41:57.183822+00:00",
"order_url": "https://api.online.emspay.eu/pay/1c969951-f5f1-4290-ae41-6177961fb3cb/",
"project_id": "1ef558ed-d77d-470d-b43b-c0f4a131bcef",
"status": "new"
}
GET_ORDER_RESPONSE = {
'amount': 995,
'client': {'user_agent': 'Testing API'},
'created': '2016-07-04T11:41:55.635115+00:00',
'currency': 'EUR',
'description': 'Example order #1',
'id': ORDER_ID,
'last_transaction_added': '2016-07-04T11:41:55.831655+00:00',
'merchant_id': '7131b462-1b7d-489f-aba9-de2f0eadc9dc',
'merchant_order_id': 'EXAMPLE001',
'modified': '2016-07-04T11:41:56.215543+00:00',
'project_id': '1ef558ed-d77d-470d-b43b-c0f4a131bcef',
'return_url': 'http://www.example.com/',
'status': 'completed',
'transactions': [
"amount": 995,
"client": {
"user_agent": "Testing API"
},
"created": "2016-07-04T11:41:55.635115+00:00",
"currency": "EUR",
"description": "Example order #1",
"id": ORDER_ID,
"last_transaction_added": "2016-07-04T11:41:55.831655+00:00",
"merchant_id": "7131b462-1b7d-489f-aba9-de2f0eadc9dc",
"merchant_order_id": "EXAMPLE001",
"modified": "2016-07-04T11:41:56.215543+00:00",
"project_id": "1ef558ed-d77d-470d-b43b-c0f4a131bcef",
"return_url": "http://www.example.com/",
"status": "completed",
"transactions": [
{
'amount': 995,
'balance': 'internal',
'created': '2016-07-04T11:41:55.831655+00:00',
'credit_debit': 'credit',
'currency': 'EUR',
'description': 'Example order #1',
'events': [
"amount": 995,
"balance": "internal",
"created": "2016-07-04T11:41:55.831655+00:00",
"credit_debit": "credit",
"currency": "EUR",
"description": "Example order #1",
"events": [
{
'event': 'new',
'id': '0c4bd0cd-f197-446b-b218-39cbeb028290',
'noticed': '2016-07-04T11:41:55.987468+00:00',
'occurred': '2016-07-04T11:41:55.831655+00:00',
'source': 'set_status',
"event": "new",
"id": "0c4bd0cd-f197-446b-b218-39cbeb028290",
"noticed": "2016-07-04T11:41:55.987468+00:00",
"occurred": "2016-07-04T11:41:55.831655+00:00",
"source": "set_status"
}
],
'expiration_period': 'PT60M',
'id': '6c81499c-14e4-4974-99e5-fe72ce019411',
'merchant_id': '7131b462-1b7d-489f-aba9-de2f0eadc9dc',
'modified': '2016-07-04T11:41:56.065147+00:00',
'order_id': ORDER_ID,
'payment_method': 'ideal',
'payment_method_details': {
'issuer_id': 'INGBNL2A',
"expiration_period": "PT60M",
"id": "6c81499c-14e4-4974-99e5-fe72ce019411",
"merchant_id": "7131b462-1b7d-489f-aba9-de2f0eadc9dc",
"modified": "2016-07-04T11:41:56.065147+00:00",
"order_id": ORDER_ID,
"payment_method": "ideal",
"payment_method_details": {
"issuer_id": "INGBNL2A",
},
'payment_url': 'https://api.online.emspay.eu/redirect/6c81499c-14e4-4974-99e5-fe72ce019411/to/payment/',
'project_id': '1ef558ed-d77d-470d-b43b-c0f4a131bcef',
'status': 'completed',
"payment_url": "https://api.online.emspay.eu/redirect/6c81499c-14e4-4974-99e5-fe72ce019411/to/payment/",
"project_id": "1ef558ed-d77d-470d-b43b-c0f4a131bcef",
"status": "completed"
}
],
]
}
@ -98,13 +105,11 @@ GET_ORDER_RESPONSE = {
def add_order(url, request):
return response(200, POST_ORDER_RESPONSE, request=request)
@remember_called
@urlmatch(scheme='https', netloc='api.online.emspay.eu', path='/v1/orders', method='GET')
def successful_order(url, request):
return response(200, GET_ORDER_RESPONSE, request=request)
@remember_called
@urlmatch(scheme='https', netloc='api.online.emspay.eu', path=r'/v1/orders', method='DELETE')
def cancelled_order(url, request):
@ -112,7 +117,6 @@ def cancelled_order(url, request):
resp['status'] = 'cancelled'
return response(200, resp, request=request)
@remember_called
@urlmatch(scheme='https', netloc='api.online.emspay.eu', path='/v1/orders')
def error_order(url, request):
@ -120,35 +124,29 @@ def error_order(url, request):
resp['status'] = 'error'
return response(200, resp, request=request)
@remember_called
@urlmatch(scheme='https', netloc='api.online.emspay.eu', path='/v1/orders', method='GET')
def connection_error(url, request):
raise requests.ConnectionError('test msg')
@remember_called
@urlmatch(scheme='https', netloc='api.online.emspay.eu', path='/v1/orders', method='GET')
def http_error(url, request):
error_payload = {'error': {'status': 400, 'type': 'Bad request', 'value': 'error'}}
return response(400, error_payload, request=request)
@remember_called
@urlmatch(scheme='https', netloc='api.online.emspay.eu', path='/v1/orders', method='GET')
def invalid_json(url, request):
return response(200, '{', request=request)
@pytest.fixture
def keyware():
return Payment(
{
'normal_return_url': RETURN_URL,
'automatic_return_url': WEBHOOK_URL,
'api_key': API_KEY,
}
)
return Payment({
'normal_return_url': RETURN_URL,
'automatic_return_url': WEBHOOK_URL,
'api_key': API_KEY,
})
@with_httmock(add_order)

View File

@ -1,157 +0,0 @@
# eopayment - online payment library
# Copyright (C) 2011-2022 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 pytest
import eopayment
def test_get_backends():
assert len(eopayment.get_backends()) > 1
GUESS_TEST_VECTORS = [
{
'name': 'tipi',
'kwargs': {
'query_string': 'objet=tout+a+fait&montant=12312&saisie=T&mel=info%40entrouvert.com'
'&numcli=12345&exer=9999&refdet=999900000000999999&resultrans=P',
},
'result': ['tipi', '999900000000999999'],
},
{
'name': 'payfip_ws',
'kwargs': {
'query_string': 'idOp=1234',
},
'result': ['payfip_ws', '1234'],
},
{
'name': 'systempayv2-old-transaction-id',
'kwargs': {
'query_string': 'vads_amount=1042&vads_auth_mode=FULL&vads_auth_number=3feadf'
'&vads_auth_result=00&vads_capture_delay=0&vads_card_brand=CB'
'&vads_result=00'
'&vads_card_number=497010XXXXXX0000'
'&vads_payment_certificate=582ba2b725057618706d7a06e9e59acdbf69ff53'
'&vads_ctx_mode=TEST&vads_currency=978&vads_effective_amount=1042'
'&vads_site_id=70168983&vads_trans_date=20161013101355'
'&vads_trans_id=226787&vads_trans_uuid=4b5053b3b1fe4b02a07753e7a'
'&vads_effective_creation_date=20200330162530'
'&signature=c17fab393f94dc027dc029510c85d5fc46c4710f',
},
'result': ['systempayv2', '20161013101355_226787'],
},
{
'name': 'systempayv2-eo-trans-id',
'kwargs': {
'query_string': 'vads_amount=1042&vads_auth_mode=FULL&vads_auth_number=3feadf'
'&vads_auth_result=00&vads_capture_delay=0&vads_card_brand=CB'
'&vads_result=00'
'&vads_card_number=497010XXXXXX0000'
'&vads_payment_certificate=582ba2b725057618706d7a06e9e59acdbf69ff53'
'&vads_ctx_mode=TEST&vads_currency=978&vads_effective_amount=1042'
'&vads_site_id=70168983&vads_trans_date=20161013101355'
'&vads_trans_id=226787&vads_trans_uuid=4b5053b3b1fe4b02a07753e7a'
'&vads_effective_creation_date=20200330162530'
'&signature=c17fab393f94dc027dc029510c85d5fc46c4710f'
'&vads_ext_info_eopayment_trans_id=123456',
},
'result': ['systempayv2', '123456'],
},
{
'name': 'paybox',
'kwargs': {
'query_string': 'montant=4242&reference=abcdef&code_autorisation=A'
'&erreur=00000&date_transaction=20200101&heure_transaction=01%3A01%3A01',
},
'result': ['paybox', 'abcdef'],
},
{
'name': 'ogone-no-complus',
'kwargs': {
'query_string': 'orderid=myorder&status=9&payid=3011229363&cn=Us%C3%A9r&ncerror=0'
'&trxdate=10%2F24%2F16&acceptance=test123&currency=eur&amount=7.5',
},
'result': ['ogone', 'myorder'],
},
{
'name': 'ogone-with-complus',
'kwargs': {
'query_string': 'complus=neworder&orderid=myorder&status=9&payid=3011229363&cn=Us%C3%A9r'
'&ncerror=0&trxdate=10%2F24%2F16&acceptance=test123&currency=eur&amount=7.5',
},
'result': ['ogone', 'neworder'],
},
{
'name': 'mollie',
'kwargs': {
'body': b'id=tr_7UhSN1zuXS',
},
'result': ['mollie', 'tr_7UhSN1zuXS'],
},
{
'name': 'sips2',
'kwargs': {
'body': (
b'Data=captureDay%3D0%7CcaptureMode%3DAUTHOR_CAPTURE%7CcurrencyCode%3D978%7CmerchantId%3D002001000000001%7CorderChannel%3D'
b'INTERNET%7CresponseCode%3D00%7CtransactionDateTime%3D2016-02-01T17%3A44%3A20%2B01%3A00%7C'
b'transactionReference%3D668930%7CkeyVersion%3D1%7CacquirerResponseCode%3D00%7Camou'
b'nt%3D1200%7CauthorisationId%3D12345%7CcardCSCResultCode%3D4E%7CpanExpiryDate%3D201605%7Cpay'
b'mentMeanBrand%3DMASTERCARD%7CpaymentMeanType%3DCARD%7CcustomerIpAddress%3D82.244.203.243%7CmaskedPan'
b'%3D5100%23%23%23%23%23%23%23%23%23%23%23%2300%7CorderId%3Dd4903de7027f4d56ac01634fd7ab9526%7CholderAuthentRelegation'
b'%3DN%7CholderAuthentStatus%3D3D_ERROR%7CtransactionOrigin%3DINTERNET%7CpaymentPattern%3D'
b'ONE_SHOT&Seal=6ca3247765a19b45d25ad54ef4076483e7d55583166bd5ac9c64357aac097602&InterfaceVersion=HP_2.0&Encode='
),
},
'result': ['sips2', '668930'],
},
{
'name': 'dummy',
'kwargs': {
'query_string': b'transaction_id=123&ok=1&signed=1',
},
'result': ['dummy', '123'],
},
{
'name': 'notfound',
'kwargs': {},
'exception': eopayment.BackendNotFound,
},
{
'name': 'notfound-2',
'kwargs': {'query_string': None, 'body': [12323], 'headers': {b'1': '2'}},
'exception': eopayment.BackendNotFound,
},
{
'name': 'backends-limitation',
'kwargs': {
'body': b'id=tr_7UhSN1zuXS',
'backends': ['payfips_ws'],
},
'exception': eopayment.BackendNotFound,
},
]
@pytest.mark.parametrize('test_vector', GUESS_TEST_VECTORS, ids=lambda tv: tv['name'])
def test_guess(test_vector):
kwargs, result, exception = test_vector['kwargs'], test_vector.get('result'), test_vector.get('exception')
if exception is not None:
with pytest.raises(exception):
eopayment.Payment.guess(**kwargs)
else:
assert list(eopayment.Payment.guess(**kwargs)) == result

View File

@ -16,73 +16,95 @@
import json
import pytest
import requests
from httmock import remember_called, response, urlmatch, with_httmock
import six
import eopayment
import pytest
from eopayment.mollie import Payment
from httmock import remember_called, response, urlmatch, with_httmock
pytestmark = pytest.mark.skipif(six.PY2, reason='this payment module only supports python3')
WEBHOOK_URL = 'https://callback.example.com'
RETURN_URL = 'https://return.example.com'
API_KEY = 'test'
PAYMENT_ID = 'tr_7UhSN1zuXS'
PAYMENT_ID = "tr_7UhSN1zuXS"
QUERY_STRING = 'id=' + PAYMENT_ID
POST_PAYMENTS_RESPONSE = {
'resource': 'payment',
'id': PAYMENT_ID,
'mode': 'test',
'createdAt': '2018-03-20T09:13:37+00:00',
'amount': {'value': '3.50', 'currency': 'EUR'},
'description': 'Payment #12345',
'method': 'null',
'status': 'open',
'isCancelable': True,
'expiresAt': '2018-03-20T09:28:37+00:00',
'sequenceType': 'oneoff',
'redirectUrl': 'https://webshop.example.org/payment/12345/',
'webhookUrl': 'https://webshop.example.org/payments/webhook/',
'_links': {
'checkout': {
'href': 'https://www.mollie.com/payscreen/select-method/7UhSN1zuXS',
'type': 'text/html',
},
"resource": "payment",
"id": PAYMENT_ID,
"mode": "test",
"createdAt": "2018-03-20T09:13:37+00:00",
"amount": {
"value": "3.50",
"currency": "EUR"
},
"description": "Payment #12345",
"method": "null",
"status": "open",
"isCancelable": True,
"expiresAt": "2018-03-20T09:28:37+00:00",
"sequenceType": "oneoff",
"redirectUrl": "https://webshop.example.org/payment/12345/",
"webhookUrl": "https://webshop.example.org/payments/webhook/",
"_links": {
"checkout": {
"href": "https://www.mollie.com/payscreen/select-method/7UhSN1zuXS",
"type": "text/html"
},
}
}
GET_PAYMENTS_RESPONSE = {
'amount': {'currency': 'EUR', 'value': '3.50'},
'amountRefunded': {'currency': 'EUR', 'value': '0.00'},
'amountRemaining': {'currency': 'EUR', 'value': '3.50'},
'countryCode': 'FR',
'createdAt': '2020-05-06T13:04:26+00:00',
'description': 'Publik',
'details': {
'cardAudience': 'consumer',
'cardCountryCode': 'NL',
'cardHolder': 'T. TEST',
'cardLabel': 'Mastercard',
'cardNumber': '6787',
'cardSecurity': 'normal',
'feeRegion': 'other',
"amount": {
"currency": "EUR",
"value": "3.50"
},
'id': PAYMENT_ID,
'metadata': {'email': 'test@entrouvert.com', 'first_name': 'test', 'last_name': 'test'},
'isCancelable': True,
'method': 'creditcard',
'mode': 'test',
'paidAt': '2020-05-06T14:01:04+00:00',
'profileId': 'pfl_WNPCPTGepu',
'redirectUrl': 'https://localhost/lingo/return-payment-backend/3/MTAw.1jWJis.6TbbjwSEurag6v4Z2VCheISBFjw/',
'resource': 'payment',
'sequenceType': 'oneoff',
'settlementAmount': {'currency': 'EUR', 'value': '3.50'},
'status': 'paid',
'webhookUrl': 'https://localhost/lingo/callback-payment-backend/3/',
"amountRefunded": {
"currency": "EUR",
"value": "0.00"
},
"amountRemaining": {
"currency": "EUR",
"value": "3.50"
},
"countryCode": "FR",
"createdAt": "2020-05-06T13:04:26+00:00",
"description": "Publik",
"details": {
"cardAudience": "consumer",
"cardCountryCode": "NL",
"cardHolder": "T. TEST",
"cardLabel": "Mastercard",
"cardNumber": "6787",
"cardSecurity": "normal",
"feeRegion": "other"
},
"id": PAYMENT_ID,
"metadata": {
"email": "test@entrouvert.com",
"first_name": "test",
"last_name": "test"
},
"isCancelable": True,
"method": "creditcard",
"mode": "test",
"paidAt": "2020-05-06T14:01:04+00:00",
"profileId": "pfl_WNPCPTGepu",
"redirectUrl": "https://localhost/lingo/return-payment-backend/3/MTAw.1jWJis.6TbbjwSEurag6v4Z2VCheISBFjw/",
"resource": "payment",
"sequenceType": "oneoff",
"settlementAmount": {
"currency": "EUR",
"value": "3.50"
},
"status": "paid",
"webhookUrl": "https://localhost/lingo/callback-payment-backend/3/"
}
@ -155,13 +177,11 @@ def invalid_json(url, request):
@pytest.fixture
def mollie():
return Payment(
{
'normal_return_url': RETURN_URL,
'automatic_return_url': WEBHOOK_URL,
'api_key': API_KEY,
}
)
return Payment({
'normal_return_url': RETURN_URL,
'automatic_return_url': WEBHOOK_URL,
'api_key': API_KEY,
})
@with_httmock(add_payment)
@ -181,44 +201,6 @@ def test_mollie_request(mollie):
assert body['redirectUrl'] == RETURN_URL
@with_httmock(add_payment)
def test_mollie_request_orderid(mollie):
email = 'test@test.com'
payment_id, kind, url = mollie.request(2.5, email=email, orderid='1234')
assert payment_id == PAYMENT_ID
assert kind == eopayment.URL
assert 'mollie.com/payscreen/' in url
body = json.loads(add_payment.call['requests'][0].body.decode())
assert body['amount']['value'] == '2.5'
assert body['amount']['currency'] == 'EUR'
assert body['metadata']['email'] == email
assert body['metadata']['orderid'] == '1234'
assert body['webhookUrl'] == WEBHOOK_URL
assert body['redirectUrl'] == RETURN_URL
assert body['description'] == '1234'
@with_httmock(add_payment)
def test_mollie_request_orderid_subject(mollie):
email = 'test@test.com'
payment_id, kind, url = mollie.request(2.5, email=email, orderid='1234', subject='Ticket cantine #1234')
assert payment_id == PAYMENT_ID
assert kind == eopayment.URL
assert 'mollie.com/payscreen/' in url
body = json.loads(add_payment.call['requests'][0].body.decode())
assert body['amount']['value'] == '2.5'
assert body['amount']['currency'] == 'EUR'
assert body['metadata']['email'] == email
assert body['metadata']['orderid'] == '1234'
assert body['webhookUrl'] == WEBHOOK_URL
assert body['redirectUrl'] == RETURN_URL
assert body['description'] == 'Ticket cantine #1234'
@with_httmock(successful_payment)
def test_mollie_response(mollie):
payment_response = mollie.response(QUERY_STRING)
@ -237,9 +219,8 @@ def test_mollie_response(mollie):
@with_httmock(successful_payment)
def test_mollie_response_on_redirect(mollie):
payment_response = mollie.response(
query_string=None, redirect=True, order_id_hint=PAYMENT_ID, order_status_hint=0
)
payment_response = mollie.response(query_string=None, redirect=True, order_id_hint=PAYMENT_ID,
order_status_hint=0)
assert payment_response.result == eopayment.PAID
request = successful_payment.call['requests'][0]
@ -247,9 +228,8 @@ def test_mollie_response_on_redirect(mollie):
def test_mollie_response_on_redirect_final_status(mollie):
payment_response = mollie.response(
query_string=None, redirect=True, order_id_hint=PAYMENT_ID, order_status_hint=eopayment.PAID
)
payment_response = mollie.response(query_string=None, redirect=True, order_id_hint=PAYMENT_ID,
order_status_hint=eopayment.PAID)
assert payment_response.result == eopayment.PAID
assert payment_response.order_id == PAYMENT_ID

View File

@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# eopayment - online payment library
# Copyright (C) 2011-2020 Entr'ouvert
#
@ -14,144 +15,117 @@
# 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/>.
from urllib import parse as urllib
from unittest import TestCase
from xml.etree import ElementTree as ET
import pytest
import six
from six.moves.urllib import parse as urllib
import eopayment
import eopayment.ogone as ogone
from eopayment import ResponseError
PSPID = '2352566ö'
PSPID = u'2352566ö'
BACKEND_PARAMS = {
'environment': ogone.ENVIRONMENT_TEST,
'pspid': PSPID,
'sha_in': u'sécret',
'sha_out': u'sécret',
'automatic_return_url': u'http://example.com/autömatic_réturn_url'
}
@pytest.fixture(params=[None, 'iso-8859-1', 'utf-8'])
def params(request):
params = {
'environment': ogone.ENVIRONMENT_TEST,
'pspid': PSPID,
'sha_in': 'sécret',
'sha_out': 'sécret',
'automatic_return_url': 'http://example.com/autömatic_réturn_url',
}
encoding = request.param
if encoding:
params['encoding'] = encoding
return params
class OgoneTests(TestCase):
if six.PY2:
def assertRaisesRegex(self, *args, **kwargs):
return self.assertRaisesRegexp(*args, **kwargs)
def test_request(self):
ogone_backend = eopayment.Payment('ogone', BACKEND_PARAMS)
amount = '42.42'
order_id = u'my ordér'
reference, kind, what = ogone_backend.request(
amount=amount,
orderid=order_id,
email='foo@example.com')
self.assertEqual(len(reference), 30)
assert reference.startswith(order_id)
root = ET.fromstring(str(what))
self.assertEqual(root.tag, 'form')
self.assertEqual(root.attrib['method'], 'POST')
self.assertEqual(root.attrib['action'], ogone.ENVIRONMENT_TEST_URL)
values = {
'CURRENCY': u'EUR',
'ORDERID': reference,
'PSPID': PSPID,
'EMAIL': 'foo@example.com',
'AMOUNT': amount.replace('.', ''),
'LANGUAGE': 'fr_FR',
}
values.update({'SHASIGN': ogone_backend.backend.sha_sign_in(values)})
for node in root:
self.assertIn(node.attrib['type'], ('hidden', 'submit'))
self.assertEqual(set(node.attrib.keys()), set(['type', 'name', 'value']))
name = node.attrib['name']
if node.attrib['type'] == 'hidden':
self.assertIn(name, values)
self.assertEqual(node.attrib['value'], values[name])
def test_request(params):
ogone_backend = eopayment.Payment('ogone', params)
amount = '42.42'
order_id = 'my ordér'
reference, kind, what = ogone_backend.request(amount=amount, orderid=order_id, email='foo@example.com')
assert len(reference) == 32
root = ET.fromstring(str(what))
assert root.tag == 'form'
assert root.attrib['method'] == 'POST'
assert root.attrib['action'] == ogone.ENVIRONMENT_TEST_URL
values = {
'CURRENCY': 'EUR',
'ORDERID': order_id,
'PSPID': PSPID,
'EMAIL': 'foo@example.com',
'AMOUNT': amount.replace('.', ''),
'LANGUAGE': 'fr_FR',
'COMPLUS': reference,
}
values.update({'SHASIGN': ogone_backend.backend.sha_sign_in(values)})
for node in root:
assert node.attrib['type'] in ('hidden', 'submit')
assert set(node.attrib.keys()), {'type', 'name' == 'value'}
name = node.attrib['name']
if node.attrib['type'] == 'hidden':
assert name in values
assert node.attrib['value'] == values[name]
def test_unicode_response(self):
ogone_backend = eopayment.Payment('ogone', BACKEND_PARAMS)
order_id = 'myorder'
data = {'orderid': u'myorder', 'status': u'9', 'payid': u'3011229363',
'cn': u'Usér', 'ncerror': u'0',
'trxdate': u'10/24/16', 'acceptance': u'test123',
'currency': u'eur', 'amount': u'7.5',
'shasign': u'CA4B3C2767B5EFAB33B9122A5D4CF6F27747303D'}
# uniformize to utf-8 first
for k in data:
data[k] = eopayment.common.force_byte(data[k])
response = ogone_backend.response(urllib.urlencode(data))
assert response.signed
self.assertEqual(response.order_id, order_id)
def test_iso_8859_1_response(self):
ogone_backend = eopayment.Payment('ogone', BACKEND_PARAMS)
order_id = 'lRXK4Rl1N2yIR3R5z7Kc'
backend_response = (
'orderID=lRXK4Rl1N2yIR3R5z7Kc&currency=EUR&amount=7%2E5'
'&PM=CreditCard&ACCEPTANCE=test123&STATUS=9'
'&CARDNO=XXXXXXXXXXXX9999&ED=0118'
'&CN=Miha%EF+Serghe%EF&TRXDATE=10%2F24%2F16'
'&PAYID=3011228911&NCERROR=0&BRAND=MasterCard'
'&IP=80%2E12%2E92%2E47&SHASIGN=C429BE892FACFBFCE5E2CC809B102D866DD3D48C'
)
response = ogone_backend.response(backend_response)
assert response.signed
self.assertEqual(response.order_id, order_id)
def test_response(params):
ogone_backend = eopayment.Payment('ogone', params)
order_id = 'myorder'
data = {
'orderid': 'myorder',
'status': '9',
'payid': '3011229363',
'cn': 'Usér',
'ncerror': '0',
'trxdate': '10/24/16',
'acceptance': 'test123',
'currency': 'eur',
'amount': '7.5',
}
data['shasign'] = ogone_backend.backend.sha_sign_out(data, encoding=params.get('encoding', 'iso-8859-1'))
# uniformize to utf-8 first
for k in data:
data[k] = eopayment.common.force_byte(data[k], encoding=params.get('encoding', 'iso-8859-1'))
response = ogone_backend.response(urllib.urlencode(data))
assert response.signed
assert response.order_id == order_id
def test_bad_response(self):
ogone_backend = eopayment.Payment('ogone', BACKEND_PARAMS)
data = {'payid': '32100123', 'status': 9, 'ncerror': 0}
with self.assertRaisesRegex(ResponseError, 'missing ORDERID, PAYID, STATUS or NCERROR'):
ogone_backend.response(urllib.urlencode(data))
def test_iso_8859_1_response():
params = {
'environment': ogone.ENVIRONMENT_TEST,
'pspid': PSPID,
'sha_in': 'sécret',
'sha_out': 'sécret',
'automatic_return_url': 'http://example.com/autömatic_réturn_url',
}
ogone_backend = eopayment.Payment('ogone', params)
order_id = 'lRXK4Rl1N2yIR3R5z7Kc'
backend_response = (
'orderID=lRXK4Rl1N2yIR3R5z7Kc&currency=EUR&amount=7%2E5'
'&PM=CreditCard&ACCEPTANCE=test123&STATUS=9'
'&CARDNO=XXXXXXXXXXXX9999&ED=0118'
'&CN=Miha%EF+Serghe%EF&TRXDATE=10%2F24%2F16'
'&PAYID=3011228911&NCERROR=0&BRAND=MasterCard'
'&IP=80%2E12%2E92%2E47&SHASIGN=C429BE892FACFBFCE5E2CC809B102D866DD3D48C'
)
response = ogone_backend.response(backend_response)
assert response.signed
assert response.order_id == order_id
def test_bad_response(params):
ogone_backend = eopayment.Payment('ogone', params)
data = {'payid': '32100123', 'status': 9, 'ncerror': 0}
with pytest.raises(ResponseError, match='missing ORDERID, PAYID, STATUS or NCERROR'):
ogone_backend.response(urllib.urlencode(data))
def test_bank_transfer_response(params):
ogone_backend = eopayment.Payment('ogone', params)
data = {
'orderid': 'myorder',
'status': '41',
'payid': '3011229363',
'cn': 'User',
'ncerror': '0',
'trxdate': '10/24/16',
'brand': 'Bank transfer',
'pm': 'bank transfer',
'currency': 'eur',
'amount': '7.5',
'shasign': '944CBD1E010BA4945415AE4B16CC40FD533F6CE2',
}
# uniformize to expected encoding
for k in data:
data[k] = eopayment.common.force_byte(data[k], encoding=params.get('encoding', 'iso-8859-1'))
response = ogone_backend.response(urllib.urlencode(data))
assert response.signed
assert response.result == eopayment.WAITING
# check utf-8 based signature is also ok
data['shasign'] = b'0E35F687ACBEAA6CA769E0ADDBD0863EB6C1678A'
response = ogone_backend.response(urllib.urlencode(data))
assert response.signed
assert response.result == eopayment.WAITING
# check invalid signature is not marked ok
data['shasign'] = b'0000000000000000000000000000000000000000'
response = ogone_backend.response(urllib.urlencode(data))
assert not response.signed
def test_bank_transfer_response(self):
ogone_backend = eopayment.Payment('ogone', BACKEND_PARAMS)
data = {
'orderid': u'myorder',
'status': u'41',
'payid': u'3011229363',
'cn': u'User',
'ncerror': u'0',
'trxdate': u'10/24/16',
'brand': 'Bank transfer',
'pm': 'bank transfer',
'currency': u'eur',
'amount': u'7.5',
'shasign': u'944CBD1E010BA4945415AE4B16CC40FD533F6CE2',
}
# uniformize to utf-8 first
for k in data:
data[k] = eopayment.common.force_byte(data[k])
response = ogone_backend.response(urllib.urlencode(data))
assert response.signed
assert response.result == eopayment.WAITING

View File

@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# eopayment - online payment library
# Copyright (C) 2011-2020 Entr'ouvert
#
@ -15,59 +16,57 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import base64
import codecs
from unittest import TestCase
from decimal import Decimal
from unittest import TestCase, mock
from urllib import parse as urllib
import base64
import mock
import six
from six.moves.urllib import parse as urllib
from xml.etree import ElementTree as ET
import pytest
import eopayment
import eopayment.paybox as paybox
import eopayment
BACKEND_PARAMS = {
'platform': 'test',
'site': '12345678',
'rang': '001',
'identifiant': '12345678',
'platform': u'test',
'site': u'12345678',
'rang': u'001',
'identifiant': u'12345678',
'shared_secret': (
'0123456789ABCDEF0123456789ABCDEF01234'
'56789ABCDEF0123456789ABCDEF0123456789'
'ABCDEF0123456789ABCDEF0123456789ABCDE'
'F0123456789ABCDEF'
u'0123456789ABCDEF0123456789ABCDEF01234'
u'56789ABCDEF0123456789ABCDEF0123456789'
u'ABCDEF0123456789ABCDEF0123456789ABCDE'
u'F0123456789ABCDEF'
),
'automatic_return_url': 'http://example.com/callback',
'automatic_return_url': u'http://example.com/callback',
}
class PayboxTests(TestCase):
if six.PY2:
def assertRaisesRegex(self, *args, **kwargs):
return self.assertRaisesRegexp(*args, **kwargs)
def test_sign(self):
key = (
b'0123456789ABCDEF0123456789ABCDEF0123456789'
b'ABCDEF0123456789ABCDEF0123456789ABCDEF0123'
b'456789ABCDEF0123456789ABCDEF0123456789ABCD'
b'EF'
)
key = (b'0123456789ABCDEF0123456789ABCDEF0123456789'
b'ABCDEF0123456789ABCDEF0123456789ABCDEF0123'
b'456789ABCDEF0123456789ABCDEF0123456789ABCD'
b'EF')
key = codecs.decode(key, 'hex')
d = dict(
paybox.sign(
[
['PBX_SITE', '12345678'],
['PBX_RANG', '32'],
['PBX_IDENTIFIANT', '12345678'],
['PBX_TOTAL', '999'],
['PBX_DEVISE', '978'],
['PBX_CMD', 'appel à Paybox'],
['PBX_PORTEUR', 'test@paybox.com'],
['PBX_RETOUR', 'Mt:M;Ref:R;Auto:A;Erreur:E'],
['PBX_HASH', 'SHA512'],
['PBX_TIME', '2015-06-08T16:21:16+02:00'],
],
key,
)
)
d = dict(paybox.sign([
['PBX_SITE', u'12345678'],
['PBX_RANG', u'32'],
['PBX_IDENTIFIANT', u'12345678'],
['PBX_TOTAL', u'999'],
['PBX_DEVISE', u'978'],
['PBX_CMD', u'appel à Paybox'],
['PBX_PORTEUR', u'test@paybox.com'],
['PBX_RETOUR', u'Mt:M;Ref:R;Auto:A;Erreur:E'],
['PBX_HASH', u'SHA512'],
['PBX_TIME', u'2015-06-08T16:21:16+02:00'],
], key))
result = (
'7E74D8E9A0DBB65AAE51C5C50C2668FD98FC99AED'
'F18244BB1935F602B6C2E953B61FD84364F34FDB8'
@ -77,32 +76,16 @@ class PayboxTests(TestCase):
self.assertIn('PBX_HMAC', d)
self.assertEqual(d['PBX_HMAC'], result)
@mock.patch('eopayment.paybox.Payment.make_pbx_archivage', wraps=True)
def test_request(self, make_pbx_archivage):
def test_request(self):
backend = eopayment.Payment('paybox', BACKEND_PARAMS)
# fix PBX_ARCHIVAGE for test purpose
make_pbx_archivage.return_value = '4YQEFSFZSNWA'
time = '2015-07-15T18:26:32+02:00'
email = 'bdauvergne@entrouvert.com'
order_id = '20160216'
transaction = '1234'
amount = '19.99'
transaction_id, kind, what = backend.request(
Decimal(amount),
email=email,
orderid=order_id,
transaction_id=transaction,
time=time,
manual_validation=False,
total_quantity='1',
first_name='Kyan <oo>',
last_name='Khojandi',
address1='169 rue du Château',
zipcode='75014',
city='Paris',
country_code=250,
)
Decimal(amount), email=email, orderid=order_id,
transaction_id=transaction, time=time, manual_validation=False)
self.assertEqual(kind, eopayment.FORM)
self.assertEqual(transaction_id, '%s!%s' % (transaction, order_id))
root = ET.fromstring(str(what))
@ -125,37 +108,22 @@ class PayboxTests(TestCase):
'PBX_DEVISE': '978',
'PBX_HASH': 'SHA512',
'PBX_HMAC': (
'EC9B753691D804F15B3369BEF9CA49F20585BE32E84'
'9A9758815903CE5A89822C251C7EBC712145FCA6321'
'C6A6F90EE45EBEC618FFC8B7A69CC23E1BFC6CACC7'
'A9F561A6EA79390F1741A6B72872470BC1A1688E4581'
'F097EC80B99D2038413AB350F2F5429FFA4F8D426D99'
'B72E038164642F6F9BA10D46837EE486EEB944A2'
),
'PBX_ARCHIVAGE': '4YQEFSFZSNWA',
'PBX_ARCHIVAGE': '20160216',
'PBX_REPONDRE_A': 'http://example.com/callback',
'PBX_AUTOSEULE': 'N',
'PBX_BILLING': (
'<?xml version="1.0" encoding="utf-8"?>'
'<Billing><Address>'
'<FirstName>Kyan &lt;oo&gt;</FirstName>'
'<LastName>Khojandi</LastName>'
'<Address1>169 rue du Château</Address1>'
'<ZipCode>75014</ZipCode>'
'<City>Paris</City>'
'<CountryCode>250</CountryCode>'
'</Address></Billing>'
),
'PBX_SHOPPINGCART': (
'<?xml version="1.0" encoding="utf-8"?>'
'<shoppingcart><total><totalQuantity>1</totalQuantity></total></shoppingcart>'
),
'PBX_AUTOSEULE': 'N'
}
form_params = {}
for node in root:
self.assertIn(node.attrib['type'], ('hidden', 'submit'))
if node.attrib['type'] == 'submit':
self.assertEqual(set(node.attrib.keys()), {'type', 'value'})
self.assertEqual(set(node.attrib.keys()), set(['type', 'value']))
if node.attrib['type'] == 'hidden':
self.assertEqual(set(node.attrib.keys()), {'type', 'name', 'value'})
self.assertEqual(set(node.attrib.keys()), set(['type', 'name', 'value']))
name = node.attrib['name']
form_params[name] = node.attrib['value']
assert form_params == expected_form_values
@ -172,13 +140,12 @@ class PayboxTests(TestCase):
params['capture_day'] = capture_day
backend = eopayment.Payment('paybox', params)
transaction_id, kind, what = backend.request(
Decimal(amount), email=email, orderid=order_id, transaction_id=transaction, time=time
)
Decimal(amount), email=email, orderid=order_id,
transaction_id=transaction, time=time)
root = ET.fromstring(str(what))
form_params = {
node.attrib['name']: node.attrib['value'] for node in root if node.attrib['type'] == 'hidden'
}
form_params = dict(
((node.attrib['name'], node.attrib['value']) for node in root if node.attrib['type'] == 'hidden'))
self.assertIn('PBX_DIFF', form_params)
self.assertEqual(form_params['PBX_DIFF'], '07')
@ -186,18 +153,13 @@ class PayboxTests(TestCase):
params = BACKEND_PARAMS.copy()
backend = eopayment.Payment('paybox', params)
transaction_id, kind, what = backend.request(
Decimal(amount),
email=email,
orderid=order_id,
transaction_id=transaction,
time=time,
capture_day='2',
)
Decimal(amount), email=email, orderid=order_id,
transaction_id=transaction, time=time, capture_day='2')
root = ET.fromstring(str(what))
form_params = {
node.attrib['name']: node.attrib['value'] for node in root if node.attrib['type'] == 'hidden'
}
form_params = dict(((
node.attrib['name'], node.attrib['value']) for node in root
if node.attrib['type'] == 'hidden'))
self.assertIn('PBX_DIFF', form_params)
self.assertEqual(form_params['PBX_DIFF'], '02')
@ -207,18 +169,13 @@ class PayboxTests(TestCase):
params['capture_day'] = '7'
backend = eopayment.Payment('paybox', params)
transaction_id, kind, what = backend.request(
Decimal(amount),
email=email,
orderid=order_id,
transaction_id=transaction,
time=time,
capture_day='2',
)
Decimal(amount), email=email, orderid=order_id,
transaction_id=transaction, time=time, capture_day='2')
root = ET.fromstring(str(what))
form_params = {
node.attrib['name']: node.attrib['value'] for node in root if node.attrib['type'] == 'hidden'
}
form_params = dict(((
node.attrib['name'], node.attrib['value']) for node in root
if node.attrib['type'] == 'hidden'))
self.assertIn('PBX_DIFF', form_params)
self.assertEqual(form_params['PBX_DIFF'], '02')
@ -233,13 +190,12 @@ class PayboxTests(TestCase):
params['capture_mode'] = 'AUTHOR_CAPTURE'
backend = eopayment.Payment('paybox', params)
transaction_id, kind, what = backend.request(
Decimal(amount), email=email, orderid=order_id, transaction_id=transaction, time=time
)
Decimal(amount), email=email, orderid=order_id,
transaction_id=transaction, time=time)
root = ET.fromstring(str(what))
form_params = {
node.attrib['name']: node.attrib['value'] for node in root if node.attrib['type'] == 'hidden'
}
form_params = dict(
((node.attrib['name'], node.attrib['value']) for node in root if node.attrib['type'] == 'hidden'))
self.assertEqual(form_params['PBX_AUTOSEULE'], 'O')
def test_response(self):
@ -253,8 +209,7 @@ class PayboxTests(TestCase):
'code_autorisation': 'A',
'erreur': '00000',
'date_transaction': '20200101',
'heure_transaction': '01:01:01',
}
'heure_transaction': '01:01:01'}
response = backend.response(urllib.urlencode(data))
self.assertEqual(response.order_id, reference)
assert not response.signed
@ -275,22 +230,20 @@ class PayboxTests(TestCase):
'reference': ['830657461681'],
}
backend_raw_response = (
'NUMTRANS=0013989865&NUMAPPEL=0030378572&NUMQUESTION=0013989862'
'&SITE=1999888&RANG=32&AUTORISATION=XXXXXX&CODEREPONSE=00000'
'&COMMENTAIRE=Demande traitée avec succès&REFABONNE=&PORTEUR='
u'NUMTRANS=0013989865&NUMAPPEL=0030378572&NUMQUESTION=0013989862'
u'&SITE=1999888&RANG=32&AUTORISATION=XXXXXX&CODEREPONSE=00000'
u'&COMMENTAIRE=Demande traitée avec succès&REFABONNE=&PORTEUR='
)
backend_expected_response = {
'CODEREPONSE': '00000',
'RANG': '32',
'AUTORISATION': 'XXXXXX',
'NUMTRANS': '0013989865',
'PORTEUR': '',
'COMMENTAIRE': 'Demande traitée avec succès',
'SITE': '1999888',
'NUMAPPEL': '0030378572',
'REFABONNE': '',
'NUMQUESTION': '0013989862',
}
backend_expected_response = {"CODEREPONSE": "00000",
"RANG": "32",
"AUTORISATION": "XXXXXX",
"NUMTRANS": "0013989865",
"PORTEUR": "",
"COMMENTAIRE": u"Demande traitée avec succès",
"SITE": "1999888",
"NUMAPPEL": "0030378572",
"REFABONNE": "",
"NUMQUESTION": "0013989862"}
with mock.patch('eopayment.paybox.requests.post') as requests_post:
response = mock.Mock(status_code=200, text=backend_raw_response)
@ -313,7 +266,7 @@ class PayboxTests(TestCase):
'REFERENCE': '830657461681',
'RANG': backend.backend.rang,
'SITE': backend.backend.site,
'DEVISE': backend.backend.devise,
'DEVISE': backend.backend.devise
}
self.assertEqual(params_sent, expected_params)
self.assertEqual(backend_response, backend_expected_response)
@ -327,7 +280,7 @@ class PayboxTests(TestCase):
self.assertEqual(requests_post.call_args[0][0], 'https://ppps.paybox.com/PPPS.php')
with mock.patch('eopayment.paybox.requests.post') as requests_post:
error_response = """CODEREPONSE=00015&COMMENTAIRE=PAYBOX : Transaction non trouvée"""
error_response = u"""CODEREPONSE=00015&COMMENTAIRE=PAYBOX : Transaction non trouvée"""
response = mock.Mock(status_code=200, text=error_response)
requests_post.return_value = response
self.assertRaisesRegex(
@ -335,8 +288,7 @@ class PayboxTests(TestCase):
'Transaction non trouvée',
getattr(backend, operation_name),
Decimal('10'),
bank_data,
)
bank_data)
def test_validate_payment(self):
params = BACKEND_PARAMS.copy()
@ -345,12 +297,12 @@ class PayboxTests(TestCase):
bank_data = {
'numero_transaction': ['13957441'],
'numero_appel': ['30310733'],
'reference': ['830657461681'],
'reference': ['830657461681']
}
backend_raw_response = (
'NUMTRANS=0013989865&NUMAPPEL=0030378572&NUMQUESTION=0013989862'
'&SITE=1999888&RANG=32&AUTORISATION=XXXXXX&CODEREPONSE=00000'
'&COMMENTAIRE=Demande traitée avec succès&REFABONNE=&PORTEUR='
u'NUMTRANS=0013989865&NUMAPPEL=0030378572&NUMQUESTION=0013989862'
u'&SITE=1999888&RANG=32&AUTORISATION=XXXXXX&CODEREPONSE=00000'
u'&COMMENTAIRE=Demande traitée avec succès&REFABONNE=&PORTEUR='
)
with mock.patch('eopayment.paybox.requests.post') as requests_post:
@ -382,12 +334,15 @@ FBFKOZhgBJnkC+l6+XhT4aYWKaQ4ocmOMV92yjeXTE4='''
backend = eopayment.Payment('paybox', params)
transaction_id, kind, what = backend.request(
Decimal(amount), email=email, orderid=order_id, transaction_id=transaction, time=time
)
Decimal(amount),
email=email,
orderid=order_id,
transaction_id=transaction,
time=time)
root = ET.fromstring(str(what))
form_params = {
node.attrib['name']: node.attrib['value'] for node in root if node.attrib['type'] == 'hidden'
}
form_params = dict((
(node.attrib['name'], node.attrib['value']) for node in root
if node.attrib['type'] == 'hidden'))
self.assertIn('PBX_AUTOSEULE', form_params)
self.assertEqual(form_params['PBX_AUTOSEULE'], 'N')
@ -397,46 +352,10 @@ FBFKOZhgBJnkC+l6+XhT4aYWKaQ4ocmOMV92yjeXTE4='''
orderid=order_id,
transaction_id=transaction,
time=time,
manual_validation=True,
)
manual_validation=True)
root = ET.fromstring(str(what))
form_params = {
node.attrib['name']: node.attrib['value'] for node in root if node.attrib['type'] == 'hidden'
}
form_params = dict((
(node.attrib['name'], node.attrib['value']) for node in root
if node.attrib['type'] == 'hidden'))
self.assertIn('PBX_AUTOSEULE', form_params)
self.assertEqual(form_params['PBX_AUTOSEULE'], 'O')
def test_invalid_signature(self):
backend = eopayment.Payment('paybox', BACKEND_PARAMS)
order_id = '20160216'
transaction = '1234'
reference = '%s!%s' % (transaction, order_id)
data = {
'montant': '4242',
'reference': reference,
'code_autorisation': 'A',
'erreur': '00000',
'date_transaction': '20200101',
'heure_transaction': '01:01:01',
'signature': 'a',
}
with pytest.raises(eopayment.ResponseError, match='invalid signature'):
backend.response(urllib.urlencode(data))
@pytest.mark.parametrize(
'name,value,result',
[
('shared_secret', '1f', True),
('shared_secret', '1fxx', False),
('shared_secret', '1fa', False),
('shared_secret', '1fa2', True),
],
)
def test_param_validation(name, value, result):
for param in paybox.Payment.description['parameters']:
if param['name'] == name:
assert param['validation'](value) is result
break
else:
assert False, 'param %s not found' % name

View File

@ -1,3 +1,4 @@
# coding: utf-8
#
# eopayment - online payment library
# Copyright (C) 2011-2020 Entr'ouvert
@ -15,20 +16,24 @@
# 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/>.
from __future__ import print_function, unicode_literals
import datetime
import json
from unittest import mock
import lxml.etree as ET
import mock
import pytz
import httmock
import lxml.etree as ET
import pytest
import pytz
from zeep.plugins import HistoryPlugin
import eopayment
from eopayment.payfip_ws import PayFiP, PayFiPError, normalize_objet
NUMCLI = '090909'
NOTIF_URL = 'https://notif.payfip.example.com/'
REDIRECT_URL = 'https://redirect.payfip.example.com/'
@ -51,7 +56,7 @@ def freezer(freezer):
return freezer
class PayFiPHTTMock:
class PayFiPHTTMock(object):
def __init__(self, history_name):
history_path = 'tests/data/payfip-%s.json' % history_name
with open(history_path) as fd:
@ -72,21 +77,13 @@ def history_name(request):
@pytest.fixture
def history(history_name, request, zeep_history_plugin):
def history(history_name, request):
if 'update_data' not in request.keywords:
history_mock = PayFiPHTTMock(history_name)
with httmock.HTTMock(history_mock.mock):
yield history_mock
else:
yield None
history_path = 'tests/data/payfip-%s.json' % history_name
d = [
(xmlindent(exchange['sent']['envelope']), xmlindent(exchange['received']['envelope']))
for exchange in zeep_history_plugin._buffer
]
content = json.dumps(d)
with open(history_path, 'wb') as fd:
fd.write(content)
@pytest.fixture
@ -100,42 +97,39 @@ def get_idop():
def backend(request):
with mock.patch('eopayment.payfip_ws.Payment._generate_refdet') as _generate_refdet:
_generate_refdet.return_value = REFDET_GEN
yield eopayment.Payment(
'payfip_ws',
{
'numcli': '090909',
'automatic_return_url': NOTIF_URL,
'normal_return_url': REDIRECT_URL,
},
)
@httmock.urlmatch()
def raise_on_request(url, request):
# ensure we do not access network
from requests.exceptions import RequestException
raise RequestException('huhu')
yield eopayment.Payment('payfip_ws', {
'numcli': '090909',
'automatic_return_url': NOTIF_URL,
'normal_return_url': REDIRECT_URL,
})
@pytest.fixture
def zeep_history_plugin():
return HistoryPlugin()
def payfip(history, history_name, request):
history = HistoryPlugin()
@httmock.urlmatch()
def raise_on_request(url, request):
# ensure we do not access network
from requests.exceptions import RequestException
raise RequestException('huhu')
@pytest.fixture
def payfip(zeep_history_plugin):
with httmock.HTTMock(raise_on_request):
payfip = PayFiP(
wsdl_url='./eopayment/resource/PaiementSecuriseService.wsdl',
zeep_client_kwargs={'plugins': [zeep_history_plugin]},
)
payfip = PayFiP(wsdl_url='./eopayment/resource/PaiementSecuriseService.wsdl',
zeep_client_kwargs={'plugins': [history]})
yield payfip
@pytest.fixture
def payfip_history(history, payfip, zeep_history_plugin, request):
yield
# add @pytest.mark.update_data to test to update fixtures data
if 'update_data' in request.keywords:
history_path = 'tests/data/payfip-%s.json' % history_name
d = [
(xmlindent(exchange['sent']['envelope']),
xmlindent(exchange['received']['envelope']))
for exchange in history._buffer
]
content = json.dumps(d)
with open(history_path, 'wb') as fd:
fd.write(content)
def set_history_name(name):
@ -143,20 +137,18 @@ def set_history_name(name):
def decorator(func):
func.history_name = name
return func
return decorator
# pytestmark = pytest.mark.update_data
def test_get_client_info(history, payfip):
def test_get_client_info(payfip):
result = payfip.get_info_client(NUMCLI)
assert result.numcli == NUMCLI
assert result.libelleN2 == 'POUETPOUET'
def test_get_idop_ok(history, payfip):
def test_get_idop_ok(payfip):
result = payfip.get_idop(
numcli=NUMCLI,
exer='2019',
@ -166,12 +158,11 @@ def test_get_idop_ok(history, payfip):
objet='coucou',
url_notification=NOTIF_URL,
url_redirect=REDIRECT_URL,
saisie='T',
)
saisie='T')
assert result == 'cc0cb210-1cd4-11ea-8cca-0213ad91a103'
def test_get_idop_refdet_error(history, payfip):
def test_get_idop_refdet_error(payfip):
with pytest.raises(PayFiPError, match='.*R3.*Le format.*REFDET.*conforme'):
payfip.get_idop(
numcli=NUMCLI,
@ -182,11 +173,10 @@ def test_get_idop_refdet_error(history, payfip):
objet='coucou',
url_notification='https://notif.payfip.example.com/',
url_redirect='https://redirect.payfip.example.com/',
saisie='T',
)
saisie='T')
def test_get_idop_adresse_mel_incorrect(payfip, payfip_history):
def test_get_idop_adresse_mel_incorrect(payfip):
with pytest.raises(PayFiPError, match='.*A2.*Adresse.*incorrecte'):
payfip.get_idop(
numcli=NUMCLI,
@ -197,11 +187,10 @@ def test_get_idop_adresse_mel_incorrect(payfip, payfip_history):
objet='coucou',
url_notification='https://notif.payfip.example.com/',
url_redirect='https://redirect.payfip.example.com/',
saisie='T',
)
saisie='T')
def test_get_info_paiement_ok(history, payfip):
def test_get_info_paiement_ok(payfip):
result = payfip.get_info_paiement('cc0cb210-1cd4-11ea-8cca-0213ad91a103')
assert {k: result[k] for k in result} == {
'dattrans': '12122019',
@ -215,11 +204,11 @@ def test_get_info_paiement_ok(history, payfip):
'objet': 'coucou',
'refdet': 'EFEFAEFG',
'resultrans': 'V',
'saisie': 'T',
'saisie': 'T'
}
def test_get_info_paiement_P1(history, payfip, freezer):
def test_get_info_paiement_P1(payfip, freezer):
# idop par pas encore reçu par la plate-forme ou déjà nettoyé (la nuit)
with pytest.raises(PayFiPError, match='.*P1.*IdOp incorrect.*'):
payfip.get_info_paiement('cc0cb210-1cd4-11ea-8cca-0213ad91a103')
@ -227,7 +216,6 @@ def test_get_info_paiement_P1(history, payfip, freezer):
@set_history_name('test_get_info_paiement_P1')
def test_P1_and_payment_status(history, backend):
assert backend.has_payment_status
response = backend.payment_status(transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103')
assert response.result == eopayment.EXPIRED
@ -235,18 +223,14 @@ def test_P1_and_payment_status(history, backend):
@set_history_name('test_get_info_paiement_P1')
def test_P1_and_payment_status_utc_aware_now(history, backend):
utc_now = datetime.datetime.now(pytz.utc)
response = backend.payment_status(
transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=utc_now
)
response = backend.payment_status(transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=utc_now)
assert response.result == eopayment.EXPIRED
@set_history_name('test_get_info_paiement_P1')
def test_P1_and_payment_status_utc_naive_now(history, backend):
now = datetime.datetime.now()
response = backend.payment_status(
transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=now
)
response = backend.payment_status(transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=now)
assert response.result == eopayment.EXPIRED
@ -254,100 +238,84 @@ def test_P1_and_payment_status_utc_naive_now(history, backend):
def test_P1_and_payment_status_utc_aware_now_later(history, backend, freezer):
utc_now = datetime.datetime.now(pytz.utc)
freezer.move_to(datetime.timedelta(minutes=25))
response = backend.payment_status(
transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=utc_now
)
response = backend.payment_status(transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=utc_now)
assert response.result == eopayment.EXPIRED
@set_history_name('test_get_info_paiement_P1')
def test_P1_and_payment_status_utc_naive_now_later(history, payfip, backend, freezer):
def test_P1_and_payment_status_utc_naive_now_later(payfip, backend, freezer):
now = datetime.datetime.now()
freezer.move_to(datetime.timedelta(minutes=25))
response = backend.payment_status(
transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=now
)
response = backend.payment_status(transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=now)
assert response.result == eopayment.EXPIRED
def test_get_info_paiement_P5(history, payfip):
def test_get_info_paiement_P5(payfip):
# idop reçu par la plate-forme mais transaction en cours
with pytest.raises(PayFiPError, match='.*P5.*sultat de la transaction non connu.*'):
payfip.get_info_paiement('cc0cb210-1cd4-11ea-8cca-0213ad91a103')
@set_history_name('test_get_info_paiement_P5')
def test_P5_and_payment_status(history, payfip, backend, freezer):
def test_P5_and_payment_status(payfip, backend, freezer):
response = backend.payment_status(transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103')
assert response.result == eopayment.WAITING
@set_history_name('test_get_info_paiement_P5')
def test_P5_and_payment_status_utc_aware_now(history, payfip, backend, freezer):
def test_P5_and_payment_status_utc_aware_now(payfip, backend, freezer):
utc_now = datetime.datetime.now(pytz.utc)
response = backend.payment_status(
transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=utc_now
)
response = backend.payment_status(transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=utc_now)
assert response.result == eopayment.WAITING
@set_history_name('test_get_info_paiement_P5')
def test_P5_and_payment_status_utc_naive_now(history, payfip, backend, freezer):
def test_P5_and_payment_status_utc_naive_now(payfip, backend, freezer):
now = datetime.datetime.now()
response = backend.payment_status(
transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=now
)
response = backend.payment_status(transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=now)
assert response.result == eopayment.WAITING
@set_history_name('test_get_info_paiement_P5')
def test_P5_and_payment_status_utc_aware_now_later(history, payfip, backend, freezer):
def test_P5_and_payment_status_utc_aware_now_later(payfip, backend, freezer):
utc_now = datetime.datetime.now(pytz.utc)
freezer.move_to(datetime.timedelta(minutes=25))
response = backend.payment_status(
transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=utc_now
)
response = backend.payment_status(transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=utc_now)
assert response.result == eopayment.EXPIRED
@set_history_name('test_get_info_paiement_P5')
def test_P5_and_payment_status_utc_naive_now_later(history, payfip, backend, freezer):
def test_P5_and_payment_status_utc_naive_now_later(payfip, backend, freezer):
now = datetime.datetime.now()
freezer.move_to(datetime.timedelta(minutes=25))
response = backend.payment_status(
transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=now
)
response = backend.payment_status(transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=now)
assert response.result == eopayment.EXPIRED
def test_payment_ok(history, payfip, backend):
def test_payment_ok(payfip, backend):
payment_id, kind, url = backend.request(
amount='10.00',
email=MEL,
# make test deterministic
refdet='201912261758460053903194',
)
refdet='201912261758460053903194')
assert payment_id == 'cc0cb210-1cd4-11ea-8cca-0213ad91a103'
assert kind == eopayment.URL
assert url == 'https://www.payfip.gouv.fr/tpa/paiementws.web?idop=cc0cb210-1cd4-11ea-8cca-0213ad91a103'
assert url == 'https://www.tipi.budget.gouv.fr/tpa/paiementws.web?idop=cc0cb210-1cd4-11ea-8cca-0213ad91a103'
response = backend.response('idop=%s' % payment_id)
assert response.result == eopayment.PAID
assert response.bank_status == 'paid CB'
assert response.order_id == payment_id
assert response.transaction_id == (
'201912261758460053903194 cc0cb210-1cd4-11ea-8cca-0213ad91a103 112233445566-tip'
)
'201912261758460053903194 cc0cb210-1cd4-11ea-8cca-0213ad91a103 112233445566-tip')
@set_history_name('test_payment_ok')
def test_payment_status_ok(history, backend, freezer):
history.counter = 1 # only the response
now = datetime.datetime.now()
response = backend.payment_status(
transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=now
)
response = backend.payment_status(transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=now)
assert response.result == eopayment.PAID
@ -356,12 +324,11 @@ def test_payment_denied(history, backend):
amount='10.00',
email=MEL,
# make test deterministic
refdet='201912261758460053903194',
)
refdet='201912261758460053903194')
assert payment_id == 'cc0cb210-1cd4-11ea-8cca-0213ad91a103'
assert kind == eopayment.URL
assert url == 'https://www.payfip.gouv.fr/tpa/paiementws.web?idop=cc0cb210-1cd4-11ea-8cca-0213ad91a103'
assert url == 'https://www.tipi.budget.gouv.fr/tpa/paiementws.web?idop=cc0cb210-1cd4-11ea-8cca-0213ad91a103'
response = backend.response('idop=%s' % payment_id)
assert response.result == eopayment.DENIED
@ -374,9 +341,7 @@ def test_payment_denied(history, backend):
def test_payment_status_denied(history, backend, freezer):
history.counter = 1 # only the response
now = datetime.datetime.now()
response = backend.payment_status(
transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=now
)
response = backend.payment_status(transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=now)
assert response.result == eopayment.DENIED
@ -385,16 +350,15 @@ def test_payment_cancelled(history, backend):
amount='10.00',
email=MEL,
# make test deterministic
refdet='201912261758460053903194',
)
refdet='201912261758460053903194')
assert payment_id == 'cc0cb210-1cd4-11ea-8cca-0213ad91a103'
assert kind == eopayment.URL
assert url == 'https://www.payfip.gouv.fr/tpa/paiementws.web?idop=cc0cb210-1cd4-11ea-8cca-0213ad91a103'
assert url == 'https://www.tipi.budget.gouv.fr/tpa/paiementws.web?idop=cc0cb210-1cd4-11ea-8cca-0213ad91a103'
response = backend.response('idop=%s' % payment_id)
assert response.result == eopayment.WAITING
assert response.bank_status == 'cancelled CB - still waiting as idop is still active'
assert response.result == eopayment.CANCELLED
assert response.bank_status == 'cancelled CB'
assert response.order_id == payment_id
assert response.transaction_id == '201912261758460053903194 cc0cb210-1cd4-11ea-8cca-0213ad91a103'
@ -403,20 +367,8 @@ def test_payment_cancelled(history, backend):
def test_payment_status_cancelled(history, backend, freezer):
history.counter = 1 # only the response
now = datetime.datetime.now()
response = backend.payment_status(
transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=now
)
assert response.result == eopayment.WAITING
assert response.bank_status == 'cancelled CB - still waiting as idop is still active'
freezer.move_to(datetime.timedelta(minutes=20, seconds=1))
history.counter = 1 # only the response
response = backend.payment_status(
transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=now
)
response = backend.payment_status(transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=now)
assert response.result == eopayment.CANCELLED
assert response.bank_status == 'cancelled CB'
def test_normalize_objet():
@ -433,8 +385,7 @@ def test_refdet_exer(get_idop, backend):
email=MEL,
# make test deterministic
exer=EXER,
refdet=REFDET,
)
refdet=REFDET)
assert payment_id == 'idop-1234'
kwargs = get_idop.call_args[1]
@ -460,8 +411,7 @@ def test_transaction_id_orderid_subject(get_idop, backend):
exer=EXER,
transaction_id='TR12345',
orderid='F20190003',
subject='Précompte famille #1234',
)
subject='Précompte famille #1234')
assert payment_id == 'idop-1234'
kwargs = get_idop.call_args[1]
@ -487,8 +437,7 @@ def test_invalid_transaction_id_valid_orderid(get_idop, backend):
exer=EXER,
transaction_id='TR-12345',
orderid='F20190003',
subject='Précompte famille #1234',
)
subject='Précompte famille #1234')
assert payment_id == 'idop-1234'
kwargs = get_idop.call_args[1]
@ -514,8 +463,7 @@ def test_invalid_transaction_id_invalid_orderid(get_idop, backend):
exer=EXER,
transaction_id='TR-12345',
orderid='F/20190003',
subject='Précompte famille #1234',
)
subject='Précompte famille #1234')
assert payment_id == 'idop-1234'
kwargs = get_idop.call_args[1]
@ -531,26 +479,3 @@ def test_invalid_transaction_id_invalid_orderid(get_idop, backend):
'url_notification': NOTIF_URL,
'url_redirect': REDIRECT_URL,
}
def test_get_min_time_between_transactions(backend):
assert backend.get_min_time_between_transactions() == 20 * 60
def test_get_minimal_amount(backend):
assert backend.get_minimal_amount() is not None
def test_get_maximal_amount(backend):
assert backend.get_maximal_amount() is not None
def test_request_error(payfip, backend):
with pytest.raises(PayFiPError):
with httmock.HTTMock(raise_on_request):
payment_id, kind, url = backend.request(
amount='10.00',
email=MEL,
# make test deterministic
refdet='201912261758460053903194',
)

View File

@ -1,3 +1,4 @@
# coding: utf-8
#
# eopayment - online payment library
# Copyright (C) 2011-2020 Entr'ouvert
@ -15,10 +16,12 @@
# 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/>.
from __future__ import print_function, unicode_literals
import json
import pytest
import zeep.transports
import eopayment
@ -41,20 +44,13 @@ def saga(record_http_session):
@pytest.fixture
def backend_factory(saga, target_url):
def factory(**kwargs):
return eopayment.Payment(
'saga',
dict(
{
'base_url': target_url,
'num_service': '868',
'compte': '70688',
'automatic_return_url': 'https://automatic.notif.url/automatic/',
'normal_return_url': 'https://normal.notif.url/normal/',
},
**kwargs,
),
)
return eopayment.Payment('saga', dict({
'base_url': target_url,
'num_service': '868',
'compte': '70688',
'automatic_return_url': 'https://automatic.notif.url/automatic/',
'normal_return_url': 'https://normal.notif.url/normal/',
}, **kwargs))
return factory
@ -62,19 +58,19 @@ def test_error_parametrage(backend_factory):
payment = backend_factory(num_service='1', compte='1')
with pytest.raises(eopayment.PaymentException, match='Impossible de déterminer le paramétrage'):
transaction_id, kind, url = payment.request(
amount='10.00', email='john.doe@example.com', subject='Réservation concert XYZ numéro 1234'
)
amount='10.00',
email='john.doe@example.com',
subject='Réservation concert XYZ numéro 1234')
def test_request(backend_factory):
transaction_id, kind, url = backend_factory().request(
amount='10.00', email='john.doe@example.com', subject='Réservation concert XYZ numéro 1234'
)
amount='10.00',
email='john.doe@example.com',
subject='Réservation concert XYZ numéro 1234')
assert transaction_id == '347b2060-1a37-11eb-af92-0213ad91a103'
assert kind == eopayment.URL
assert (
url == 'https://www.tipi.budget.gouv.fr/tpa/paiementws.web?idop=347b2060-1a37-11eb-af92-0213ad91a103'
)
assert url == 'https://www.tipi.budget.gouv.fr/tpa/paiementws.web?idop=347b2060-1a37-11eb-af92-0213ad91a103'
def test_response(backend_factory):
@ -87,7 +83,7 @@ def test_response(backend_factory):
'montant': '10.00',
'num_service': '868',
'numcp': '70688',
'numcpt_lib_ecriture': 'COUCOU',
'numcpt_lib_ecriture': 'COUCOU'
},
'bank_status': 'paid',
'order_id': '28b52f40-1ace-11eb-8ce3-0213ad91a104',
@ -100,3 +96,4 @@ def test_response(backend_factory):
}
# Check bank_data is JSON serializable
json.dumps(response.bank_data)

View File

@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# eopayment - online payment library
# Copyright (C) 2011-2020 Entr'ouvert
#
@ -15,32 +16,32 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import pytest
import eopayment
import pytest
def test_build_request():
backend = eopayment.Payment('sips2', {})
transaction, f, form = backend.request(amount='12', last_name='Foo', first_name='Félix000000')
transaction, f, form = backend.request(amount=u'12', last_name=u'Foo',
first_name=u'Félix000000')
data = [f for f in form.fields if f['name'] == 'Data']
assert 'lix000000' not in data[0]['value']
assert u'lix000000' not in data[0]['value']
transaction, f, form = backend.request(amount='12')
transaction, f, form = backend.request(amount=u'12')
data = [f for f in form.fields if f['name'] == 'Data']
assert 'statementReference=%s' % transaction in data[0]['value']
transaction, f, form = backend.request(amount='12', info1='foobar')
transaction, f, form = backend.request(amount=u'12', info1='foobar')
data = [f for f in form.fields if f['name'] == 'Data']
assert 'statementReference=foobar' in data[0]['value']
transaction, f, form = backend.request(amount='12', info1='foobar', capture_day='1')
transaction, f, form = backend.request(amount=u'12', info1='foobar', capture_day=u'1')
data = [f for f in form.fields if f['name'] == 'Data']
assert 'captureDay=1' in data[0]['value']
def test_options():
payment = eopayment.Payment('sips2', {'capture_mode': 'VALIDATION'})
payment = eopayment.Payment('sips2', {'capture_mode': u'VALIDATION'})
assert payment.backend.get_data()['captureMode'] == 'VALIDATION'
payment = eopayment.Payment('sips2', {})

View File

@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# eopayment - online payment library
# Copyright (C) 2011-2020 Entr'ouvert
#
@ -16,20 +17,21 @@
from datetime import datetime, timedelta
from urllib import parse as urlparse
import pytest
from six.moves.urllib import parse as urlparse
import eopayment
from eopayment.systempayv2 import Payment, VADS_CUST_FIRST_NAME, \
VADS_CUST_LAST_NAME, PAID
from eopayment import ResponseError
from eopayment.systempayv2 import PAID, VADS_CUST_FIRST_NAME, VADS_CUST_LAST_NAME, Payment
PARAMS = {
'secret_test': '1122334455667788',
'vads_site_id': '12345678',
'vads_ctx_mode': 'TEST',
'vads_trans_date': '20090501193530',
'signature_algo': 'sha1',
'secret_test': u'1122334455667788',
'vads_site_id': u'12345678',
'vads_ctx_mode': u'TEST',
'vads_trans_date': u'20090501193530',
'signature_algo': 'sha1'
}
@ -47,7 +49,12 @@ def get_field(form, field_name):
def test_systempayv2(caplog):
caplog.set_level(0)
p = Payment(PARAMS)
data = {'amount': 15.24, 'orderid': '654321', 'first_name': 'Jean Michél', 'last_name': 'Mihaï'}
data = {
'amount': 15.24,
'orderid': '654321',
'first_name': u'Jean Michél',
'last_name': u'Mihaï'
}
qs = (
'vads_version=V2&vads_page_action=PAYMENT&vads_action_mode=INTERACTIV'
'E&vads_payment_config=SINGLE&vads_site_id=12345678&vads_ctx_mode=TES'
@ -63,20 +70,18 @@ def test_systempayv2(caplog):
# check that user first and last names are unicode
for field in form.fields:
if field['name'] in (VADS_CUST_FIRST_NAME, VADS_CUST_LAST_NAME):
assert field['value'] in ('Jean Michél', 'Mihaï')
assert field['value'] in (u'Jean Michél', u'Mihaï')
response_qs = (
'vads_amount=1042&vads_auth_mode=FULL&vads_auth_number=3feadf'
'&vads_auth_result=00&vads_capture_delay=0&vads_card_brand=CB'
'&vads_result=00'
'&vads_card_number=497010XXXXXX0000'
'&vads_payment_certificate=582ba2b725057618706d7a06e9e59acdbf69ff53'
'&vads_ctx_mode=TEST&vads_currency=978&vads_effective_amount=1042'
'&vads_site_id=70168983&vads_trans_date=20161013101355'
'&vads_trans_id=226787&vads_trans_uuid=4b5053b3b1fe4b02a07753e7a'
'&vads_effective_creation_date=20200330162530'
'&signature=c17fab393f94dc027dc029510c85d5fc46c4710f'
)
response_qs = 'vads_amount=1042&vads_auth_mode=FULL&vads_auth_number=3feadf' \
'&vads_auth_result=00&vads_capture_delay=0&vads_card_brand=CB' \
'&vads_result=00' \
'&vads_card_number=497010XXXXXX0000' \
'&vads_payment_certificate=582ba2b725057618706d7a06e9e59acdbf69ff53' \
'&vads_ctx_mode=TEST&vads_currency=978&vads_effective_amount=1042' \
'&vads_site_id=70168983&vads_trans_date=20161013101355' \
'&vads_trans_id=226787&vads_trans_uuid=4b5053b3b1fe4b02a07753e7a' \
'&vads_effective_creation_date=20200330162530' \
'&signature=c17fab393f94dc027dc029510c85d5fc46c4710f'
response = p.response(response_qs)
assert response.result == PAID
assert response.signed
@ -86,18 +91,16 @@ def test_systempayv2(caplog):
PARAMS['signature_algo'] = 'hmac_sha256'
p = Payment(PARAMS)
assert p.signature(qs) == 'aHrJ7IzSGFa4pcYA8kh99+M/xBzoQ4Odnu3f4BUrpIA='
response_qs = (
'vads_amount=1042&vads_auth_mode=FULL&vads_auth_number=3feadf'
'&vads_result=00'
'&vads_auth_result=00&vads_capture_delay=0&vads_card_brand=CB'
'&vads_card_number=497010XXXXXX0000'
'&vads_payment_certificate=582ba2b725057618706d7a06e9e59acdbf69ff53'
'&vads_ctx_mode=TEST&vads_currency=978&vads_effective_amount=1042'
'&vads_site_id=70168983&vads_trans_date=20161013101355'
'&vads_trans_id=226787&vads_trans_uuid=4b5053b3b1fe4b02a07753e7a'
'&vads_effective_creation_date=20200330162530'
'&signature=Wbz3bP6R6wDvAwb2HnSiH9%2FiUUoRVCxK7mdLtCMz8Xw%3D'
)
response_qs = 'vads_amount=1042&vads_auth_mode=FULL&vads_auth_number=3feadf' \
'&vads_result=00' \
'&vads_auth_result=00&vads_capture_delay=0&vads_card_brand=CB' \
'&vads_card_number=497010XXXXXX0000' \
'&vads_payment_certificate=582ba2b725057618706d7a06e9e59acdbf69ff53' \
'&vads_ctx_mode=TEST&vads_currency=978&vads_effective_amount=1042' \
'&vads_site_id=70168983&vads_trans_date=20161013101355' \
'&vads_trans_id=226787&vads_trans_uuid=4b5053b3b1fe4b02a07753e7a' \
'&vads_effective_creation_date=20200330162530' \
'&signature=Wbz3bP6R6wDvAwb2HnSiH9%2FiUUoRVCxK7mdLtCMz8Xw%3D'
response = p.response(response_qs)
assert response.result == PAID
assert response.signed
@ -109,11 +112,14 @@ def test_systempayv2(caplog):
def test_systempayv2_deferred_payment():
default_params = {
'secret_test': '1122334455667788',
'vads_site_id': '12345678',
'vads_ctx_mode': 'TEST',
'secret_test': u'1122334455667788',
'vads_site_id': u'12345678',
'vads_ctx_mode': u'TEST',
}
default_data = {
'amount': 15.24, 'orderid': '654321', 'first_name': u'John',
'last_name': u'Doe'
}
default_data = {'amount': 15.24, 'orderid': '654321', 'first_name': 'John', 'last_name': 'Doe'}
# default vads_capture_delay used
params = default_params.copy()
@ -139,18 +145,21 @@ def test_systempayv2_deferred_payment():
params['vads_capture_delay'] = 1
p = eopayment.Payment('systempayv2', params)
data = default_data.copy()
data['capture_date'] = datetime.now().date() + timedelta(days=4)
data['capture_date'] = (datetime.now().date() + timedelta(days=4))
transaction_id, f, form = p.request(**data)
assert get_field(form, 'vads_capture_delay')['value'] == '4'
def test_manual_validation():
params = {
'secret_test': '1122334455667788',
'vads_site_id': '12345678',
'vads_ctx_mode': 'TEST',
'secret_test': u'1122334455667788',
'vads_site_id': u'12345678',
'vads_ctx_mode': u'TEST',
}
data = {
'amount': 15.24, 'orderid': '654321', 'first_name': u'John',
'last_name': u'Doe'
}
data = {'amount': 15.24, 'orderid': '654321', 'first_name': 'John', 'last_name': 'Doe'}
backend = eopayment.Payment('systempayv2', params)
transaction_id, f, form = backend.request(**data.copy())
@ -164,10 +173,8 @@ def test_manual_validation():
transaction_id, f, form = backend.request(**data.copy())
assert get_field(form, 'vads_validation_mode')['value'] == ''
FIXED_TRANSACTION_ID = '1234'
def test_transaction_id_request(backend):
transaction_id, kind, form = backend.request(10.0, transaction_id=FIXED_TRANSACTION_ID)
assert transaction_id == FIXED_TRANSACTION_ID

View File

@ -16,13 +16,13 @@
import datetime
from decimal import Decimal
from urllib.parse import parse_qs, urlparse
from six.moves.urllib.parse import urlparse, parse_qs
import pytest
import pytz
import eopayment
import eopayment.tipi
import pytest
def test_tipi():
@ -34,8 +34,7 @@ def test_tipi():
objet='tout a fait',
email='info@entrouvert.com',
urlcl='http://example.com/tipi/test',
saisie='T',
)
saisie='T')
assert eopayment.tipi.Payment.REFDET_RE.match(payment_id) is not None
parsed_qs = parse_qs(urlparse(url).query)
assert parsed_qs['objet'][0].startswith('tout a fait')
@ -48,8 +47,7 @@ def test_tipi():
response = p.response(
'objet=tout+a+fait&montant=12312&saisie=T&mel=info%40entrouvert.com'
'&numcli=12345&exer=9999&refdet=999900000000999999&resultrans=P'
)
'&numcli=12345&exer=9999&refdet=999900000000999999&resultrans=P')
assert response.signed # ...
assert response.order_id == '999900000000999999'
assert response.transaction_id == '999900000000999999'
@ -62,8 +60,10 @@ def test_tipi():
def test_tipi_no_orderid_no_refdet():
p = eopayment.Payment('tipi', {'numcli': '12345'})
payment_id, kind, url = p.request(
amount=Decimal('123.12'), exer=9999, email='info@entrouvert.com', saisie='T'
)
amount=Decimal('123.12'),
exer=9999,
email='info@entrouvert.com',
saisie='T')
assert eopayment.tipi.Payment.REFDET_RE.match(payment_id) is not None
parsed_qs = parse_qs(urlparse(url).query)
assert 'objet' not in parsed_qs
@ -72,16 +72,15 @@ def test_tipi_no_orderid_no_refdet():
assert parsed_qs['mel'] == ['info@entrouvert.com']
assert parsed_qs['numcli'] == ['12345']
assert parsed_qs['exer'] == ['9999']
assert parsed_qs['refdet'][0].startswith(
datetime.datetime.now(pytz.timezone('Europe/Paris')).strftime('%Y%m%d')
)
assert parsed_qs['refdet'][0].startswith(datetime.datetime.now(pytz.timezone('Europe/Paris')).strftime('%Y%m%d'))
def test_tipi_orderid_refdef_compatible():
p = eopayment.Payment('tipi', {'numcli': '12345', 'saisie': 'A'})
payment_id, kind, url = p.request(
amount=Decimal('123.12'), email='info@entrouvert.com', orderid='F121212'
)
amount=Decimal('123.12'),
email='info@entrouvert.com',
orderid='F121212')
assert eopayment.tipi.Payment.REFDET_RE.match(payment_id)
expected_url = urlparse(eopayment.tipi.TIPI_URL)
parsed_url = urlparse(url)
@ -99,8 +98,10 @@ def test_tipi_orderid_refdef_compatible():
def test_tipi_orderid_not_refdef_compatible():
p = eopayment.Payment('tipi', {'numcli': '12345', 'saisie': 'A'})
payment_id, kind, url = p.request(
amount=Decimal('123.12'), email='info@entrouvert.com', objet='coucou', orderid='F12-12-12'
)
amount=Decimal('123.12'),
email='info@entrouvert.com',
objet='coucou',
orderid='F12-12-12')
assert eopayment.tipi.Payment.REFDET_RE.match(payment_id) is not None
expected_url = urlparse(eopayment.tipi.TIPI_URL)
parsed_url = urlparse(url)
@ -114,16 +115,3 @@ def test_tipi_orderid_not_refdef_compatible():
assert parsed_qs['refdet'][0].startswith(datetime.datetime.now().strftime('%Y%m%d'))
assert 'coucou' in parsed_qs['objet'][0]
assert 'F12-12-12' in parsed_qs['objet'][0]
@pytest.fixture
def payment():
return eopayment.Payment('tipi', {'numcli': '12345'})
def test_get_minimal_amount(payment):
assert payment.get_minimal_amount() is not None
def test_get_maximal_amount(payment):
assert payment.get_maximal_amount() is not None

View File

@ -4,7 +4,7 @@
# and then run "tox" from this directory.
[tox]
envlist = py3,codestyle
envlist = py2,py3
toxworkdir = {env:TMPDIR:/tmp}/tox-{env:USER}/eopayment/{env:BRANCH_NAME:}
[testenv]
@ -12,8 +12,8 @@ toxworkdir = {env:TMPDIR:/tmp}/tox-{env:USER}/eopayment/{env:BRANCH_NAME:}
setenv =
SETUPTOOLS_USE_DISTUTILS=stdlib
commands =
py3: py.test {posargs: --junitxml=junit-{envname}.xml --cov-report xml --cov-report html --cov-config .coveragerc --cov=eopayment/ tests}
codestyle: pre-commit run --all-files --show-diff-on-failure
py2: py.test {posargs: --junitxml=junit-{envname}.xml --cov-report xml --cov-report html --cov=eopayment/ tests}
py3: py.test {posargs: --junitxml=junit-{envname}.xml --cov-report xml --cov-report html --cov=eopayment/ tests}
usedevelop = True
deps = coverage
pytest
@ -22,7 +22,6 @@ deps = coverage
mock<4
httmock
lxml
pre-commit
[pytest]
filterwarnings =