[REF] payment_adyen: migrate Adyen to the new payment API
This commits also switches the Adyen implementation from the Hosted Payment Pages API to the new Checkout API, hence replacing the payment with redirection flow by a direct payment flow. See the merge commit for more details. task-2479832
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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',),
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import main
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="payment.payment_acquirer_adyen" model="payment.acquirer">
|
||||
<field name="name">Adyen</field>
|
||||
<field name="image_128" type="base64" file="payment_adyen/static/src/img/adyen_icon.png"/>
|
||||
<field name="provider">adyen</field>
|
||||
<field name="company_id" ref="base.main_company"/>
|
||||
<field name="view_template_id" ref="adyen_form"/>
|
||||
</record>
|
||||
<record id="payment.payment_acquirer_adyen" model="payment.acquirer">
|
||||
<field name="provider">adyen</field>
|
||||
<field name="inline_form_view_id" ref="inline_form"/>
|
||||
<field name="support_authorization">False</field>
|
||||
<field name="support_fees_computation">False</field>
|
||||
<field name="support_tokenization">True</field>
|
||||
<field name="allow_tokenization">True</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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}'
|
||||
@@ -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."))
|
||||
@@ -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
|
||||
)
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<template id="assets_frontend" inherit_id="web.assets_frontend">
|
||||
<xpath expr="link[last()]" position="after">
|
||||
<link rel="stylesheet"
|
||||
href="https://checkoutshopper-live.adyen.com/checkoutshopper/sdk/3.9.4/adyen.css"
|
||||
integrity="sha384-6qrXvoxlnBlrflZQ9g5Yf5oZapUSSXctPxacP9oRcEukbEO7lXisuSyMKG8pDX8V"
|
||||
crossorigin="anonymous"/>
|
||||
</xpath>
|
||||
<xpath expr="script[last()]" position="after">
|
||||
<script src="https://checkoutshopper-live.adyen.com/checkoutshopper/sdk/3.9.4/adyen.js"
|
||||
integrity="sha384-/rpftrZptnl7EIuIvqz4PAEehQoysKXmwqUAGyH9lK3WHgcvY0lWj/S5LH7xrbf+"
|
||||
crossorigin="anonymous"/>
|
||||
<!-- Load after fetching Adyen SDK -->
|
||||
<script type="text/javascript" src="/payment_adyen/static/src/js/payment_form.js"/>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -1,27 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<template id="adyen_form">
|
||||
<input type="hidden" name="data_set" t-att-data-action-url="tx_url" data-remove-me=""/>
|
||||
<input type="hidden" name="merchantReference" t-att-value="merchantReference"/>
|
||||
<input type="hidden" name="paymentAmount" t-att-value="paymentAmount"/>
|
||||
<input type="hidden" name="currencyCode" t-att-value="currencyCode"/>
|
||||
<input type="hidden" name="shipBeforeDate" t-att-value="shipBeforeDate"/>
|
||||
<input type="hidden" name="skinCode" t-att-value="skinCode"/>
|
||||
<input type="hidden" name="merchantAccount" t-att-value="merchantAccount"/>
|
||||
<input type="hidden" name="shopperLocale" t-att-value="shopperLocale"/>
|
||||
<input type="hidden" name="sessionValidity" t-att-value="sessionValidity"/>
|
||||
<input type="hidden" name="merchantSig" t-att-value="merchantSig"/>
|
||||
<!-- URLs -->
|
||||
<input type="hidden" name='resURL'
|
||||
t-att-value="resURL"/>
|
||||
<!-- custom -->
|
||||
<input t-if="merchantReturnData" type="hidden" name='merchantReturnData'
|
||||
t-att-value="merchantReturnData"/>
|
||||
<!-- shopperEmail is not included for SHA-1. To avoid breaking compatibility,
|
||||
include only if filled in -->
|
||||
<input t-if="shopperEmail"
|
||||
type="hidden" name='shopperEmail' t-att-value="shopperEmail"/>
|
||||
</template>
|
||||
</data>
|
||||
|
||||
<template id="inline_form">
|
||||
<div t-attf-id="o_adyen_dropin_container_{{acquirer_id}}"/>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
|
||||
<record id="acquirer_form_adyen" model="ir.ui.view">
|
||||
<field name="name">acquirer.form.adyen</field>
|
||||
<field name="model">payment.acquirer</field>
|
||||
<field name="inherit_id" ref="payment.acquirer_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr='//group[@name="acquirer"]' position='inside'>
|
||||
<group attrs="{'invisible': [('provider', '!=', 'adyen')]}">
|
||||
<field name="adyen_merchant_account" attrs="{'required':[('provider', '=', 'adyen'), ('state', '!=', 'disabled')]}"/>
|
||||
<field name="adyen_skin_code" attrs="{'required':[('provider', '=', 'adyen'), ('state', '!=', 'disabled')]}"/>
|
||||
<field name="adyen_skin_hmac_key" attrs="{'required':[('provider', '=', 'adyen'), ('state', '!=', 'disabled')]}"/>
|
||||
<a colspan="2" href="https://www.adyen.com/home/payment-services/online-payments" target="_blank">How to configure your Adyen account?</a>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
<record id="payment_acquirer_form" model="ir.ui.view">
|
||||
<field name="name">Adyen Acquirer Form</field>
|
||||
<field name="model">payment.acquirer</field>
|
||||
<field name="inherit_id" ref="payment.payment_acquirer_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr='//group[@name="acquirer"]' position='inside'>
|
||||
<group attrs="{'invisible': [('provider', '!=', 'adyen')]}">
|
||||
<field name="adyen_merchant_account" attrs="{'required':[('provider', '=', 'adyen'), ('state', '!=', 'disabled')]}"/>
|
||||
<field name="adyen_api_key" attrs="{'required':[('provider', '=', 'adyen'), ('state', '!=', 'disabled')]}"/>
|
||||
<field name="adyen_hmac_key" attrs="{'required':[('provider', '=', 'adyen'), ('state', '!=', 'disabled')]}"/>
|
||||
<field name="adyen_checkout_api_url" attrs="{'required':[('provider', '=', 'adyen'), ('state', '!=', 'disabled')]}"/>
|
||||
<field name="adyen_recurring_api_url" attrs="{'required':[('provider', '=', 'adyen'), ('state', '!=', 'disabled')]}"/>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
Reference in New Issue
Block a user