diff --git a/addons/payment/models/res_config.py b/addons/payment/models/res_config.py index a74e595b52e..b64b698c83d 100644 --- a/addons/payment/models/res_config.py +++ b/addons/payment/models/res_config.py @@ -19,4 +19,7 @@ class AccountPaymentConfig(osv.TransientModel): 'module_payment_buckaroo': fields.boolean( 'Manage Payments Using Buckaroo', help='-It installs the module payment_buckaroo.'), + 'module_payment_authorize': fields.boolean( + 'Manage Payments Using Authorize.Net', + help='-It installs the module payment_authorize.'), } diff --git a/addons/payment/views/res_config_view.xml b/addons/payment/views/res_config_view.xml index e4e6fc96652..8f5e5f31321 100644 --- a/addons/payment/views/res_config_view.xml +++ b/addons/payment/views/res_config_view.xml @@ -24,6 +24,10 @@ diff --git a/addons/payment_authorize/__init__.py b/addons/payment_authorize/__init__.py new file mode 100644 index 00000000000..9332521b0ee --- /dev/null +++ b/addons/payment_authorize/__init__.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# Odoo, Open Source Management Solution +# Copyright (C) 2014-TODAY Odoo SA (). +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## + +import models +import controllers diff --git a/addons/payment_authorize/__openerp__.py b/addons/payment_authorize/__openerp__.py new file mode 100644 index 00000000000..57bf2c7e711 --- /dev/null +++ b/addons/payment_authorize/__openerp__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- + +{ + 'name': 'Authorize.Net Payment Acquirer', + 'category': 'Hidden', + 'summary': 'Payment Acquirer: Authorize.net Implementation', + 'version': '1.0', + 'description': """Authorize.Net Payment Acquirer""", + 'author': 'Odoo SA', + 'depends': ['payment'], + 'data': [ + 'views/authorize.xml', + 'views/payment_acquirer.xml', + 'data/authorize.xml', + 'views/payment_authorize_template.xml', + ], + 'installable': True, +} diff --git a/addons/payment_authorize/controllers/__init__.py b/addons/payment_authorize/controllers/__init__.py new file mode 100644 index 00000000000..bbd183e955b --- /dev/null +++ b/addons/payment_authorize/controllers/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +import main diff --git a/addons/payment_authorize/controllers/main.py b/addons/payment_authorize/controllers/main.py new file mode 100644 index 00000000000..1279c23ce26 --- /dev/null +++ b/addons/payment_authorize/controllers/main.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +import pprint +import logging +import urlparse + +from openerp import http +from openerp.http import request + +_logger = logging.getLogger(__name__) + + +class AuthorizeController(http.Controller): + _return_url = '/payment/authorize/return/' + _cancel_url = '/payment/authorize/cancel/' + + @http.route([ + '/payment/authorize/return/', + '/payment/authorize/cancel/', + ], type='http', auth='public') + def authorize_form_feedback(self, **post): + _logger.info('Authorize: entering form_feedback with post data %s', pprint.pformat(post)) + return_url = '/' + if post: + request.env['payment.transaction'].sudo().form_feedback(post, 'authorize') + return_url = post.pop('return_url', '/') + base_url = request.env['ir.config_parameter'].get_param('web.base.url') + # Authorize.Net is expecting a response to the POST sent by their server. + # This response is in the form of a URL that Authorize.Net will pass on to the + # client's browser to redirect them to the desired location need javascript. + return request.render('payment_authorize.payment_authorize_redirect', { + 'return_url': '%s' % urlparse.urljoin(base_url, return_url) + }) diff --git a/addons/payment_authorize/data/authorize.xml b/addons/payment_authorize/data/authorize.xml new file mode 100644 index 00000000000..d1d38106252 --- /dev/null +++ b/addons/payment_authorize/data/authorize.xml @@ -0,0 +1,18 @@ + + + + + + Authorize.Net + authorize + + + test + You will be redirected to the Authorize website after clicking on the payment button.

]]>
+ dummy + dummy +
+ +
+
diff --git a/addons/payment_authorize/models/__init__.py b/addons/payment_authorize/models/__init__.py new file mode 100644 index 00000000000..92e55eeff24 --- /dev/null +++ b/addons/payment_authorize/models/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +import authorize diff --git a/addons/payment_authorize/models/authorize.py b/addons/payment_authorize/models/authorize.py new file mode 100644 index 00000000000..2708daa37a6 --- /dev/null +++ b/addons/payment_authorize/models/authorize.py @@ -0,0 +1,165 @@ +# -*- coding: utf-'8' "-*-" + +import hashlib +import hmac +import logging +import time +import urlparse + +from openerp import api, fields, models +from openerp.addons.payment.models.payment_acquirer import ValidationError +from openerp.addons.payment_authorize.controllers.main import AuthorizeController +from openerp.tools.float_utils import float_compare + +_logger = logging.getLogger(__name__) + + +class PaymentAcquirerAuthorize(models.Model): + _inherit = 'payment.acquirer' + + def _get_authorize_urls(self, environment): + """ Authorize URLs """ + if environment == 'prod': + return {'authorize_form_url': 'https://secure.authorize.net/gateway/transact.dll'} + else: + return {'authorize_form_url': 'https://test.authorize.net/gateway/transact.dll'} + + @api.model + def _get_providers(self): + providers = super(PaymentAcquirerAuthorize, self)._get_providers() + providers.append(['authorize', 'Authorize.Net']) + return providers + + authorize_login = fields.Char(string='API Login Id', required_if_provider='authorize') + authorize_transaction_key = fields.Char(string='API Transaction Key', required_if_provider='authorize') + + def _authorize_generate_hashing(self, values): + data = '^'.join([ + values['x_login'], + values['x_fp_sequence'], + values['x_fp_timestamp'], + values['x_amount'], + values['x_currency_code']]) + return hmac.new(str(values['x_trans_key']), data, hashlib.md5).hexdigest() + + @api.multi + def authorize_form_generate_values(self, partner_values, tx_values): + self.ensure_one() + base_url = self.env['ir.config_parameter'].get_param('web.base.url') + authorize_tx_values = dict(tx_values) + temp_authorize_tx_values = { + 'x_login': self.authorize_login, + 'x_trans_key': self.authorize_transaction_key, + 'x_amount': str(tx_values['amount']), + 'x_show_form': 'PAYMENT_FORM', + 'x_type': 'AUTH_CAPTURE', + 'x_method': 'CC', + 'x_fp_sequence': '%s%s' % (self.id, int(time.time())), + 'x_version': '3.1', + 'x_relay_response': 'TRUE', + 'x_fp_timestamp': str(int(time.time())), + 'x_relay_url': '%s' % urlparse.urljoin(base_url, AuthorizeController._return_url), + 'x_cancel_url': '%s' % urlparse.urljoin(base_url, AuthorizeController._cancel_url), + 'x_currency_code': tx_values['currency'] and tx_values['currency'].name or '', + 'address': partner_values['address'], + 'city': partner_values['city'], + 'country': partner_values['country'] and partner_values['country'].name or '', + 'email': partner_values['email'], + 'zip': partner_values['zip'], + 'first_name': partner_values['first_name'], + 'last_name': partner_values['last_name'], + 'phone': partner_values['phone'], + 'state': partner_values.get('state') and partner_values['state'].name or '', + } + temp_authorize_tx_values['returndata'] = authorize_tx_values.pop('return_url', '') + temp_authorize_tx_values['x_fp_hash'] = self._authorize_generate_hashing(temp_authorize_tx_values) + authorize_tx_values.update(temp_authorize_tx_values) + return partner_values, authorize_tx_values + + @api.multi + def authorize_get_form_action_url(self): + self.ensure_one() + return self._get_authorize_urls(self.environment)['authorize_form_url'] + + +class TxAuthorize(models.Model): + _inherit = 'payment.transaction' + + authorize_txnid = fields.Char(string='Transaction ID') + + _authorize_valid_tx_status = 1 + _authorize_pending_tx_status = 4 + _authorize_cancel_tx_status = 2 + + # -------------------------------------------------- + # FORM RELATED METHODS + # -------------------------------------------------- + + @api.model + def _authorize_form_get_tx_from_data(self, data): + """ Given a data dict coming from authorize, verify it and find the related + transaction record. """ + 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) + raise ValidationError(error_msg) + tx = self.search([('reference', '=', reference)]) + if not tx or len(tx) > 1: + error_msg = 'Authorize: received data for reference %s' % (reference) + if not tx: + error_msg += '; no order found' + else: + error_msg += '; multiple order found' + _logger.error(error_msg) + raise ValidationError(error_msg) + return tx[0] + + @api.model + def _authorize_form_get_invalid_parameters(self, tx, data): + invalid_parameters = [] + + if tx.authorize_txnid and data.get('x_trans_id') != tx.authorize_txnid: + invalid_parameters.append(('Transaction Id', data.get('x_trans_id'), tx.authorize_txnid)) + # check what is buyed + if float_compare(float(data.get('x_amount', '0.0')), tx.amount, 2) != 0: + invalid_parameters.append(('Amount', data.get('x_amount'), '%.2f' % tx.amount)) + return invalid_parameters + + @api.model + def _authorize_form_validate(self, tx, data): + if tx.state == 'done': + _logger.warning('Authorize: trying to validate an already validated tx (ref %s)' % tx.reference) + return True + status_code = int(data.get('x_response_code', '0')) + if status_code == self._authorize_valid_tx_status: + tx.write({ + 'state': 'done', + 'authorize_txnid': data.get('x_trans_id'), + 'acquirer_reference': data['x_invoice_num'], + }) + return True + elif status_code == self._authorize_pending_tx_status: + tx.write({ + 'state': 'pending', + 'authorize_txnid': data.get('x_trans_id'), + 'acquirer_reference': data['x_invoice_num'], + }) + return True + elif status_code == self._authorize_cancel_tx_status: + tx.write({ + 'state': 'cancel', + 'authorize_txnid': data.get('x_trans_id'), + 'acquirer_reference': data['x_invoice_num'], + }) + return True + else: + error = data.get('x_response_reason_text') + _logger.info(error) + tx.write({ + 'state': 'error', + 'state_message': error, + 'authorize_txnid': data.get('x_trans_id'), + 'acquirer_reference': data['x_invoice_num'], + }) + return False diff --git a/addons/payment_authorize/static/description/icon.png b/addons/payment_authorize/static/description/icon.png new file mode 100644 index 00000000000..c2bab4f7a07 Binary files /dev/null and b/addons/payment_authorize/static/description/icon.png differ diff --git a/addons/payment_authorize/static/src/img/authorize_icon.png b/addons/payment_authorize/static/src/img/authorize_icon.png new file mode 100644 index 00000000000..5f0393871e7 Binary files /dev/null and b/addons/payment_authorize/static/src/img/authorize_icon.png differ diff --git a/addons/payment_authorize/tests/__init__.py b/addons/payment_authorize/tests/__init__.py new file mode 100644 index 00000000000..7c3bcfce559 --- /dev/null +++ b/addons/payment_authorize/tests/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from openerp.addons.payment_authorize.tests import test_authorize diff --git a/addons/payment_authorize/tests/test_authorize.py b/addons/payment_authorize/tests/test_authorize.py new file mode 100644 index 00000000000..f49aefaee40 --- /dev/null +++ b/addons/payment_authorize/tests/test_authorize.py @@ -0,0 +1,173 @@ +# -*- coding: utf-8 -*- + +import hashlib +import hmac +import time +import urlparse +from lxml import objectify + +import openerp +from openerp.addons.payment.models.payment_acquirer import ValidationError +from openerp.addons.payment.tests.common import PaymentAcquirerCommon +from openerp.addons.payment_authorize.controllers.main import AuthorizeController +from openerp.tools import mute_logger + + +@openerp.tests.common.at_install(True) +@openerp.tests.common.post_install(True) +class AuthorizeCommon(PaymentAcquirerCommon): + + def setUp(self): + super(AuthorizeCommon, self).setUp() + self.base_url = self.env['ir.config_parameter'].get_param('web.base.url') + # authorize only support USD in test environment + self.currency_usd = self.env['res.currency'].search([('name', '=', 'USD')], limit=1)[0] + # get the authorize account + model, self.authorize_id = self.env['ir.model.data'].get_object_reference('payment_authorize', 'payment_acquirer_authorize') + + +@openerp.tests.common.at_install(True) +@openerp.tests.common.post_install(True) +class AuthorizeForm(AuthorizeCommon): + + def _authorize_generate_hashing(self, values): + data = '^'.join([ + values['x_login'], + values['x_fp_sequence'], + values['x_fp_timestamp'], + values['x_amount'], + ]) + '^' + return hmac.new(str(values['x_trans_key']), data, hashlib.md5).hexdigest() + + def test_10_Authorize_form_render(self): + authorize = self.env['payment.acquirer'].browse(self.authorize_id) + self.assertEqual(authorize.environment, 'test', 'test without test environment') + + # ---------------------------------------- + # Test: button direct rendering + # ---------------------------------------- + form_values = { + 'x_login': authorize.authorize_login, + 'x_trans_key': authorize.authorize_transaction_key, + 'x_amount': '320.0', + 'x_show_form': 'PAYMENT_FORM', + 'x_type': 'AUTH_CAPTURE', + 'x_method': 'CC', + 'x_fp_sequence': '%s%s' % (authorize.id, int(time.time())), + 'x_version': '3.1', + 'x_relay_response': 'TRUE', + 'x_fp_timestamp': str(int(time.time())), + 'x_relay_url': '%s' % urlparse.urljoin(self.base_url, AuthorizeController._return_url), + 'x_cancel_url': '%s' % urlparse.urljoin(self.base_url, AuthorizeController._cancel_url), + 'return_url': None, + 'x_currency_code': 'USD', + 'x_invoice_num': 'SO004', + 'x_first_name': 'Buyer', + 'x_last_name': 'Norbert', + 'x_address': 'Huge Street 2/543', + 'x_city': 'Sin City', + 'x_zip': '1000', + 'x_country': 'Belgium', + 'x_phone': '0032 12 34 56 78', + 'x_email': 'norbert.buyer@example.com', + 'x_state': None, + } + + form_values['x_fp_hash'] = self._authorize_generate_hashing(form_values) + # render the button + cr, uid, context = self.env.cr, self.env.uid, {} + res = self.payment_acquirer.render( + cr, uid, self.authorize_id, 'SO004', 320.0, self.currency_usd.id, + partner_id=None, partner_values=self.buyer_values, context=context) + # check form result + tree = objectify.fromstring(res) + self.assertEqual(tree.get('action'), 'https://test.authorize.net/gateway/transact.dll', 'Authorize: wrong form POST url') + for form_input in tree.input: + # Generated and received 'x_fp_hash' are always different so skeep it. + if form_input.get('name') in ['submit', 'x_fp_hash']: + continue + self.assertEqual( + form_input.get('value'), + form_values[form_input.get('name')], + 'Authorize: wrong value for input %s: received %s instead of %s' % (form_input.get('name'), form_input.get('value'), form_values[form_input.get('name')]) + ) + + @mute_logger('openerp.addons.payment_authorize.models.authorize', 'ValidationError') + def test_20_authorize_form_management(self): + cr, uid, context = self.env.cr, self.env.uid, {} + # be sure not to do stupid thing + authorize = self.env['payment.acquirer'].browse(self.authorize_id) + self.assertEqual(authorize.environment, 'test', 'test without test environment') + + # typical data posted by authorize after client has successfully paid + authorize_post_data = { + 'return_url': u'/shop/payment/validate', + 'x_MD5_Hash': u'7934485E1C105940BE854208D10FAB4F', + 'x_account_number': u'XXXX0027', + 'x_address': u'Huge Street 2/543', + 'x_amount': u'320.00', + 'x_auth_code': u'E4W7IU', + 'x_avs_code': u'Y', + 'x_card_type': u'Visa', + 'x_cavv_response': u'2', + 'x_city': u'Sun City', + 'x_company': u'', + 'x_country': u'Belgium', + 'x_cust_id': u'', + 'x_cvv2_resp_code': u'', + 'x_description': u'', + 'x_duty': u'0.00', + 'x_email': u'norbert.buyer@exampl', + 'x_fax': u'', + 'x_first_name': u'Norbert', + 'x_freight': u'0.00', + 'x_invoice_num': u'SO004', + 'x_last_name': u'Buyer', + 'x_method': u'CC', + 'x_phone': u'0032 12 34 56 78', + 'x_po_num': u'', + 'x_response_code': u'1', + 'x_response_reason_code': u'1', + 'x_response_reason_text': u'This transaction has been approved.', + 'x_ship_to_address': u'Huge Street 2/543', + 'x_ship_to_city': u'Sun City', + 'x_ship_to_company': u'', + 'x_ship_to_country': u'Belgium', + 'x_ship_to_first_name': u'Norbert', + 'x_ship_to_last_name': u'Buyer', + 'x_ship_to_state': u'', + 'x_ship_to_zip': u'1000', + 'x_state': u'', + 'x_tax': u'0.00', + 'x_tax_exempt': u'FALSE', + 'x_test_request': u'false', + 'x_trans_id': u'2217460311', + 'x_type': u'auth_capture', + 'x_zip': u'1000' + } + + # should raise error about unknown tx + with self.assertRaises(ValidationError): + self.payment_transaction.form_feedback(cr, uid, authorize_post_data, 'authorize', context=context) + + tx = self.env['payment.transaction'].create({ + 'amount': 320.0, + 'acquirer_id': self.authorize_id, + 'currency_id': self.currency_usd.id, + 'reference': 'SO004', + 'partner_name': 'Norbert Buyer', + 'partner_country_id': self.country_france_id}) + # validate it + self.payment_transaction.form_feedback(cr, uid, authorize_post_data, 'authorize', context=context) + # check state + self.assertEqual(tx.state, 'done', 'Authorize: validation did not put tx into done state') + self.assertEqual(tx.authorize_txnid, authorize_post_data.get('x_trans_id'), 'Authorize: validation did not update tx payid') + + # reset tx + tx.write({'state': 'draft', 'date_validate': False, 'authorize_txnid': False}) + + # simulate an error + authorize_post_data['x_response_code'] = u'3' + self.payment_transaction.form_feedback(cr, uid, authorize_post_data, 'authorize', context=context) + # check state + self.assertEqual(tx.state, 'error', 'Authorize: erroneous validation did not put tx into error state') diff --git a/addons/payment_authorize/views/authorize.xml b/addons/payment_authorize/views/authorize.xml new file mode 100644 index 00000000000..7285ccb860a --- /dev/null +++ b/addons/payment_authorize/views/authorize.xml @@ -0,0 +1,42 @@ + + + + + + + + diff --git a/addons/payment_authorize/views/payment_acquirer.xml b/addons/payment_authorize/views/payment_acquirer.xml new file mode 100644 index 00000000000..a370f9f7a2e --- /dev/null +++ b/addons/payment_authorize/views/payment_acquirer.xml @@ -0,0 +1,33 @@ + + + + + acquirer.form.authorize + payment.acquirer + + + + + + + + + + + + + acquirer.transaction.form.authorize + payment.transaction + + + + + + + + + + + + + diff --git a/addons/payment_authorize/views/payment_authorize_template.xml b/addons/payment_authorize/views/payment_authorize_template.xml new file mode 100644 index 00000000000..58b1ee85e67 --- /dev/null +++ b/addons/payment_authorize/views/payment_authorize_template.xml @@ -0,0 +1,10 @@ + + + + + +