# 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 . import os.path import os import random import logging from datetime import date from decimal import ROUND_DOWN, Decimal import six if six.PY3: import html else: import cgi __all__ = ['PaymentCommon', 'URL', 'HTML', 'RANDOM', 'RECEIVED', 'ACCEPTED', 'PAID', 'ERROR', 'WAITING'] RANDOM = random.SystemRandom() URL = 1 HTML = 2 FORM = 3 RECEIVED = 1 ACCEPTED = 2 PAID = 3 DENIED = 4 CANCELLED = 5 CANCELED = 5 # typo for backward compatibility WAITING = 6 ERROR = 99 # separator between order and transaction ids ORDERID_TRANSACTION_SEPARATOR = '!' def force_text(s, encoding='utf-8'): if issubclass(type(s), six.text_type): return s try: 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 = six.text_type(bytes(s), encoding) else: s = s.decode(encoding) except UnicodeDecodeError: return six.text_type(s, encoding, 'ignore') return s def force_byte(s, encoding='utf-8'): if isinstance(s, bytes): return s try: return s.encode(encoding) except UnicodeEncodeError: return s.encode() class PaymentException(Exception): pass class ResponseError(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. ''' 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 self.return_content = return_content self.bank_status = bank_status self.transaction_id = transaction_id self.order_id = order_id self.test = test self.transaction_date = transaction_date def __repr__(self): return '<%s %s>' % (self.__class__.__name__, repr(self.__dict__)) def is_received(self): return self.result == RECEIVED def is_accepted(self): return self.result == ACCEPTED def is_paid(self): return self.result == PAID def is_error(self): return self.result == ERROR class PaymentCommon(object): has_free_transaction_id = False PATH = '/tmp' BANK_ID = '__bank_id' def __init__(self, options, logger=None): self.logger = logger or logging.getLogger(self.__class__.__module__) self.logger.debug('initializing with options %s', options) for value in self.description['parameters']: key = value['name'] if 'default' in value: setattr(self, key, options.get(key, None) or value['default']) else: setattr(self, key, options.get(key)) def transaction_id(self, length, choices, *prefixes): 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)) try: fd = os.open(os.path.join(self.PATH, name), os.O_CREAT | os.O_EXCL) except Exception: raise else: os.close(fd) return id @staticmethod def clean_amount(amount, min_amount=0, max_amount=None, cents=True): 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('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) 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 self.encoding = encoding self.submit_name = submit_name self.submit_value = submit_value def __repr__(self): s = '%s(' % self.__class__.__name__ kwargs = [] for k in ('url', 'method', 'fields', 'encoding', 'submit_name', 'submit_value'): kwargs.append('%s=%r' % (k, getattr(self, k))) s += ', '.join(kwargs) s += ')' return s def escape(self, s): 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 = '
' % (self.method, self.url) for field in self.fields: s += '\n ' % (self.escape(field['type']), self.escape(field['name']), self.escape(field['value'])) s += '\n