diff --git a/payment_forte/__init__.py b/payment_forte/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/payment_forte/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/payment_forte/__manifest__.py b/payment_forte/__manifest__.py new file mode 100644 index 00000000..927f5df0 --- /dev/null +++ b/payment_forte/__manifest__.py @@ -0,0 +1,14 @@ +{ + 'name': 'Forte Payment Acquirer', + 'author': 'Hibou Corp. ', + 'category': 'Accounting', + 'summary': 'Payment Acquirer: Forte Implementation', + 'version': '11.0.1.0.0', + 'description': """Forte Payment Acquirer""", + 'depends': ['payment'], + 'data': [ + 'views/payment_views.xml', + 'data/payment_acquirer_data.xml', + ], + 'installable': True, +} diff --git a/payment_forte/data/payment_acquirer_data.xml b/payment_forte/data/payment_acquirer_data.xml new file mode 100644 index 00000000..2106e9d1 --- /dev/null +++ b/payment_forte/data/payment_acquirer_data.xml @@ -0,0 +1,16 @@ + + + + + + Forte + forte + + test + 1000 + dummy + dummy + + + + diff --git a/payment_forte/models/__init__.py b/payment_forte/models/__init__.py new file mode 100644 index 00000000..f6528426 --- /dev/null +++ b/payment_forte/models/__init__.py @@ -0,0 +1 @@ +from . import payment diff --git a/payment_forte/models/forte_request.py b/payment_forte/models/forte_request.py new file mode 100644 index 00000000..ee27b761 --- /dev/null +++ b/payment_forte/models/forte_request.py @@ -0,0 +1,81 @@ +from base64 import b64encode +from json import dumps +import requests + + +class ForteAPI: + + url_prod = 'https://api.forte.net/v3' + url_test = 'https://sandbox.forte.net/api/v3' + + def __init__(self, organization_id, access_id, secure_key, environment): + self.organization_id = organization_id + self.access_id = access_id + self.secure_key = secure_key + self.basic_key = b64encode(bytes(access_id + ':' + secure_key, 'UTF-8')).decode() + self.environment = environment + self.headers = { + 'Content-Type': 'application/json', + 'X-Forte-Auth-Organization-Id': 'org_' + self.organization_id, + 'Authorization': 'Basic ' + self.basic_key, + } + + def _build_request(self, location, method, data=None): + url = self.url_prod + if self.environment == 'test': + url = self.url_test + url += location + return requests.request(method, url, headers=self.headers, data=data) + + def test_authenticate(self, location=None): + if location: + url = '/organizations/org_%s/locations/loc_%s/transactions/' % (self.organization_id, location) + else: + url = '/organizations/org_%s/transactions/' % (self.organization_id, ) + return self._build_request(url, 'GET') + + def _echeck_values(self, location, amount, account_type, routing_number, account_number, account_holder): + #{ + # "action": "sale", + # "authorization_amount": 200.0, + # "billing_address": { + # "first_name": "Jared", + # "last_name": "Kipe"}, + # "echeck": { + # "sec_code": "WEB", + # "account_type": "Checking", + # "routing_number": "021000021", + # "account_number": "000111222", + # "account_holder": "Jared Kipe"} + #} + holder_array = account_holder.strip().split() + first_name = '' + last_name = holder_array[-1] + if len(holder_array) >= 2: + first_name = ' '.join(holder_array[:-1]) + data = { + 'action': 'sale', + 'authorization_amount': amount, + 'billing_address': { + 'first_name': first_name, + 'last_name': last_name, + }, + 'echeck': { + 'sec_code': 'WEB', + 'account_type': account_type, + 'routing_number': routing_number, + 'account_number': account_number, + 'account_holder': account_holder, + }, + } + url = '/organizations/org_%s/locations/loc_%s/transactions/' % (self.organization_id, location) + return url, data + + def echeck_sale(self, location, amount, account_type, routing_number, account_number, account_holder): + url, data = self._echeck_values(location, amount, account_type, routing_number, account_number, account_holder) + return self._build_request(url, 'POST', data=dumps(data)) + + def echeck_credit(self, location, amount, account_type, routing_number, account_number, account_holder): + url, data = self._echeck_values(location, amount, account_type, routing_number, account_number, account_holder) + data['action'] = 'credit' + return self._build_request(url, 'POST', data=dumps(data)) diff --git a/payment_forte/models/payment.py b/payment_forte/models/payment.py new file mode 100644 index 00000000..1bbb7e8a --- /dev/null +++ b/payment_forte/models/payment.py @@ -0,0 +1,132 @@ +from odoo import api, fields, models +from odoo.exceptions import ValidationError +from .forte_request import ForteAPI +from json import dumps +import logging + +_logger = logging.getLogger(__name__) + + +def forte_get_api(acquirer): + return ForteAPI(acquirer.forte_organization_id, + acquirer.forte_access_id, + acquirer.forte_secure_key, + acquirer.environment) + + +class PaymentAcquirerForte(models.Model): + _inherit = 'payment.acquirer' + + provider = fields.Selection(selection_add=[('forte', 'Forte')]) + forte_organization_id = fields.Char(string='Organization ID') + forte_location_id = fields.Char(string='Location ID') # Probably move to Journal... + forte_access_id = fields.Char(string='Access ID') + forte_secure_key = fields.Char(string='Secure Key') + + 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 + """ + res = super(PaymentAcquirerForte, self)._get_feature_support() + res['authorize'].append('authorize') + res['tokenize'].append('authorize') + return res + + @api.multi + def forte_test_credentials(self): + self.ensure_one() + api = forte_get_api(self) + resp = api.test_authenticate() + if not resp.ok: + result = resp.json() + if result and result.get('response'): + raise ValidationError('Error: ' + dumps(result.get('response'))) + return True + + +class AccountPayment(models.Model): + _inherit = 'account.payment' + + def _do_payment(self): + self = self.with_context(payment_type=self.payment_type) + return super(AccountPayment, self)._do_payment() + + +class TxForte(models.Model): + _inherit = 'payment.transaction' + + def forte_s2s_do_transaction(self, **data): + self.ensure_one() + api = forte_get_api(self.acquirer_id) + location = self.acquirer_id.forte_location_id + amount = self.amount + account_type = self.payment_token_id.forte_account_type + routing_number = self.payment_token_id.forte_routing_number + account_number = self.payment_token_id.forte_account_number + account_holder = self.payment_token_id.forte_account_holder + if not self.env.context.get('payment_type'): + _logger.warn('Trying to do a payment with Forte and no contextual payment_type will result in an inbound transaction.') + if self.env.context.get('payment_type', 'inbound') == 'inbound': + resp = api.echeck_sale(location, amount, account_type, routing_number, account_number, account_holder) + else: + resp = api.echeck_credit(location, amount, account_type, routing_number, account_number, account_holder) + + if resp.ok and resp.json()['response']['response_desc'] == 'APPROVED': + ref = resp.json()['response']['authorization_code'] + return self.write({'state': 'done', 'acquirer_reference': ref}) + else: + result = resp.json() + if result and result.get('response'): + raise ValidationError('Error: ' + dumps(result.get('response'))) + + def forte_s2s_do_refund(self, **data): + self.ensure_one() + api = forte_get_api(self.acquirer_id) + location = self.acquirer_id.forte_location_id + amount = self.amount + account_type = self.payment_token_id.forte_account_type + routing_number = self.payment_token_id.forte_routing_number + account_number = self.payment_token_id.forte_account_number + account_holder = self.payment_token_id.forte_account_holder + if not self.env.context.get('payment_type'): + _logger.warn('Trying to do a refund payment with Forte and no contextual payment_type will result in an inbound transaction refund.') + if self.env.context.get('payment_type', 'inbound') == 'inbound': + resp = api.echeck_credit(location, amount, account_type, routing_number, account_number, account_holder) + else: + resp = api.echeck_sale(location, amount, account_type, routing_number, account_number, account_holder) + + if resp.ok and resp.json()['response']['response_desc'] == 'APPROVED': + ref = resp.json()['response']['authorization_code'] + return self.write({'state': 'refunded', 'acquirer_reference': ref}) + else: + result = resp.json() + if result and result.get('response'): + raise ValidationError('Error: ' + dumps(result.get('response'))) + + + +class PaymentToken(models.Model): + _inherit = 'payment.token' + + forte_account_type = fields.Char(string='Forte Account Type', help='e.g. Checking') + forte_routing_number = fields.Char(string='Forte Routing Number', help='e.g. 021000021') + forte_account_number = fields.Char(string='Forte Account Number', help='e.g. 000111222') + forte_account_holder = fields.Char(string='Forte Account Holder', help='e.g. John Doe') + # Boilerplate for views + provider = fields.Selection(string='Provider', related='acquirer_id.provider') + + @api.model + def forte_create(self, values): + if values.get('forte_account_number'): + #acquirer = self.env['payment.acquirer'].browse(values['acquirer_id']) + #partner = self.env['res.partner'].browse(values['partner_id']) + # eventually check the types and account numbers + pass + return values diff --git a/payment_forte/tests/__init__.py b/payment_forte/tests/__init__.py new file mode 100644 index 00000000..6a35afa9 --- /dev/null +++ b/payment_forte/tests/__init__.py @@ -0,0 +1 @@ +from . import test_forte diff --git a/payment_forte/tests/test_forte.py b/payment_forte/tests/test_forte.py new file mode 100644 index 00000000..01ee8d9f --- /dev/null +++ b/payment_forte/tests/test_forte.py @@ -0,0 +1,45 @@ +from odoo.addons.payment.tests.common import PaymentAcquirerCommon +from odoo.exceptions import ValidationError + + +class ForteCommon(PaymentAcquirerCommon): + def setUp(self): + super(ForteCommon, self).setUp() + self.currency_usd = self.env['res.currency'].search([('name', '=', 'USD')], limit=1)[0] + self.forte = self.env.ref('payment.payment_acquirer_forte') + self.method = self.env['account.payment.method'].search([('code', '=', 'electronic')], limit=1)[0] + self.journal = self.env['account.journal'].search([], limit=1)[0] + + +class ForteACH(ForteCommon): + def test_10_forte_api(self): + self.assertEqual(self.forte.environment, 'test', 'Must test with test environment.') + response = self.forte.forte_test_credentials() + + # Create/Save a Payment Token. + # Change Token numbers to real values if you want to try to get an approval. + token = self.env['payment.token'].create({ + 'partner_id': self.buyer_id, + 'acquirer_id': self.forte.id, + 'acquirer_ref': 'Test Token', + 'forte_account_type': 'Checking', + 'forte_routing_number': '021000021', + 'forte_account_number': '000111222', + 'forte_account_holder': self.buyer.name, + }) + + # Create Payment + try: + payment = self.env['account.payment'].create({ + 'payment_type': 'inbound', + 'journal_id': self.journal.id, + 'partner_id': self.buyer_id, + 'payment_token_id': token.id, + 'payment_method_id': self.method.id, + 'amount': 22.00, + }) + self.assertEqual(payment.payment_transaction_id.amount, 22.00) + self.assertTrue(payment.payment_transaction_id.acquirer_reference) + except ValidationError as e: + self.assertTrue(e.name.find('U02') >= 0) + diff --git a/payment_forte/views/payment_views.xml b/payment_forte/views/payment_views.xml new file mode 100644 index 00000000..863b4fb8 --- /dev/null +++ b/payment_forte/views/payment_views.xml @@ -0,0 +1,33 @@ + + + + acquirer.form.forte + payment.acquirer + + + + + + + + + + + + + + + payment.token.form + payment.token + + + + + + + + + + + +