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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/payment_adyen/views/payment_adyen_templates.xml b/addons/payment_adyen/views/payment_adyen_templates.xml
index 75d2fddc7b3..5213c2deb16 100644
--- a/addons/payment_adyen/views/payment_adyen_templates.xml
+++ b/addons/payment_adyen/views/payment_adyen_templates.xml
@@ -1,27 +1,8 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
diff --git a/addons/payment_adyen/views/payment_views.xml b/addons/payment_adyen/views/payment_views.xml
index af6fac2582d..876d360e9d7 100644
--- a/addons/payment_adyen/views/payment_views.xml
+++ b/addons/payment_adyen/views/payment_views.xml
@@ -1,22 +1,21 @@
-
-
- acquirer.form.adyen
- payment.acquirer
-
-
-
-
-
-
-
- How to configure your Adyen account?
-
-
-
-
+
+ Adyen Acquirer Form
+ payment.acquirer
+
+
+
+
+
+
+
+
+
+
+
+
+
-