[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) <tbe@odoo.com>
Signed-off-by: Antoine Vandevenne (anv) <AntoineVDV@users.noreply.github.com>
This commit is contained in:
Antoine Vandevenne (anv)
2021-06-30 12:18:50 +00:00
parent b0b88e9cb1
commit 1b2c7cbb43
8 changed files with 240 additions and 69 deletions
+17 -2
View File
@@ -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
+6 -5
View File
@@ -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
+32 -9
View File
@@ -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
@@ -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"
@@ -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',
@@ -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.
+51 -2
View File
@@ -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()
@@ -2,6 +2,31 @@
<odoo>
<template id="redirect_form">
<form t-att-action="api_url" method="post">
<input type="hidden" name="PSPID" t-att-value="PSPID"/>
<input type="hidden" name="ORDERID" t-att-value="ORDERID"/>
<input type="hidden" name="AMOUNT" t-att-value="AMOUNT"/>
<input type="hidden" name="CURRENCY" t-att-value="CURRENCY"/>
<input type="hidden" name="LANGUAGE" t-att-value="LANGUAGE"/>
<input type="hidden" name="EMAIL" t-att-value="EMAIL"/>
<input type="hidden" name="OWNERADDRESS" t-att-value="OWNERADDRESS"/>
<input type="hidden" name="OWNERZIP" t-att-value="OWNERZIP"/>
<input type="hidden" name="OWNERTOWN" t-att-value="OWNERTOWN"/>
<input type="hidden" name="OWNERCTY" t-att-value="OWNERCTY"/>
<input type="hidden" name="OWNERTELNO" t-att-value="OWNERTELNO"/>
<input type="hidden" name="OPERATION" t-att-value="OPERATION"/>
<input type="hidden" name="USERID" t-att-value="USERID"/>
<input type="hidden" name="ACCEPTURL" t-att-value="ACCEPTURL"/>
<input type="hidden" name="DECLINEURL" t-att-value="DECLINEURL"/>
<input type="hidden" name="EXCEPTIONURL" t-att-value="EXCEPTIONURL"/>
<input type="hidden" name="CANCELURL" t-att-value="CANCELURL"/>
<input type="hidden" name="ALIAS" t-att-value="ALIAS"/>
<input type="hidden" name="ALIASUSAGE" t-att-value="ALIASUSAGE"/>
<input type="hidden" name="SHASIGN" t-att-value="SHASIGN"/>
</form>
</template>
<template id="redirect_form_validation">
<form t-att-action="api_url" method="post">
<input type="hidden" name="ACCOUNT.PSPID" t-att-value="ACCOUNT_PSPID"/>
<input type="hidden" name="ALIAS.ALIASID" t-att-value="ALIAS_ALIASID"/>