From 406611967b4c8eca711e666b9f707b93c7faf19e Mon Sep 17 00:00:00 2001 From: Damien Bouvy Date: Mon, 29 Aug 2016 15:40:56 +0200 Subject: [PATCH 1/8] [FIX] payment: use standard odoo exceptions An old hack simple overrode the standard python ValueError instead of except_orm, which explains how it survived for so long. --- addons/payment/models/payment_acquirer.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/addons/payment/models/payment_acquirer.py b/addons/payment/models/payment_acquirer.py index cc723a4cdfa..45b159f692c 100644 --- a/addons/payment/models/payment_acquirer.py +++ b/addons/payment/models/payment_acquirer.py @@ -4,6 +4,7 @@ import logging from odoo import api, exceptions, fields, models, _ from odoo.tools import float_round, image_resize_images from odoo.addons.base.module import module +from odoo.exceptions import ValidationError _logger = logging.getLogger(__name__) @@ -16,11 +17,6 @@ def _partner_split_name(partner_name): return [' '.join(partner_name.split()[:-1]), ' '.join(partner_name.split()[-1:])] -class ValidationError(ValueError): - """ Used for value error when validating transaction data coming from acquirers. """ - pass - - class PaymentAcquirer(models.Model): """ Acquirer Model. Each specific acquirer can extend the model by adding its own fields, using the acquirer_name as a prefix for the new fields. From a2d249f1a4d4c64c01e2c8c3f1145b817b7544e3 Mon Sep 17 00:00:00 2001 From: Damien Bouvy Date: Mon, 29 Aug 2016 15:42:25 +0200 Subject: [PATCH 2/8] [IMP] website_(portal_sale|sale|quote), payment: add authorization payment flow steps This allows using a modern e-commerce flow where amounts are first authorized at checkout then captured when the picking is in fact shipped instead of immediately capturing the amount. To access this flow, a new selection value is available on the payment acquirer: "Authorize the amount and confirm the SO on acquirer confirmation". If this value is selected, s2s/form transactions should only require authorization to the acquirer; the user can then capture/void the payment manually from the Odoo backend transaction form view by simply using buttons. --- addons/payment/models/payment_acquirer.py | 29 ++++++++++++++++++- addons/payment/views/payment_views.xml | 2 ++ addons/website_portal_sale/models/payment.py | 3 ++ .../views/website_quote_templates.xml | 5 ++++ addons/website_sale/controllers/main.py | 5 ++-- addons/website_sale/views/sale_order.xml | 2 +- addons/website_sale/views/templates.xml | 5 +++- 7 files changed, 46 insertions(+), 5 deletions(-) diff --git a/addons/payment/models/payment_acquirer.py b/addons/payment/models/payment_acquirer.py index 45b159f692c..aed9564d234 100644 --- a/addons/payment/models/payment_acquirer.py +++ b/addons/payment/models/payment_acquirer.py @@ -71,6 +71,7 @@ class PaymentAcquirer(models.Model): help="Make this payment acquirer available (Customer invoices, etc.)") auto_confirm = fields.Selection([ ('none', 'No automatic confirmation'), + ('authorize', 'Authorize the amount and confirm the SO on acquirer confirmation'), ('confirm_so', 'Confirm the SO on acquirer confirmation'), ('generate_and_pay_invoice', 'On acquirer confirmation, confirm the SO, generate the invoice and pay it')], string='Order Confirmation', default='confirm_so', required=True) @@ -374,6 +375,7 @@ class PaymentTransaction(models.Model): state = fields.Selection([ ('draft', 'Draft'), ('pending', 'Pending'), + ('authorized', 'Authorized'), ('done', 'Done'), ('error', 'Error'), ('cancel', 'Canceled')], 'Status', @@ -558,15 +560,40 @@ class PaymentTransaction(models.Model): if hasattr(self, custom_method_name): return getattr(self, custom_method_name)(**kwargs) + @api.multi + def s2s_capture_transaction(self, **kwargs): + custom_method_name = '%s_s2s_capture_transaction' % self.acquirer_id.provider + if hasattr(self, custom_method_name): + return getattr(self, custom_method_name)(**kwargs) + + @api.multi + def s2s_void_transaction(self, **kwargs): + custom_method_name = '%s_s2s_void_transaction' % self.acquirer_id.provider + if hasattr(self, custom_method_name): + return getattr(self, custom_method_name)(**kwargs) + @api.multi def s2s_get_tx_status(self): """ Get the tx status. """ invalid_param_method_name = '_%s_s2s_get_tx_status' % self.acquirer_id.provider if hasattr(self, invalid_param_method_name): return getattr(self, invalid_param_method_name)() - return True + @api.multi + def action_capture(self): + if any(self.mapped(lambda tx: tx.state != 'authorized')): + raise ValidationError('Only transactions in the Authorized status can be captured.') + for tx in self: + tx.s2s_capture_transaction() + + @api.multi + def action_void(self): + if any(self.mapped(lambda tx: tx.state != 'authorized')): + raise ValidationError('Only transactions in the Authorized status can be voided.') + for tx in self: + tx.s2s_void_transaction() + class PaymentToken(models.Model): _name = 'payment.token' diff --git a/addons/payment/views/payment_views.xml b/addons/payment/views/payment_views.xml index 448de81273b..51bf831a719 100644 --- a/addons/payment/views/payment_views.xml +++ b/addons/payment/views/payment_views.xml @@ -208,6 +208,8 @@
+
diff --git a/addons/website_portal_sale/models/payment.py b/addons/website_portal_sale/models/payment.py index 2b2ceeaa6f1..8170cc37139 100644 --- a/addons/website_portal_sale/models/payment.py +++ b/addons/website_portal_sale/models/payment.py @@ -52,6 +52,9 @@ class PaymentTransaction(models.Model): # verify SO/TX match, excluding tx.fees which are currently not included in SO amount_matches = float_compare(tx.amount, tx.sale_order_id.amount_total, 2) == 0 if amount_matches: + if tx.state == 'authorized' and tx.acquirer_id.auto_confirm == 'authorize': + _logger.info('<%s> transaction authorized, auto-confirming order %s (ID %s)', acquirer_name, tx.sale_order_id.name, tx.sale_order_id.id) + tx.sale_order_id.with_context(send_email=True).action_confirm() if tx.state == 'done' and tx.acquirer_id.auto_confirm in ['confirm_so', 'generate_and_pay_invoice']: _logger.info('<%s> transaction completed, auto-confirming order %s (ID %s)', acquirer_name, tx.sale_order_id.name, tx.sale_order_id.id) tx.sale_order_id.with_context(send_email=True).action_confirm() diff --git a/addons/website_quote/views/website_quote_templates.xml b/addons/website_quote/views/website_quote_templates.xml index 4e1e1a20a33..7505867f21d 100644 --- a/addons/website_quote/views/website_quote_templates.xml +++ b/addons/website_quote/views/website_quote_templates.xml @@ -271,6 +271,11 @@ Your transaction is waiting confirmation. +
+ + Your payment has been authorized. + +
This order diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index a756173183e..6db1998fcde 100644 --- a/addons/website_sale/controllers/main.py +++ b/addons/website_sale/controllers/main.py @@ -748,7 +748,8 @@ class WebsiteSale(http.Controller): tx.partner_id == tx.sale_order_id.partner_id): try: s2s_result = tx.s2s_do_transaction() - if not s2s_result or tx.state != 'done': + valid_state = 'authorized' if tx.acquirer_id.auto_confirm == 'authorize' else 'done' + if not s2s_result or tx.state != valid_state: return dict(success=False, error=_("Payment transaction failed (%s)") % tx.state_message) return dict(success=True, url='/shop/payment/validate') except Exception, e: @@ -884,7 +885,7 @@ class WebsiteSale(http.Controller): if not order or (order.amount_total and not tx): return request.redirect('/shop') - if (not order.amount_total and not tx) or tx.state in ['pending', 'done']: + if (not order.amount_total and not tx) or tx.state in ['pending', 'done', 'authorized']: if (not order.amount_total and not tx): # Orders are confirmed by payment transactions, but there is none for free orders, # (e.g. free events), so confirm immediately diff --git a/addons/website_sale/views/sale_order.xml b/addons/website_sale/views/sale_order.xml index a77bc74de35..0040ac33fe3 100644 --- a/addons/website_sale/views/sale_order.xml +++ b/addons/website_sale/views/sale_order.xml @@ -8,7 +8,7 @@ - + diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml index 3f54c9eb319..f3782fdb109 100644 --- a/addons/website_sale/views/templates.xml +++ b/addons/website_sale/views/templates.xml @@ -1535,6 +1535,9 @@

Your payment has been received.

+ +

Your payment has been authorized.

+

The payment seems to have been canceled.

@@ -1547,7 +1550,7 @@
- +

Your transaction is waiting confirmation.

From ba87ff438d1cdf5e1f5c57563a16a17f6eea6529 Mon Sep 17 00:00:00 2001 From: Shivam Dudhat Date: Mon, 29 Aug 2016 15:49:13 +0200 Subject: [PATCH 3/8] [IMP] payment: avoid warning on payment.token creation Since the creates overrides needs some information that do not end up in the final record, let's avoid warning by removing all non-model fields from the call to super. --- addons/payment/models/payment_acquirer.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/addons/payment/models/payment_acquirer.py b/addons/payment/models/payment_acquirer.py index aed9564d234..d0733c16fe3 100644 --- a/addons/payment/models/payment_acquirer.py +++ b/addons/payment/models/payment_acquirer.py @@ -617,8 +617,10 @@ class PaymentToken(models.Model): custom_method_name = '%s_create' % acquirer.provider if hasattr(self, custom_method_name): values.update(getattr(self, custom_method_name)(values)) - - return super(PaymentToken, self).create(values) + # remove all non-model fields used by (provider)_create method to avoid warning + fields_wl = set(self._fields.keys()) & set(values.keys()) + clean_vals = {field: values[field] for field in fields_wl} + return super(PaymentToken, self).create(clean_vals) @api.multi @api.depends('name') From a2b9b3a6bb39aded694c30620820034f74cd0f77 Mon Sep 17 00:00:00 2001 From: Damien Bouvy Date: Mon, 29 Aug 2016 15:50:53 +0200 Subject: [PATCH 4/8] [IMP] payment_authorize: add server2server support This commit add s2s communication to the Authorize.net payment provider, allowing the user to: - Create a payment token - Charge a payment token - Authorize/capture transactions --- addons/payment_authorize/controllers/main.py | 14 + .../data/payment_acquirer_data.xml | 1 + addons/payment_authorize/models/__init__.py | 1 - .../models/authorize_request.py | 247 ++++++++++++++++++ addons/payment_authorize/models/payment.py | 146 ++++++++++- .../payment_authorize/tests/test_authorize.py | 81 +++++- .../views/payment_authorize_templates.xml | 35 +++ 7 files changed, 518 insertions(+), 7 deletions(-) create mode 100644 addons/payment_authorize/models/authorize_request.py diff --git a/addons/payment_authorize/controllers/main.py b/addons/payment_authorize/controllers/main.py index aec2aa1f777..5fc8ba87a69 100644 --- a/addons/payment_authorize/controllers/main.py +++ b/addons/payment_authorize/controllers/main.py @@ -2,6 +2,7 @@ import pprint import logging import urlparse +import werkzeug from odoo import http from odoo.http import request @@ -30,3 +31,16 @@ class AuthorizeController(http.Controller): return request.render('payment_authorize.payment_authorize_redirect', { 'return_url': '%s' % urlparse.urljoin(base_url, return_url) }) + + @http.route(['/payment/authorize/s2s/create_json'], type='json', auth='public') + def authorize_s2s_create_json(self, **kwargs): + acquirer_id = int(kwargs.get('acquirer_id')) + acquirer = request.env['payment.acquirer'].browse(acquirer_id) + return acquirer.s2s_process(kwargs) + + @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 werkzeug.utils.redirect(post.get('return_url', '/')) diff --git a/addons/payment_authorize/data/payment_acquirer_data.xml b/addons/payment_authorize/data/payment_acquirer_data.xml index 8748535c4b9..de2c3a4eb45 100644 --- a/addons/payment_authorize/data/payment_acquirer_data.xml +++ b/addons/payment_authorize/data/payment_acquirer_data.xml @@ -8,6 +8,7 @@ authorize + test You will be redirected to the Authorize website after clicking on the payment button.

]]>
diff --git a/addons/payment_authorize/models/__init__.py b/addons/payment_authorize/models/__init__.py index 10b988e0c28..6d0ae2698db 100644 --- a/addons/payment_authorize/models/__init__.py +++ b/addons/payment_authorize/models/__init__.py @@ -1,3 +1,2 @@ # -*- coding: utf-8 -*- - import payment diff --git a/addons/payment_authorize/models/authorize_request.py b/addons/payment_authorize/models/authorize_request.py new file mode 100644 index 00000000000..e94c4e1c484 --- /dev/null +++ b/addons/payment_authorize/models/authorize_request.py @@ -0,0 +1,247 @@ +# -*- coding: utf-8 -*- +from lxml import etree, objectify +from urllib2 import urlopen, Request +from StringIO import StringIO +import xml.etree.ElementTree as ET +from uuid import uuid4 + +from odoo.exceptions import ValidationError, UserError + +XMLNS = 'AnetApi/xml/v1/schema/AnetApiSchema.xsd' + + +def strip_ns(xml, ns): + """Strip the provided name from tag names. + + :param str xml: xml document + :param str ns: namespace to strip + + :rtype: etree._Element + :return: the parsed xml string with the namespace prefix removed + """ + it = ET.iterparse(StringIO(xml)) + ns_prefix = '{%s}' % XMLNS + for _, el in it: + if el.tag.startswith(ns_prefix): + el.tag = el.tag[len(ns_prefix):] # strip all Auth.net namespaces + return it.root + + +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 + (http://developer.authorize.net/api/reference); namely: + - Customer Profile/Payment Profile creation + - Transaction authorization/capture/voiding + """ + def __init__(self, acquirer): + """Initiate the environment with the acquirer data. + + :param record acquirer: payment.acquirer account that will be contacted + """ + if acquirer.environment == 'test': + self.url = 'https://apitest.authorize.net/xml/v1/request.api' + else: + self.url = 'https://api.authorize.net/xml/v1/request.api' + self.name = acquirer.authorize_login + self.transaction_key = acquirer.authorize_transaction_key + + def _authorize_request(self, data): + """Encode, send and process the request to the Authorize.net API. + + Encodes the xml data and process the response. Note that only a basic + processing is done at this level (namespace cleanup, basic error management). + + :param etree._Element data: etree data to process + """ + data = etree.tostring(data, xml_declaration=True, encoding='utf-8') + request = Request(self.url, data) + request.add_header('Content-Type', 'text/xml') + response = urlopen(request).read() + response = strip_ns(response, XMLNS) + if response.find('messages/resultCode').text == 'Error': + messages = map(lambda m: m.text, response.findall('messages/message/text')) + raise ValidationError('Authorize.net Error Message(s):\n %s' % '\n'.join(messages)) + return response + + def _base_tree(self, requestType): + """Create a basic tree containing authentication information. + + Create a etree Element of type requestType and appends the Authorize.net + credentials (they are always required). + :param str requestType: the type of request to send to Authorize.net + See http://developer.authorize.net/api/reference + for available types. + :return: basic etree Element of the requested type + containing credentials information + :rtype: etree._Element + """ + root = etree.Element(requestType, xmlns=XMLNS) + auth = etree.SubElement(root, "merchantAuthentication") + etree.SubElement(auth, "name").text = self.name + etree.SubElement(auth, "transactionKey").text = self.transaction_key + return root + + # Customer profiles + def create_customer_profile(self, partner, cardnumber, expiration_date, card_code): + """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 + """ + root = self._base_tree('createCustomerProfileRequest') + profile = etree.SubElement(root, "profile") + etree.SubElement(profile, "merchantCustomerId").text = 'ODOO-%s-%s' % (partner.id, uuid4().hex[:8]) + etree.SubElement(profile, "email").text = partner.email + payment_profile = etree.SubElement(profile, "paymentProfiles") + etree.SubElement(payment_profile, "customerType").text = 'business' if partner.is_company else 'individual' + billTo = etree.SubElement(payment_profile, "billTo") + etree.SubElement(billTo, "address").text = (partner.street + (partner.street2 if partner.street2 else '')) or None + etree.SubElement(billTo, "city").text = partner.city + etree.SubElement(billTo, "state").text = partner.state_id.name or None + etree.SubElement(billTo, "zip").text = partner.zip + etree.SubElement(billTo, "country").text = partner.country_id.name or None + payment = etree.SubElement(payment_profile, "payment") + creditCard = etree.SubElement(payment, "creditCard") + etree.SubElement(creditCard, "cardNumber").text = cardnumber + etree.SubElement(creditCard, "expirationDate").text = expiration_date + etree.SubElement(creditCard, "cardCode").text = card_code + etree.SubElement(root, "validationMode").text = 'liveMode' + response = self._authorize_request(root) + res = dict() + res['profile_id'] = response.find('customerProfileId').text + res['payment_profile_id'] = response.find('customerPaymentProfileIdList/numericString').text + return res + + # Transaction management + def auth_and_capture(self, token, amount, reference): + """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 + + :return: a dict containing the response code, transaction id and transaction type + :rtype: dict + """ + root = self._base_tree('createTransactionRequest') + tx = etree.SubElement(root, "transactionRequest") + etree.SubElement(tx, "transactionType").text = "authCaptureTransaction" + etree.SubElement(tx, "amount").text = str(amount) + profile = etree.SubElement(tx, "profile") + etree.SubElement(profile, "customerProfileId").text = token.authorize_profile + payment_profile = etree.SubElement(profile, "paymentProfile") + etree.SubElement(payment_profile, "paymentProfileId").text = token.acquirer_ref + order = etree.SubElement(tx, "order") + etree.SubElement(order, "invoiceNumber").text = reference + response = self._authorize_request(root) + res = dict() + res['x_response_code'] = response.find('transactionResponse/responseCode').text + res['x_trans_id'] = response.find('transactionResponse/transId').text + res['x_type'] = 'auth_capture' + return res + + def authorize(self, token, amount, reference): + """Authorize a payment for the given amount. + + Authorize (without 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 + + :return: a dict containing the response code, transaction id and transaction type + :rtype: dict + """ + root = self._base_tree('createTransactionRequest') + tx = etree.SubElement(root, "transactionRequest") + etree.SubElement(tx, "transactionType").text = "authOnlyTransaction" + etree.SubElement(tx, "amount").text = str(amount) + profile = etree.SubElement(tx, "profile") + etree.SubElement(profile, "customerProfileId").text = token.authorize_profile + payment_profile = etree.SubElement(profile, "paymentProfile") + etree.SubElement(payment_profile, "paymentProfileId").text = token.acquirer_ref + order = etree.SubElement(tx, "order") + etree.SubElement(order, "invoiceNumber").text = reference + response = self._authorize_request(root) + res = dict() + res['x_response_code'] = response.find('transactionResponse/responseCode').text + res['x_trans_id'] = response.find('transactionResponse/transId').text + res['x_type'] = 'auth_only' + return res + + def capture(self, transaction_id, amount): + """Capture a previously authorized payment for the given amount. + + Capture a previsouly authorized payment. Note that the amount is required + even though we do not support partial capture. + + :param str transaction_id: the id of the authorized transaction in the + Authorize.net backend + :param str amount: transaction amount (up to 15 digits with decimal point) + + :return: a dict containing the response code, transaction id and transaction type + :rtype: dict + """ + root = self._base_tree('createTransactionRequest') + tx = etree.SubElement(root, "transactionRequest") + etree.SubElement(tx, "transactionType").text = "priorAuthCaptureTransaction" + etree.SubElement(tx, "amount").text = str(amount) + etree.SubElement(tx, "refTransId").text = transaction_id + response = self._authorize_request(root) + res = dict() + res['x_response_code'] = response.find('transactionResponse/responseCode').text + res['x_trans_id'] = response.find('transactionResponse/transId').text + res['x_type'] = 'prior_auth_capture' + return res + + 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 + """ + root = self._base_tree('createTransactionRequest') + tx = etree.SubElement(root, "transactionRequest") + etree.SubElement(tx, "transactionType").text = "voidTransaction" + etree.SubElement(tx, "refTransId").text = transaction_id + response = self._authorize_request(root) + res = dict() + res['x_response_code'] = response.find('transactionResponse/responseCode').text + res['x_trans_id'] = response.find('transactionResponse/transId').text + res['x_type'] = 'void' + return res + + # Test + def test_authenticate(self): + """Test Authorize.net communication with a simple credentials check. + + :return: True if authentication was successful, else False (or throws an error) + :rtype: bool + """ + test_auth = self._base_tree('authenticateTestRequest') + response = self._authorize_request(test_auth) + root = objectify.fromstring(response) + if root.find('{ns}messages/{ns}resultCode'.format(ns='{%s}' % XMLNS)) == 'Ok': + return True + return False diff --git a/addons/payment_authorize/models/payment.py b/addons/payment_authorize/models/payment.py index b7488937e61..af57569eb5f 100644 --- a/addons/payment_authorize/models/payment.py +++ b/addons/payment_authorize/models/payment.py @@ -1,5 +1,7 @@ # coding: utf-8 +from authorize_request import AuthorizeAPI +from datetime import datetime import hashlib import hmac import logging @@ -10,6 +12,7 @@ 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 +from odoo.tools.safe_eval import safe_eval _logger = logging.getLogger(__name__) @@ -47,7 +50,7 @@ class PaymentAcquirerAuthorize(models.Model): 'x_trans_key': self.authorize_transaction_key, 'x_amount': str(values['amount']), 'x_show_form': 'PAYMENT_FORM', - 'x_type': 'AUTH_CAPTURE', + 'x_type': 'AUTH_CAPTURE' if self.auto_confirm != 'authorize' else 'AUTH_ONLY', 'x_method': 'CC', 'x_fp_sequence': '%s%s' % (self.id, int(time.time())), 'x_version': '3.1', @@ -85,6 +88,37 @@ class PaymentAcquirerAuthorize(models.Model): self.ensure_one() return self._get_authorize_urls(self.environment)['authorize_form_url'] + @api.model + def authorize_s2s_form_process(self, data): + values = { + 'cc_number': data.get('cc_number'), + 'cc_holder_name': data.get('cc_holder_name'), + 'cc_expiry': data.get('cc_expiry'), + 'cc_cvc': data.get('cc_cvc'), + 'cc_brand': data.get('cc_brand'), + 'acquirer_id': int(data.get('acquirer_id')), + 'partner_id': int(data.get('partner_id')) + } + PaymentMethod = self.env['payment.token'].sudo().create(values) + return PaymentMethod.id + + @api.multi + def authorize_s2s_form_validate(self, data): + error = dict() + mandatory_fields = ["cc_number", "cc_cvc", "cc_holder_name", "cc_expiry", "cc_brand"] + # Validation + for field_name in mandatory_fields: + if not data.get(field_name): + error[field_name] = 'missing' + if data['cc_expiry'] and datetime.now().strftime('%y%M') > datetime.strptime(data['cc_expiry'], '%M / %y').strftime('%y%M'): + return False + return False if error else True + + @api.multi + def authorize_test_credentials(self): + self.ensure_one() + transaction = AuthorizeAPI(self.acquirer_id) + return transaction.test_authenticate() class TxAuthorize(models.Model): _inherit = 'payment.transaction' @@ -104,7 +138,7 @@ class TxAuthorize(models.Model): reference, trans_id, fingerprint = data.get('x_invoice_num'), data.get('x_trans_id'), 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.error(error_msg) + _logger.info(error_msg) raise ValidationError(error_msg) tx = self.search([('reference', '=', reference)]) if not tx or len(tx) > 1: @@ -113,7 +147,7 @@ class TxAuthorize(models.Model): error_msg += '; no order found' else: error_msg += '; multiple order found' - _logger.error(error_msg) + _logger.info(error_msg) raise ValidationError(error_msg) return tx[0] @@ -136,8 +170,9 @@ class TxAuthorize(models.Model): status_code = int(data.get('x_response_code', '0')) if status_code == self._authorize_valid_tx_status: self.write({ - 'state': 'done', + 'state': 'done' if data.get('x_type').lower() in ['auth_capture', 'prior_auth_capture'] else 'authorized', 'acquirer_reference': data.get('x_trans_id'), + 'date_validate': fields.Datetime.now() if data.get('x_type').lower() in ['auth_capture', 'prior_auth_capture'] else False, }) return True elif status_code == self._authorize_pending_tx_status: @@ -161,3 +196,106 @@ class TxAuthorize(models.Model): 'acquirer_reference': data.get('x_trans_id'), }) return False + + @api.multi + def authorize_s2s_do_transaction(self, **data): + self.ensure_one() + transaction = AuthorizeAPI(self.acquirer_id) + if self.acquirer_id.auto_confirm != "authorize": + res = transaction.auth_and_capture(self.payment_token_id, self.amount, self.reference) + else: + res = transaction.authorize(self.payment_token_id, self.amount, self.reference) + return self._authorize_s2s_validate_tree(res) + + @api.multi + def authorize_s2s_capture_transaction(self): + self.ensure_one() + transaction = AuthorizeAPI(self.acquirer_id) + tree = transaction.capture(self.acquirer_reference, self.amount) + return self._authorize_s2s_validate_tree(tree) + + @api.multi + def authorize_s2s_void_transaction(self): + self.ensure_one() + transaction = AuthorizeAPI(self.acquirer_id) + tree = transaction.void(self.acquirer_reference) + return self._authorize_s2s_validate_tree(tree) + + @api.multi + def _authorize_s2s_validate_tree(self, tree): + return self._authorize_s2s_validate(tree) + + @api.multi + def _authorize_s2s_validate(self, tree): + self.ensure_one() + 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']: + if self.callback_eval and self.state != 'authorized': + safe_eval(self.callback_eval, {'self': self}) + self.write({ + 'state': 'done', + 'acquirer_reference': tree.get('x_trans_id'), + 'date_validate': fields.Datetime.now(), + }) + if tree.get('x_type').lower() == 'auth_only': + if self.callback_eval: + safe_eval(self.callback_eval, {'self': self}) + self.write({ + 'state': 'authorized', + 'acquirer_reference': tree.get('x_trans_id'), + }) + if tree.get('x_type').lower() == 'void': + self.write({ + 'state': 'cancel', + }) + return True + elif status_code == self._authorize_pending_tx_status: + self.write({ + 'state': 'pending', + 'acquirer_reference': tree.get('x_trans_id'), + }) + return True + elif status_code == self._authorize_cancel_tx_status: + self.write({ + 'state': 'cancel', + 'acquirer_reference': tree.get('x_trans_id'), + }) + return True + else: + error = tree.get('x_response_reason_text') + _logger.info(error) + self.write({ + 'state': 'error', + 'state_message': error, + 'acquirer_reference': tree.get('x_trans_id'), + }) + 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') + + @api.model + def authorize_create(self, values): + if values.get('cc_number'): + values['cc_number'] = values['cc_number'].replace(' ', '') + acquirer = self.env['payment.acquirer'].browse(values['acquirer_id']) + expiry = str(values['cc_expiry'][:2]) + str(values['cc_expiry'][-2:]) + partner = self.env['res.partner'].browse(values['partner_id']) + transaction = AuthorizeAPI(acquirer) + res = transaction.create_customer_profile(partner, values['cc_number'], expiry, values['cc_cvc']) + if res.get('profile_id') and res.get('payment_profile_id'): + return { + 'authorize_profile': res.get('profile_id'), + 'name': 'XXXXXXXXXXXX%s - %s' % (values['cc_number'][-4:], values['cc_holder_name']), + 'acquirer_ref': res.get('payment_profile_id'), + } + else: + raise ValidationError('The Customer Profile creation in Authorize.NET failed.') diff --git a/addons/payment_authorize/tests/test_authorize.py b/addons/payment_authorize/tests/test_authorize.py index 464a794d481..50e4e95fdc4 100644 --- a/addons/payment_authorize/tests/test_authorize.py +++ b/addons/payment_authorize/tests/test_authorize.py @@ -4,6 +4,7 @@ import hashlib import hmac import time import urlparse +import unittest from lxml import objectify import odoo @@ -23,6 +24,8 @@ class AuthorizeCommon(PaymentAcquirerCommon): self.currency_usd = self.env['res.currency'].search([('name', '=', 'USD')], limit=1)[0] # get the authorize account self.authorize = self.env.ref('payment.payment_acquirer_authorize') + # Be sure to be in 'capture' mode + self.authorize.auto_confirm = 'confirm_so' @odoo.tests.common.at_install(True) @@ -97,7 +100,7 @@ class AuthorizeForm(AuthorizeCommon): 'Authorize: wrong value for input %s: received %s instead of %s' % (values[1], values[2], form_values[values[1]]) ) - @mute_logger('openerp.addons.payment_authorize.models.payment', 'ValidationError') + @mute_logger('odoo.addons.payment_authorize.models.payment', 'ValidationError') def test_20_authorize_form_management(self): # be sure not to do stupid thing self.assertEqual(self.authorize.environment, 'test', 'test without test environment') @@ -120,7 +123,7 @@ class AuthorizeForm(AuthorizeCommon): 'x_cvv2_resp_code': u'', 'x_description': u'', 'x_duty': u'0.00', - 'x_email': u'norbert.buyer@exampl', + 'x_email': u'norbert.buyer@example.com', 'x_fax': u'', 'x_first_name': u'Norbert', 'x_freight': u'0.00', @@ -174,3 +177,77 @@ class AuthorizeForm(AuthorizeCommon): self.env['payment.transaction'].form_feedback(authorize_post_data, 'authorize') # check state self.assertEqual(tx.state, 'error', 'Authorize: erroneous validation did not put tx into error state') + + @unittest.skip("Authorize s2s test disabled: We do not want to overload Authorize.net with runbot's requests") + def test_30_authorize_s2s(self): + # be sure not to do stupid thing + authorize = self.authorize + self.assertEqual(authorize.environment, 'test', 'test without test environment') + + # add credential + # FIXME: put this test in master-nightly on odoo/odoo + create sandbox account + authorize.write({ + 'authorize_transaction_key': '', + 'authorize_login': '', + }) + self.assertTrue(authorize.authorize_test_credentials, 'Authorize.net: s2s authentication failed') + + # create payment meethod + payment_token = self.env['payment.token'].create({ + 'acquirer_id': authorize.id, + 'partner_id': self.buyer_id, + 'cc_number': '4111 1111 1111 1111', + 'cc_expiry': '02 / 26', + 'cc_brand': 'visa', + 'cc_cvc': '111', + 'cc_holder_name': 'test', + }) + + # create normal s2s transaction + transaction = self.env['payment.transaction'].create({ + 'amount': 500, + 'acquirer_id': authorize.id, + 'type': 'server2server', + 'currency_id': self.currency_usd.id, + 'reference': 'test_ref_%s' % odoo.fields.Date.today(), + 'payment_token_id': payment_token.id, + 'partner_id': self.buyer_id, + + }) + transaction.authorize_s2s_do_transaction() + self.assertEqual(transaction.state, 'done',) + + # switch to 'authorize only' + # create authorize only s2s transaction & capture it + self.authorize.auto_confirm = 'authorize' + transaction = self.env['payment.transaction'].create({ + 'amount': 500, + 'acquirer_id': authorize.id, + 'type': 'server2server', + 'currency_id': self.currency_usd.id, + 'reference': 'test_%s' % int(time.time()), + 'payment_token_id': payment_token.id, + 'partner_id': self.buyer_id, + + }) + transaction.authorize_s2s_do_transaction() + self.assertEqual(transaction.state, 'authorized') + transaction.action_capture() + self.assertEqual(transaction.state, 'done') + + # create authorize only s2s transaction & void it + self.authorize.auto_confirm = 'authorize' + transaction = self.env['payment.transaction'].create({ + 'amount': 500, + 'acquirer_id': authorize.id, + 'type': 'server2server', + 'currency_id': self.currency_usd.id, + 'reference': 'test_%s' % int(time.time()), + 'payment_token_id': payment_token.id, + 'partner_id': self.buyer_id, + + }) + transaction.authorize_s2s_do_transaction() + self.assertEqual(transaction.state, 'authorized') + transaction.action_void() + self.assertEqual(transaction.state, 'cancel') diff --git a/addons/payment_authorize/views/payment_authorize_templates.xml b/addons/payment_authorize/views/payment_authorize_templates.xml index 9c39fcd1c8e..abbbc80b93a 100644 --- a/addons/payment_authorize/views/payment_authorize_templates.xml +++ b/addons/payment_authorize/views/payment_authorize_templates.xml @@ -52,5 +52,40 @@ window.location.href = ''; + + From cd9873128430c70ab9189b4121204a6d6ece48d3 Mon Sep 17 00:00:00 2001 From: Damien Bouvy Date: Mon, 29 Aug 2016 17:12:01 +0200 Subject: [PATCH 5/8] [IMP] payment: prevent 'authorize-only' transaction to settle invoice --- addons/payment/models/account_payment.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/addons/payment/models/account_payment.py b/addons/payment/models/account_payment.py index c2449129215..732ba6c4a7c 100644 --- a/addons/payment/models/account_payment.py +++ b/addons/payment/models/account_payment.py @@ -10,7 +10,8 @@ class AccountPayment(models.Model): _inherit = 'account.payment' payment_transaction_id = fields.Many2one('payment.transaction', string="Payment Transaction") - payment_token_id = fields.Many2one('payment.token', string="Saved payment token") + payment_token_id = fields.Many2one('payment.token', string="Saved payment token", domain=[('acquirer_id.auto_confirm', '!=', 'authorize')], + help="Note that tokens from acquirers set to only authorize transactions (instead of capturing the amount) are not available.") payment_type = fields.Selection(selection_add=[('electronic', 'Electronically receive money')]) payment_method_id_code = fields.Char(related='payment_method_id.code') @@ -18,14 +19,14 @@ class AccountPayment(models.Model): def _onchange_partner_id(self): res = {} if self.partner_id: - res['domain'] = {'payment_token_id': [('partner_id', '=', self.partner_id.id)]} + res['domain'] = {'payment_token_id': [('partner_id', '=', self.partner_id.id), ('acquirer_id.auto_confirm', '!=', 'authorize')]} return res @api.onchange('payment_method_id', 'journal_id') def _onchange_payment_method(self): if self.payment_method_id.code == 'electronic': - self.payment_token_id = self.env['payment.token'].search([('partner_id', '=', self.partner_id.id)], limit=1) + self.payment_token_id = self.env['payment.token'].search([('partner_id', '=', self.partner_id.id), ('acquirer_id.auto_confirm', '!=', 'authorize')], limit=1) else: self.payment_token_id = False @@ -38,6 +39,9 @@ class AccountPayment(models.Model): return account_payment def _do_payment(self): + if self.payment_token_id.acquirer_id.auto_confirm == 'authorize': + raise ValidationError('This feature is not available for payment acquirers set to the "Authorize" mode.\n' + 'Please use a token from another provider than %s.' % self.payment_token_id.acquirer_id.name) reference = "PAYMENT-%s-%s" % (self.id, datetime.datetime.now().strftime('%y%m%d_%H%M%S')) tx = self.env['payment.transaction'].create({ 'amount': self.amount, From d9c9d0c96b2aaf66da476adae8f8e935bd7dfebf Mon Sep 17 00:00:00 2001 From: Damien Bouvy Date: Thu, 1 Sep 2016 10:51:10 +0200 Subject: [PATCH 6/8] [IMP] website_sale: allow saving card data from checkout Instead of having to register a card then do a payment, a little checkbox during the checkout process is usually simpler. --- addons/website_sale/controllers/main.py | 7 +++---- .../static/src/js/website_sale_payment.js | 6 +++--- addons/website_sale/views/templates.xml | 11 +++++++---- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index 6db1998fcde..85f870469e6 100644 --- a/addons/website_sale/controllers/main.py +++ b/addons/website_sale/controllers/main.py @@ -769,7 +769,7 @@ class WebsiteSale(http.Controller): return request.redirect("/shop/payment?error=no_token_or_missmatch_tx") @http.route(['/shop/payment/transaction/'], type='json', auth="public", website=True) - def payment_transaction(self, acquirer_id, token=None): + def payment_transaction(self, acquirer_id, tx_type='form', token=None): """ Json method that creates a payment.transaction, used to create a transaction when the user clicks on 'pay now' button. After having created the transaction, the event continues and the user is redirected @@ -780,7 +780,6 @@ class WebsiteSale(http.Controller): """ Transaction = request.env['payment.transaction'].sudo() order = request.website.sale_get_order() - if not order or not order.order_line or acquirer_id is None: return request.redirect("/shop/checkout") @@ -795,11 +794,11 @@ class WebsiteSale(http.Controller): # new or distinct token tx = False elif tx.state == 'draft': # button cliked but no more info -> rewrite on tx or create a new one ? - tx.write(dict(Transaction.on_change_partner_id(order.partner_id.id).get('value', {}), amount=order.amount_total)) + tx.write(dict(Transaction.on_change_partner_id(order.partner_id.id).get('value', {}), amount=order.amount_total, type=tx_type)) if not tx: tx_values = { 'acquirer_id': acquirer_id, - 'type': 'form', + 'type': tx_type, 'amount': order.amount_total, 'currency_id': order.pricelist_id.currency_id.id, 'partner_id': order.partner_id.id, diff --git a/addons/website_sale/static/src/js/website_sale_payment.js b/addons/website_sale/static/src/js/website_sale_payment.js index d04dfc77774..32d9a8dbca9 100644 --- a/addons/website_sale/static/src/js/website_sale_payment.js +++ b/addons/website_sale/static/src/js/website_sale_payment.js @@ -24,13 +24,13 @@ $(document).ready(function () { $("div.js_payment a.list-group-item").removeClass("list-group-item-info"); $('span.js_radio').switchClass(ico_on, ico_off, 0); if (token) { - $("div.oe_sale_acquirer_button div.pre_msg").hide(); + $("div.oe_sale_acquirer_button div.token_hide").hide(); $(ev.currentTarget).find('span.js_radio').switchClass(ico_off, ico_on, 0); $(ev.currentTarget).parents('li').find('input').prop("checked", true); $(ev.currentTarget).addClass("list-group-item-info"); } else{ - $("div.oe_sale_acquirer_button div.pre_msg").show(); + $("div.oe_sale_acquirer_button div.token_hide").show(); } $("div.oe_sale_acquirer_button[data-id]", $payment).addClass("hidden"); $("div.oe_sale_acquirer_button[data-id='"+payment_id+"']", $payment).removeClass("hidden"); @@ -46,7 +46,7 @@ $(document).ready(function () { var acquirer = $(ev.currentTarget).parents('div.oe_sale_acquirer_button').first(); var acquirer_id = acquirer.data('id'); var acquirer_token = acquirer.attr('data-token'); // !=data - var params = {}; + var params = {'tx_type': acquirer.find('input[name="odoo_save_token"]').is(':checked')?'form_save':'form'}; if (! acquirer_id) { return false; } diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml index f3782fdb109..6ae09998f82 100644 --- a/addons/website_sale/views/templates.xml +++ b/addons/website_sale/views/templates.xml @@ -1350,9 +1350,6 @@ (processing fees apply) - - New card - @@ -1361,7 +1358,13 @@