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/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 @@
From ffd527d0273197925102175feb95d4a6f4fcd8d5 Mon Sep 17 00:00:00 2001
From: Damien Bouvy
Date: Thu, 1 Sep 2016 13:07:12 +0200
Subject: [PATCH 7/8] [IMP] payment, payment_*: add/improve tokenization
support
This commit improves the tokenization process, allowing users
to set the acquirer to save payment data never, always or letting
the customer decide (only implemented in ecommerce for now).
Support for tokenization is improved for Ogone and Authorize.net
This commits also factorizes advanced payment features support
(authorization, tokenization, fees computation) in a generic method
overriden by every provider to specifiy which features are supported.
This process can be used in views to hide/show fields depending on
feature support or in constraint checks.
---
addons/payment/models/payment_acquirer.py | 43 ++++++++++++++--
addons/payment/views/payment_views.xml | 2 +
.../models/authorize_request.py | 38 +++++++++++++-
addons/payment_authorize/models/payment.py | 46 +++++++++++++++--
addons/payment_ogone/models/payment.py | 49 +++++++++++++------
.../views/payment_ogone_templates.xml | 2 +-
addons/payment_paypal/models/payment.py | 15 ++++++
7 files changed, 168 insertions(+), 27 deletions(-)
diff --git a/addons/payment/models/payment_acquirer.py b/addons/payment/models/payment_acquirer.py
index d0733c16fe3..d9442e0ae1c 100644
--- a/addons/payment/models/payment_acquirer.py
+++ b/addons/payment/models/payment_acquirer.py
@@ -101,8 +101,18 @@ class PaymentAcquirer(models.Model):
'Error Message', translate=True,
default='Error, Please be aware that an error occurred during the transaction. The order has been confirmed but will not be paid. Do not hesitate to contact us if you have any questions on the status of your order.',
help='Message displayed, if error is occur during the payment process.')
+ save_token = fields.Selection([
+ ('none', 'Never'),
+ ('ask', 'Let the customer decide'),
+ ('always', 'Always')],
+ string='Store Card Data', default='none',
+ help="Determine if card data is saved as a token automatically or not. "
+ "Payment tokens allow your customer to reuse their cards in the e-commerce "
+ "or allow you to charge an invoice directly on a credit card. If set to "
+ "'let the customer decide', ecommerce customers will have a checkbox displayed on the payment page.")
+ token_implemented = fields.Boolean('Saving Card Data supported', compute='_compute_feature_support')
- fees_implemented = fields.Boolean('Fees Computation Supported', compute='_compute_fees_implemented')
+ fees_implemented = fields.Boolean('Fees Computation Supported', compute='_compute_feature_support')
fees_active = fields.Boolean('Add Extra Fees')
fees_dom_fixed = fields.Float('Fixed domestic fees')
fees_dom_var = fields.Float('Variable domestic fees (in percents)')
@@ -127,9 +137,12 @@ class PaymentAcquirer(models.Model):
"resized as a 64x64px image, with aspect ratio preserved. "
"Use this field anywhere a small image is required.")
- @api.one
- def _compute_fees_implemented(self):
- self.fees_implemented = hasattr(self, '%s_compute_fees' % self.provider)
+ @api.multi
+ def _compute_feature_support(self):
+ feature_support = self._get_feature_support()
+ for acquirer in self:
+ acquirer.fees_implemented = acquirer.provider in feature_support['fees']
+ acquirer.token_implemented = acquirer.provider in feature_support['tokenize']
@api.multi
def _check_required_if_provider(self):
@@ -140,10 +153,30 @@ class PaymentAcquirer(models.Model):
return False
return True
+ @api.constrains('auto_confirm')
+ def _check_authorization_support(self):
+ for acquirer in self:
+ if acquirer.auto_confirm == 'authorize' and acquirer.provider not in self._get_feature_support()['authorize']:
+ raise ValidationError('Transaction Authorization is not supported by this payment provider.')
+ return True
+
_constraints = [
(_check_required_if_provider, 'Required fields not filled', []),
]
+ def _get_feature_support(self):
+ """Get advanced feature support by provider.
+
+ Each provider should add its technical in the corresponding
+ key for the following features:
+ * fees: support payment fees computations
+ * authorize: support authorizing payment (separates
+ authorization and capture)
+ * tokenize: support saving payment data in a payment.tokenize
+ object
+ """
+ return dict(authorize=[], tokenize=[], fees=[])
+
@api.model
def create(self, vals):
image_resize_images(vals)
@@ -370,7 +403,7 @@ class PaymentTransaction(models.Model):
type = fields.Selection([
('server2server', 'Server To Server'),
('form', 'Form'),
- ('form_save', 'Form with credentials storage')], 'Type',
+ ('form_save', 'Form with tokenization')], 'Type',
default='form', required=True)
state = fields.Selection([
('draft', 'Draft'),
diff --git a/addons/payment/views/payment_views.xml b/addons/payment/views/payment_views.xml
index 51bf831a719..a8677057aff 100644
--- a/addons/payment/views/payment_views.xml
+++ b/addons/payment/views/payment_views.xml
@@ -25,6 +25,7 @@