From 1b2c7cbb43b03adb81f6bf1f7522cbc8a1886efe Mon Sep 17 00:00:00 2001 From: "Antoine Vandevenne (anv)" Date: Tue, 29 Jun 2021 14:54:41 +0000 Subject: [PATCH] [FIX] payment, payment_ogone: bring back the Hosted Payment Page API With commit 139dd9d, the Hosted Payment Page API of Ogone was entirely replaced by the FlexCheckout API. This new API allows customers to tokenize a payment method for a later use without the need of making a purchase. However, it proved itself to be less convenient for regular purchases as it offers less payment options than the HPP, and payments are no longer cardholder-initiated transactions but merchant-initiated transactions which have a higher chance of triggering an authentication check. Furthermore, the payment flow itself is more complicated than before. This commit brings back the Hosted Payment Page API to work in parallel with the FlexCheckout API and the other APIs that were left untouched. It will be exclusively used for making online payments with a new card. The validation flow is managed by the FlexCheckout API while the DirectLink API manages the payment by token and offline payment flows. task-2494916 closes odoo/odoo#72991 X-original-commit: 4fa7b772c71979f61eb098ff8d005deba834527c Signed-off-by: Toufik Benjaa (tbe) Signed-off-by: Antoine Vandevenne (anv) --- addons/payment/models/payment_acquirer.py | 19 ++- addons/payment/models/payment_transaction.py | 11 +- addons/payment_ogone/controllers/main.py | 41 ++++-- addons/payment_ogone/i18n/payment_ogone.pot | 6 + .../payment_ogone/models/payment_acquirer.py | 15 ++ .../models/payment_transaction.py | 139 +++++++++++------- addons/payment_ogone/tests/test_ogone.py | 53 ++++++- .../views/payment_ogone_templates.xml | 25 ++++ 8 files changed, 240 insertions(+), 69 deletions(-) diff --git a/addons/payment/models/payment_acquirer.py b/addons/payment/models/payment_acquirer.py index aace35c59bb..f4baccbd7a7 100644 --- a/addons/payment/models/payment_acquirer.py +++ b/addons/payment/models/payment_acquirer.py @@ -192,6 +192,10 @@ class PaymentAcquirer(models.Model): elif payment_method: payment_method.unlink() + def _get_default_payment_method(self): + self.ensure_one() + return self.env.ref('account.account_payment_method_manual_in').id + #=== CONSTRAINT METHODS ===# @api.constrains('fees_dom_var', 'fees_int_var') @@ -389,6 +393,17 @@ class PaymentAcquirer(models.Model): self.ensure_one() return self.journal_id.currency_id or self.company_id.currency_id - def _get_default_payment_method(self): + def _get_redirect_form_view(self, is_validation=False): + """ Return the view of the template used to render the redirect form. + + For an acquirer to return a different view depending on whether the operation is a + validation, it must override this method and return the appropriate view. + + Note: self.ensure_one() + + :param bool is_validation: Whether the operation is a validation + :return: The redirect form template + :rtype: record of `ir.ui.view` + """ self.ensure_one() - return self.env.ref('account.account_payment_method_manual_in').id + return self.redirect_form_view_id diff --git a/addons/payment/models/payment_transaction.py b/addons/payment/models/payment_transaction.py index 7691c066fcd..4140a4b1d71 100644 --- a/addons/payment/models/payment_transaction.py +++ b/addons/payment/models/payment_transaction.py @@ -424,17 +424,18 @@ class PaymentTransaction(models.Model): ) # Render the html form for the redirect flow if available - if self.operation in ('online_redirect', 'validation') \ - and self.acquirer_id.redirect_form_view_id: + if self.operation in ('online_redirect', 'validation'): rendering_values = self._get_specific_rendering_values(processing_values) _logger.info( "acquirer-specific rendering values for transaction with id %s:\n%s", self.id, pprint.pformat(rendering_values) ) - redirect_form_html = self.acquirer_id.redirect_form_view_id._render( - rendering_values, engine='ir.qweb' + redirect_form_view = self.acquirer_id._get_redirect_form_view( + is_validation=self.operation == 'validation' ) - processing_values.update(redirect_form_html=redirect_form_html) + if redirect_form_view: # Some acquirer don't need a redirect form + redirect_form_html = redirect_form_view._render(rendering_values, engine='ir.qweb') + processing_values.update(redirect_form_html=redirect_form_html) return processing_values diff --git a/addons/payment_ogone/controllers/main.py b/addons/payment_ogone/controllers/main.py index e9973ad80b3..1b5986a7fca 100644 --- a/addons/payment_ogone/controllers/main.py +++ b/addons/payment_ogone/controllers/main.py @@ -16,6 +16,7 @@ _logger = logging.getLogger(__name__) class OgoneController(http.Controller): + _hosted_payment_page_return_url = '/payment/ogone/hostedpaymentpage' _flexcheckout_return_url = '/payment/ogone/flexcheckout' _directlink_return_url = '/payment/ogone/directlink' _backward_compatibility_urls = [ @@ -28,6 +29,25 @@ class OgoneController(http.Controller): '/payment/ogone/validate/exception', ] # Facilitates the migration of users who registered the URLs in Ogone's backend prior to 14.3 + @http.route( + _hosted_payment_page_return_url, type='http', auth='public', methods=['GET', 'POST'], + csrf=False + ) # 'GET' or 'POST' depending on the configuration in Ogone backend + def ogone_return_from_hosted_payment_page(self, **feedback_data): + """ Process the data returned by Ogone after redirection to the Hosted Payment Page. + + :param dict feedback_data: The feedback data + """ + # Check the source and integrity of the data + data = self._homogenize_data(feedback_data) + self._verify_signature(feedback_data, data) + + # Handle the feedback data + data['FEEDBACK_TYPE'] = 'hosted_payment_page' + _logger.info("entering _handle_feedback_data with data:\n%s", pprint.pformat(data)) + request.env['payment.transaction'].sudo()._handle_feedback_data('ogone', data) + return werkzeug.utils.redirect('/payment/status') + @http.route( _flexcheckout_return_url, type='http', auth='public', methods=['GET', 'POST'], csrf=False ) # 'GET' or 'POST' depending on the configuration in Ogone backend @@ -47,6 +67,15 @@ class OgoneController(http.Controller): # Process the payment through the token tree = tx_sudo._ogone_send_order_request(request_3ds_authentication=True) + feedback_data = { + 'FEEDBACK_TYPE': 'directlink', + 'ORDERID': tree.get('orderID'), + 'tree': tree, + } + _logger.info( + "entering _handle_feedback_data with data:\n%s", pprint.pformat(feedback_data) + ) + request.env['payment.transaction'].sudo()._handle_feedback_data('ogone', feedback_data) # Handle the response redirect_html_element = tree.find('HTML_ANSWER') @@ -64,15 +93,6 @@ class OgoneController(http.Controller): 'payment_ogone.directlink_feedback', {'redirect_html': redirect_html} ) else: - feedback_data = { - 'FEEDBACK_TYPE': 'directlink', - 'ORDERID': tree.get('orderID'), - 'tree': tree, - } - _logger.info( - "entering _handle_feedback_data with data:\n%s", pprint.pformat(feedback_data) - ) - request.env['payment.transaction'].sudo()._handle_feedback_data('ogone', feedback_data) if tx_sudo.state in ('cancel', 'error'): tx_sudo.token_id.active = False # The initial payment failed, archive the token return werkzeug.utils.redirect('/payment/status') @@ -89,6 +109,9 @@ class OgoneController(http.Controller): token as this is the only case where we specifically request the authentication if necessary and handle the redirection request if one is returned. + This route can also accept S2S notifications from Ogone if it is configured as a webhook in + Ogone's backend. + :param dict feedback_data: The feedback data """ # Check the source and integrity of the data diff --git a/addons/payment_ogone/i18n/payment_ogone.pot b/addons/payment_ogone/i18n/payment_ogone.pot index 87f5df057b3..1ce45a2f737 100644 --- a/addons/payment_ogone/i18n/payment_ogone.pot +++ b/addons/payment_ogone/i18n/payment_ogone.pot @@ -124,6 +124,12 @@ msgstr "" msgid "Saved payment methods cannot be restored once they have been archived." msgstr "" +#. module: payment_ogone +#: code:addons/payment_ogone/models/payment_transaction.py:89 +#, python-format +msgid "Storing your payment details is necessary for future use." +msgstr "" + #. module: payment_ogone #: model:ir.model.fields,help:payment_ogone.field_payment_acquirer__ogone_userid msgid "The ID solely used to identify the API user with Ogone" diff --git a/addons/payment_ogone/models/payment_acquirer.py b/addons/payment_ogone/models/payment_acquirer.py index e8b8f703873..dd4da827084 100644 --- a/addons/payment_ogone/models/payment_acquirer.py +++ b/addons/payment_ogone/models/payment_acquirer.py @@ -43,6 +43,19 @@ class PaymentAcquirer(models.Model): return 1.0 + def _get_redirect_form_view(self, is_validation=False): + """ Override of payment to return the FlexCheckout form for validation operations. + + :param bool is_validation: Whether the operation is a validation + :return: The redirect form template + :rtype: record of `ir.ui.view` + """ + res = super()._get_redirect_form_view() + if self.provider != 'ogone' or not is_validation: + return res # self.redirect_form_view_id + + return self.env.ref('payment_ogone.redirect_form_validation') + def _ogone_get_api_url(self, api_key): """ Return the appropriate URL of the requested API for the acquirer state. @@ -56,12 +69,14 @@ class PaymentAcquirer(models.Model): if self.state == 'enabled': api_urls = { + 'hosted_payment_page': 'https://secure.ogone.com/ncol/prod/orderstandard_utf8.asp', 'flexcheckout': 'https://secure.ogone.com/Tokenization/HostedPage', 'directlink': 'https://secure.ogone.com/ncol/prod/orderdirect_utf8.asp', 'maintenancedirect': 'https://secure.ogone.com/ncol/prod/maintenancedirect_utf8.asp', } else: # 'test' api_urls = { + 'hosted_payment_page': 'https://ogone.test.v-psp.com/ncol/test/orderstandard_utf8.asp', 'flexcheckout': 'https://ogone.test.v-psp.com/Tokenization/HostedPage', 'directlink': 'https://ogone.test.v-psp.com/ncol/test/orderdirect_utf8.asp', 'maintenancedirect': 'https://ogone.test.v-psp.com/ncol/test/maintenancedirect_utf8.asp', diff --git a/addons/payment_ogone/models/payment_transaction.py b/addons/payment_ogone/models/payment_transaction.py index cb902274350..6a60f048b24 100644 --- a/addons/payment_ogone/models/payment_transaction.py +++ b/addons/payment_ogone/models/payment_transaction.py @@ -62,23 +62,56 @@ class PaymentTransaction(models.Model): return res base_url = self.acquirer_id.get_base_url() - return_url = urls.url_join(base_url, OgoneController._flexcheckout_return_url) - rendering_values = { - 'ACCOUNT_PSPID': self.acquirer_id.ogone_pspid, - 'ALIAS_ALIASID': payment_utils.singularize_reference_prefix(prefix='ODOO-ALIAS'), - 'ALIAS_ORDERID': self.reference, - 'ALIAS_STOREPERMANENTLY': 'Y' if self.tokenize else 'N', - 'CARD_PAYMENTMETHOD': 'CreditCard', - 'LAYOUT_LANGUAGE': self.partner_lang, - 'PARAMETERS_ACCEPTURL': return_url, - 'PARAMETERS_EXCEPTIONURL': return_url, - } - rendering_values.update({ - 'SHASIGNATURE_SHASIGN': self.acquirer_id._ogone_generate_signature( - rendering_values, incoming=False, format_keys=True - ).upper(), - 'api_url': self.acquirer_id._ogone_get_api_url('flexcheckout'), - }) + if self.operation == 'online_redirect': + return_url = urls.url_join(base_url, OgoneController._hosted_payment_page_return_url) + rendering_values = { + 'PSPID': self.acquirer_id.ogone_pspid, + 'ORDERID': self.reference, + 'AMOUNT': payment_utils.to_minor_currency_units(self.amount, None, 2), + 'CURRENCY': self.currency_id.name, + 'LANGUAGE': self.partner_lang or 'en_US', + 'EMAIL': self.partner_email or '', + 'OWNERADDRESS': self.partner_address or '', + 'OWNERZIP': self.partner_zip or '', + 'OWNERTOWN': self.partner_city or '', + 'OWNERCTY': self.partner_country_id.code or '', + 'OWNERTELNO': self.partner_phone or '', + 'OPERATION': 'SAL', # direct sale + 'USERID': self.acquirer_id.ogone_userid, + 'ACCEPTURL': return_url, + 'DECLINEURL': return_url, + 'EXCEPTIONURL': return_url, + 'CANCELURL': return_url, + } + if self.tokenize: + rendering_values.update({ + 'ALIAS': payment_utils.singularize_reference_prefix(prefix='ODOO-ALIAS'), + 'ALIASUSAGE': _("Storing your payment details is necessary for future use."), + }) + rendering_values.update({ + 'SHASIGN': self.acquirer_id._ogone_generate_signature( + rendering_values, incoming=False + ).upper(), + 'api_url': self.acquirer_id._ogone_get_api_url('hosted_payment_page'), + }) + else: # validation + return_url = urls.url_join(base_url, OgoneController._flexcheckout_return_url) + rendering_values = { + 'ACCOUNT_PSPID': self.acquirer_id.ogone_pspid, + 'ALIAS_ALIASID': payment_utils.singularize_reference_prefix(prefix='ODOO-ALIAS'), + 'ALIAS_ORDERID': self.reference, + 'ALIAS_STOREPERMANENTLY': 'Y' if self.tokenize else 'N', + 'CARD_PAYMENTMETHOD': 'CreditCard', + 'LAYOUT_LANGUAGE': self.partner_lang, + 'PARAMETERS_ACCEPTURL': return_url, + 'PARAMETERS_EXCEPTIONURL': return_url, + } + rendering_values.update({ + 'SHASIGNATURE_SHASIGN': self.acquirer_id._ogone_generate_signature( + rendering_values, incoming=False, format_keys=True + ).upper(), + 'api_url': self.acquirer_id._ogone_get_api_url('flexcheckout'), + }) return rendering_values def _send_payment_request(self): @@ -196,58 +229,62 @@ class PaymentTransaction(models.Model): feedback_type = data.get('FEEDBACK_TYPE') if feedback_type == 'flexcheckout': - self._process_flexcheckout_data(data) - elif feedback_type == 'directlink': - self._process_directlink_data(data) + self._ogone_tokenize_from_feedback_data( + data.get('CARDNUMBER'), + data['ALIASID'], + not self.tokenize, # Immediately archive the token if it was not requested + ) + elif feedback_type in ('hosted_payment_page', 'directlink'): + if 'tree' in data: + data = data['tree'] + + self.acquirer_reference = data.get('PAYID') + payment_status = int(data.get('STATUS', '0')) + if payment_status in const.PAYMENT_STATUS_MAPPING['pending']: + self._set_pending() + elif payment_status in const.PAYMENT_STATUS_MAPPING['done']: + has_token_data = 'ALIAS' in data + if self.tokenize and has_token_data: + self._ogone_tokenize_from_feedback_data( + data.get('CARDNO'), data['ALIAS'], False + ) + if self.token_id: + self.token_id.verified = True # Validity of the token has been confirmed + self._set_done() + elif payment_status in const.PAYMENT_STATUS_MAPPING['cancel']: + self._set_canceled() + else: # Classify unknown payment statuses as `error` tx state + _logger.info("received data with invalid payment status: %s", payment_status) + self._set_error( + "Ogone: " + _("Received data with invalid payment status: %s", payment_status) + ) else: raise ValidationError( "Ogone: " + _("Received feedback data with unknown type: %s", feedback_type) ) - def _process_flexcheckout_data(self, data): - """ Create a token from Flexcheckout feedback data. + def _ogone_tokenize_from_feedback_data(self, card_number, acquirer_ref, is_one_shot_payment): + """ Create a token from feedback data. - :param dict data: The feedback data from Flexcheckout + :param str card_number: The obfuscated number of the card + :param str acquirer_ref: The acquirer reference of the payment + :param bool is_one_shot_payment: Whether the token was created for a one-shot payment :return: None """ - token_name = data.get('CARDNUMBER') \ - or payment_utils.build_token_name() # CARD.CARNUMBER not requested in backend + token_name = card_number or payment_utils.build_token_name() # Not requested in the backend token = self.env['payment.token'].create({ 'acquirer_id': self.acquirer_id.id, 'name': token_name, # Already padded with 'X's 'partner_id': self.partner_id.id, - 'acquirer_ref': data['ALIASID'], - 'verified': False, # No payment has been processed through this token yet - 'active': self.tokenize, # Immediately archive the token if it was not requested + 'acquirer_ref': acquirer_ref, + 'verified': False, # Set to True as soon as a payment is authorized + 'active': not is_one_shot_payment, # Archive immediately if used for one-shot payment }) self.write({ 'token_id': token.id, 'tokenize': False, }) - def _process_directlink_data(self, data): - """ Update the transaction state and the acquirer reference based on the feedback data. - - :param dict data: The feedback data from DirectLink - :return: None - """ - if 'tree' in data: - data = data['tree'] - self.acquirer_reference = data.get('PAYID') - payment_status = int(data.get('STATUS', '0')) - if payment_status in const.PAYMENT_STATUS_MAPPING['pending']: - self._set_pending() - elif payment_status in const.PAYMENT_STATUS_MAPPING['done']: - self.token_id.verified = True # The payment has been authorized, the token is valid - self._set_done() - elif payment_status in const.PAYMENT_STATUS_MAPPING['cancel']: - self._set_canceled() - else: # Classify unknown payment statuses as `error` tx state - _logger.info("received data with invalid payment status: %s", payment_status) - self._set_error( - "Ogone: " + _("Received data with invalid payment status: %s", payment_status) - ) - def _send_refund_request(self): """ Override of payment to send a refund request to Authorize. diff --git a/addons/payment_ogone/tests/test_ogone.py b/addons/payment_ogone/tests/test_ogone.py index 79899923bce..99520f6157b 100644 --- a/addons/payment_ogone/tests/test_ogone.py +++ b/addons/payment_ogone/tests/test_ogone.py @@ -48,7 +48,56 @@ class OgoneTest(OgoneCommon): @freeze_time('2011-11-02 12:00:21') # Freeze time for consistent singularization behavior def test_redirect_form_values(self): - """ Test the values of the redirect form inputs. """ + """ Test the values of the redirect form inputs for online payments. """ + return_url = self._build_url(OgoneController._hosted_payment_page_return_url) + expected_values = { + 'PSPID': self.ogone.ogone_pspid, + 'ORDERID': self.reference, + 'AMOUNT': str(payment_utils.to_minor_currency_units(self.amount, None, 2)), + 'CURRENCY': self.currency.name, + 'LANGUAGE': self.partner.lang, + 'EMAIL': self.partner.email, + 'OWNERZIP': self.partner.zip, + 'OWNERADDRESS': payment_utils.format_partner_address( + self.partner.street, self.partner.street2 + ), + 'OWNERCTY': self.partner.country_id.code, + 'OWNERTOWN': self.partner.city, + 'OWNERTELNO': self.partner.phone, + 'OPERATION': 'SAL', # direct sale + 'USERID': self.ogone.ogone_userid, + 'ACCEPTURL': return_url, + 'DECLINEURL': return_url, + 'EXCEPTIONURL': return_url, + 'CANCELURL': return_url, + 'ALIAS': None, + 'ALIASUSAGE': None, + } + expected_values['SHASIGN'] = self.ogone._ogone_generate_signature( + expected_values, incoming=False + ).upper() + + tx = self.create_transaction(flow='redirect') + self.assertEqual(tx.tokenize, False) + with mute_logger('odoo.addons.payment.models.payment_transaction'): + processing_values = tx._get_processing_values() + + form_info = self._extract_values_from_html_form(processing_values['redirect_form_html']) + + self.assertEqual(form_info['action'], 'https://ogone.test.v-psp.com/ncol/test/orderstandard_utf8.asp') + inputs = form_info['inputs'] + self.assertEqual(len(expected_values), len(inputs)) + for rendering_key, value in expected_values.items(): + form_key = rendering_key.replace('_', '.') + self.assertEqual( + inputs[form_key], + value, + f"received value {inputs[form_key]} for input {form_key} (expected {value})" + ) + + @freeze_time('2011-11-02 12:00:21') # Freeze time for consistent singularization behavior + def test_redirect_form_validation_values(self): + """ Test the values of the redirect form inputs for validation. """ return_url = self._build_url(OgoneController._flexcheckout_return_url) expected_values = { 'ACCOUNT_PSPID': self.ogone.ogone_pspid, @@ -64,7 +113,7 @@ class OgoneTest(OgoneCommon): expected_values, incoming=False, format_keys=True ).upper() - tx = self.create_transaction(flow='redirect') + tx = self.create_transaction(flow='dummy', operation='validation') with mute_logger('odoo.addons.payment.models.payment_transaction'): processing_values = tx._get_processing_values() diff --git a/addons/payment_ogone/views/payment_ogone_templates.xml b/addons/payment_ogone/views/payment_ogone_templates.xml index 697410a7207..6fb278c0554 100644 --- a/addons/payment_ogone/views/payment_ogone_templates.xml +++ b/addons/payment_ogone/views/payment_ogone_templates.xml @@ -2,6 +2,31 @@ + +