diff --git a/hr_payroll_payment/__manifest__.py b/hr_payroll_payment/__manifest__.py
index 8d5dce77..5c72f984 100755
--- a/hr_payroll_payment/__manifest__.py
+++ b/hr_payroll_payment/__manifest__.py
@@ -11,7 +11,7 @@
Adds the ability to register a payment on a payslip.
""",
'website': 'https://hibou.io/',
- 'depends': ['hr_payroll_account'],
+ 'depends': ['hr_payroll_account', 'payment'],
'data': [
'hr_payroll_register_payment.xml',
],
diff --git a/hr_payroll_payment/hr_payroll_register_payment.py b/hr_payroll_payment/hr_payroll_register_payment.py
index 0c9f6cf9..3114702d 100755
--- a/hr_payroll_payment/hr_payroll_register_payment.py
+++ b/hr_payroll_payment/hr_payroll_register_payment.py
@@ -56,6 +56,12 @@ class HrPayrollRegisterPaymentWizard(models.TransientModel):
journal_id = fields.Many2one('account.journal', string='Payment Method', required=True, domain=[('type', 'in', ('bank', 'cash'))])
company_id = fields.Many2one('res.company', related='journal_id.company_id', string='Company', readonly=True, required=True)
payment_method_id = fields.Many2one('account.payment.method', string='Payment Type', required=True)
+ payment_method_code = fields.Char(related='payment_method_id.code',
+ help="Technical field used to adapt the interface to the payment type selected.", readonly=True)
+ payment_transaction_id = fields.Many2one('payment.transaction', string="Payment Transaction")
+ payment_token_id = fields.Many2one('payment.token', string="Saved payment token",
+ domain=[('acquirer_id.capture_manually', '=', False)],
+ help="Note that tokens from acquirers set to only authorize transactions (instead of capturing the amount) are not available.")
amount = fields.Monetary(string='Payment Amount', required=True, default=_default_amount)
currency_id = fields.Many2one('res.currency', string='Currency', required=True, default=lambda self: self.env.user.company_id.currency_id)
payment_date = fields.Date(string='Payment Date', default=fields.Date.context_today, required=True)
@@ -63,6 +69,24 @@ class HrPayrollRegisterPaymentWizard(models.TransientModel):
hide_payment_method = fields.Boolean(compute='_compute_hide_payment_method',
help="Technical field used to hide the payment method if the selected journal has only one available which is 'manual'")
+ @api.onchange('partner_id')
+ def _onchange_partner_id(self):
+ res = {}
+ if self.partner_id:
+ partners = self.partner_id | self.partner_id.commercial_partner_id | self.partner_id.commercial_partner_id.child_ids
+ res['domain'] = {
+ 'payment_token_id': [('partner_id', 'in', partners.ids), ('acquirer_id.capture_manually', '=', False)]}
+
+ return res
+
+ @api.onchange('payment_method_id', 'journal_id')
+ def _onchange_payment_method(self):
+ if self.payment_method_code == 'electronic':
+ self.payment_token_id = self.env['payment.token'].search(
+ [('partner_id', '=', self.partner_id.id), ('acquirer_id.capture_manually', '=', False)], limit=1)
+ else:
+ self.payment_token_id = False
+
@api.one
@api.constrains('amount')
def _check_amount(self):
@@ -106,7 +130,9 @@ class HrPayrollRegisterPaymentWizard(models.TransientModel):
'amount': self.amount,
'currency_id': self.currency_id.id,
'payment_date': self.payment_date,
- 'communication': self.communication
+ 'communication': self.communication,
+ 'payment_transaction_id': self.payment_transaction_id.id if self.payment_transaction_id else False,
+ 'payment_token_id': self.payment_token_id.id if self.payment_token_id else False,
})
payment.post()
diff --git a/hr_payroll_payment/hr_payroll_register_payment.xml b/hr_payroll_payment/hr_payroll_register_payment.xml
index 44e2a0b8..f4aefb09 100755
--- a/hr_payroll_payment/hr_payroll_register_payment.xml
+++ b/hr_payroll_payment/hr_payroll_register_payment.xml
@@ -33,6 +33,10 @@
+
+
@@ -42,6 +46,7 @@
+
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
+
+
+
+
+
+
+
+
+
+
+
+