From 660dc0ebaf819ad25b8e584598f44d3038814536 Mon Sep 17 00:00:00 2001 From: Kevin Baptiste Date: Fri, 26 Mar 2021 18:07:25 +0100 Subject: [PATCH] [REF] payment_authorize: migrate Authorize.Net to the new payment API This commit also drops the payment with redirection flow in favor of the direct payment flow only, while preserving the currently used APIs. See the merge commit for more details. task-2333030 Co-authored-by: Adrien Horgnies --- addons/payment_authorize/__init__.py | 12 +- addons/payment_authorize/__manifest__.py | 8 +- .../payment_authorize/controllers/__init__.py | 2 +- addons/payment_authorize/controllers/main.py | 123 ++--- .../data/payment_acquirer_data.xml | 20 +- addons/payment_authorize/models/__init__.py | 7 +- .../models/authorize_request.py | 465 ++++++++---------- addons/payment_authorize/models/payment.py | 335 ------------- .../models/payment_acquirer.py | 94 ++++ .../payment_authorize/models/payment_token.py | 47 ++ .../models/payment_transaction.py | 243 +++++++++ .../static/src/js/payment_form.js | 278 ++++++----- .../static/src/scss/payment_authorize.scss | 15 + addons/payment_authorize/tests/__init__.py | 3 +- addons/payment_authorize/tests/common.py | 20 + .../payment_authorize/tests/test_authorize.py | 309 ++---------- addons/payment_authorize/views/assets.xml | 13 + .../views/payment_authorize_templates.xml | 87 +--- .../payment_authorize/views/payment_views.xml | 78 +-- 19 files changed, 952 insertions(+), 1207 deletions(-) delete mode 100644 addons/payment_authorize/models/payment.py create mode 100644 addons/payment_authorize/models/payment_acquirer.py create mode 100644 addons/payment_authorize/models/payment_token.py create mode 100644 addons/payment_authorize/models/payment_transaction.py create mode 100644 addons/payment_authorize/static/src/scss/payment_authorize.scss create mode 100644 addons/payment_authorize/tests/common.py create mode 100644 addons/payment_authorize/views/assets.xml diff --git a/addons/payment_authorize/__init__.py b/addons/payment_authorize/__init__.py index 6752d7211e6..d338ed54df9 100644 --- a/addons/payment_authorize/__init__.py +++ b/addons/payment_authorize/__init__.py @@ -1,11 +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, 'authorize') - + reset_payment_acquirer(cr, registry, 'authorize') diff --git a/addons/payment_authorize/__manifest__.py b/addons/payment_authorize/__manifest__.py index 17c11402e6f..2c45c3ab757 100644 --- a/addons/payment_authorize/__manifest__.py +++ b/addons/payment_authorize/__manifest__.py @@ -1,20 +1,20 @@ -# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. { 'name': 'Authorize.Net Payment Acquirer', + 'version': '2.0', 'category': 'Accounting/Payment Acquirers', 'sequence': 350, 'summary': 'Payment Acquirer: Authorize.net Implementation', - 'version': '1.0', 'description': """Authorize.Net Payment Acquirer""", 'depends': ['payment'], 'data': [ + 'views/assets.xml', 'views/payment_views.xml', 'views/payment_authorize_templates.xml', 'data/payment_acquirer_data.xml', ], - 'installable': True, 'application': True, - 'post_init_hook': 'create_missing_journal_for_acquirers', + 'post_init_hook': 'create_missing_journals', 'uninstall_hook': 'uninstall_hook', } diff --git a/addons/payment_authorize/controllers/__init__.py b/addons/payment_authorize/controllers/__init__.py index 65a8c12013d..80ee4da1c5e 100644 --- a/addons/payment_authorize/controllers/__init__.py +++ b/addons/payment_authorize/controllers/__init__.py @@ -1,3 +1,3 @@ -# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. from . import main diff --git a/addons/payment_authorize/controllers/main.py b/addons/payment_authorize/controllers/main.py index cf917adffcb..c7283b1907b 100644 --- a/addons/payment_authorize/controllers/main.py +++ b/addons/payment_authorize/controllers/main.py @@ -1,85 +1,60 @@ -# -*- coding: utf-8 -*- -import pprint -import logging -from werkzeug import urls, utils +# Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import http, _ +import logging +import pprint + +from odoo import _, http +from odoo.exceptions import ValidationError from odoo.http import request -from odoo.exceptions import ValidationError, UserError + +from odoo.addons.payment import utils as payment_utils _logger = logging.getLogger(__name__) class AuthorizeController(http.Controller): - _return_url = '/payment/authorize/return/' - _cancel_url = '/payment/authorize/cancel/' - @http.route([ - '/payment/authorize/return/', - '/payment/authorize/cancel/', - ], type='http', auth='public', csrf=False) - def authorize_form_feedback(self, **post): - _logger.info('Authorize: entering form_feedback with post data %s', pprint.pformat(post)) - if post: - request.env['payment.transaction'].sudo().form_feedback(post, 'authorize') - base_url = request.env['ir.config_parameter'].sudo().get_param('web.base.url') - # Authorize.Net is expecting a response to the POST sent by their server. - # This response is in the form of a URL that Authorize.Net will pass on to the - # client's browser to redirect them to the desired location need javascript. - return request.render('payment_authorize.payment_authorize_redirect', { - 'return_url': urls.url_join(base_url, "/payment/process") - }) + @http.route('/payment/authorize/get_acquirer_info', type='json', auth='public') + def authorize_get_acquirer_info(self, acquirer_id): + """ Return public information on the acquirer. - @http.route(['/payment/authorize/s2s/create_json_3ds'], type='json', auth='public', csrf=False) - def authorize_s2s_create_json_3ds(self, verify_validity=False, **kwargs): - token = False - acquirer = request.env['payment.acquirer'].browse(int(kwargs.get('acquirer_id'))) - - try: - if not kwargs.get('partner_id'): - kwargs = dict(kwargs, partner_id=request.env.user.partner_id.id) - token = acquirer.s2s_process(kwargs) - except ValidationError as e: - message = e.args[0] - if isinstance(message, dict) and 'missing_fields' in message: - if request.env.user._is_public(): - message = _("Please sign in to complete the payment.") - # update message if portal mode = b2b - if request.env['ir.config_parameter'].sudo().get_param('auth_signup.allow_uninvited', 'False').lower() == 'false': - message += _(" If you don't have any account, ask your salesperson to grant you a portal access. ") - else: - msg = _("The transaction cannot be processed because some contact details are missing or invalid: ") - message = msg + ', '.join(message['missing_fields']) + '. ' - message += _("Please complete your profile. ") - - return { - 'error': message - } - - if not token: - res = { - 'result': False, - } - return res - - res = { - 'result': True, - 'id': token.id, - 'short_name': token.short_name, - '3d_secure': False, - 'verified': True, #Authorize.net does a transaction type of Authorization Only - #As Authorize.net already verify this card, we do not verify this card again. + :param int acquirer_id: The acquirer handling the transaction, as a `payment.acquirer` id + :return: Information on the acquirer, namely: the state, login ID and public client key + :rtype: dict + """ + acquirer_sudo = request.env['payment.acquirer'].sudo().browse(acquirer_id).exists() + return { + 'state': acquirer_sudo.state, + # The public API key solely used to identify the seller account with Authorize.Net + 'login_id': acquirer_sudo.authorize_login, + # The public client key solely used to identify requests from the Accept.js suite + 'client_key': acquirer_sudo.authorize_client_key, } - #token.validate() don't work with Authorize.net. - #Payments made via Authorize.net are settled and allowed to be refunded only on the next day. - #https://account.authorize.net/help/Miscellaneous/FAQ/Frequently_Asked_Questions.htm#Refund - #The original transaction that you wish to refund must have a status of Settled Successfully. - #You cannot issue refunds against unsettled, voided, declined or errored transactions. - return res - @http.route(['/payment/authorize/s2s/create'], type='http', auth='public') - def authorize_s2s_create(self, **post): - acquirer_id = int(post.get('acquirer_id')) - acquirer = request.env['payment.acquirer'].browse(acquirer_id) - acquirer.s2s_process(post) - return utils.redirect("/payment/process") + @http.route('/payment/authorize/payment', type='json', auth='public') + def authorize_payment(self, reference, partner_id, access_token, opaque_data): + """ Make a payment request and handle the response. + + :param str reference: The reference of the transaction + :param int partner_id: The partner making the transaction, as a `res.partner` id + :param str access_token: The access token used to verify the provided values + :param dict opaque_data: The payment details obfuscated by Authorize.Net + :return: None + """ + # Check that the transaction details have not been altered + if not payment_utils.check_access_token(access_token, reference, partner_id): + raise ValidationError("Authorize.Net: " + _("Received tampered payment request data.")) + + # Make the payment request to Authorize.Net + tx_sudo = request.env['payment.transaction'].sudo().search([('reference', '=', reference)]) + response_content = tx_sudo._authorize_create_transaction_request(opaque_data) + + # Handle the payment request response + _logger.info("make payment response:\n%s", pprint.pformat(response_content)) + # As the API has no redirection flow, we always know the reference of the transaction. + # Still, we prefer to simulate the matching of the transaction by crafting dummy feedback + # data in order to go through the centralized `_handle_feedback_data` method. + feedback_data = {'reference': tx_sudo.reference, 'response': response_content} + request.env['payment.transaction'].sudo()._handle_feedback_data( + 'authorize', feedback_data + ) diff --git a/addons/payment_authorize/data/payment_acquirer_data.xml b/addons/payment_authorize/data/payment_acquirer_data.xml index 3f465665f24..bb66a4704fb 100644 --- a/addons/payment_authorize/data/payment_acquirer_data.xml +++ b/addons/payment_authorize/data/payment_acquirer_data.xml @@ -1,15 +1,13 @@ - - + - - Authorize.Net - - authorize - - - - + + authorize + + True + False + True + True + - diff --git a/addons/payment_authorize/models/__init__.py b/addons/payment_authorize/models/__init__.py index 01bcbec2ab2..22eb69117db 100644 --- a/addons/payment_authorize/models/__init__.py +++ b/addons/payment_authorize/models/__init__.py @@ -1,2 +1,5 @@ -# -*- coding: utf-8 -*- -from . import payment +# 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_authorize/models/authorize_request.py b/addons/payment_authorize/models/authorize_request.py index 78db4977b05..97a0f1519e9 100644 --- a/addons/payment_authorize/models/authorize_request.py +++ b/addons/payment_authorize/models/authorize_request.py @@ -1,20 +1,19 @@ -# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + import json import logging -import requests - +import pprint from uuid import uuid4 -from odoo import _ -from odoo.exceptions import UserError +from odoo.addons.payment import utils as payment_utils -from odoo.addons.payment.models.payment_acquirer import _partner_split_name +import requests _logger = logging.getLogger(__name__) -class AuthorizeAPI(): - """Authorize.net Gateway API integration. +class AuthorizeAPI: + """ Authorize.net Gateway API integration. This class allows contacting the Authorize.net API with simple operation requests. It implements a *very limited* subset of the complete API @@ -23,12 +22,12 @@ class AuthorizeAPI(): - Transaction authorization/capture/voiding """ - AUTH_ERROR_STATUS = 3 + AUTH_ERROR_STATUS = '3' def __init__(self, acquirer): """Initiate the environment with the acquirer data. - :param record acquirer: payment.acquirer account that will be contacted + :param recordset acquirer: payment.acquirer account that will be contacted """ if acquirer.state == 'test': self.url = 'https://apitest.authorize.net/xml/v1/request.api' @@ -39,87 +38,48 @@ class AuthorizeAPI(): self.name = acquirer.authorize_login self.transaction_key = acquirer.authorize_transaction_key - def _authorize_request(self, data): - _logger.info('_authorize_request: Sending values to URL %s, values:\n%s', self.url, data) - resp = requests.post(self.url, json.dumps(data)) - resp.raise_for_status() - resp = json.loads(resp.content) - _logger.info("_authorize_request: Received response:\n%s", resp) - messages = resp.get('messages') + def _make_request(self, operation, data=None): + request = { + operation: { + 'merchantAuthentication': { + 'name': self.name, + 'transactionKey': self.transaction_key, + }, + **(data or {}) + } + } + + _logger.info("sending request to %s:\n%s", self.url, pprint.pformat(request)) + response = requests.post(self.url, json.dumps(request), timeout=60) + response.raise_for_status() + response = json.loads(response.content) + _logger.info("response received:\n%s", pprint.pformat(response)) + + messages = response.get('messages') if messages and messages.get('resultCode') == 'Error': return { 'err_code': messages.get('message')[0].get('code'), 'err_msg': messages.get('message')[0].get('text') } - return resp + return response + + def _format_response(self, response, operation): + if response and response.get('err_code'): + return { + 'x_response_code': self.AUTH_ERROR_STATUS, + 'x_response_reason_text': response.get('err_msg') + } + else: + return { + 'x_response_code': response.get('transactionResponse', {}).get('responseCode'), + 'x_trans_id': response.get('transactionResponse', {}).get('transId'), + 'x_type': operation, + } # Customer profiles - def create_customer_profile(self, partner, opaqueData): - """Create a payment and customer profile in the Authorize.net backend. - - Creates a customer profile for the partner/credit card combination and links - a corresponding payment profile to it. Note that a single partner in the Odoo - database can have multiple customer profiles in Authorize.net (i.e. a customer - profile is created for every res.partner/payment.token couple). - - :param record partner: the res.partner record of the customer - :param str cardnumber: cardnumber in string format (numbers only, no separator) - :param str expiration_date: expiration date in 'YYYY-MM' string format - :param str card_code: three- or four-digit verification number - - :return: a dict containing the profile_id and payment_profile_id of the - newly created customer profile and payment profile - :rtype: dict - """ - values = { - 'createCustomerProfileRequest': { - 'merchantAuthentication': { - 'name': self.name, - 'transactionKey': self.transaction_key - }, - 'profile': { - 'description': ('ODOO-%s-%s' % (partner.id, uuid4().hex[:8]))[:20], - 'email': partner.email or '', - 'paymentProfiles': { - 'customerType': 'business' if partner.is_company else 'individual', - 'billTo': { - 'firstName': '' if partner.is_company else _partner_split_name(partner.name)[0], - 'lastName': _partner_split_name(partner.name)[1], - 'address': (partner.street or '' + (partner.street2 if partner.street2 else '')) or None, - 'city': partner.city, - 'state': partner.state_id.name or None, - 'zip': partner.zip or '', - 'country': partner.country_id.name or None, - 'phoneNumber': partner.phone or '', - }, - 'payment': { - 'opaqueData': { - 'dataDescriptor': opaqueData.get('dataDescriptor'), - 'dataValue': opaqueData.get('dataValue') - } - } - } - }, - 'validationMode': 'liveMode' if self.state == 'enabled' else 'testMode' - } - } - - response = self._authorize_request(values) - - if response and response.get('err_code'): - raise UserError(_( - "Authorize.net Error:\nCode: %s\nMessage: %s", - response.get('err_code'), response.get('err_msg'), - )) - - return { - 'profile_id': response.get('customerProfileId'), - 'payment_profile_id': response.get('customerPaymentProfileIdList')[0] - } - - def create_customer_profile_from_tx(self, partner, transaction_id): - """Create an Auth.net payment/customer profile from an existing transaction. + def create_customer_profile(self, partner, transaction_id): + """ Create an Auth.net payment/customer profile from an existing transaction. Creates a customer profile for the partner/credit card combination and links a corresponding payment profile to it. Note that a single partner in the Odoo @@ -127,7 +87,7 @@ class AuthorizeAPI(): profile is created for every res.partner/payment.token couple). Note that this function makes 2 calls to the authorize api, since we need to - obtain a partial cardnumber to generate a meaningful payment.token name. + obtain a partial card number to generate a meaningful payment.token name. :param record partner: the res.partner record of the customer :param str transaction_id: id of the authorized transaction in the @@ -138,26 +98,18 @@ class AuthorizeAPI(): last digits of the card number :rtype: dict """ - values = { - 'createCustomerProfileFromTransactionRequest': { - "merchantAuthentication": { - "name": self.name, - "transactionKey": self.transaction_key - }, - 'transId': transaction_id, - 'customer': { - 'merchantCustomerId': ('ODOO-%s-%s' % (partner.id, uuid4().hex[:8]))[:20], - 'email': partner.email or '' - } + response = self._make_request('createCustomerProfileFromTransactionRequest', { + 'transId': transaction_id, + 'customer': { + 'merchantCustomerId': ('ODOO-%s-%s' % (partner.id, uuid4().hex[:8]))[:20], + 'email': partner.email or '' } - } - - response = self._authorize_request(values) + }) if not response.get('customerProfileId'): _logger.warning( - 'Unable to create customer payment profile, data missing from transaction. Transaction_id: %s - Partner_id: %s' - % (transaction_id, partner) + 'Unable to create customer payment profile, data missing from transaction. Transaction_id: %s - Partner_id: %s', + transaction_id, partner, ) return False @@ -166,126 +118,118 @@ class AuthorizeAPI(): 'payment_profile_id': response.get('customerPaymentProfileIdList')[0] } - values = { - 'getCustomerPaymentProfileRequest': { - "merchantAuthentication": { - "name": self.name, - "transactionKey": self.transaction_key - }, - 'customerProfileId': res['profile_id'], - 'customerPaymentProfileId': res['payment_profile_id'], - } - } - - response = self._authorize_request(values) + response = self._make_request('getCustomerPaymentProfileRequest', { + 'customerProfileId': res['profile_id'], + 'customerPaymentProfileId': res['payment_profile_id'], + }) res['name'] = response.get('paymentProfile', {}).get('payment', {}).get('creditCard', {}).get('cardNumber') return res - # Transaction management - def auth_and_capture(self, token, amount, reference): + def delete_customer_profile(self, profile_id): + """Delete a customer profile + + :param str profile_id: the id of the customer profile in the Authorize.net backend + + :return: a dict containing the response code + :rtype: dict + """ + response = self._make_request("deleteCustomerProfileRequest", {'customerProfileId': profile_id}) + return self._format_response(response, 'deleteCustomerProfile') + + #=== Transaction management ===# + + def authorize(self, amount, reference, token=None, opaque_data=None): + """ Authorize (without capture) a payment for the given amount. + + :param float amount: The amount to pay + :param str reference: The "invoiceNumber" in Authorize.net backend + :param recordset token: The token of the payment method to charge, as a `payment.token` + record + :param dict opaque_data: The payment details obfuscated by Authorize.Net + :return: a dict containing the response code, transaction id and transaction type + :rtype: dict + """ + tx_data = self._prepare_tx_data(token=token, opaque_data=opaque_data) + response = self._make_request('createTransactionRequest', { + 'transactionRequest': { + 'transactionType': 'authOnlyTransaction', + 'amount': str(amount), + **tx_data, + 'order': { + 'invoiceNumber': reference[:20], + 'description': reference[:255], + }, + 'customerIP': payment_utils.get_customer_ip_address(), + } + }) + return self._format_response(response, 'auth_only') + + def auth_and_capture(self, amount, reference, token=None, opaque_data=None): """Authorize and capture a payment for the given amount. Authorize and immediately capture a payment for the given payment.token record for the specified amount with reference as communication. - :param record token: the payment.token record that must be charged :param str amount: transaction amount (up to 15 digits with decimal point) :param str reference: used as "invoiceNumber" in the Authorize.net backend + :param record token: the payment.token record that must be charged + :param str opaque_data: the transaction opaque_data obtained from Authorize.net :return: a dict containing the response code, transaction id and transaction type :rtype: dict """ - values = { - 'createTransactionRequest': { - "merchantAuthentication": { - "name": self.name, - "transactionKey": self.transaction_key + tx_data = self._prepare_tx_data(token=token, opaque_data=opaque_data) + response = self._make_request('createTransactionRequest', { + 'transactionRequest': { + 'transactionType': 'authCaptureTransaction', + 'amount': str(amount), + **tx_data, + 'order': { + 'invoiceNumber': reference[:20], + 'description': reference[:255], }, - 'transactionRequest': { - 'transactionType': 'authCaptureTransaction', - 'amount': str(amount), - 'profile': { - 'customerProfileId': token.authorize_profile, - 'paymentProfile': { - 'paymentProfileId': token.acquirer_ref, - } - }, - 'order': { - 'invoiceNumber': reference[:20], - 'description': reference[:255], - } - } - + 'customerIP': payment_utils.get_customer_ip_address(), } - } - response = self._authorize_request(values) + }) - if response and response.get('err_code'): - return { - 'x_response_code': self.AUTH_ERROR_STATUS, - 'x_response_reason_text': response.get('err_msg') - } - - result = { - 'x_response_code': response.get('transactionResponse', {}).get('responseCode'), - 'x_trans_id': response.get('transactionResponse', {}).get('transId'), - 'x_type': 'auth_capture' - } + result = self._format_response(response, 'auth_capture') errors = response.get('transactionResponse', {}).get('errors') if errors: result['x_response_reason_text'] = '\n'.join([e.get('errorText') for e in errors]) return result - def authorize(self, token, amount, reference): - """Authorize a payment for the given amount. + def _prepare_tx_data(self, token=None, opaque_data=False): + """ + :param token: The token of the payment method to charge, as a `payment.token` record + :param dict opaque_data: The payment details obfuscated by Authorize.Net + """ + assert (token or opaque_data) and not (token and opaque_data), "Exactly one of token or opaque_data must be specified" + if token: + token.ensure_one() + return { + 'profile': { + 'customerProfileId': token.authorize_profile, + 'paymentProfile': { + 'paymentProfileId': token.acquirer_ref, + } + }, + } + else: + return { + 'payment': { + 'opaqueData': opaque_data, + } + } - Authorize (without capture) a payment for the given payment.token - record for the specified amount with reference as communication. + def _get_transaction_details(self, transaction_id): + """ Return detailed information about a specific transaction. Useful to issue refunds. - :param record token: the payment.token record that must be charged - :param str amount: transaction amount (up to 15 digits with decimal point) - :param str reference: used as "invoiceNumber" in the Authorize.net backend - - :return: a dict containing the response code, transaction id and transaction type + :param str transaction_id: transaction id + :return: a dict containing the transaction details :rtype: dict """ - values = { - 'createTransactionRequest': { - "merchantAuthentication": { - "name": self.name, - "transactionKey": self.transaction_key - }, - 'transactionRequest': { - 'transactionType': 'authOnlyTransaction', - 'amount': str(amount), - 'profile': { - 'customerProfileId': token.authorize_profile, - 'paymentProfile': { - 'paymentProfileId': token.acquirer_ref, - } - }, - 'order': { - 'invoiceNumber': reference[:20], - 'description': reference[:255], - } - } - - } - } - response = self._authorize_request(values) - - if response and response.get('err_code'): - return { - 'x_response_code': self.AUTH_ERROR_STATUS, - 'x_response_reason_text': response.get('err_msg') - } - - return { - 'x_response_code': response.get('transactionResponse', {}).get('responseCode'), - 'x_trans_id': response.get('transactionResponse', {}).get('transId'), - 'x_type': 'auth_only' - } + return self._make_request('getTransactionDetailsRequest', {'transId': transaction_id}) def capture(self, transaction_id, amount): """Capture a previously authorized payment for the given amount. @@ -300,102 +244,83 @@ class AuthorizeAPI(): :return: a dict containing the response code, transaction id and transaction type :rtype: dict """ - values = { - 'createTransactionRequest': { - "merchantAuthentication": { - "name": self.name, - "transactionKey": self.transaction_key - }, - 'transactionRequest': { - 'transactionType': 'priorAuthCaptureTransaction', - 'amount': str(amount), - 'refTransId': transaction_id, - } + response = self._make_request('createTransactionRequest', { + 'transactionRequest': { + 'transactionType': 'priorAuthCaptureTransaction', + 'amount': str(amount), + 'refTransId': transaction_id, } - } - - response = self._authorize_request(values) - - if response and response.get('err_code'): - return { - 'x_response_code': self.AUTH_ERROR_STATUS, - 'x_response_reason_text': response.get('err_msg') - } - - return { - 'x_response_code': response.get('transactionResponse', {}).get('responseCode'), - 'x_trans_id': response.get('transactionResponse', {}).get('transId'), - 'x_type': 'prior_auth_capture' - } + }) + return self._format_response(response, 'prior_auth_capture') def void(self, transaction_id): """Void a previously authorized payment. :param str transaction_id: the id of the authorized transaction in the Authorize.net backend - :return: a dict containing the response code, transaction id and transaction type :rtype: dict """ - values = { - 'createTransactionRequest': { - "merchantAuthentication": { - "name": self.name, - "transactionKey": self.transaction_key - }, - 'transactionRequest': { - 'transactionType': 'voidTransaction', - 'refTransId': transaction_id - } + response = self._make_request('createTransactionRequest', { + 'transactionRequest': { + 'transactionType': 'voidTransaction', + 'refTransId': transaction_id } - } + }) + return self._format_response(response, 'void') - response = self._authorize_request(values) + def refund(self, transaction_id, amount): + """Refund a previously authorized payment. If the transaction is not settled + yet, it will be voided. - if response and response.get('err_code'): + :param str transaction_id: the id of the authorized transaction in the + Authorize.net backend + :param float amount: transaction amount to refund + :return: a dict containing the response code, transaction id and transaction type + :rtype: dict + """ + tx_details = self._get_transaction_details(transaction_id) + + if tx_details and tx_details.get('err_code'): return { 'x_response_code': self.AUTH_ERROR_STATUS, - 'x_response_reason_text': response.get('err_msg') + 'x_response_reason_text': tx_details.get('err_msg') } - return { - 'x_response_code': response.get('transactionResponse', {}).get('responseCode'), - 'x_trans_id': response.get('transactionResponse', {}).get('transId'), - 'x_type': 'void' - } + # Void transaction not yet settled instead of issuing a refund + # (spoiler alert: a refund on a non settled transaction will throw an error) + if tx_details.get('transaction', {}).get('transactionStatus') in ['authorizedPendingCapture', 'capturedPendingSettlement']: + return self.void(transaction_id) + + card = tx_details.get('transaction', {}).get('payment', {}).get('creditCard', {}).get('cardNumber') + response = self._make_request('createTransactionRequest', { + 'transactionRequest': { + 'transactionType': 'refundTransaction', + 'amount': str(amount), + 'payment': { + 'creditCard': { + 'cardNumber': card, + 'expirationDate': 'XXXX', + } + }, + 'refTransId': transaction_id, + } + }) + return self._format_response(response, 'refund') + + # Acquirer configuration: fetch authorize_client_key & currencies + def merchant_details(self): + """ Retrieves the merchant details and generate a new public client key if none exists. + + :return: Dictionary containing the merchant details + :rtype: dict""" + return self._make_request('getMerchantDetailsRequest') # Test def test_authenticate(self): - """Test Authorize.net communication with a simple credentials check. + """ Test Authorize.net communication with a simple credentials check. - :return: True if authentication was successful, else False (or throws an error) - :rtype: bool + :return: The authentication results + :rtype: dict """ - values = { - 'authenticateTestRequest': { - "merchantAuthentication": { - "name": self.name, - "transactionKey": self.transaction_key - }, - } - } - - response = self._authorize_request(values) - if response and response.get('err_code'): - return False - return True - - # Client Key - def get_client_secret(self): - """ Create a client secret that will be needed for the AcceptJS integration. """ - values = { - "getMerchantDetailsRequest": { - "merchantAuthentication": { - "name": self.name, - "transactionKey": self.transaction_key, - } - } - } - response = self._authorize_request(values) - client_secret = response.get('publicClientKey') - return client_secret + return self._make_request('authenticateTestRequest') diff --git a/addons/payment_authorize/models/payment.py b/addons/payment_authorize/models/payment.py deleted file mode 100644 index c414625397f..00000000000 --- a/addons/payment_authorize/models/payment.py +++ /dev/null @@ -1,335 +0,0 @@ -# coding: utf-8 -from werkzeug import urls - -from .authorize_request import AuthorizeAPI -import hashlib -import hmac -import logging -import time - -from odoo import _, api, fields, models -from odoo.addons.payment.models.payment_acquirer import ValidationError -from odoo.addons.payment_authorize.controllers.main import AuthorizeController -from odoo.tools.float_utils import float_compare, float_repr -from odoo.exceptions import UserError - -_logger = logging.getLogger(__name__) - - -class PaymentAcquirerAuthorize(models.Model): - _inherit = 'payment.acquirer' - - provider = fields.Selection(selection_add=[ - ('authorize', 'Authorize.Net') - ], ondelete={'authorize': 'set default'}) - authorize_login = fields.Char(string='API Login Id', required_if_provider='authorize', groups='base.group_user') - authorize_transaction_key = fields.Char(string='API Transaction Key', required_if_provider='authorize', groups='base.group_user') - authorize_signature_key = fields.Char(string='API Signature Key', required_if_provider='authorize', groups='base.group_user') - authorize_client_key = fields.Char(string='API Client Key', groups='base.group_user') - - @api.onchange('provider', 'check_validity') - def onchange_check_validity(self): - if self.provider == 'authorize' and self.check_validity: - self.check_validity = False - return {'warning': { - 'title': _("Warning"), - 'message': ('This option is not supported for Authorize.net')}} - - def action_client_secret(self): - api = AuthorizeAPI(self) - if not api.test_authenticate(): - raise UserError(_('Unable to fetch Client Key, make sure the API Login and Transaction Key are correct.')) - self.authorize_client_key = api.get_client_secret() - return True - - def _get_feature_support(self): - """Get advanced feature support by provider. - - Each provider should add its technical in the corresponding - key for the following features: - * fees: support payment fees computations - * authorize: support authorizing payment (separates - authorization and capture) - * tokenize: support saving payment data in a payment.tokenize - object - """ - res = super(PaymentAcquirerAuthorize, self)._get_feature_support() - res['authorize'].append('authorize') - res['tokenize'].append('authorize') - return res - - def _get_authorize_urls(self, environment): - """ Authorize URLs """ - if environment == 'prod': - return {'authorize_form_url': 'https://secure2.authorize.net/gateway/transact.dll'} - else: - return {'authorize_form_url': 'https://test.authorize.net/gateway/transact.dll'} - - def _authorize_generate_hashing(self, values): - data = '^'.join([ - values['x_login'], - values['x_fp_sequence'], - values['x_fp_timestamp'], - values['x_amount'], - values['x_currency_code']]).encode('utf-8') - - return hmac.new(bytes.fromhex(self.authorize_signature_key), data, hashlib.sha512).hexdigest().upper() - - def authorize_form_generate_values(self, values): - self.ensure_one() - # State code is only supported in US, use state name by default - # See https://developer.authorize.net/api/reference/ - state = values['partner_state'].name if values.get('partner_state') else '' - if values.get('partner_country') and values.get('partner_country') == self.env.ref('base.us', False): - state = values['partner_state'].code if values.get('partner_state') else '' - billing_state = values['billing_partner_state'].name if values.get('billing_partner_state') else '' - if values.get('billing_partner_country') and values.get('billing_partner_country') == self.env.ref('base.us', False): - billing_state = values['billing_partner_state'].code if values.get('billing_partner_state') else '' - - base_url = self.get_base_url() - authorize_tx_values = dict(values) - temp_authorize_tx_values = { - 'x_login': self.authorize_login, - 'x_amount': float_repr(values['amount'], values['currency'].decimal_places if values['currency'] else 2), - 'x_show_form': 'PAYMENT_FORM', - 'x_type': 'AUTH_CAPTURE' if not self.capture_manually else 'AUTH_ONLY', - 'x_method': 'CC', - 'x_fp_sequence': '%s%s' % (self.id, int(time.time())), - 'x_version': '3.1', - 'x_relay_response': 'TRUE', - 'x_fp_timestamp': str(int(time.time())), - 'x_relay_url': urls.url_join(base_url, AuthorizeController._return_url), - 'x_cancel_url': urls.url_join(base_url, AuthorizeController._cancel_url), - 'x_currency_code': values['currency'] and values['currency'].name or '', - 'address': values.get('partner_address'), - 'city': values.get('partner_city'), - 'country': values.get('partner_country') and values.get('partner_country').name or '', - 'email': values.get('partner_email'), - 'zip_code': values.get('partner_zip'), - 'first_name': values.get('partner_first_name'), - 'last_name': values.get('partner_last_name'), - 'phone': values.get('partner_phone'), - 'state': state, - 'billing_address': values.get('billing_partner_address'), - 'billing_city': values.get('billing_partner_city'), - 'billing_country': values.get('billing_partner_country') and values.get('billing_partner_country').name or '', - 'billing_email': values.get('billing_partner_email'), - 'billing_zip_code': values.get('billing_partner_zip'), - 'billing_first_name': values.get('billing_partner_first_name'), - 'billing_last_name': values.get('billing_partner_last_name'), - 'billing_phone': values.get('billing_partner_phone'), - 'billing_state': billing_state, - } - temp_authorize_tx_values['returndata'] = authorize_tx_values.pop('return_url', '') - temp_authorize_tx_values['x_fp_hash'] = self._authorize_generate_hashing(temp_authorize_tx_values) - authorize_tx_values.update(temp_authorize_tx_values) - return authorize_tx_values - - def authorize_get_form_action_url(self): - self.ensure_one() - environment = 'prod' if self.state == 'enabled' else 'test' - return self._get_authorize_urls(environment)['authorize_form_url'] - - @api.model - def authorize_s2s_form_process(self, data): - values = { - 'opaqueData': data.get('opaqueData'), - 'encryptedCardData': data.get('encryptedCardData'), - 'acquirer_id': int(data.get('acquirer_id')), - 'partner_id': int(data.get('partner_id')) - } - PaymentMethod = self.env['payment.token'].sudo().create(values) - return PaymentMethod - - def authorize_s2s_form_validate(self, data): - error = dict() - mandatory_fields = ["opaqueData", "encryptedCardData"] - # Validation - for field_name in mandatory_fields: - if not data.get(field_name): - error[field_name] = 'missing' - return False if error else True - - def authorize_test_credentials(self): - self.ensure_one() - transaction = AuthorizeAPI(self.acquirer_id) - return transaction.test_authenticate() - -class TxAuthorize(models.Model): - _inherit = 'payment.transaction' - - _authorize_valid_tx_status = 1 - _authorize_pending_tx_status = 4 - _authorize_cancel_tx_status = 2 - _authorize_error_tx_status = 3 - - # -------------------------------------------------- - # FORM RELATED METHODS - # -------------------------------------------------- - - @api.model - def _authorize_form_get_tx_from_data(self, data): - """ Given a data dict coming from authorize, verify it and find the related - transaction record. """ - reference, description, trans_id, fingerprint = data.get('x_invoice_num'), data.get('x_description'), data.get('x_trans_id'), data.get('x_SHA2_Hash') or data.get('x_MD5_Hash') - if not reference or not trans_id or not fingerprint: - error_msg = _('Authorize: received data with missing reference (%s) or trans_id (%s) or fingerprint (%s)') % (reference, trans_id, fingerprint) - _logger.info(error_msg) - raise ValidationError(error_msg) - tx = self.search(['|', ('reference', '=', reference), ('reference', '=', description)]) - if not tx or len(tx) > 1: - error_msg = 'Authorize: received data for x_invoice_num %s and x_description %s' % (reference, description) - if not tx: - error_msg += '; no order found' - else: - error_msg += '; multiple order found' - _logger.info(error_msg) - raise ValidationError(error_msg) - return tx[0] - - def _authorize_form_get_invalid_parameters(self, data): - invalid_parameters = [] - - if self.acquirer_reference and data.get('x_trans_id') != self.acquirer_reference: - invalid_parameters.append(('Transaction Id', data.get('x_trans_id'), self.acquirer_reference)) - # check what is buyed - if float_compare(float(data.get('x_amount', '0.0')), self.amount, 2) != 0: - invalid_parameters.append(('Amount', data.get('x_amount'), '%.2f' % self.amount)) - return invalid_parameters - - def _authorize_form_validate(self, data): - if self.state == 'done': - _logger.warning('Authorize: trying to validate an already validated tx (ref %s)' % self.reference) - return True - status_code = int(data.get('x_response_code', '0')) - if status_code == self._authorize_valid_tx_status: - if data.get('x_type').lower() in ['auth_capture', 'prior_auth_capture']: - self.write({ - 'acquirer_reference': data.get('x_trans_id'), - 'date': fields.Datetime.now(), - }) - self._set_transaction_done() - elif data.get('x_type').lower() in ['auth_only']: - self.write({'acquirer_reference': data.get('x_trans_id')}) - self._set_transaction_authorized() - if self.partner_id and not self.payment_token_id and \ - (self.type == 'form_save' or self.acquirer_id.save_token == 'always'): - transaction = AuthorizeAPI(self.acquirer_id) - res = transaction.create_customer_profile_from_tx(self.partner_id, self.acquirer_reference) - if res: - token_id = self.env['payment.token'].create({ - 'authorize_profile': res.get('profile_id'), - 'name': res.get('name'), - 'acquirer_ref': res.get('payment_profile_id'), - 'acquirer_id': self.acquirer_id.id, - 'partner_id': self.partner_id.id, - }) - self.payment_token_id = token_id - return True - elif status_code == self._authorize_pending_tx_status: - self.write({'acquirer_reference': data.get('x_trans_id')}) - self._set_transaction_pending() - return True - else: - error = data.get('x_response_reason_text') - _logger.info(error) - self.write({ - 'state_message': error, - 'acquirer_reference': data.get('x_trans_id'), - }) - self._set_transaction_cancel() - return False - - def authorize_s2s_do_transaction(self, **data): - self.ensure_one() - transaction = AuthorizeAPI(self.acquirer_id) - - if not self.payment_token_id.authorize_profile: - raise UserError(_('Invalid token found: the Authorize profile is missing.' - 'Please make sure the token has a valid acquirer reference.')) - - if not self.acquirer_id.capture_manually: - res = transaction.auth_and_capture(self.payment_token_id, round(self.amount, self.currency_id.decimal_places), self.reference) - else: - res = transaction.authorize(self.payment_token_id, round(self.amount, self.currency_id.decimal_places), self.reference) - return self._authorize_s2s_validate_tree(res) - - def authorize_s2s_capture_transaction(self): - self.ensure_one() - transaction = AuthorizeAPI(self.acquirer_id) - tree = transaction.capture(self.acquirer_reference or '', round(self.amount, self.currency_id.decimal_places)) - return self._authorize_s2s_validate_tree(tree) - - def authorize_s2s_void_transaction(self): - self.ensure_one() - transaction = AuthorizeAPI(self.acquirer_id) - tree = transaction.void(self.acquirer_reference or '') - return self._authorize_s2s_validate_tree(tree) - - def _authorize_s2s_validate_tree(self, tree): - return self._authorize_s2s_validate(tree) - - def _authorize_s2s_validate(self, tree): - if self.state == 'done': - _logger.warning('Authorize: trying to validate an already validated tx (ref %s)' % self.reference) - return True - status_code = int(tree.get('x_response_code', '0')) - if status_code == self._authorize_valid_tx_status: - if tree.get('x_type').lower() in ['auth_capture', 'prior_auth_capture']: - init_state = self.state - self.write({ - 'acquirer_reference': tree.get('x_trans_id'), - 'date': fields.Datetime.now(), - }) - - self._set_transaction_done() - - if init_state != 'authorized': - self.execute_callback() - if tree.get('x_type').lower() == 'auth_only': - self.write({'acquirer_reference': tree.get('x_trans_id')}) - self._set_transaction_authorized() - self.execute_callback() - if tree.get('x_type').lower() == 'void': - self._set_transaction_cancel() - return True - elif status_code == self._authorize_pending_tx_status: - self.write({'acquirer_reference': tree.get('x_trans_id')}) - self._set_transaction_pending() - return True - else: - error = tree.get('x_response_reason_text') - _logger.info(error) - self.write({ - 'acquirer_reference': tree.get('x_trans_id'), - }) - self._set_transaction_error(msg=error) - return False - - -class PaymentToken(models.Model): - _inherit = 'payment.token' - - authorize_profile = fields.Char(string='Authorize.net Profile ID', help='This contains the unique reference ' - 'for this partner/payment token combination in the Authorize.net backend') - provider = fields.Selection(string='Provider', related='acquirer_id.provider', readonly=False) - save_token = fields.Selection(string='Save Cards', related='acquirer_id.save_token', readonly=False) - - @api.model - def authorize_create(self, values): - if values.get('opaqueData') and values.get('encryptedCardData'): - acquirer = self.env['payment.acquirer'].browse(values['acquirer_id']) - partner = self.env['res.partner'].browse(values['partner_id']) - transaction = AuthorizeAPI(acquirer) - res = transaction.create_customer_profile(partner, values['opaqueData']) - if res.get('profile_id') and res.get('payment_profile_id'): - return { - 'authorize_profile': res.get('profile_id'), - 'name': values['encryptedCardData'].get('cardNumber'), - 'acquirer_ref': res.get('payment_profile_id'), - 'verified': True - } - else: - raise ValidationError(_('The Customer Profile creation in Authorize.NET failed.')) - else: - return values diff --git a/addons/payment_authorize/models/payment_acquirer.py b/addons/payment_authorize/models/payment_acquirer.py new file mode 100644 index 00000000000..fb8ced9f8bc --- /dev/null +++ b/addons/payment_authorize/models/payment_acquirer.py @@ -0,0 +1,94 @@ +# 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 + +from .authorize_request import AuthorizeAPI + +_logger = logging.getLogger(__name__) + + +class PaymentAcquirer(models.Model): + _inherit = 'payment.acquirer' + + provider = fields.Selection( + selection_add=[('authorize', 'Authorize.Net')], ondelete={'authorize': 'set default'}) + authorize_login = fields.Char( + string="API Login ID", help="The ID solely used to identify the account with Authorize.Net", + required_if_provider='authorize') + authorize_transaction_key = fields.Char( + string="API Transaction Key", required_if_provider='authorize', groups='base.group_system') + authorize_signature_key = fields.Char( + string="API Signature Key", required_if_provider='authorize', groups='base.group_system') + authorize_client_key = fields.Char( + string="API Client Key", + help="The public client key. To generate directly from Odoo or from Authorize.Net backend.") + # Authorize.Net supports only one currency: "One gateway account is required for each currency" + # See https://community.developer.authorize.net/t5/The-Authorize-Net-Developer-Blog/Authorize-Net-UK-Europe-Update/ba-p/35957 + authorize_currency_id = fields.Many2one( + string="Authorize Currency", comodel_name='res.currency', groups='base.group_system') + + def action_update_merchant_details(self): + """ Fetch the merchant details to update the client key and the account currency. """ + self.ensure_one() + + if self.state == 'disabled': + raise UserError(_("This action cannot be performed while the acquirer is disabled.")) + + authorize_API = AuthorizeAPI(self) + + # Validate the API Login ID and Transaction Key + res_content = authorize_API.test_authenticate() + _logger.info("test_authenticate request response:\n%s", pprint.pformat(res_content)) + if res_content.get('err_msg'): + raise UserError(_("Failed to authenticate.\n%s", res_content['err_msg'])) + + # Update the merchant details + res_content = authorize_API.merchant_details() + _logger.info("merchant_details request response:\n%s", pprint.pformat(res_content)) + if res_content.get('err_msg'): + raise UserError(_("Could not fetch merchant details:\n%s", res_content['err_msg'])) + + currency = self.env['res.currency'].search([('name', 'in', res_content.get('currencies'))]) + self.authorize_currency_id = currency + self.authorize_client_key = res_content.get('publicClientKey') + + @api.model + def _get_compatible_acquirers(self, *args, currency_id=None, **kwargs): + """ Override of payment to unlist Authorize acquirers for unsupported currencies. """ + acquirers = super()._get_compatible_acquirers(*args, currency_id=currency_id, **kwargs) + + currency = self.env['res.currency'].browse(currency_id).exists() + if currency: + acquirers = acquirers.filtered( + lambda a: a.provider != 'authorize' or currency == a.authorize_currency_id + ) + + return acquirers + + def _get_validation_amount(self): + """ Override of payment to return the amount for Authorize.Net validation operations. + + :return: The validation amount + :rtype: float + """ + res = super()._get_validation_amount() + if self.provider != 'authorize': + return res + + return 0.01 + + def _get_validation_currency(self): + """ Override of payment to return the currency for Authorize.Net validation operations. + + :return: The validation currency + :rtype: recordset of `res.currency` + """ + res = super()._get_validation_currency() + if self.provider != 'authorize': + return res + + return self.authorize_currency_id diff --git a/addons/payment_authorize/models/payment_token.py b/addons/payment_authorize/models/payment_token.py new file mode 100644 index 00000000000..16a0bb0b5e0 --- /dev/null +++ b/addons/payment_authorize/models/payment_token.py @@ -0,0 +1,47 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging +import pprint + +from odoo import _, fields, models +from odoo.exceptions import UserError + +from .authorize_request import AuthorizeAPI + +_logger = logging.getLogger(__name__) + + +class PaymentToken(models.Model): + _inherit = 'payment.token' + + authorize_profile = fields.Char( + string="Authorize.Net Profile ID", + help="The unique reference for the partner/token combination in the Authorize.net backend.") + + def _handle_deactivation_request(self): + """ Override of payment to request Authorize.Net to delete the token. + + Note: self.ensure_one() + + :return: None + """ + super()._handle_deactivation_request() + if self.provider != 'authorize': + return + + authorize_API = AuthorizeAPI(self.acquirer_id) + res_content = authorize_API.delete_customer_profile(self.authorize_profile) + _logger.info("delete_customer_profile request response:\n%s", pprint.pformat(res_content)) + + def _handle_reactivation_request(self): + """ Override of payment to raise an error informing that Auth.net tokens cannot be restored. + + Note: self.ensure_one() + + :return: None + """ + super()._handle_reactivation_request() + if self.provider != 'authorize': + return + + raise UserError(_("Saved payment methods cannot be restored once they have been deleted.")) diff --git a/addons/payment_authorize/models/payment_transaction.py b/addons/payment_authorize/models/payment_transaction.py new file mode 100644 index 00000000000..c6d41252603 --- /dev/null +++ b/addons/payment_authorize/models/payment_transaction.py @@ -0,0 +1,243 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging +import pprint + +from odoo import _, api, models + +from odoo.addons.payment import utils as payment_utils +from odoo.exceptions import UserError, ValidationError + +from .authorize_request import AuthorizeAPI + +_logger = logging.getLogger(__name__) + + +class PaymentTransaction(models.Model): + _inherit = 'payment.transaction' + + def _get_specific_processing_values(self, processing_values): + """ Override of payment to return an access token as acquirer-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 != 'authorize': + return res + + return { + 'access_token': payment_utils.generate_access_token( + processing_values['reference'], processing_values['partner_id'] + ) + } + + def _authorize_create_transaction_request(self, opaque_data): + """ Create an Authorize.Net payment transaction request. + + Note: self.ensure_one() + + :param dict opaque_data: The payment details obfuscated by Authorize.Net + :return: + """ + self.ensure_one() + + authorize_API = AuthorizeAPI(self.acquirer_id) + if self.acquirer_id.capture_manually or self.operation == 'validation': + return authorize_API.authorize(self.amount, self.reference, opaque_data=opaque_data) + else: + return authorize_API.auth_and_capture( + self.amount, self.reference, opaque_data=opaque_data + ) + + def _send_payment_request(self): + """ Override of payment to send a payment request to Authorize. + + Note: self.ensure_one() + + :return: None + :raise: UserError if the transaction is not linked to a token + """ + super()._send_payment_request() + if self.provider != 'authorize': + return + + if not self.token_id.authorize_profile: + raise UserError("Authorize.Net: " + _("The transaction is not linked to a token.")) + + authorize_API = AuthorizeAPI(self.acquirer_id) + if self.acquirer_id.capture_manually: + res_content = authorize_API.authorize(self.amount, self.reference, token=self.token_id) + _logger.info("authorize request response:\n%s", pprint.pformat(res_content)) + else: + res_content = authorize_API.auth_and_capture( + self.amount, self.reference, token=self.token_id + ) + _logger.info("auth_and_capture request response:\n%s", pprint.pformat(res_content)) + + # As the API has no redirection flow, we always know the reference of the transaction. + # Still, we prefer to simulate the matching of the transaction by crafting dummy feedback + # data in order to go through the centralized `_handle_feedback_data` method. + feedback_data = {'reference': self.reference, 'response': res_content} + self._handle_feedback_data('authorize', feedback_data) + + @api.model + def _get_tx_from_feedback_data(self, provider, data): + """ Find the transaction based on the feedback data. + + :param str provider: The provider of the acquirer that handled the transaction + :param dict data: The feedback data sent by the acquirer + :return: The transaction if found + :rtype: recordset of `payment.transaction` + """ + tx = super()._get_tx_from_feedback_data(provider, data) + if provider != 'authorize': + return tx + + reference = data.get('reference') + tx = self.search([('reference', '=', reference), ('provider', '=', 'authorize')]) + if not tx: + raise ValidationError( + "Authorize.Net: " + _("No transaction found matching reference %s.", reference) + ) + return tx + + def _process_feedback_data(self, data): + """ Override of payment to process the transaction based on Authorize data. + + Note: self.ensure_one() + + :param dict data: The feedback data sent by the provider + :return: None + """ + super()._process_feedback_data(data) + if self.provider != 'authorize': + return + + response_content = data.get('response') + + self.acquirer_reference = response_content.get('x_trans_id') + status_code = response_content.get('x_response_code', '3') + if status_code == '1': # Approved + status_type = response_content.get('x_type').lower() + if status_type in ('auth_capture', 'prior_auth_capture'): + self._set_done() + if self.tokenize and not self.token_id: + self._authorize_tokenize() + elif status_type == 'auth_only': + self._set_authorized() + if self.tokenize and not self.token_id: + self._authorize_tokenize() + elif status_type == 'void': + self._set_canceled() + elif status_code == '2': # Declined + self._set_canceled() + elif status_code == '4': # Held for Review + self._set_pending() + else: # Error / Unknown code + error_code = response_content.get('x_response_reason_text') + _logger.info( + "received data with invalid status code %s and error code %s", + status_code, error_code + ) + self._set_error( + "Authorize.Net: " + _( + "Received data with status code \"%(status)s\" and error code \"%(error)s\"", + status=status_code, error=error_code + ) + ) + + def _authorize_tokenize(self): + """ Create a token for the current transaction. + + Note: self.ensure_one() + + :return: None + """ + self.ensure_one() + + authorize_API = AuthorizeAPI(self.acquirer_id) + cust_profile = authorize_API.create_customer_profile( + self.partner_id, self.acquirer_reference + ) + _logger.info("create_customer_profile request response:\n%s", pprint.pformat(cust_profile)) + if cust_profile: + token = self.env['payment.token'].create({ + 'acquirer_id': self.acquirer_id.id, + 'name': cust_profile.get('name'), + 'partner_id': self.partner_id.id, + 'acquirer_ref': cust_profile.get('payment_profile_id'), + 'authorize_profile': cust_profile.get('profile_id'), + }) + self.write({ + 'token_id': token.id, + 'tokenize': False, + }) + _logger.info( + "created token with id %s for partner with id %s", token.id, self.partner_id.id + ) + + def _send_refund_request(self): + """ Override of payment to send a refund request to Authorize. + + Note: self.ensure_one() + + :return: None + """ + super()._send_refund_request() + if self.provider != 'authorize': + return + + authorize_API = AuthorizeAPI(self.acquirer_id) + rounded_amount = round(self.amount, self.currency_id.decimal_places) + res_content = authorize_API.refund(self.acquirer_reference, rounded_amount) + _logger.info("refund request response:\n%s", pprint.pformat(res_content)) + # As the API has no redirection flow, we always know the reference of the transaction. + # Still, we prefer to simulate the matching of the transaction by crafting dummy feedback + # data in order to go through the centralized `_handle_feedback_data` method. + feedback_data = {'reference': self.reference, 'response': res_content} + self._handle_feedback_data('authorize', feedback_data) + + def _send_capture_request(self): + """ Override of payment to send a capture request to Authorize. + + Note: self.ensure_one() + + :return: None + """ + super()._send_capture_request() + if self.provider != 'authorize': + return + + authorize_API = AuthorizeAPI(self.acquirer_id) + rounded_amount = round(self.amount, self.currency_id.decimal_places) + res_content = authorize_API.capture(self.acquirer_reference, rounded_amount) + _logger.info("capture request response:\n%s", pprint.pformat(res_content)) + # As the API has no redirection flow, we always know the reference of the transaction. + # Still, we prefer to simulate the matching of the transaction by crafting dummy feedback + # data in order to go through the centralized `_handle_feedback_data` method. + feedback_data = {'reference': self.reference, 'response': res_content} + self._handle_feedback_data('authorize', feedback_data) + + def _send_void_request(self): + """ Override of payment to send a void request to Authorize. + + Note: self.ensure_one() + + :return: None + """ + super()._send_void_request() + if self.provider != 'authorize': + return + + authorize_API = AuthorizeAPI(self.acquirer_id) + res_content = authorize_API.void(self.acquirer_reference) + _logger.info("void request response:\n%s", pprint.pformat(res_content)) + # As the API has no redirection flow, we always know the reference of the transaction. + # Still, we prefer to simulate the matching of the transaction by crafting dummy feedback + # data in order to go through the centralized `_handle_feedback_data` method. + feedback_data = {'reference': self.reference, 'response': res_content} + self._handle_feedback_data('authorize', feedback_data) diff --git a/addons/payment_authorize/static/src/js/payment_form.js b/addons/payment_authorize/static/src/js/payment_form.js index 54007ce4cc2..d2e9d075ae5 100644 --- a/addons/payment_authorize/static/src/js/payment_form.js +++ b/addons/payment_authorize/static/src/js/payment_form.js @@ -1,160 +1,156 @@ -odoo.define('payment_authorize.payment_form', function (require) { -"use strict"; +odoo.define('payment_authorize.payment_form', require => { + 'use strict'; -var ajax = require('web.ajax'); -var core = require('web.core'); -var PaymentForm = require('payment.payment_form'); + const core = require('web.core'); + const ajax = require('web.ajax'); -var _t = core._t; + const checkoutForm = require('payment.checkout_form'); + const manageForm = require('payment.manage_form'); -PaymentForm.include({ + const _t = core._t; - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- + const authorizeMixin = { - /** - * Returns the parameters for the AcceptUI button that AcceptJS will use. - * - * @private - * @param {Object} formData data obtained by getFormData - * @returns {Object} params for the AcceptJS button - */ - _acceptJsParams: function (formData) { - return { - 'class': 'AcceptUI d-none', - 'data-apiLoginID': formData.login_id, - 'data-clientKey': formData.client_key, - 'data-billingAddressOptions': '{"show": false, "required": false}', - 'data-responseHandler': 'responseHandler' - }; - }, - - /** - * called when clicking on pay now or add payment event to create token for credit card/debit card. - * - * @private - * @param {Event} ev - * @param {DOMElement} checkedRadio - * @param {Boolean} addPmEvent - */ - _createAuthorizeToken: function (ev, $checkedRadio, addPmEvent) { - var self = this; - if (ev.type === 'submit') { - var button = $(ev.target).find('*[type="submit"]')[0] - } else { - var button = ev.target; - } - var acquirerID = this.getAcquirerIdFromRadio($checkedRadio); - var acquirerForm = this.$('#o_payment_add_token_acq_' + acquirerID); - var inputsForm = $('input', acquirerForm); - var formData = self.getFormData(inputsForm); - if (this.options.partnerId === undefined) { - console.warn('payment_form: unset partner_id when adding new token; things could go wrong'); - } - var AcceptJs = false; - if (formData.acquirer_state === 'enabled') { - AcceptJs = 'https://js.authorize.net/v3/AcceptUI.js'; - } else { - AcceptJs = 'https://jstest.authorize.net/v3/AcceptUI.js'; - } - - window.responseHandler = function (response) { - _.extend(formData, response); - - if (response.messages.resultCode === "Error") { - var errorMessage = ""; - _.each(response.messages.message, function (message) { - errorMessage += message.code + ": " + message.text; - }) - acquirerForm.removeClass('d-none'); - return self.displayError(_t('Server Error'), errorMessage); + /** + * Prepare the inline form of Authorize.Net 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 !== 'authorize') { + return this._super(...arguments); } - self._rpc({ - route: formData.data_set, - params: formData - }).then (function (data) { - if (addPmEvent) { - if (formData.return_url) { - window.location = formData.return_url; - } else { - window.location.reload(); - } - } else { - $checkedRadio.val(data.id); - self.el.submit(); + if (flow === 'token') { + return Promise.resolve(); // Don't show the form for tokens + } + + this._setPaymentFlow('direct'); + + let acceptJSUrl = 'https://js.authorize.net/v1/Accept.js'; + return this._rpc({ + route: '/payment/authorize/get_acquirer_info', + params: { + 'acquirer_id': paymentOptionId, + }, + }).then(acquirerInfo => { + if (acquirerInfo.state !== 'enabled') { + acceptJSUrl = 'https://jstest.authorize.net/v1/Accept.js'; } - }).guardedCatch(function (error) { - // if the rpc fails, pretty obvious + this.authorizeInfo = acquirerInfo; + }).then(() => { + ajax.loadJS(acceptJSUrl); + }).guardedCatch((error) => { error.event.preventDefault(); - acquirerForm.removeClass('d-none'); - self.displayError( - _t('Server Error'), - _t("We are not able to add your payment method at the moment.") + - self._parseError(error) + this._displayError( + _t("Server Error"), + _t("An error occurred when displayed this payment form."), + error.message.data.message ); }); - }; + }, - if (this.$button === undefined) { - this.$button = $('