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 @@ - + Card Number @@ -21,6 +21,32 @@ + + + Bank Name + + + + Name On Account + + + + Account Number + + + + ABA Routing Number + + + + Bank Account Type + + Personal Checking + Personal Savings + Business Checking + + + 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 @@ + + + @@ -43,6 +47,7 @@ +