diff --git a/addons/payment_adyen/__init__.py b/addons/payment_adyen/__init__.py index c0f04c7528c..aed6fceb8ec 100644 --- a/addons/payment_adyen/__init__.py +++ b/addons/payment_adyen/__init__.py @@ -1,10 +1,11 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from . import models from . import controllers -from odoo.addons.payment.models.payment_acquirer import create_missing_journal_for_acquirers -from odoo.addons.payment import reset_payment_provider +from . import models + +from odoo.addons.payment import reset_payment_acquirer +from odoo.addons.payment.models.payment_acquirer import create_missing_journals # post-init hook + def uninstall_hook(cr, registry): - reset_payment_provider(cr, registry, 'adyen') + reset_payment_acquirer(cr, registry, 'adyen') diff --git a/addons/payment_adyen/__manifest__.py b/addons/payment_adyen/__manifest__.py index d3706222bd0..87953dc43b5 100644 --- a/addons/payment_adyen/__manifest__.py +++ b/addons/payment_adyen/__manifest__.py @@ -1,20 +1,20 @@ -# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. { 'name': 'Adyen Payment Acquirer', + 'version': '2.0', 'category': 'Accounting/Payment Acquirers', 'sequence': 340, 'summary': 'Payment Acquirer: Adyen Implementation', - 'version': '1.0', 'description': """Adyen Payment Acquirer""", 'depends': ['payment'], 'data': [ - 'views/payment_views.xml', + 'views/assets.xml', 'views/payment_adyen_templates.xml', - 'data/payment_acquirer_data.xml', + 'views/payment_views.xml', + 'data/payment_acquirer_data.xml', # Depends on views/payment_adyen_templates.xml ], - 'installable': True, 'application': True, - 'post_init_hook': 'create_missing_journal_for_acquirers', + 'post_init_hook': 'create_missing_journals', 'uninstall_hook': 'uninstall_hook', } diff --git a/addons/payment_adyen/const.py b/addons/payment_adyen/const.py new file mode 100644 index 00000000000..ea6d4a80761 --- /dev/null +++ b/addons/payment_adyen/const.py @@ -0,0 +1,33 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +# Endpoints of the API. +# See https://docs.adyen.com/api-explorer/#/CheckoutService/v53/overview for Checkout API +# See https://docs.adyen.com/api-explorer/#/Recurring/v49/overview for Recurring API +API_ENDPOINT_VERSIONS = { + '/disable': 49, # Recurring API + '/originKeys': 53, # Checkout API + '/payments': 53, # Checkout API + '/payments/details': 53, # Checkout API + '/paymentMethods': 53, # Checkout API +} + +# Adyen-specific mapping of currency codes in ISO 4217 format to the number of decimals. +# Only currencies for which Adyen does not follow the ISO 4217 norm are listed here. +# See https://docs.adyen.com/development-resources/currency-codes +CURRENCY_DECIMALS = { + 'CLP': 2, + 'CVE': 0, + 'IDR': 0, + 'ISK': 2, +} + +# Mapping of transaction states to Adyen result codes. +# See https://docs.adyen.com/checkout/payment-result-codes for the exhaustive list of result codes. +RESULT_CODES_MAPPING = { + 'pending': ( + 'ChallengeShopper', 'IdentifyShopper', 'Pending', 'PresentToShopper', 'Received', + 'RedirectShopper' + ), + 'done': ('Authorised',), + 'cancel': ('Cancelled',), +} diff --git a/addons/payment_adyen/controllers/__init__.py b/addons/payment_adyen/controllers/__init__.py index 65a8c12013d..80ee4da1c5e 100644 --- a/addons/payment_adyen/controllers/__init__.py +++ b/addons/payment_adyen/controllers/__init__.py @@ -1,3 +1,3 @@ -# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. from . import main diff --git a/addons/payment_adyen/controllers/main.py b/addons/payment_adyen/controllers/main.py index cc09d580050..a2c44b2d5fd 100644 --- a/addons/payment_adyen/controllers/main.py +++ b/addons/payment_adyen/controllers/main.py @@ -1,37 +1,359 @@ -# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import base64 +import binascii +import hashlib +import hmac import json import logging import pprint -import werkzeug -from odoo import http +import werkzeug +from werkzeug import urls + +from odoo import _, http +from odoo.exceptions import ValidationError from odoo.http import request +from odoo.tools.pycompat import to_text + +from odoo.addons.payment import utils as payment_utils +from odoo.addons.payment_adyen.const import CURRENCY_DECIMALS _logger = logging.getLogger(__name__) class AdyenController(http.Controller): - _return_url = '/payment/adyen/return/' - @http.route([ - '/payment/adyen/return', - ], type='http', auth='public', csrf=False) - def adyen_return(self, **post): - _logger.info('Beginning Adyen form_feedback with post data %s', pprint.pformat(post)) # debug - if post.get('authResult') not in ['CANCELLED']: - request.env['payment.transaction'].sudo().form_feedback(post, 'adyen') - return werkzeug.utils.redirect('/payment/process') + @http.route('/payment/adyen/acquirer_state', type='json', auth='public') + def adyen_acquirer_state(self, acquirer_id): + """ Return the state of the acquirer. - @http.route([ - '/payment/adyen/notification', - ], type='http', auth='public', methods=['POST'], csrf=False) - def adyen_notification(self, **post): - tx = post.get('merchantReference') and request.env['payment.transaction'].sudo().search([('reference', 'in', [post.get('merchantReference')])], limit=1) - if post.get('eventCode') in ['AUTHORISATION'] and tx: - states = (post.get('merchantReference'), post.get('success'), tx.state) - if (post.get('success') == 'true' and tx.state == 'done') or (post.get('success') == 'false' and tx.state in ['cancel', 'error']): - _logger.info('Notification from Adyen for the reference %s: received %s, state is %s', states) + :param int acquirer_id: The acquirer handling the transaction, as a `payment.acquirer` id + :return: The state of the acquirer: 'enabled' or 'test' + :rtype: str + """ + acquirer_sudo = request.env['payment.acquirer'].sudo().browse(acquirer_id).exists() + return acquirer_sudo.state + + @http.route('/payment/adyen/payment_methods', type='json', auth='public') + def adyen_payment_methods(self, acquirer_id, amount=None, currency_id=None, partner_id=None): + """ Query the available payment methods based on the transaction context. + + :param int acquirer_id: The acquirer handling the transaction, as a `payment.acquirer` id + :param float amount: The transaction amount + :param int currency_id: The transaction currency, as a `res.currency` id + :param int partner_id: The partner making the transaction, as a `res.partner` id + :return: The JSON-formatted content of the response + :rtype: dict + """ + acquirer_sudo = request.env['payment.acquirer'].sudo().browse(acquirer_id) + currency = request.env['res.currency'].browse(currency_id) + currency_code = currency_id and currency.name + converted_amount = amount and currency_code and payment_utils.to_minor_currency_units( + amount, currency, CURRENCY_DECIMALS.get(currency_code) + ) + partner_sudo = partner_id and request.env['res.partner'].sudo().browse(partner_id).exists() + # The lang is taken from the context rather than from the partner because it is not required + # to be logged in to make a payment, and because the lang is not always set on the partner. + # Adyen only supports a limited set of languages but, instead of looking for the closest + # match in https://docs.adyen.com/checkout/components-web/localization-components, we simply + # provide the lang string as is (after adapting the format) and let Adyen find the best fit. + lang_code = (request.context.get('lang') or 'en-US').replace('_', '-') + shopper_reference = partner_sudo and f'ODOO_PARTNER_{partner_sudo.id}' + data = { + 'merchantAccount': acquirer_sudo.adyen_merchant_account, + 'amount': converted_amount, + 'countryCode': partner_sudo.country_id.code or None, # ISO 3166-1 alpha-2 (e.g.: 'BE') + 'shopperLocale': lang_code, # IETF language tag (e.g.: 'fr-BE') + 'shopperReference': shopper_reference, + 'channel': 'Web', + } + response_content = acquirer_sudo._adyen_make_request( + url_field_name='adyen_checkout_api_url', + endpoint='/paymentMethods', + payload=data, + method='POST' + ) + _logger.info("paymentMethods request response:\n%s", pprint.pformat(response_content)) + return response_content + + @http.route('/payment/adyen/origin_key', type='json', auth='public') + def adyen_origin_key(self, acquirer_id): + """ Request an origin key based on the current domain. + + :param int acquirer_id: The acquirer handling the transaction, as a `payment.acquirer` id + :return: The JSON-formatted content of the response + :rtype: dict + """ + acquirer_sudo = request.env['payment.acquirer'].browse(acquirer_id).sudo() + domain = acquirer_sudo._get_base_url() + data = { + 'originDomains': [domain], + } + response_content = acquirer_sudo._adyen_make_request( + url_field_name='adyen_checkout_api_url', + endpoint='/originKeys', + payload=data, + method='POST' + ) + _logger.info("originKeys request response:\n%s", pprint.pformat(response_content)) + return response_content + + @http.route('/payment/adyen/payments', type='json', auth='public') + def adyen_payments( + self, acquirer_id, reference, converted_amount, currency_id, partner_id, payment_method, + access_token, browser_info=None + ): + """ Make a payment request and handle the response. + + :param int acquirer_id: The acquirer handling the transaction, as a `payment.acquirer` id + :param str reference: The reference of the transaction + :param int converted_amount: The amount of the transaction in minor units of the currency + :param int currency_id: The currency of the transaction, as a `res.currency` id + :param int partner_id: The partner making the transaction, as a `res.partner` id + :param dict payment_method: The details of the payment method used for the transaction + :param str access_token: The access token used to verify the provided values + :param dict browser_info: The browser info to pass to Adyen + :return: The JSON-formatted content of the response + :rtype: dict + """ + # Check that the transaction details have not been altered. This allows preventing users + # from validating transactions by paying less than agreed upon. + if not payment_utils.check_access_token( + access_token, reference, converted_amount, partner_id + ): + raise ValidationError("Adyen: " + _("Received tampered payment request data.")) + + # Make the payment request to Adyen + acquirer_sudo = request.env['payment.acquirer'].sudo().browse(acquirer_id).exists() + tx_sudo = request.env['payment.transaction'].sudo().search([('reference', '=', reference)]) + data = { + 'merchantAccount': acquirer_sudo.adyen_merchant_account, + 'amount': { + 'value': converted_amount, + 'currency': request.env['res.currency'].browse(currency_id).name, # ISO 4217 + }, + 'reference': reference, + 'paymentMethod': payment_method, + 'shopperReference': acquirer_sudo._adyen_compute_shopper_reference(partner_id), + 'recurringProcessingModel': 'CardOnFile', # Most susceptible to trigger a 3DS check + 'shopperIP': payment_utils.get_customer_ip_address(), + 'shopperInteraction': 'Ecommerce', + 'storePaymentMethod': tx_sudo.tokenize, # True by default on Adyen side + 'additionalData': { + 'allow3DS2': True + }, + 'channel': 'web', # Required to support 3DS + 'origin': acquirer_sudo._get_base_url(), # Required to support 3DS + 'browserInfo': browser_info, # Required to support 3DS + 'returnUrl': urls.url_join( + acquirer_sudo._get_base_url(), + # Include the reference in the return url to be able to match it after redirection. + # The key 'merchantReference' is chosen on purpose to be the same than that returned + # by the /payments endpoint of Adyen. + f'/payment/adyen/return?merchantReference={reference}' + ), + } + response_content = acquirer_sudo._adyen_make_request( + url_field_name='adyen_checkout_api_url', + endpoint='/payments', + payload=data, + method='POST' + ) + + # Handle the payment request response + _logger.info("payment request response:\n%s", pprint.pformat(response_content)) + request.env['payment.transaction'].sudo()._handle_feedback_data( + 'adyen', dict(response_content, merchantReference=reference), # Match the transaction + ) + if 'action' in response_content and response_content['action']['type'] == 'redirect': + tx_sudo.adyen_payment_data = response_content['paymentData'] + + return response_content + + @http.route('/payment/adyen/payment_details', type='json', auth='public') + def adyen_payment_details(self, acquirer_id, reference, details, payment_data): + """ Query the status of a transaction that required additional actions and process it. + + The additional actions can have been performed both from the inline form or during a + redirection. + + :param int acquirer_id: The acquirer handling the transaction, as a `payment.acquirer` id + :param str reference: The reference of the transaction + :param dict details: The specification of the additional actions + :param str payment_data: The encrypted payment data of the transaction + :return: The JSON-formatted content of the response + :rtype: dict + """ + # Make the payment details request to Adyen + acquirer_sudo = request.env['payment.acquirer'].browse(acquirer_id).sudo() + data = { + 'details': details, + 'paymentData': payment_data, + } + response_content = acquirer_sudo._adyen_make_request( + url_field_name='adyen_checkout_api_url', + endpoint='/payments/details', + payload=data, + method='POST' + ) + + # Handle the payment details request response + _logger.info("payment details request response:\n%s", pprint.pformat(response_content)) + request.env['payment.transaction'].sudo()._handle_feedback_data( + 'adyen', dict(response_content, merchantReference=reference), # Match the transaction + ) + + return response_content + + @http.route('/payment/adyen/return', type='http', auth='public', csrf=False) + def adyen_return_from_redirect(self, **data): + """ Process the data returned by Adyen after redirection. + + :param dict data: Feedback data. May include custom params sent to Adyen in the request to + allow matching the transaction when redirected here. + """ + # Retrieve the transaction based on the reference included in the return url + tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_feedback_data( + 'adyen', data + ) + + # Overwrite the operation to force the flow to 'redirect'. This is necessary because even + # thought Adyen is implemented as a direct payment provider, it will redirect the user out + # of Odoo in some cases. For instance, when a 3DS1 authentication is required, or for + # special payment methods that are not handled by the drop-in (e.g. Sofort). + tx_sudo.operation = 'online_redirect' + + # Query and process the result of the additional actions that have been performed + _logger.info("handling redirection from Adyen with data:\n%s", pprint.pformat(data)) + self.adyen_payment_details( + tx_sudo.acquirer_id.id, + data['merchantReference'], + {detail: value for detail, value in data.items() if detail != 'merchantReference'}, + tx_sudo.adyen_payment_data, + ) + + # Redirect the user to the status page + return werkzeug.utils.redirect('/payment/status') + + @http.route('/payment/adyen/notification', type='json', auth='public') + def adyen_notification(self): + """ Process the data sent by Adyen to the webhook based on the event code. + + See https://docs.adyen.com/development-resources/webhooks/understand-notifications for the + exhaustive list of event codes. + + :return: The '[accepted]' string to acknowledge the notification + :rtype: str + """ + data = json.loads(request.httprequest.data) + for notification_item in data['notificationItems']: + notification_data = notification_item['NotificationRequestItem'] + + # Check the source and integrity of the notification + received_signature = notification_data.get('additionalData', {}).get('hmacSignature') + acquirer_sudo = request.env['payment.transaction'].sudo()._get_tx_from_feedback_data( + 'adyen', notification_data + ).acquirer_id # Find the acquirer based on the transaction + if not self._verify_notification_signature( + received_signature, notification_data, acquirer_sudo.adyen_hmac_key + ): + continue + + # Check whether the event of the notification succeeded + _logger.info("notification received:\n%s", pprint.pformat(notification_data)) + if notification_data['success'] != 'true': + continue # Don't handle failed events + + # Reshape the notification data for parsing + event_code = notification_data['eventCode'] + if event_code == 'AUTHORISATION': + notification_data['resultCode'] = 'Authorised' + elif event_code == 'CANCELLATION': + notification_data['resultCode'] = 'Cancelled' else: - _logger.warning('Notification from Adyen for the reference %s: received %s but state is %s', states) - return '[accepted]' + continue # Don't handle unsupported event codes + + # Handle the notification data as a regular feedback + try: + request.env['payment.transaction'].sudo()._handle_feedback_data( + 'adyen', notification_data + ) + except ValidationError: # Acknowledge the notification to avoid getting spammed + _logger.exception("unable to handle the notification data; skipping to acknowledge") + + return '[accepted]' # Acknowledge the notification + + def _verify_notification_signature(self, received_signature, payload, hmac_key): + """ Check that the signature computed from the payload matches the received one. + + See https://docs.adyen.com/development-resources/webhooks/verify-hmac-signatures + + :param str received_signature: The signature sent with the notification + :param dict payload: The notification payload + :param str hmac_key: The HMAC key of the acquirer handling the transaction + :return: Whether the signatures match + :rtype: str + """ + + def _flatten_dict(_value, _path_base='', _separator='.'): + """ Recursively generate a flat representation of a dict. + + :param Object _value: The value to flatten. A dict or an already flat value + :param str _path_base: They base path for keys of _value, including preceding separators + :param str _separator: The string to use as a separator in the key path + """ + if isinstance(_value, dict): # The inner value is a dict, flatten it + _path_base = _path_base if not _path_base else _path_base + _separator + for _key in _value: + yield from _flatten_dict(_value[_key], _path_base + str(_key)) + else: # The inner value cannot be flattened, yield it + yield _path_base, _value + + def _to_escaped_string(_value): + """ Escape payload values that are using illegal symbols and cast them to string. + + String values containing `\\` or `:` are prefixed with `\\`. + Empty values (`None`) are replaced by an empty string. + + :param Object _value: The value to escape + :return: The escaped value + :rtype: string + """ + if isinstance(_value, str): + return _value.replace('\\', '\\\\').replace(':', '\\:') + elif _value is None: + return '' + else: + return str(_value) + + if not received_signature: + _logger.warning("ignored notification with missing signature") + return False + + # Compute the signature from the payload + signature_keys = [ + 'pspReference', 'originalReference', 'merchantAccountCode', 'merchantReference', + 'amount.value', 'amount.currency', 'eventCode', 'success' + ] + # Flatten the payload to allow accessing inner dicts naively + flattened_payload = {k: v for k, v in _flatten_dict(payload)} + # Build the list of signature values as per the list of required signature keys + signature_values = [flattened_payload.get(key) for key in signature_keys] + # Escape values using forbidden symbols + escaped_values = [_to_escaped_string(value) for value in signature_values] + # Concatenate values together with ':' as delimiter + signing_string = ':'.join(escaped_values) + # Convert the HMAC key to the binary representation + binary_hmac_key = binascii.a2b_hex(hmac_key.encode('ascii')) + # Calculate the HMAC with the binary representation of the signing string with SHA-256 + binary_hmac = hmac.new(binary_hmac_key, signing_string.encode('utf-8'), hashlib.sha256) + # Calculate the signature by encoding the result with Base64 + expected_signature = base64.b64encode(binary_hmac.digest()) + + # Compare signatures + if received_signature != to_text(expected_signature): + _logger.warning("ignored event with invalid signature") + return False + + return True diff --git a/addons/payment_adyen/data/payment_acquirer_data.xml b/addons/payment_adyen/data/payment_acquirer_data.xml index beace039983..4c2fccf496e 100644 --- a/addons/payment_adyen/data/payment_acquirer_data.xml +++ b/addons/payment_adyen/data/payment_acquirer_data.xml @@ -1,14 +1,13 @@ - - + - - Adyen - - adyen - - - + + adyen + + False + False + True + True + - diff --git a/addons/payment_adyen/models/__init__.py b/addons/payment_adyen/models/__init__.py index ef12533682f..22eb69117db 100644 --- a/addons/payment_adyen/models/__init__.py +++ b/addons/payment_adyen/models/__init__.py @@ -1,3 +1,5 @@ -# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. -from . import payment +from . import payment_acquirer +from . import payment_token +from . import payment_transaction diff --git a/addons/payment_adyen/models/payment.py b/addons/payment_adyen/models/payment.py deleted file mode 100644 index 7a5388fab89..00000000000 --- a/addons/payment_adyen/models/payment.py +++ /dev/null @@ -1,269 +0,0 @@ -# coding: utf-8 - -import base64 -import json -import binascii -from collections import OrderedDict -import hashlib -import hmac -import logging -from itertools import chain - -from werkzeug import urls - -from odoo import api, fields, models, tools, _ -from odoo.addons.payment.models.payment_acquirer import ValidationError -from odoo.addons.payment_adyen.controllers.main import AdyenController -from odoo.tools.pycompat import to_text - -_logger = logging.getLogger(__name__) - -# https://docs.adyen.com/developers/development-resources/currency-codes -CURRENCY_CODE_MAPS = { - "BHD": 3, - "CVE": 0, - "DJF": 0, - "GNF": 0, - "IDR": 0, - "JOD": 3, - "JPY": 0, - "KMF": 0, - "KRW": 0, - "KWD": 3, - "LYD": 3, - "OMR": 3, - "PYG": 0, - "RWF": 0, - "TND": 3, - "UGX": 0, - "VND": 0, - "VUV": 0, - "XAF": 0, - "XOF": 0, - "XPF": 0, -} - - -class AcquirerAdyen(models.Model): - _inherit = 'payment.acquirer' - - provider = fields.Selection(selection_add=[ - ('adyen', 'Adyen') - ], ondelete={'adyen': 'set default'}) - adyen_merchant_account = fields.Char('Merchant Account', required_if_provider='adyen', groups='base.group_user') - adyen_skin_code = fields.Char('Skin Code', required_if_provider='adyen', groups='base.group_user') - adyen_skin_hmac_key = fields.Char('Skin HMAC Key', required_if_provider='adyen', groups='base.group_user') - - @api.model - def _adyen_convert_amount(self, amount, currency): - """ - Adyen requires the amount to be multiplied by 10^k, - where k depends on the currency code. - """ - k = CURRENCY_CODE_MAPS.get(currency.name, 2) - paymentAmount = int(tools.float_round(amount, k) * (10**k)) - return paymentAmount - - @api.model - def _get_adyen_urls(self, environment): - """ Adyen URLs: yhpp: hosted payment page: pay.shtml for single, select.shtml for multiple """ - return { - 'adyen_form_url': 'https://%s.adyen.com/hpp/pay.shtml' % ('live' if environment == 'prod' else environment), - } - - def _adyen_generate_merchant_sig_sha256(self, inout, values): - """ Generate the shasign for incoming or outgoing communications., when using the SHA-256 - signature. - - :param string inout: 'in' (odoo contacting adyen) or 'out' (adyen - contacting odoo). In this last case only some - fields should be contained (see e-Commerce basic) - :param dict values: transaction values - :return string: shasign - """ - def escapeVal(val): - return val.replace('\\', '\\\\').replace(':', '\\:') - - def signParams(parms): - signing_string = ':'.join( - escapeVal(v) - for v in chain(parms.keys(), parms.values()) - ) - hm = hmac.new(hmac_key, signing_string.encode('utf-8'), hashlib.sha256) - return base64.b64encode(hm.digest()) - - assert inout in ('in', 'out') - assert self.provider == 'adyen' - - if inout == 'in': - # All the fields sent to Adyen must be included in the signature. ALL the fucking - # fields, despite what is claimed in the documentation. For example, in - # https://docs.adyen.com/developers/hpp-manual, it is stated: "The resURL parameter does - # not need to be included in the signature." It's a trap, it must be included as well! - keys = [ - 'merchantReference', 'paymentAmount', 'currencyCode', 'shipBeforeDate', 'skinCode', - 'merchantAccount', 'sessionValidity', 'merchantReturnData', 'shopperEmail', - 'shopperReference', 'allowedMethods', 'blockedMethods', 'offset', - 'shopperStatement', 'recurringContract', 'billingAddressType', - 'deliveryAddressType', 'brandCode', 'countryCode', 'shopperLocale', 'orderData', - 'offerEmail', 'resURL', - ] - else: - keys = [ - 'authResult', 'merchantReference', 'merchantReturnData', 'paymentMethod', - 'pspReference', 'shopperLocale', 'skinCode', - ] - - hmac_key = binascii.a2b_hex(self.adyen_skin_hmac_key.encode('ascii')) - raw_values = {k: values.get(k, '') for k in keys if k in values} - raw_values_ordered = OrderedDict(sorted(raw_values.items(), key=lambda t: t[0])) - - return signParams(raw_values_ordered) - - def _adyen_generate_merchant_sig(self, inout, values): - """ Generate the shasign for incoming or outgoing communications, when using the SHA-1 - signature (deprecated by Adyen). - - :param string inout: 'in' (odoo contacting adyen) or 'out' (adyen - contacting odoo). In this last case only some - fields should be contained (see e-Commerce basic) - :param dict values: transaction values - - :return string: shasign - """ - assert inout in ('in', 'out') - assert self.provider == 'adyen' - - if inout == 'in': - keys = "paymentAmount currencyCode shipBeforeDate merchantReference skinCode merchantAccount sessionValidity shopperEmail shopperReference recurringContract allowedMethods blockedMethods shopperStatement merchantReturnData billingAddressType deliveryAddressType offset".split() - else: - keys = "authResult pspReference merchantReference skinCode merchantReturnData".split() - - def get_value(key): - if values.get(key): - return values[key] - return '' - - sign = ''.join('%s' % get_value(k) for k in keys).encode('ascii') - key = self.adyen_skin_hmac_key.encode('ascii') - return base64.b64encode(hmac.new(key, sign, hashlib.sha1).digest()) - - def adyen_form_generate_values(self, values): - base_url = self.get_base_url() - # tmp - import datetime - from dateutil import relativedelta - - paymentAmount = self._adyen_convert_amount(values['amount'], values['currency']) - if self.provider == 'adyen' and len(self.adyen_skin_hmac_key) == 64: - tmp_date = datetime.datetime.today() + relativedelta.relativedelta(days=1) - - values.update({ - 'merchantReference': values['reference'], - 'paymentAmount': '%d' % paymentAmount, - 'currencyCode': values['currency'] and values['currency'].name or '', - 'shipBeforeDate': tmp_date.strftime('%Y-%m-%d'), - 'skinCode': self.adyen_skin_code, - 'merchantAccount': self.adyen_merchant_account, - 'shopperLocale': values.get('partner_lang', ''), - 'sessionValidity': tmp_date.isoformat('T')[:19] + "Z", - 'resURL': urls.url_join(base_url, AdyenController._return_url), - 'merchantReturnData': json.dumps({'return_url': '%s' % values.pop('return_url')}) if values.get('return_url', '') else False, - 'shopperEmail': values.get('partner_email') or values.get('billing_partner_email') or '', - }) - values['merchantSig'] = self._adyen_generate_merchant_sig_sha256('in', values) - - else: - tmp_date = datetime.date.today() + relativedelta.relativedelta(days=1) - - values.update({ - 'merchantReference': values['reference'], - 'paymentAmount': '%d' % paymentAmount, - 'currencyCode': values['currency'] and values['currency'].name or '', - 'shipBeforeDate': tmp_date, - 'skinCode': self.adyen_skin_code, - 'merchantAccount': self.adyen_merchant_account, - 'shopperLocale': values.get('partner_lang'), - 'sessionValidity': tmp_date, - 'resURL': urls.url_join(base_url, AdyenController._return_url), - 'merchantReturnData': json.dumps({'return_url': '%s' % values.pop('return_url')}) if values.get('return_url') else False, - }) - values['merchantSig'] = self._adyen_generate_merchant_sig('in', values) - - return values - - def adyen_get_form_action_url(self): - self.ensure_one() - environment = 'prod' if self.state == 'enabled' else 'test' - return self._get_adyen_urls(environment)['adyen_form_url'] - - -class TxAdyen(models.Model): - _inherit = 'payment.transaction' - - # -------------------------------------------------- - # FORM RELATED METHODS - # -------------------------------------------------- - - @api.model - def _adyen_form_get_tx_from_data(self, data): - reference, pspReference = data.get('merchantReference'), data.get('pspReference') - if not reference or not pspReference: - error_msg = _('Adyen: received data with missing reference (%s) or missing pspReference (%s)') % (reference, pspReference) - _logger.info(error_msg) - raise ValidationError(error_msg) - - # find tx -> @TDENOTE use pspReference ? - tx = self.env['payment.transaction'].search([('reference', '=', reference)]) - if not tx or len(tx) > 1: - error_msg = _('Adyen: received data for reference %s') % (reference) - if not tx: - error_msg += _('; no order found') - else: - error_msg += _('; multiple order found') - _logger.info(error_msg) - raise ValidationError(error_msg) - - # verify shasign - if len(tx.acquirer_id.adyen_skin_hmac_key) == 64: - shasign_check = tx.acquirer_id._adyen_generate_merchant_sig_sha256('out', data) - else: - shasign_check = tx.acquirer_id._adyen_generate_merchant_sig('out', data) - if to_text(shasign_check) != to_text(data.get('merchantSig')): - error_msg = _('Adyen: invalid merchantSig, received %s, computed %s') % (data.get('merchantSig'), shasign_check) - _logger.warning(error_msg) - raise ValidationError(error_msg) - - return tx - - def _adyen_form_get_invalid_parameters(self, data): - invalid_parameters = [] - - # reference at acquirer: pspReference - if self.acquirer_reference and data.get('pspReference') != self.acquirer_reference: - invalid_parameters.append(('pspReference', data.get('pspReference'), self.acquirer_reference)) - # seller - if data.get('skinCode') != self.acquirer_id.adyen_skin_code: - invalid_parameters.append(('skinCode', data.get('skinCode'), self.acquirer_id.adyen_skin_code)) - # result - if not data.get('authResult'): - invalid_parameters.append(('authResult', data.get('authResult'), 'something')) - - return invalid_parameters - - def _adyen_form_validate(self, data): - status = data.get('authResult', 'PENDING') - if status == 'AUTHORISED': - self.write({'acquirer_reference': data.get('pspReference')}) - self._set_transaction_done() - return True - elif status == 'PENDING': - self.write({'acquirer_reference': data.get('pspReference')}) - self._set_transaction_pending() - return True - else: - error = _('Adyen: feedback error') - _logger.info(error) - self.write({'state_message': error}) - self._set_transaction_cancel() - return False diff --git a/addons/payment_adyen/models/payment_acquirer.py b/addons/payment_adyen/models/payment_acquirer.py new file mode 100644 index 00000000000..7f91ea992f8 --- /dev/null +++ b/addons/payment_adyen/models/payment_acquirer.py @@ -0,0 +1,119 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging +import re + +import requests + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +from odoo.addons.payment_adyen.const import API_ENDPOINT_VERSIONS + +_logger = logging.getLogger(__name__) + + +class PaymentAcquirer(models.Model): + _inherit = 'payment.acquirer' + + provider = fields.Selection( + selection_add=[('adyen', "Adyen")], ondelete={'adyen': 'set default'}) + adyen_merchant_account = fields.Char( + string="Merchant Account", + help="The code of the merchant account to use with this acquirer", + required_if_provider='adyen', groups='base.group_system') + adyen_api_key = fields.Char( + string="API Key", help="The API key of the user account", required_if_provider='adyen', + groups='base.group_system') + adyen_hmac_key = fields.Char( + string="HMAC Key", help="The HMAC key of the webhook", required_if_provider='adyen', + groups='base.group_system') + adyen_checkout_api_url = fields.Char( + string="Checkout API URL", help="The base URL for the Checkout API endpoints", + required_if_provider='adyen') + adyen_recurring_api_url = fields.Char( + string="Recurring API URL", help="The base URL for the Recurring API endpoints", + required_if_provider='adyen') + + #=== CRUD METHODS ===# + + @api.model_create_multi + def create(self, values_list): + for values in values_list: + self._adyen_trim_api_urls(values) + return super().create(values_list) + + def write(self, values): + self._adyen_trim_api_urls(values) + return super().write(values) + + @api.model + def _adyen_trim_api_urls(self, values): + """ Remove the version and the endpoint from the url of Adyen API fields. + + :param dict values: The create or write values + :return: None + """ + for field_name in ('adyen_checkout_api_url', 'adyen_recurring_api_url'): + if values.get(field_name): # Test the value in case we're duplicating an acquirer + values[field_name] = re.sub(r'[vV]\d+(/.*)?', '', values[field_name]) + + #=== BUSINESS METHODS ===# + + def _adyen_make_request(self, url_field_name, endpoint, payload=None, method='POST'): + """ Make a request to Adyen API at the specified endpoint. + + Note: self.ensure_one() + + :param str url_field_name: The name of the field holding the base URL for the request + :param str endpoint: The endpoint to be reached by the request + :param dict payload: The payload of the request + :param str method: The HTTP method of the request + :return The JSON-formatted content of the response + :rtype: dict + :raise: ValidationError if an HTTP error occurs + """ + + def _build_url(_base_url, _version, _endpoint): + """ Build an API URL by appending the version and endpoint to a base URL. + + The final URL follows this pattern : `<_base>/V<_version>/<_endpoint>`. + + :param str _base_url: The base of the url prefixed with `https://` + :param int _version: The version of the endpoint + :param str _endpoint: The endpoint of the URL. + :return: The final URL + :rtype: str + """ + _base = _base_url.rstrip('/') # Remove potential trailing slash + _endpoint = _endpoint.lstrip('/') # Remove potential leading slash + return f'{_base}/V{_version}/{_endpoint}' + + self.ensure_one() + + version = API_ENDPOINT_VERSIONS[endpoint] + base_url = self[url_field_name] # Restrict request URL to the stored API URL fields + url = _build_url(base_url, version, endpoint) + headers = {'X-API-Key': self.adyen_api_key} + try: + response = requests.request(method, url, json=payload, headers=headers, timeout=60) + response.raise_for_status() + except requests.exceptions.ConnectionError: + _logger.exception("unable to reach endpoint at %s", url) + raise ValidationError("Adyen: " + _("Could not establish the connection to the API.")) + except requests.exceptions.HTTPError: + _logger.exception("invalid API request at %s with data %s", url, payload) + raise ValidationError("Adyen: " + _("The communication with the API failed.")) + return response.json() + + def _adyen_compute_shopper_reference(self, partner_id): + """ Compute a unique reference of the partner for Adyen. + + This is used for the `shopperReference` field in communications with Adyen and stored in the + `adyen_shopper_reference` field on `payment.token` if the payment method is tokenized. + + :param recordset partner_id: The partner making the transaction, as a `res.partner` id + :return: The unique reference for the partner + :rtype: str + """ + return f'ODOO_PARTNER_{partner_id}' diff --git a/addons/payment_adyen/models/payment_token.py b/addons/payment_adyen/models/payment_token.py new file mode 100644 index 00000000000..d885eacf938 --- /dev/null +++ b/addons/payment_adyen/models/payment_token.py @@ -0,0 +1,54 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import _, fields, models +from odoo.exceptions import UserError, ValidationError + + +class PaymentToken(models.Model): + _inherit = 'payment.token' + + adyen_shopper_reference = fields.Char( + string="Shopper Reference", help="The unique reference of the partner owning this token", + readonly=True) + + #=== BUSINESS METHODS ===# + + def _handle_deactivation_request(self): + """ Override of payment to request request Adyen to delete the token. + + Note: self.ensure_one() + + :return: None + """ + super()._handle_deactivation_request() + if self.provider != 'adyen': + return + + data = { + 'merchantAccount': self.acquirer_id.adyen_merchant_account, + 'shopperReference': self.adyen_shopper_reference, + 'recurringDetailReference': self.acquirer_ref, + } + try: + self.acquirer_id._adyen_make_request( + url_field_name='adyen_recurring_api_url', + endpoint='/disable', + payload=data, + method='POST' + ) + except ValidationError: + pass # Deactivating the token in Odoo is more important than in Adyen + + def _handle_reactivation_request(self): + """ Override of payment to raise an error informing that Adyen tokens cannot be restored. + + Note: self.ensure_one() + + :return: None + :raise: UserError if the token is managed by Adyen + """ + super()._handle_reactivation_request() + if self.provider != 'adyen': + return + + raise UserError(_("Saved payment methods cannot be restored once they have been deleted.")) diff --git a/addons/payment_adyen/models/payment_transaction.py b/addons/payment_adyen/models/payment_transaction.py new file mode 100644 index 00000000000..4d5bfa63e65 --- /dev/null +++ b/addons/payment_adyen/models/payment_transaction.py @@ -0,0 +1,183 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging +import pprint + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError + +from odoo.addons.payment import utils as payment_utils +from odoo.addons.payment_adyen.const import CURRENCY_DECIMALS, RESULT_CODES_MAPPING + +_logger = logging.getLogger(__name__) + + +class PaymentTransaction(models.Model): + _inherit = 'payment.transaction' + + adyen_payment_data = fields.Char( + string="Saved Payment Data", + help="Data that must be passed back to Adyen when returning from redirect", readonly=True, + groups='base.group_system') + + #=== BUSINESS METHODS ===# + + def _get_specific_processing_values(self, processing_values): + """ Override of payment to return Adyen-specific processing values. + + Note: self.ensure_one() from `_get_processing_values` + + :param dict processing_values: The generic processing values of the transaction + :return: The dict of acquirer-specific processing values + :rtype: dict + """ + res = super()._get_specific_processing_values(processing_values) + if self.provider != 'adyen': + return res + + converted_amount = payment_utils.to_minor_currency_units( + self.amount, self.currency_id, CURRENCY_DECIMALS.get(self.currency_id.name) + ) + return { + 'converted_amount': converted_amount, + 'access_token': payment_utils.generate_access_token( + processing_values['reference'], + converted_amount, + processing_values['partner_id'] + ) + } + + def _send_payment_request(self): + """ Override of payment to send a payment request to Adyen. + + Note: self.ensure_one() + + :return: None + :raise: UserError if the transaction is not linked to a token + """ + super()._send_payment_request() + if self.provider != 'adyen': + return + + # Make the payment request to Adyen + if not self.token_id: + raise UserError("Adyen: " + _("The transaction is not linked to a token.")) + + converted_amount = payment_utils.to_minor_currency_units( + self.amount, self.currency_id, CURRENCY_DECIMALS.get(self.currency_id.name) + ) + data = { + 'merchantAccount': self.acquirer_id.adyen_merchant_account, + 'amount': { + 'value': converted_amount, + 'currency': self.currency_id.name, + }, + 'reference': self.reference, + 'paymentMethod': { + 'recurringDetailReference': self.token_id.acquirer_ref + }, # Required by Adyen although it is also provided with 'storedPaymentMethodId' + 'storedPaymentMethodId': self.token_id.acquirer_ref, + 'shopperReference': self.token_id.adyen_shopper_reference, + 'recurringProcessingModel': 'Subscription', + 'shopperIP': payment_utils.get_customer_ip_address(), + 'shopperInteraction': 'ContAuth', + } + response_content = self.acquirer_id._adyen_make_request( + url_field_name='adyen_checkout_api_url', + endpoint='/payments', + payload=data, + method='POST' + ) + + # Handle the payment request response + _logger.info("payment request response:\n%s", pprint.pformat(response_content)) + self._handle_feedback_data('adyen', response_content) + + @api.model + def _get_tx_from_feedback_data(self, provider, data): + """ Override of payment to find the transaction based on Adyen data. + + :param str provider: The provider of the acquirer that handled the transaction + :param dict data: The feedback data sent by the provider + :return: The transaction if found + :rtype: recordset of `payment.transaction` + :raise: ValidationError if inconsistent data were received + :raise: ValidationError if the data match no transaction + """ + tx = super()._get_tx_from_feedback_data(provider, data) + if provider != 'adyen': + return tx + + reference = data.get('merchantReference') + if not reference: + raise ValidationError("Adyen: " + _("Received data with missing merchant reference")) + + tx = self.search([('reference', '=', reference), ('provider', '=', 'adyen')]) + if not tx: + raise ValidationError( + "Adyen: " + _("No transaction found matching reference %s.", reference) + ) + return tx + + def _process_feedback_data(self, data): + """ Override of payment to process the transaction based on Adyen data. + + Note: self.ensure_one() + + :param dict data: The feedback data sent by the provider + :return: None + :raise: ValidationError if inconsistent data were received + """ + super()._process_feedback_data(data) + if self.provider != 'adyen': + return + + # Handle the acquirer reference + if 'pspReference' in data: + self.acquirer_reference = data.get('pspReference') + + # Handle the payment state + payment_state = data.get('resultCode') + if not payment_state: + raise ValidationError("Adyen: " + _("Received data with missing payment state.")) + + if payment_state in RESULT_CODES_MAPPING['pending']: + self._set_pending() + elif payment_state in RESULT_CODES_MAPPING['done']: + has_token_data = 'recurring.recurringDetailReference' in data.get('additionalData', {}) + if self.tokenize and has_token_data: + self._adyen_tokenize_from_feedback_data(data) + self._set_done() + elif payment_state in RESULT_CODES_MAPPING['cancel']: + self._set_canceled() + else: # Classify unsupported payment state as `error` tx state + _logger.warning("received data with invalid payment state: %s", payment_state) + self._set_error( + "Adyen: " + _("Received data with invalid payment state: %s", payment_state) + ) + + def _adyen_tokenize_from_feedback_data(self, data): + """ Create a new token based on the feedback data. + + Note: self.ensure_one() + + :param dict data: The feedback data sent by the provider + :return: None + """ + self.ensure_one() + + token = self.env['payment.token'].create({ + 'acquirer_id': self.acquirer_id.id, + 'name': payment_utils.build_token_name(data['additionalData'].get('cardSummary')), + 'partner_id': self.partner_id.id, + 'acquirer_ref': data['additionalData']['recurring.recurringDetailReference'], + 'adyen_shopper_reference': data['additionalData']['recurring.shopperReference'], + 'verified': True, # The payment is authorized, so the payment method is valid + }) + self.write({ + 'token_id': token, + 'tokenize': False, + }) + _logger.info( + "created token with id %s for partner with id %s", token.id, self.partner_id.id + ) diff --git a/addons/payment_adyen/static/src/js/payment_form.js b/addons/payment_adyen/static/src/js/payment_form.js new file mode 100644 index 00000000000..952fdb6c9fd --- /dev/null +++ b/addons/payment_adyen/static/src/js/payment_form.js @@ -0,0 +1,220 @@ +odoo.define('payment_adyen.payment_form', require => { + 'use strict'; + + const core = require('web.core'); + const checkoutForm = require('payment.checkout_form'); + const manageForm = require('payment.manage_form'); + + const _t = core._t; + + const adyenMixin = { + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Handle the additional details event of the Adyen drop-in. + * + * @private + * @param {object} state - The state of the drop-in + * @param {object} dropin - The drop-in + * @return {Promise} + */ + _dropinOnAdditionalDetails: function (state, dropin) { + this._hideInputs(); // Only the inputs of the inline form should be used + return this._rpc({ + route: '/payment/adyen/payment_details', + params: { + 'acquirer_id': dropin.acquirerId, + 'reference': this.adyenDropin.reference, + 'details': state.data.details, + 'payment_data': state.data.paymentData, + }, + }).then(paymentDetails => { + if (paymentDetails.action) { // Additional action required from the shopper + dropin.handleAction(paymentDetails.action); + } else { // The payment reached a final state, redirect to the status page + window.location = '/payment/status'; + } + }).guardedCatch((error) => { + error.event.preventDefault(); + this._displayError( + _t("Server Error"), + _t("We are not able to process your payment."), + error.message.data.message + ); + }); + }, + + /** + * Handle the error event of the Adyen drop-in. + * + * @private + * @param {object} error - The error in the drop-in + * @return {undefined} + */ + _dropinOnError: function (error) { + this._displayError( + _t("Incorrect Payment Details"), + _t("Please verify your payment details.") + ); + }, + + /** + * Handle the submit event of the Adyen drop-in and initiate the payment. + * + * @private + * @param {object} state - The state of the drop-in + * @param {object} dropin - The drop-in + * @return {Promise} + */ + _dropinOnSubmit: function (state, dropin) { + // Create the transaction and retrieve the processing values + return this._rpc({ + route: this.txContext.transactionRoute, + params: this._prepareTransactionRouteParams('adyen', dropin.acquirerId, 'direct'), + }).then(processingValues => { + this.adyenDropin.reference = processingValues.reference; // Store final reference + // Initiate the payment + return this._rpc({ + route: '/payment/adyen/payments', + params: { + 'acquirer_id': dropin.acquirerId, + 'reference': processingValues.reference, + 'converted_amount': processingValues.converted_amount, + 'currency_id': processingValues.currency_id, + 'partner_id': processingValues.partner_id, + 'payment_method': state.data.paymentMethod, + 'access_token': processingValues.access_token, + 'browser_info': state.data.browserInfo, + }, + }); + }).then(paymentResponse => { + if (paymentResponse.action) { // Additional action required from the shopper + dropin.handleAction(paymentResponse.action); + } else { // The payment reached a final state, redirect to the status page + window.location = '/payment/status'; + } + }).guardedCatch((error) => { + error.event.preventDefault(); + this._displayError( + _t("Server Error"), + _t("We are not able to process your payment."), + error.message.data.message + ); + }); + }, + + /** + * Prepare the inline form of Adyen for direct payment. + * + * @override method from payment.payment_form_mixin + * @private + * @param {string} provider - The provider of the selected payment option's acquirer + * @param {number} paymentOptionId - The id of the selected payment option + * @param {string} flow - The online payment flow of the selected payment option + * @return {Promise} + */ + _prepareInlineForm: function (provider, paymentOptionId, flow) { + if (provider !== 'adyen') { + return this._super(...arguments); + } + + // Check if instantiation of the drop-in is needed + if (flow === 'token') { + return Promise.resolve(); // No drop-in for tokens + } else if (this.adyenDropin && this.adyenDropin.acquirerId === paymentOptionId) { + this._setPaymentFlow('direct'); // Overwrite the flow even if no re-instantiation + return Promise.resolve(); // Don't re-instantiate if already done for this acquirer + } + + // Overwrite the flow of the select payment option + this._setPaymentFlow('direct'); + + // Get the acquirer state ("enabled" or "test") + return this._rpc({ + route: '/payment/adyen/acquirer_state', + params: { + 'acquirer_id': paymentOptionId, + }, + }).then(acquirerState => { + // Get the available payment methods + return this._rpc({ + route: '/payment/adyen/payment_methods', + params: { + 'acquirer_id': paymentOptionId, + 'partner_id': parseInt(this.txContext.partnerId), + 'amount': this.txContext.amount + ? parseFloat(this.txContext.amount) + : undefined, + 'currency_id': this.txContext.currencyId + ? parseInt(this.txContext.currencyId) + : undefined, + }, + }).then(paymentMethodsResult => { + // Generate the origin key + return this._rpc({ + route: '/payment/adyen/origin_key', + params: { + 'acquirer_id': paymentOptionId, + }, + }).then(originKeyResult => { + // Extract the origin key from the returned object + const originKeyResultKeys = Object.keys(originKeyResult.originKeys); + const originKey = originKeyResult.originKeys[originKeyResultKeys[0]]; + + // Instantiate the drop-in + const configuration = { + paymentMethodsResponse: paymentMethodsResult, + originKey: originKey, + locale: (this._getContext().lang || 'en-US').replace('_', '-'), + environment: acquirerState === 'enabled' ? 'live' : 'test', + openFirstPaymentMethod: true, + openFirstStoredPaymentMethod: false, + showStoredPaymentMethods: false, + showPaymentMethods: true, + showPayButton: false, + onAdditionalDetails: this._dropinOnAdditionalDetails.bind(this), + onError: this._dropinOnError.bind(this), + onSubmit: this._dropinOnSubmit.bind(this), + }; + const checkout = new AdyenCheckout(configuration); + this.adyenDropin = checkout.create('dropin').mount( + `#o_adyen_dropin_container_${paymentOptionId}` + ); + this.adyenDropin.acquirerId = paymentOptionId; + }); + }); + }).guardedCatch((error) => { + error.event.preventDefault(); + this._displayError( + _t("Server Error"), + _t("An error occurred when displayed this payment form."), + error.message.data.message + ); + }); + }, + + /** + * Trigger the payment processing by submitting the drop-in. + * + * @override method from payment.payment_form_mixin + * @private + * @param {string} provider - The provider of the payment option's acquirer + * @param {number} paymentOptionId - The id of the payment option handling the transaction + * @param {string} flow - The online payment flow of the transaction + * @return {Promise} + */ + _processPayment: function (provider, paymentOptionId, flow) { + if (provider !== 'adyen' || flow === 'token') { + return this._super(...arguments); // Tokens are handled by the generic flow + } + return this.adyenDropin.submit(); + }, + + }; + + checkoutForm.include(adyenMixin); + manageForm.include(adyenMixin); +}); diff --git a/addons/payment_adyen/tests/__init__.py b/addons/payment_adyen/tests/__init__.py index 40a96afc6ff..74a9084729b 100644 --- a/addons/payment_adyen/tests/__init__.py +++ b/addons/payment_adyen/tests/__init__.py @@ -1 +1,4 @@ -# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import common +from . import test_adyen diff --git a/addons/payment_adyen/tests/common.py b/addons/payment_adyen/tests/common.py new file mode 100644 index 00000000000..52f10a826aa --- /dev/null +++ b/addons/payment_adyen/tests/common.py @@ -0,0 +1,20 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo.addons.payment.tests.common import PaymentCommon + + +class AdyenCommon(PaymentCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.adyen = cls._prepare_acquirer('adyen', update_values={ + 'adyen_merchant_account': 'dummy', + 'adyen_api_key': 'dummy', + 'adyen_hmac_key': 'dummy', + 'adyen_checkout_api_url': 'https://this.is.an.url', + 'adyen_recurring_api_url': 'https://this.is.an.url', + }) + + # Override default values + cls.acquirer = cls.adyen diff --git a/addons/payment_adyen/tests/test_adyen.py b/addons/payment_adyen/tests/test_adyen.py index 1d792ccc089..0c0eb0dbf2d 100644 --- a/addons/payment_adyen/tests/test_adyen.py +++ b/addons/payment_adyen/tests/test_adyen.py @@ -1,79 +1,43 @@ -# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. -from lxml import objectify +from unittest.mock import patch -from odoo.addons.payment.tests.common import PaymentAcquirerCommon -from odoo.addons.payment_adyen.controllers.main import AdyenController -from werkzeug import urls -import odoo.tests +from odoo.exceptions import UserError +from odoo.tests import tagged +from odoo.tools import mute_logger + +from odoo.addons.payment import utils as payment_utils + +from .common import AdyenCommon -class AdyenCommon(PaymentAcquirerCommon): - - @classmethod - def setUpClass(cls, chart_template_ref=None): - super().setUpClass(chart_template_ref=chart_template_ref) - - # some CC (always use expiration date 06 / 2016, cvc 737, cid 7373 (amex)) - cls.amex = (('370000000000002', '7373')) - cls.dinersclub = (('36006666333344', '737')) - cls.discover = (('6011601160116611', '737'), ('644564456445644', '737')) - cls.jcb = (('3530111333300000', '737')) - cls.mastercard = (('5555444433331111', '737'), ('5555555555554444', '737')) - cls.visa = (('4111 1111 1111 1111', '737'), ('4444333322221111', '737')) - cls.mcdebit = (('5500000000000004', '737')) - cls.visadebit = (('4400000000000008', '737')) - cls.maestro = (('6731012345678906', '737')) - cls.laser = (('630495060000000000', '737')) - cls.hipercard = (('6062828888666688', '737')) - cls.dsmastercard = (('521234567890 1234', '737', 'user', 'password')) - cls.dsvisa = (('4212345678901237', '737', 'user', 'password')) - cls.mistercash = (('6703444444444449', None, 'user', 'password')) - cls.adyen = cls.env.ref('payment.payment_acquirer_adyen') - cls.adyen.write({ - 'adyen_merchant_account': 'dummy', - 'adyen_skin_code': 'dummy', - 'adyen_skin_hmac_key': 'dummy', - 'state': 'test', - }) - - -@odoo.tests.tagged('post_install', '-at_install', 'external', '-standard') +@tagged('post_install', '-at_install') class AdyenForm(AdyenCommon): - def test_10_adyen_form_render(self): - # be sure not to do stupid things - adyen = self.adyen - self.assertEqual(adyen.state, 'test', 'test without test environment') + def test_processing_values(self): + tx = self.create_transaction(flow='direct') + with mute_logger('odoo.addons.payment.models.payment_transaction'), \ + patch( + 'odoo.addons.payment.utils.generate_access_token', + new=self._generate_test_access_token + ): + processing_values = tx._get_processing_values() - # ---------------------------------------- - # Test: button direct rendering - # ---------------------------------------- + converted_amount = 111111 + self.assertEqual( + payment_utils.to_minor_currency_units(self.amount, self.currency), + converted_amount, + ) + self.assertEqual(processing_values['converted_amount'], converted_amount) + with patch( + 'odoo.addons.payment.utils.generate_access_token', new=self._generate_test_access_token + ): + self.assertTrue(payment_utils.check_access_token( + processing_values['access_token'], self.reference, converted_amount, self.partner.id + )) - base_url = self.env['ir.config_parameter'].get_param('web.base.url') - form_values = { - 'merchantAccount': 'OpenERPCOM', - 'merchantReference': 'test_ref0', - 'skinCode': 'cbqYWvVL', - 'paymentAmount': '1', - 'currencyCode': 'EUR', - 'resURL': urls.url_join(base_url, AdyenController._return_url), - } - - # render the button - res = adyen.render( - 'test_ref0', 0.01, self.currency_euro.id, - partner_id=None, - partner_values=self.buyer_values) - - # check form result - tree = objectify.fromstring(res) - self.assertEqual(tree.get('action'), 'https://test.adyen.com/hpp/pay.shtml', 'adyen: wrong form POST url') - for form_input in tree.input: - if form_input.get('name') in ['submit', 'shipBeforeDate', 'sessionValidity', 'shopperLocale', 'merchantSig']: - continue - self.assertEqual( - form_input.get('value'), - form_values[form_input.get('name')], - 'adyen: wrong value for input %s: received %s instead of %s' % (form_input.get('name'), form_input.get('value'), form_values[form_input.get('name')]) - ) + def test_token_activation(self): + """Activation of disabled adyen tokens is forbidden""" + token = self.create_token(active=False) + with self.assertRaises(UserError): + token._handle_reactivation_request() diff --git a/addons/payment_adyen/views/assets.xml b/addons/payment_adyen/views/assets.xml new file mode 100644 index 00000000000..e950ba8447e --- /dev/null +++ b/addons/payment_adyen/views/assets.xml @@ -0,0 +1,20 @@ + + + +