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 @@ + +