diff --git a/addons/payment/models/payment_acquirer.py b/addons/payment/models/payment_acquirer.py index 4777cbc8318..80c84094c30 100644 --- a/addons/payment/models/payment_acquirer.py +++ b/addons/payment/models/payment_acquirer.py @@ -2,6 +2,7 @@ import hashlib import hmac import logging +import datetime from odoo import api, exceptions, fields, models, _ from odoo.tools import consteq, float_round, image_resize_images, ustr, pycompat @@ -428,6 +429,7 @@ class PaymentTransaction(models.Model): acquirer_id = fields.Many2one('payment.acquirer', 'Acquirer', required=True) provider = fields.Selection(string='Provider', related='acquirer_id.provider') type = fields.Selection([ + ('validation', 'Validation of the bank card'), ('server2server', 'Server To Server'), ('form', 'Form'), ('form_save', 'Form with tokenization')], 'Type', @@ -437,6 +439,8 @@ class PaymentTransaction(models.Model): ('pending', 'Pending'), ('authorized', 'Authorized'), ('done', 'Done'), + ('refunding', 'Refunding'), + ('refunded', 'Refunded'), ('error', 'Error'), ('cancel', 'Canceled')], 'Status', copy=False, default='draft', required=True, track_visibility='onchange') @@ -643,6 +647,12 @@ class PaymentTransaction(models.Model): if hasattr(self, custom_method_name): return getattr(self, custom_method_name)(**kwargs) + @api.multi + def s2s_do_refund(self, **kwargs): + custom_method_name = '%s_s2s_do_refund' % self.acquirer_id.provider + if hasattr(self, custom_method_name): + return getattr(self, custom_method_name)(**kwargs) + @api.multi def s2s_capture_transaction(self, **kwargs): custom_method_name = '%s_s2s_capture_transaction' % self.acquirer_id.provider @@ -711,6 +721,7 @@ class PaymentToken(models.Model): acquirer_ref = fields.Char('Acquirer Ref.', required=True) active = fields.Boolean('Active', default=True) payment_ids = fields.One2many('payment.transaction', 'payment_token_id', 'Payment Transactions') + verified = fields.Boolean(string='Verified', default=False) @api.model def create(self, values): @@ -726,9 +737,85 @@ class PaymentToken(models.Model): fields_wl = set(self._fields) & set(values) values = {field: values[field] for field in fields_wl} return super(PaymentToken, self).create(values) + """ + @TBE: stolen shamelessly from there https://www.paypal.com/us/selfhelp/article/why-is-there-a-$1.95-charge-on-my-card-statement-faq554 + Most of them are ~1.50€s + TODO: See this with @AL & @DBO + """ + VALIDATION_AMOUNTS = { + 'CAD': 2.45, + 'EUR': 1.50, + 'GBP': 1.00, + 'JPY': 200, + 'AUD': 2.00, + 'NZD': 3.00, + 'CHF': 3.00, + 'HKD': 15.00, + 'SEK': 15.00, + 'DKK': 12.50, + 'PLN': 6.50, + 'NOK': 15.00, + 'HUF': 400.00, + 'CZK': 50.00, + 'BRL': 4.00, + 'MYR': 10.00, + 'MXN': 20.00, + 'ILS': 8.00, + 'PHP': 100.00, + 'TWD': 70.00, + 'THB': 70.00 + } + + @api.model + def validate(self, **kwargs): + """ + This method allow to verify if this payment method is valid or not. + It does this by withdrawing a certain amount and then refund it right after. + """ + currency = self.partner_id.currency_id + + if self.VALIDATION_AMOUNTS.get(currency.name): + amount = self.VALIDATION_AMOUNTS.get(currency.name) + else: + # If we don't find the user's currency, then we set the currency to EUR and the amount to 1€50. + currency = self.env['res.currency'].search([('name', '=', 'EUR')]) + amount = 1.5 + + if len(currency) != 1: + _logger.error("Error 'EUR' currency not found for payment method validation!") + return False + + reference = "VALIDATION-%s-%s" % (self.id, datetime.datetime.now().strftime('%y%m%d_%H%M%S')) + tx = self.env['payment.transaction'].create({ + 'amount': amount, + 'acquirer_id': self.acquirer_id.id, + 'type': 'validation', + 'currency_id': currency.id, + 'reference': reference, + 'payment_token_id': self.id, + 'partner_id': self.partner_id.id, + 'partner_country_id': self.partner_id.country_id.id, + }) + + try: + kwargs.update({'3d_secure': True}) + tx.s2s_do_transaction(**kwargs) + # if 3D secure is called, then we do not refund right now + if tx.html_3ds: + return tx + except: + _logger.error('Error while validating a payment method') + finally: + tx.s2s_do_refund() + return tx @api.multi @api.depends('name') def _compute_short_name(self): for token in self: token.short_name = token.name.replace('XXXXXXXXXXXX', '***') + + @api.multi + def _get_linked_records(self): + """ Returns the list of records currently using the records in self as payment token. """ + return {} \ No newline at end of file diff --git a/addons/payment/views/payment_templates.xml b/addons/payment/views/payment_templates.xml index 84047969c46..abf18e0cf25 100644 --- a/addons/payment/views/payment_templates.xml +++ b/addons/payment/views/payment_templates.xml @@ -5,4 +5,63 @@ + + diff --git a/addons/payment_ogone/controllers/main.py b/addons/payment_ogone/controllers/main.py index 53150686f25..25a9bd9e48d 100644 --- a/addons/payment_ogone/controllers/main.py +++ b/addons/payment_ogone/controllers/main.py @@ -38,12 +38,34 @@ class OgoneController(http.Controller): error = '' acq = request.env['payment.acquirer'].browse(int(post.get('acquirer_id'))) try: - acq.s2s_process(post) + token = acq.s2s_process(post) except Exception as e: # synthax error: 'CHECK ERROR: |Not a valid date\n\n50001111: None' error = str(e).splitlines()[0].split('|')[-1] or '' + + if token and post.get('verify_validity'): + baseurl = request.env['ir.config_parameter'].sudo().get_param('web.base.url') + params = { + 'accept_url': baseurl + '/payment/ogone/validate/accept', + 'decline_url': baseurl + '/payment/ogone/validate/decline', + 'exception_url': baseurl + '/payment/ogone/validate/exception', + 'return_url': post.get('return_url', baseurl) + } + tx = token.validate(**params) + if tx and tx.html_3ds: + return tx.html_3ds return werkzeug.utils.redirect(post.get('return_url', '/') + (error and '#error=%s' % werkzeug.url_quote(error) or '')) + @http.route([ + '/payment/ogone/validate/accept', + '/payment/ogone/validate/decline', + '/payment/ogone/validate/exception', + ], type='http', auth='none') + def ogone_validation_form_feedback(self, **post): + """ Feedback from 3d secure for a bank card validation """ + request.env['payment.transaction'].sudo().form_feedback(post, 'ogone') + return werkzeug.utils.redirect(werkzeug.url_unquote(post.pop('return_url', '/'))) + @http.route(['/payment/ogone/s2s/feedback'], auth='none', csrf=False) def feedback(self, **kwargs): try: diff --git a/addons/payment_ogone/models/payment.py b/addons/payment_ogone/models/payment.py index 559f119fbbd..0702d38c8bb 100644 --- a/addons/payment_ogone/models/payment.py +++ b/addons/payment_ogone/models/payment.py @@ -8,7 +8,7 @@ from unicodedata import normalize import requests from lxml import etree, objectify -from werkzeug import urls +from werkzeug import urls, url_encode from odoo import api, fields, models, _ from odoo.addons.payment.models.payment_acquirer import ValidationError @@ -280,7 +280,7 @@ class PaymentTxOgone(models.Model): return invalid_parameters def _ogone_form_validate(self, data): - if self.state == 'done': + if self.state in ['done', 'refunded']: _logger.info('Ogone: trying to validate an already validated tx (ref %s)', self.reference) return True @@ -302,7 +302,12 @@ class PaymentTxOgone(models.Model): }) vals.update(payment_token_id=pm.id) self.write(vals) + self.payment_token_id.verified = True self.execute_callback() + # if this transaction is a validation one, then we refund the money we just withdrawn + if self.type == 'verification': + self.s2s_do_refund() + return True elif status in self._ogone_cancel_tx_status: self.write({ @@ -331,12 +336,15 @@ class PaymentTxOgone(models.Model): # -------------------------------------------------- # S2S RELATED METHODS # -------------------------------------------------- - def ogone_s2s_do_transaction(self, **kwargs): # TODO: create tx with s2s type account = self.acquirer_id reference = self.reference or "ODOO-%s-%s" % (datetime.datetime.now().strftime('%y%m%d_%H%M%S'), self.partner_id.id) + param_plus = { + 'return_url': kwargs.get('return_url', False) + } + data = { 'PSPID': account.ogone_pspid, 'USERID': account.ogone_userid, @@ -348,6 +356,7 @@ class PaymentTxOgone(models.Model): 'ECI': 2, # Recurring (from MOTO) 'ALIAS': self.payment_token_id.acquirer_ref, 'RTIMEOUT': 30, + 'PARAMPLUS' : url_encode(param_plus) } if kwargs.get('3d_secure'): @@ -380,19 +389,58 @@ class PaymentTxOgone(models.Model): return self._ogone_s2s_validate_tree(tree) + def ogone_s2s_do_refund(self, **kwargs): + + # we refund only if this transaction hasn't been already refunded and was paid. + if self.state != 'done': + return False + + self.state = 'refunding' + account = self.acquirer_id + reference = self.reference or "ODOO-%s-%s" % (datetime.datetime.now().strftime('%y%m%d_%H%M%S'), self.partner_id.id) + + data = { + 'PSPID': account.ogone_pspid, + 'USERID': account.ogone_userid, + 'PSWD': account.ogone_password, + 'ORDERID': reference, + 'AMOUNT': int(self.amount * 100), + 'CURRENCY': self.currency_id.name, + 'OPERATION': 'RFD', + 'ECI': 2, # Recurring (from MOTO) + 'ALIAS': self.payment_token_id.acquirer_ref, + 'RTIMEOUT': 30, + } + + direct_order_url = 'https://secure.ogone.com/ncol/%s/orderdirect.asp' % (self.acquirer_id.environment) + + _logger.debug("Ogone data %s", pformat(data)) + result = requests.post(direct_order_url, data=data).content + _logger.debug('Ogone response = %s', result) + + try: + tree = objectify.fromstring(result) + except etree.XMLSyntaxError: + # invalid response from ogone + _logger.exception('Invalid xml response from ogone') + raise + + return self._ogone_s2s_validate_tree(tree) + def _ogone_s2s_validate(self): tree = self._ogone_s2s_get_tx_status() return self._ogone_s2s_validate_tree(tree) def _ogone_s2s_validate_tree(self, tree, tries=2): - if self.state not in ('draft', 'pending'): + if self.state not in ('draft', 'pending', 'refunding'): _logger.info('Ogone: trying to validate an already validated tx (ref %s)', self.reference) return True status = int(tree.get('STATUS') or 0) if status in self._ogone_valid_tx_status: + new_state = 'refunded' if self.state == 'refunding' else 'done' self.write({ - 'state': 'done', + 'state': new_state, 'date_validate': datetime.date.today().strftime(DEFAULT_SERVER_DATE_FORMAT), 'acquirer_reference': tree.get('PAYID'), }) @@ -406,6 +454,7 @@ class PaymentTxOgone(models.Model): 'name': tree.get('CARDNO'), }) self.write({'payment_token_id': pm.id}) + self.payment_token_id.verified = True self.execute_callback() return True elif status in self._ogone_cancel_tx_status: @@ -414,8 +463,9 @@ class PaymentTxOgone(models.Model): 'acquirer_reference': tree.get('PAYID'), }) elif status in self._ogone_pending_tx_status: + new_state = 'refunding' if self.type == 'validation' else 'pending' self.write({ - 'state': 'pending', + 'state': new_state, 'acquirer_reference': tree.get('PAYID'), 'html_3ds': str(tree.HTML_ANSWER).decode('base64') }) @@ -522,4 +572,4 @@ class PaymentToken(models.Model): 'acquirer_ref': alias, 'name': 'XXXXXXXXXXXX%s - %s' % (values['cc_number'][-4:], values['cc_holder_name']) } - return {} + return {} \ No newline at end of file diff --git a/addons/payment_ogone/views/payment_ogone_templates.xml b/addons/payment_ogone/views/payment_ogone_templates.xml index 63a5f4fd950..63b4af07142 100644 --- a/addons/payment_ogone/views/payment_ogone_templates.xml +++ b/addons/payment_ogone/views/payment_ogone_templates.xml @@ -56,6 +56,7 @@