From c6dfbbad4cec15979677b80100cf04c2c0447cdf Mon Sep 17 00:00:00 2001 From: Joren Van Onder Date: Wed, 18 Aug 2021 15:42:26 +0000 Subject: [PATCH] [IMP] payment_authorize: support ACH payments Before 660dc0ebaf it was possible to use Authorize to pay via your bank account using the "Redirection to payment acquirer" option. Since the refactor removed the redirect it was no longer possible. This commit reintroduces that feature. It does so by adding new form elements that accept bank account information. Additionally it reintroduces the `billTo` and `customer` parameters that Authorize requires when processing ACH payments. task-2628318 closes odoo/odoo#75289 Signed-off-by: Antoine Vandevenne (anv) --- addons/payment_authorize/controllers/main.py | 4 +- .../models/authorize_request.py | 53 ++++++++--- .../models/payment_acquirer.py | 36 +++++++- .../payment_authorize/models/payment_token.py | 5 + .../models/payment_transaction.py | 16 ++-- .../static/src/js/payment_form.js | 91 +++++++++++++++---- .../views/payment_authorize_templates.xml | 28 +++++- .../payment_authorize/views/payment_views.xml | 5 + 8 files changed, 194 insertions(+), 44 deletions(-) diff --git a/addons/payment_authorize/controllers/main.py b/addons/payment_authorize/controllers/main.py index c7283b1907b..461396e05a5 100644 --- a/addons/payment_authorize/controllers/main.py +++ b/addons/payment_authorize/controllers/main.py @@ -19,12 +19,14 @@ class AuthorizeController(http.Controller): """ Return public information on the acquirer. :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 + :return: Information on the acquirer, namely: the state, payment method type, login ID, and + public client key :rtype: dict """ acquirer_sudo = request.env['payment.acquirer'].sudo().browse(acquirer_id).exists() return { 'state': acquirer_sudo.state, + 'payment_method_type': acquirer_sudo.authorize_payment_method_type, # 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 diff --git a/addons/payment_authorize/models/authorize_request.py b/addons/payment_authorize/models/authorize_request.py index 535fa3d2bcf..750879efa0b 100644 --- a/addons/payment_authorize/models/authorize_request.py +++ b/addons/payment_authorize/models/authorize_request.py @@ -37,6 +37,7 @@ class AuthorizeAPI: self.state = acquirer.state self.name = acquirer.authorize_login self.transaction_key = acquirer.authorize_transaction_key + self.payment_method_type = acquirer.authorize_payment_method_type def _make_request(self, operation, data=None): request = { @@ -123,7 +124,11 @@ class AuthorizeAPI: 'customerPaymentProfileId': res['payment_profile_id'], }) - res['name'] = response.get('paymentProfile', {}).get('payment', {}).get('creditCard', {}).get('cardNumber') + payment = response.get('paymentProfile', {}).get('payment', {}) + if self.payment_method_type == 'credit_card': + res['name'] = payment.get('creditCard', {}).get('cardNumber') + else: + res['name'] = payment.get('bankAccount', {}).get('accountNumber') return res def delete_customer_profile(self, profile_id): @@ -138,25 +143,48 @@ class AuthorizeAPI: return self._format_response(response, 'deleteCustomerProfile') #=== Transaction management ===# - def _prepare_authorization_transaction_request(self, transaction_type, tx_data, amount, reference): + def _prepare_authorization_transaction_request(self, transaction_type, tx_data, tx): + # The billTo parameter is required for new ACH transactions (transactions without a payment.token), + # but is not allowed for transactions with a payment.token. + bill_to = {} + if 'profile' not in tx_data: + split_name = payment_utils.split_partner_name(tx.partner_name) + bill_to = { + 'billTo': { + 'firstName': '' if tx.partner_id.is_company else split_name[0], + 'lastName': split_name[1], # lastName is always required + 'company': tx.partner_name if tx.partner_id.is_company else '', + 'address': tx.partner_address, + 'city': tx.partner_city, + 'state': tx.partner_state_id.name or '', + 'zip': tx.partner_zip, + 'country': tx.partner_country_id.name or '', + } + } + + # These keys have to be in the order defined in + # https://apitest.authorize.net/xml/v1/schema/AnetApiSchema.xsd return { 'transactionRequest': { 'transactionType': transaction_type, - 'amount': str(amount), + 'amount': str(tx.amount), **tx_data, 'order': { - 'invoiceNumber': reference[:20], - 'description': reference[:255], + 'invoiceNumber': tx.reference[:20], + 'description': tx.reference[:255], }, + 'customer': { + 'email': tx.partner_email or '', + }, + **bill_to, 'customerIP': payment_utils.get_customer_ip_address(), } } - def authorize(self, amount, reference, token=None, opaque_data=None): + def authorize(self, tx, 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 tx: The transaction of the payment, as a `payment.transaction` record :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 @@ -166,18 +194,17 @@ class AuthorizeAPI: tx_data = self._prepare_tx_data(token=token, opaque_data=opaque_data) response = self._make_request( 'createTransactionRequest', - self._prepare_authorization_transaction_request('authOnlyTransaction', tx_data, amount, reference) + self._prepare_authorization_transaction_request('authOnlyTransaction', tx_data, tx) ) return self._format_response(response, 'auth_only') - def auth_and_capture(self, amount, reference, token=None, opaque_data=None): + def auth_and_capture(self, tx, 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 str amount: transaction amount (up to 15 digits with decimal point) - :param str reference: used as "invoiceNumber" in the Authorize.net backend + :param recordset tx: The transaction of the payment, as a `payment.transaction` record :param record token: the payment.token record that must be charged :param str opaque_data: the transaction opaque_data obtained from Authorize.net @@ -187,7 +214,7 @@ class AuthorizeAPI: tx_data = self._prepare_tx_data(token=token, opaque_data=opaque_data) response = self._make_request( 'createTransactionRequest', - self._prepare_authorization_transaction_request('authCaptureTransaction', tx_data, amount, reference) + self._prepare_authorization_transaction_request('authCaptureTransaction', tx_data, tx) ) result = self._format_response(response, 'auth_capture') diff --git a/addons/payment_authorize/models/payment_acquirer.py b/addons/payment_authorize/models/payment_acquirer.py index 9002b0ec055..1d27f3696f3 100644 --- a/addons/payment_authorize/models/payment_acquirer.py +++ b/addons/payment_authorize/models/payment_acquirer.py @@ -4,7 +4,8 @@ import logging import pprint from odoo import _, api, fields, models -from odoo.exceptions import UserError +from odoo.fields import Command +from odoo.exceptions import UserError, ValidationError from .authorize_request import AuthorizeAPI @@ -30,6 +31,39 @@ class PaymentAcquirer(models.Model): # 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') + authorize_payment_method_type = fields.Selection( + string="Allow Payments From", + help="Determines with what payment method the customer can pay.", + selection=[('credit_card', "Credit Card"), ('bank_account', "Bank Account (USA Only)")], + default='credit_card', + required_if_provider='authorize', + ) + + @api.constrains('authorize_payment_method_type') + def _check_payment_method_type(self): + for acquirer in self.filtered(lambda acq: acq.provider == "authorize"): + if self.env['payment.token'].search([('acquirer_id', '=', acquirer.id)], limit=1): + raise ValidationError(_( + "There are active tokens linked to this acquirer. To change the payment method " + "type, please disable the acquirer and duplicate it. Then, change the payment " + "method type on the duplicated acquirer." + )) + + @api.onchange('authorize_payment_method_type') + def _onchange_authorize_payment_method_type(self): + if self.authorize_payment_method_type == 'bank_account': + self.display_as = _("Bank (powered by Authorize)") + self.payment_icon_ids = [Command.clear()] + else: + self.display_as = _("Credit Card (powered by Authorize)") + self.payment_icon_ids = [Command.set([self.env.ref(icon_xml_id).id for icon_xml_id in ( + 'payment.payment_icon_cc_maestro', + 'payment.payment_icon_cc_mastercard', + 'payment.payment_icon_cc_discover', + 'payment.payment_icon_cc_diners_club_intl', + 'payment.payment_icon_cc_jcb', + 'payment.payment_icon_cc_visa', + )])] def action_update_merchant_details(self): """ Fetch the merchant details to update the client key and the account currency. """ diff --git a/addons/payment_authorize/models/payment_token.py b/addons/payment_authorize/models/payment_token.py index 16a0bb0b5e0..214d972bb01 100644 --- a/addons/payment_authorize/models/payment_token.py +++ b/addons/payment_authorize/models/payment_token.py @@ -17,6 +17,11 @@ class PaymentToken(models.Model): authorize_profile = fields.Char( string="Authorize.Net Profile ID", help="The unique reference for the partner/token combination in the Authorize.net backend.") + authorize_payment_method_type = fields.Selection( + string="Authorize.Net Payment Type", + help="The type of payment method this token is linked to.", + selection=[("credit_card", "Credit Card"), ("bank_account", "Bank Account (USA Only)")], + ) def _handle_deactivation_request(self): """ Override of payment to request Authorize.Net to delete the token. diff --git a/addons/payment_authorize/models/payment_transaction.py b/addons/payment_authorize/models/payment_transaction.py index 215aa3e0d78..cc04a3f3249 100644 --- a/addons/payment_authorize/models/payment_transaction.py +++ b/addons/payment_authorize/models/payment_transaction.py @@ -4,11 +4,10 @@ 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 +from odoo.addons.payment import utils as payment_utils _logger = logging.getLogger(__name__) @@ -47,11 +46,9 @@ class PaymentTransaction(models.Model): 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) + return authorize_API.authorize(self, opaque_data=opaque_data) else: - return authorize_API.auth_and_capture( - self.amount, self.reference, opaque_data=opaque_data - ) + return authorize_API.auth_and_capture(self, opaque_data=opaque_data) def _send_payment_request(self): """ Override of payment to send a payment request to Authorize. @@ -70,12 +67,10 @@ class PaymentTransaction(models.Model): 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) + res_content = authorize_API.authorize(self, 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 - ) + res_content = authorize_API.auth_and_capture(self, 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. @@ -237,6 +232,7 @@ class PaymentTransaction(models.Model): 'partner_id': self.partner_id.id, 'acquirer_ref': cust_profile.get('payment_profile_id'), 'authorize_profile': cust_profile.get('profile_id'), + 'authorize_payment_method_type': self.acquirer_id.authorize_payment_method_type, 'verified': True, }) self.write({ diff --git a/addons/payment_authorize/static/src/js/payment_form.js b/addons/payment_authorize/static/src/js/payment_form.js index 0e434d55026..3df283dd70e 100644 --- a/addons/payment_authorize/static/src/js/payment_form.js +++ b/addons/payment_authorize/static/src/js/payment_form.js @@ -12,6 +12,63 @@ odoo.define('payment_authorize.payment_form', require => { const authorizeMixin = { + /** + * Return all relevant inline form inputs based on the payment method type of the acquirer. + * + * @private + * @param {number} acquirerId - The id of the selected acquirer + * @return {Object} - An object mapping the name of inline form inputs to their DOM element + */ + _getInlineFormInputs: function (acquirerId) { + if (this.authorizeInfo.payment_method_type === "credit_card") { + return { + card: document.getElementById(`o_authorize_card_${acquirerId}`), + month: document.getElementById(`o_authorize_month_${acquirerId}`), + year: document.getElementById(`o_authorize_year_${acquirerId}`), + code: document.getElementById(`o_authorize_code_${acquirerId}`), + }; + } else { + return { + accountName: document.getElementById(`o_authorize_account_name_${acquirerId}`), + accountNumber: document.getElementById( + `o_authorize_account_number_${acquirerId}` + ), + abaNumber: document.getElementById(`o_authorize_aba_number_${acquirerId}`), + accountType: document.getElementById(`o_authorize_account_type_${acquirerId}`), + }; + } + }, + + /** + * Return the credit card or bank data to pass to the Accept.dispatch request. + * + * @private + * @param {number} acquirerId - The id of the selected acquirer + * @return {Object} - Data to pass to the Accept.dispatch request + */ + _getPaymentDetails: function (acquirerId) { + const inputs = this._getInlineFormInputs(acquirerId); + if (this.authorizeInfo.payment_method_type === 'credit_card') { + return { + cardData: { + cardNumber: inputs.card.value.replace(/ /g, ''), // Remove all spaces + month: inputs.month.value, + year: inputs.year.value, + cardCode: inputs.code.value, + }, + }; + } else { + return { + bankData: { + nameOnAccount: inputs.accountName.value, + accountNumber: inputs.accountNumber.value, + routingNumber: inputs.abaNumber.value, + accountType: inputs.accountType.value, + }, + }; + } + }, + /** * Prepare the inline form of Authorize.Net for direct payment. * @@ -71,18 +128,7 @@ odoo.define('payment_authorize.payment_form', require => { return this._super(...arguments); // Tokens are handled by the generic flow } - const card = document.getElementById(`o_authorize_card_${paymentOptionId}`); - const month = document.getElementById(`o_authorize_month_${paymentOptionId}`); - const year = document.getElementById(`o_authorize_year_${paymentOptionId}`); - const code = document.getElementById(`o_authorize_code_${paymentOptionId}`); - - // Basic form validation - if (!( - card.reportValidity() - && month.reportValidity() - && year.reportValidity() - && code.reportValidity() - )) { + if (!this._validateFormInputs(paymentOptionId)) { this._enableButton(); // The submit button is disabled at this point, enable it return Promise.resolve(); } @@ -93,13 +139,9 @@ odoo.define('payment_authorize.payment_form', require => { apiLoginID: this.authorizeInfo.login_id, clientKey: this.authorizeInfo.client_key, }, - cardData: { - cardNumber: card.value.replace(/ /g, ''), // Remove all spaces - month: month.value, - year: year.value, - cardCode: code.value, - } + ...this._getPaymentDetails(paymentOptionId), }; + // Dispatch secure data to Authorize.Net to get a payment nonce in return return Accept.dispatchData( secureData, response => this._responseHandler(paymentOptionId, response) @@ -150,6 +192,19 @@ odoo.define('payment_authorize.payment_form', require => { ); }); }, + + /** + * Checks that all payment inputs adhere to the DOM validation constraints. + * + * @private + * @param {number} acquirerId - The id of the selected acquirer + * @return {boolean} - Whether all elements pass the validation constraints + */ + _validateFormInputs: function (acquirerId) { + const inputs = Object.values(this._getInlineFormInputs(acquirerId)); + return inputs.every(element => element.reportValidity()); + }, + }; checkoutForm.include(authorizeMixin); diff --git a/addons/payment_authorize/views/payment_authorize_templates.xml b/addons/payment_authorize/views/payment_authorize_templates.xml index 602f9b79f21..efa90d19b94 100644 --- a/addons/payment_authorize/views/payment_authorize_templates.xml +++ b/addons/payment_authorize/views/payment_authorize_templates.xml @@ -2,7 +2,7 @@ diff --git a/addons/payment_authorize/views/payment_views.xml b/addons/payment_authorize/views/payment_views.xml index 838652e0219..e404725d06e 100644 --- a/addons/payment_authorize/views/payment_views.xml +++ b/addons/payment_authorize/views/payment_views.xml @@ -23,6 +23,10 @@ + + +