205 lines
6.7 KiB
Python
205 lines
6.7 KiB
Python
# eopayment - online payment library
|
|
# Copyright (C) 2011-2020 Entr'ouvert
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify it
|
|
# under the terms of the GNU Affero General Public License as published
|
|
# by the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU Affero General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License
|
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
import datetime
|
|
import decimal
|
|
import logging
|
|
import random
|
|
import re
|
|
import warnings
|
|
|
|
import pytz
|
|
from six.moves.urllib.parse import parse_qs, urlencode
|
|
|
|
from .common import CANCELLED, DENIED, ERROR, PAID, URL, PaymentCommon, PaymentResponse, ResponseError, _
|
|
|
|
__all__ = ['Payment']
|
|
|
|
TIPI_URL = 'https://www.payfip.gouv.fr/tpa/paiement.web'
|
|
LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
class Payment(PaymentCommon):
|
|
"""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'),
|
|
'validation': lambda s: str.isdigit(s) and (0 < int(s) < 1000000),
|
|
'required': True,
|
|
},
|
|
{
|
|
'name': 'normal_return_url',
|
|
'caption': _('Normal return URL (unused by TIPI)'),
|
|
'required': False,
|
|
},
|
|
{
|
|
'name': 'automatic_return_url',
|
|
'caption': _('Automatic return URL'),
|
|
'required': True,
|
|
},
|
|
{
|
|
'name': 'saisie',
|
|
'caption': _('Payment type'),
|
|
'required': True,
|
|
'default': 'T',
|
|
'choices': [
|
|
('T', _('test')),
|
|
('X', _('production')),
|
|
('A', _('with user account')),
|
|
('M', _('manual entry')),
|
|
],
|
|
},
|
|
],
|
|
}
|
|
|
|
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),
|
|
)
|
|
|
|
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,
|
|
)
|
|
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'):
|
|
raise ValueError('URLCL invalid URL format')
|
|
try:
|
|
if exer is not None:
|
|
exer = int(exer)
|
|
if exer > 9999:
|
|
raise ValueError()
|
|
except ValueError:
|
|
raise ValueError('EXER format invalide')
|
|
assert not (orderid and refdet), 'orderid and refdet cannot be used together'
|
|
# check or generate refdet
|
|
if refdet:
|
|
try:
|
|
if not self.REFDET_RE.match(refdet):
|
|
raise ValueError
|
|
except (TypeError, ValueError):
|
|
raise ValueError('refdet must be 6 to 30 alphanumeric characters string')
|
|
if orderid:
|
|
if self.REFDET_RE.match(orderid):
|
|
refdet = orderid
|
|
else:
|
|
objet = orderid + (' ' + objet) if objet else ''
|
|
if not refdet:
|
|
refdet = self._generate_refdet()
|
|
transaction_id = refdet
|
|
# check objet or fix objet
|
|
if objet is not None:
|
|
try:
|
|
objet = objet.encode('ascii')
|
|
except Exception as e:
|
|
raise ValueError('OBJET must be an alphanumeric string', e)
|
|
try:
|
|
mel = str(email)
|
|
if '@' not in mel:
|
|
raise ValueError('no @ in MEL')
|
|
if not (6 <= len(mel) <= 80):
|
|
raise ValueError('len(MEL) is invalid, must be between 6 and 80')
|
|
except Exception as e:
|
|
raise ValueError('MEL is not a valid email, %r' % mel, e)
|
|
|
|
# check saisie
|
|
saisie = saisie or self.saisie
|
|
if saisie not in ('M', 'T', 'X', 'A'):
|
|
raise ValueError('SAISIE invalid format, %r, must be M, T, X or A' % saisie)
|
|
|
|
params = {
|
|
'numcli': self.numcli,
|
|
'refdet': refdet,
|
|
'montant': montant,
|
|
'mel': mel,
|
|
'saisie': saisie,
|
|
}
|
|
if exer:
|
|
params['exer'] = exer
|
|
if objet:
|
|
params['objet'] = objet
|
|
if automatic_return_url:
|
|
params['urlcl'] = automatic_return_url
|
|
url = '%s?%s' % (TIPI_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'}:
|
|
raise ResponseError('missing refdet or resultrans')
|
|
for key, value in fields.items():
|
|
fields[key] = value[0]
|
|
refdet = fields.get('refdet')
|
|
if refdet is None:
|
|
raise ResponseError('refdet is missing')
|
|
|
|
result = fields.get('resultrans')
|
|
if result == 'P':
|
|
result = PAID
|
|
bank_status = ''
|
|
elif result == 'R':
|
|
result = DENIED
|
|
bank_status = 'refused'
|
|
elif result == 'A':
|
|
result = CANCELLED
|
|
bank_status = 'canceled'
|
|
else:
|
|
bank_status = 'wrong return: %r' % result
|
|
result = ERROR
|
|
|
|
test = fields.get('saisie') == 'T'
|
|
|
|
return PaymentResponse(
|
|
result=result,
|
|
bank_status=bank_status,
|
|
signed=True,
|
|
bank_data=fields,
|
|
order_id=refdet,
|
|
transaction_id=refdet,
|
|
test=test,
|
|
)
|