[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) <AntoineVDV@users.noreply.github.com>
This commit is contained in:
Joren Van Onder
2021-09-02 14:15:17 +00:00
committed by Antoine Vandevenne (anv)
parent d9a3b938fe
commit c6dfbbad4c
8 changed files with 194 additions and 44 deletions
+3 -1
View File
@@ -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
@@ -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')
@@ -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. """
@@ -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.
@@ -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({
@@ -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);
@@ -2,7 +2,7 @@
<odoo>
<template id="inline_form">
<div t-attf-id="o_authorize_form_{{acquirer_id}}" class="o_authorize_form">
<div t-if="acquirer.authorize_payment_method_type == 'credit_card'" t-attf-id="o_authorize_form_{{acquirer_id}}" class="o_authorize_form">
<div class="form-group">
<label t-attf-for="o_authorize_card_{{acquirer_id}}" class="col-form-label">Card Number</label>
<input type="text" t-attf-id="o_authorize_card_{{acquirer_id}}" required="" maxlength="19" class="form-control"/>
@@ -21,6 +21,32 @@
</div>
</div>
</div>
<div t-else="" t-attf-id="o_authorize_form_{{acquirer_id}}" class="o_authorize_form">
<div class="form-group">
<label t-attf-for="o_authorize_bank_name_{{acquirer_id}}" class="col-form-label">Bank Name</label>
<input type="text" t-attf-id="o_authorize_bank_name_{{acquirer_id}}" required="" class="form-control"/>
</div>
<div class="form-group">
<label t-attf-for="o_authorize_account_name_{{acquirer_id}}" class="col-form-label">Name On Account</label>
<input type="text" t-attf-id="o_authorize_account_name_{{acquirer_id}}" required="" class="form-control"/>
</div>
<div class="form-group">
<label t-attf-for="o_authorize_account_number_{{acquirer_id}}" class="col-form-label">Account Number</label>
<input type="text" t-attf-id="o_authorize_account_number_{{acquirer_id}}" required="" class="form-control"/>
</div>
<div class="form-group">
<label t-attf-for="o_authorize_aba_number_{{acquirer_id}}" class="col-form-label">ABA Routing Number</label>
<input type="text" t-attf-id="o_authorize_aba_number_{{acquirer_id}}" required="" class="form-control"/>
</div>
<div class="form-group">
<label t-attf-for="o_authorize_account_type_{{acquirer_id}}" class="col-form-label">Bank Account Type</label>
<select t-attf-id="o_authorize_account_type_{{acquirer_id}}" required="" class="form-control">
<option value="checking">Personal Checking</option>
<option value="savings">Personal Savings</option>
<option value="businessChecking">Business Checking</option>
</select>
</div>
</div>
</template>
</odoo>
@@ -23,6 +23,10 @@
</a>
</group>
</xpath>
<field name="display_as" position="before">
<field name="authorize_payment_method_type"
attrs="{'invisible': [('provider', '!=', 'authorize')], 'required':[('provider', '=', 'authorize'), ('state', '!=', 'disabled')]}"/>
</field>
<xpath expr="//field[@name='country_ids']" position="after">
<label for="authorize_currency_id" string="Currency" attrs="{'invisible': [('provider', '!=', 'authorize')]}"/>
<div attrs="{'invisible': [('provider', '!=', 'authorize')]}">
@@ -43,6 +47,7 @@
<xpath expr='//field[@name="acquirer_ref"]' position='after'>
<field name="provider" invisible="1"/>
<field name="authorize_profile" attrs="{'invisible':[('provider', '!=', 'authorize')]}"/>
<field name="authorize_payment_method_type" attrs="{'invisible': [('provider', '!=', 'authorize')]}"/>
</xpath>
</field>
</record>