[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:
Antoine Vandevenne (anv)
2021-03-30 09:25:51 +02:00
parent 573ed74c12
commit a30aa2aeed
18 changed files with 1080 additions and 429 deletions
+6 -5
View File
@@ -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')
+6 -6
View File
@@ -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',
}
+33
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import main
+345 -23
View File
@@ -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>
+4 -2
View File
@@ -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
-269
View File
@@ -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);
});
+4 -1
View File
@@ -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
+20
View File
@@ -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
+35 -71
View File
@@ -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()
+20
View File
@@ -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>
+16 -17
View File
@@ -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>