Initial commit of Forte Payment integration and parts of hr_payroll_payment to support it.

This commit is contained in:
Jared Kipe
2018-07-09 16:39:21 -07:00
parent cfd25c425b
commit 15d88d5bc1
12 changed files with 357 additions and 2 deletions

View File

@@ -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',
],

View File

@@ -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()

View File

@@ -33,6 +33,10 @@
<field name="journal_id" widget="selection"/>
<field name="hide_payment_method" invisible="1"/>
<field name="payment_method_id" widget="radio" attrs="{'invisible': [('hide_payment_method', '=', True)]}"/>
<field name="payment_method_code" invisible="1"/>
<field name="payment_token_id" options="{'no_create': True}"
attrs="{'invisible': [('payment_method_code', '!=', 'electronic')],
'required': [('payment_method_code', '=', 'electronic')]}"/>
<label for="amount"/>
<div name="amount_div" class="o_row">
<field name="amount"/>
@@ -42,6 +46,7 @@
<group>
<field name="payment_date"/>
<field name="communication"/>
<field name="payment_transaction_id"/>
</group>
</group>
</sheet>

View File

@@ -0,0 +1 @@
from . import models

View File

@@ -0,0 +1,14 @@
{
'name': 'Forte Payment Acquirer',
'author': 'Hibou Corp. <hello@hibou.io>',
'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,
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="payment.payment_acquirer_forte" model="payment.acquirer">
<field name="name">Forte</field>
<field name="provider">forte</field>
<field name="company_id" ref="base.main_company"/>
<field name="environment">test</field>
<field name="forte_organization_id">1000</field>
<field name="forte_access_id">dummy</field>
<field name="forte_secure_key">dummy</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1 @@
from . import payment

View File

@@ -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))

View File

@@ -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

View File

@@ -0,0 +1 @@
from . import test_forte

View File

@@ -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)

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="acquirer_form_forte" model="ir.ui.view">
<field name="name">acquirer.form.forte</field>
<field name="model">payment.acquirer</field>
<field name="inherit_id" ref="payment.acquirer_form"/>
<field name="arch" type="xml">
<xpath expr='//group[@name="acquirer"]' position='after'>
<group attrs="{'invisible': [('provider', '!=', 'forte')]}">
<field name="forte_organization_id"/>
<field name="forte_location_id"/>
<field name="forte_access_id"/>
<field name="forte_secure_key" password="True"/>
</group>
</xpath>
</field>
</record>
<record id="token_form_forte" model="ir.ui.view">
<field name='name'>payment.token.form</field>
<field name='model'>payment.token</field>
<field name="inherit_id" ref="payment.payment_token_form_view"/>
<field name="arch" type="xml">
<xpath expr='//field[@name="acquirer_ref"]' position='after'>
<field name="forte_account_type" attrs="{'invisible':[('provider', '!=', 'forte')]}"/>
<field name="forte_routing_number" attrs="{'invisible':[('provider', '!=', 'forte')]}"/>
<field name="forte_account_number" attrs="{'invisible':[('provider', '!=', 'forte')]}"/>
<field name="forte_account_holder" attrs="{'invisible':[('provider', '!=', 'forte')]}"/>
<field name="provider" invisible='1'/>
</xpath>
</field>
</record>
</odoo>