From 471a1f39f5499b93a30d8ea751bac87102138b2e Mon Sep 17 00:00:00 2001 From: "Antoine Vandevenne (anv)" Date: Fri, 26 Mar 2021 18:16:32 +0100 Subject: [PATCH] [REF] payment_odoo: migrate Odoo Payment to the new payment API This commit also renames the module `payment_odoo_by_adyen` to `payment_odoo` as well as the referring strings ("Odoo Payments" instead of "Odoo Payments by Adyen", ...). A dedicated [MOV] commit is not used because neither the [MOV] commit nor the adapted [REF] would be valid on its own. See the merge commit for more details. task-2390665 --- addons/payment_odoo/__init__.py | 11 + .../__manifest__.py | 14 +- addons/payment_odoo/const.py | 22 ++ .../controllers/__init__.py | 1 - addons/payment_odoo/controllers/main.py | 81 ++++++ .../data/payment_acquirer_data.xml | 13 + .../i18n/payment_odoo_by_adyen.pot | 0 addons/payment_odoo/models/__init__.py | 5 + .../payment_odoo/models/payment_acquirer.py | 42 ++++ addons/payment_odoo/models/payment_token.py | 9 + .../models/payment_transaction.py | 231 ++++++++++++++++++ .../static/description/icon.png | Bin .../static/description/icon.svg | 0 .../static/src/img/odoo_icon.png | Bin .../views/payment_odoo_templates.xml | 10 + addons/payment_odoo/views/payment_views.xml | 27 ++ addons/payment_odoo_by_adyen/__init__.py | 5 - .../payment_odoo_by_adyen/controllers/main.py | 22 -- .../data/payment_acquirer_data.xml | 14 -- .../payment_odoo_by_adyen/models/__init__.py | 4 - .../payment_odoo_by_adyen/models/payment.py | 185 -------------- .../views/payment_odoo_by_adyen_templates.xml | 9 - .../views/payment_views.xml | 22 -- 23 files changed, 458 insertions(+), 269 deletions(-) create mode 100644 addons/payment_odoo/__init__.py rename addons/{payment_odoo_by_adyen => payment_odoo}/__manifest__.py (52%) create mode 100644 addons/payment_odoo/const.py rename addons/{payment_odoo_by_adyen => payment_odoo}/controllers/__init__.py (79%) create mode 100644 addons/payment_odoo/controllers/main.py create mode 100644 addons/payment_odoo/data/payment_acquirer_data.xml rename addons/{payment_odoo_by_adyen => payment_odoo}/i18n/payment_odoo_by_adyen.pot (100%) create mode 100644 addons/payment_odoo/models/__init__.py create mode 100644 addons/payment_odoo/models/payment_acquirer.py create mode 100644 addons/payment_odoo/models/payment_token.py create mode 100644 addons/payment_odoo/models/payment_transaction.py rename addons/{payment_odoo_by_adyen => payment_odoo}/static/description/icon.png (100%) rename addons/{payment_odoo_by_adyen => payment_odoo}/static/description/icon.svg (100%) rename addons/{payment_odoo_by_adyen => payment_odoo}/static/src/img/odoo_icon.png (100%) create mode 100644 addons/payment_odoo/views/payment_odoo_templates.xml create mode 100644 addons/payment_odoo/views/payment_views.xml delete mode 100644 addons/payment_odoo_by_adyen/__init__.py delete mode 100644 addons/payment_odoo_by_adyen/controllers/main.py delete mode 100644 addons/payment_odoo_by_adyen/data/payment_acquirer_data.xml delete mode 100644 addons/payment_odoo_by_adyen/models/__init__.py delete mode 100644 addons/payment_odoo_by_adyen/models/payment.py delete mode 100644 addons/payment_odoo_by_adyen/views/payment_odoo_by_adyen_templates.xml delete mode 100644 addons/payment_odoo_by_adyen/views/payment_views.xml diff --git a/addons/payment_odoo/__init__.py b/addons/payment_odoo/__init__.py new file mode 100644 index 00000000000..9fe1f558a00 --- /dev/null +++ b/addons/payment_odoo/__init__.py @@ -0,0 +1,11 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import controllers +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_acquirer(cr, registry, 'odoo') diff --git a/addons/payment_odoo_by_adyen/__manifest__.py b/addons/payment_odoo/__manifest__.py similarity index 52% rename from addons/payment_odoo_by_adyen/__manifest__.py rename to addons/payment_odoo/__manifest__.py index b3d7b76a9ea..299c15f287d 100644 --- a/addons/payment_odoo_by_adyen/__manifest__.py +++ b/addons/payment_odoo/__manifest__.py @@ -1,19 +1,19 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. { - 'name': 'Odoo Payments by Adyen Payment Acquirer', + 'name': 'Odoo Payments Payment Acquirer', + 'version': '2.0', 'category': 'Accounting/Payment Acquirers', 'sequence': 330, - 'summary': 'Payment Acquirer: Odoo Payments by Adyen', - 'version': '1.0', - 'description': """Odoo Payments by Adyen""", + 'summary': 'Payment Acquirer: Odoo Payments', + 'description': """Odoo Payments""", 'depends': ['payment', 'adyen_platforms'], 'data': [ 'views/payment_views.xml', - 'views/payment_odoo_by_adyen_templates.xml', + 'views/payment_odoo_templates.xml', 'data/payment_acquirer_data.xml', ], - 'installable': True, 'application': True, + 'post_init_hook': 'create_missing_journals', + 'uninstall_hook': 'uninstall_hook', } diff --git a/addons/payment_odoo/const.py b/addons/payment_odoo/const.py new file mode 100644 index 00000000000..73db0f01799 --- /dev/null +++ b/addons/payment_odoo/const.py @@ -0,0 +1,22 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +# Adyen-specific mapping of currency codes in ISO 4217 format to the number of decimals. +# Only currencies for which Adyen does not follow the ISO 4217 norm are listed here. +# See https://docs.adyen.com/development-resources/currency-codes +CURRENCY_DECIMALS = { + 'CLP': 2, + 'CVE': 0, + 'IDR': 0, + 'ISK': 2, +} + +# Mapping of transaction states to Adyen result codes. +# See https://docs.adyen.com/checkout/payment-result-codes for the exhaustive list of result codes. +RESULT_CODES_MAPPING = { + 'pending': ( + 'ChallengeShopper', 'IdentifyShopper', 'Pending', 'PresentToShopper', 'Received', + 'RedirectShopper' + ), + 'done': ('Authorised',), + 'cancel': ('Cancelled',), +} diff --git a/addons/payment_odoo_by_adyen/controllers/__init__.py b/addons/payment_odoo/controllers/__init__.py similarity index 79% rename from addons/payment_odoo_by_adyen/controllers/__init__.py rename to addons/payment_odoo/controllers/__init__.py index 5d4b25db9c0..80ee4da1c5e 100644 --- a/addons/payment_odoo_by_adyen/controllers/__init__.py +++ b/addons/payment_odoo/controllers/__init__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import main diff --git a/addons/payment_odoo/controllers/main.py b/addons/payment_odoo/controllers/main.py new file mode 100644 index 00000000000..063eda29fab --- /dev/null +++ b/addons/payment_odoo/controllers/main.py @@ -0,0 +1,81 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import json +import logging +import pprint + +from odoo import http +from odoo.http import request + +from odoo.addons.payment import utils as payment_utils +from odoo.addons.payment_odoo.const import CURRENCY_DECIMALS + +_logger = logging.getLogger(__name__) + + +class OdooController(http.Controller): + _notification_url = '/payment/odoo/notification' + + @http.route(_notification_url, type='json', auth='public') + def odoo_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: None + """ + # Payload data represent a single notification's data. Because two notifications of a same + # batch can be related to different sub-merchants, the proxy splits the batches and send + # individual notifications one by one to this endpoint. + notification_data = json.loads(request.httprequest.data) + + # Check the source and integrity of the notification + received_signature = notification_data.get('additionalData', {}).get( + 'metadata.merchant_signature' + ) + tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_feedback_data( + 'odoo', notification_data + ) + if not self._verify_notification_signature(received_signature, tx_sudo): + return + + _logger.info("notification received:\n%s", pprint.pformat(notification_data)) + if notification_data['success'] != 'true': + return # 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: + return # Don't handle unsupported event codes + + # Handle the notification data as a regular feedback + request.env['payment.transaction'].sudo()._handle_feedback_data('odoo', notification_data) + + def _verify_notification_signature(self, received_signature, tx): + """ Check that the signature computed from the transaction values matches the received one. + + :param str received_signature: The signature sent with the notification + :param recordset tx: The transaction of the notification, as a `payment.transaction` record + :return: Whether the signatures match + :rtype: str + """ + + if not received_signature: + _logger.warning("ignored notification with missing signature") + return False + + converted_amount = payment_utils.to_minor_currency_units( + tx.amount, tx.currency_id, CURRENCY_DECIMALS.get(tx.currency_id.name) + ) + if not payment_utils.check_access_token( + received_signature, converted_amount, tx.currency_id.name, tx.reference + ): + _logger.warning("ignored notification with invalid signature") + return False + + return True diff --git a/addons/payment_odoo/data/payment_acquirer_data.xml b/addons/payment_odoo/data/payment_acquirer_data.xml new file mode 100644 index 00000000000..ac585037e32 --- /dev/null +++ b/addons/payment_odoo/data/payment_acquirer_data.xml @@ -0,0 +1,13 @@ + + + + + odoo + + False + False + True + True + + + diff --git a/addons/payment_odoo_by_adyen/i18n/payment_odoo_by_adyen.pot b/addons/payment_odoo/i18n/payment_odoo_by_adyen.pot similarity index 100% rename from addons/payment_odoo_by_adyen/i18n/payment_odoo_by_adyen.pot rename to addons/payment_odoo/i18n/payment_odoo_by_adyen.pot diff --git a/addons/payment_odoo/models/__init__.py b/addons/payment_odoo/models/__init__.py new file mode 100644 index 00000000000..22eb69117db --- /dev/null +++ b/addons/payment_odoo/models/__init__.py @@ -0,0 +1,5 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import payment_acquirer +from . import payment_token +from . import payment_transaction diff --git a/addons/payment_odoo/models/payment_acquirer.py b/addons/payment_odoo/models/payment_acquirer.py new file mode 100644 index 00000000000..57dd6e68522 --- /dev/null +++ b/addons/payment_odoo/models/payment_acquirer.py @@ -0,0 +1,42 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from werkzeug import urls + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class PaymentAcquirer(models.Model): + _inherit = 'payment.acquirer' + + provider = fields.Selection( + selection_add=[('odoo', "Odoo Payments")], ondelete={'odoo': 'set default'}) + odoo_adyen_account_id = fields.Many2one( + related='company_id.adyen_account_id', required_if_provider='odoo') + odoo_adyen_payout_id = fields.Many2one( + string="Adyen Payout", comodel_name='adyen.payout', required_if_provider='odoo', + domain='[("adyen_account_id", "=", odoo_adyen_account_id)]') + + @api.constrains('provider', 'state') + def _check_state_is_not_test(self): + if any(a.provider == 'odoo' and a.state == 'test' for a in self): + raise ValidationError(_("Odoo Payments is not available in test mode.")) + + def odoo_create_adyen_account(self): + return self.env['adyen.account'].action_create_redirect() + + def _odoo_get_api_url(self): + self.ensure_one() + proxy_url = self.env['ir.config_parameter'].sudo().get_param('adyen_platforms.proxy_url') + return urls.url_join(proxy_url, 'pay_by_link') + + def _odoo_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. + + :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'{self.odoo_adyen_account_id.adyen_uuid}_{partner_id}' diff --git a/addons/payment_odoo/models/payment_token.py b/addons/payment_odoo/models/payment_token.py new file mode 100644 index 00000000000..d8c5a13b558 --- /dev/null +++ b/addons/payment_odoo/models/payment_token.py @@ -0,0 +1,9 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class PaymentToken(models.Model): + _inherit = 'payment.token' + + odoo_payment_method_type = fields.Char(string="Payment Method Type") diff --git a/addons/payment_odoo/models/payment_transaction.py b/addons/payment_odoo/models/payment_transaction.py new file mode 100644 index 00000000000..36ba4ed44df --- /dev/null +++ b/addons/payment_odoo/models/payment_transaction.py @@ -0,0 +1,231 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import json +import logging +import pprint + +from werkzeug import urls + +from odoo import _, api, models +from odoo.exceptions import UserError, ValidationError + +from odoo.addons.payment import utils as payment_utils +from odoo.addons.payment_odoo.const import CURRENCY_DECIMALS, RESULT_CODES_MAPPING +from odoo.addons.payment_odoo.controllers.main import OdooController + +_logger = logging.getLogger(__name__) + + +class PaymentTransaction(models.Model): + _inherit = 'payment.transaction' + + def _get_specific_rendering_values(self, processing_values): + """ Override of payment to return Odoo-specific rendering values. + + Note: self.ensure_one() from `_get_processing_values` + + :param dict processing_values: The generic and specific processing values of the transaction + :return: The dict of acquirer-specific processing values + :rtype: dict + """ + res = super()._get_specific_rendering_values(processing_values) + if self.provider != 'odoo': + return res + + converted_amount = payment_utils.to_minor_currency_units( + self.amount, self.currency_id, CURRENCY_DECIMALS.get(self.currency_id.name) + ) + # The lang is taken from the context rather than from the partner because it is not required + # to be logged to make a payment and because the lang is not always set on the partner. + # Adyen only supports a reduced 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 = (self._context.get('lang') or 'en-US').replace('_', '-') + base_url = self.acquirer_id._get_base_url() + signature = payment_utils.generate_access_token( + converted_amount, self.currency_id.name, self.reference + ) + data = { + 'adyen_uuid': self.acquirer_id.odoo_adyen_account_id.adyen_uuid, + 'payout': self.acquirer_id.odoo_adyen_payout_id.code, + 'amount': { + 'value': converted_amount, + 'currency': self.currency_id.name, + }, + 'reference': self.reference, + 'shopperLocale': lang_code, + 'shopperReference': self.acquirer_id._odoo_compute_shopper_reference( + self.partner_id.id + ), + 'recurringProcessingModel': 'CardOnFile', + 'storePaymentMethod': self.tokenize, # True by default on Adyen side + # Since the Pay by Link API redirects the customer without any payload, we use the + # /payment/status route directly as return url. + 'returnUrl': urls.url_join(base_url, '/payment/status'), + 'metadata': { + 'merchant_signature': signature, + 'notification_url': urls.url_join(base_url, OdooController._notification_url), + }, # Proxy-specific data + } + return { + 'data': json.dumps(data), + 'api_url': self.acquirer_id._odoo_get_api_url(), + } + + def _send_payment_request(self): + """ Override of payment to send a payment request to Adyen through the Odoo proxy. + + Note: self.ensure_one() + + :return: None + :raise: UserError if the transaction is not linked to a token + """ + super()._send_payment_request() + if self.provider != 'odoo': + return + + # Make the payment request + if not self.token_id: + raise UserError("Odoo Payments: " + _("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) + ) + base_url = self.acquirer_id._get_base_url() + signature = payment_utils.generate_access_token( + converted_amount, self.currency_id.name, self.reference + ) + data = { + 'payout': self.acquirer_id.odoo_adyen_payout_id.code, + 'amount': { + 'value': converted_amount, + 'currency': self.currency_id.name, + }, + 'reference': self.reference, + 'paymentMethod': { + 'type': self.token_id.odoo_payment_method_type, + 'storedPaymentMethodId': self.token_id.acquirer_ref, + }, + 'shopperReference': self.acquirer_id._odoo_compute_shopper_reference( + self.partner_id.id + ), + 'recurringProcessingModel': 'Subscription', + 'shopperInteraction': 'ContAuth', + 'metadata': { + 'merchant_signature': signature, + 'notification_url': urls.url_join(base_url, OdooController._notification_url), + }, # Proxy-specific data + } + response_content = self.acquirer_id.odoo_adyen_account_id._adyen_rpc('payments', data) + + # Handle the payment request response + _logger.info("payment request response:\n%s", pprint.pformat(response_content)) + self._handle_feedback_data('odoo', 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 != 'odoo': + return tx + + reference = data.get('merchantReference') + if not reference: + raise ValidationError( + "Odoo Payments: " + _("Received data with missing merchant reference") + ) + + tx = self.search([('reference', '=', reference), ('provider', '=', 'odoo')]) + if not tx: + raise ValidationError( + "Odoo Payments: " + _("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. + + Since only webhook notifications send data, the parsing is exclusively done according to the + structure of NotificationRequestItem object. + See https://docs.adyen.com/development-resources/webhooks/notifications-api + + 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 != 'odoo': + 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( + "Odoo Payments: " + _("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._odoo_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.info("received data with invalid payment state: %s", payment_state) + self._set_error( + "Odoo Payments: " + _("Received data with invalid payment state: %s", payment_state) + ) + + def _odoo_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() + + # Retrieve all stored payment methods for the customer from the API and match them with the + # acquirer reference of the transaction to find its payment method + response_content = self.acquirer_id.odoo_adyen_account_id._adyen_rpc( + 'payment_methods', + dict(shopperReference=data['additionalData']['recurring.shopperReference']), + ) + payment_methods = response_content['storedPaymentMethods'] + acquirer_ref = data['additionalData']['recurring.recurringDetailReference'] + payment_method_type = next(pm['type'] for pm in payment_methods if pm['id'] == acquirer_ref) + + # Create the token + 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': acquirer_ref, + 'verified': True, # The payment is authorized, so the payment method is valid + 'odoo_payment_method_type': payment_method_type, + }) + self.write({ + 'token_id': token, + 'tokenize': False, + }) + _logger.info( + "created token with id %s for partner with id %s", token.id, self.partner_id.id + ) diff --git a/addons/payment_odoo_by_adyen/static/description/icon.png b/addons/payment_odoo/static/description/icon.png similarity index 100% rename from addons/payment_odoo_by_adyen/static/description/icon.png rename to addons/payment_odoo/static/description/icon.png diff --git a/addons/payment_odoo_by_adyen/static/description/icon.svg b/addons/payment_odoo/static/description/icon.svg similarity index 100% rename from addons/payment_odoo_by_adyen/static/description/icon.svg rename to addons/payment_odoo/static/description/icon.svg diff --git a/addons/payment_odoo_by_adyen/static/src/img/odoo_icon.png b/addons/payment_odoo/static/src/img/odoo_icon.png similarity index 100% rename from addons/payment_odoo_by_adyen/static/src/img/odoo_icon.png rename to addons/payment_odoo/static/src/img/odoo_icon.png diff --git a/addons/payment_odoo/views/payment_odoo_templates.xml b/addons/payment_odoo/views/payment_odoo_templates.xml new file mode 100644 index 00000000000..c9ae5def1dd --- /dev/null +++ b/addons/payment_odoo/views/payment_odoo_templates.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/addons/payment_odoo/views/payment_views.xml b/addons/payment_odoo/views/payment_views.xml new file mode 100644 index 00000000000..4bbc4184ea9 --- /dev/null +++ b/addons/payment_odoo/views/payment_views.xml @@ -0,0 +1,27 @@ + + + + + Odoo Payments Acquirer Form + payment.acquirer + + + + +