Skip to content

Commit

Permalink
[ADD] account_banking_ach_base
Browse files Browse the repository at this point in the history
  • Loading branch information
max3903 committed Jul 1, 2019
1 parent 7da0a53 commit 9f242cf
Show file tree
Hide file tree
Showing 17 changed files with 454 additions and 0 deletions.
39 changes: 39 additions & 0 deletions account_banking_ach_base/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg
:alt: License: AGPL-3

=======================
Counting House ACH Base
=======================

Add fields to Bank, Partner and Company required for ACH transactions in USA.

Installation
============

This module depends on :

* stdnum


Usage
=====

Add `routing_number` on Bank records.

Add Legal ID on Partner and Company records.

Add Mandate URL field to Company record. Use in email templates to provide customer with an easy
way to access your Mandate Authorization form to streamline ACH authorizations.

Known issues / Roadmap
======================

* Add support for EFT 1464 byte payment files required in Canada

Bug Tracker
===========

Bugs are tracked on `GitHub Issues
<https://github.com/thinkwelltwd/countinghouse>`_. In case of trouble, please
check there if your issue has already been reported. If you spotted it first,
help us smashing it by providing a detailed and welcomed feedback.
1 change: 1 addition & 0 deletions account_banking_ach_base/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
24 changes: 24 additions & 0 deletions account_banking_ach_base/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Copyright 2018 Thinkwell Designs <[email protected]>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).

{
'name': 'Localizations for North American Banking & Financials',
'summary': 'Add fields required for North American Banking & Financials',
'version': '11.0.1.0.0',
'license': 'AGPL-3',
'author': 'Thinkwell Designs',
'website': 'https://github.com/thinkwelltwd/countinghouse',
'category': 'Banking addons',
'depends': [
'account_payment_order',
'account_banking_mandate',
],
'data': [
'views/account_banking_mandate.xml',
'views/account_invoice.xml',
'views/res_bank.xml',
'views/res_company.xml',
'views/res_partner.xml',
],
'installable': True,
}
7 changes: 7 additions & 0 deletions account_banking_ach_base/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from . import account_banking_mandate
from . import account_invoice
from . import account_payment_order
from . import base
from . import res_bank
from . import res_company
from . import res_partner
48 changes: 48 additions & 0 deletions account_banking_ach_base/models/account_banking_mandate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from odoo import api, fields, models
from odoo.exceptions import UserError


class AccountBankingMandate(models.Model):
_inherit = 'account.banking.mandate'

delay_days = fields.Integer(string='Delay Days', required=True, default=10,
help='Number of days to wait after invoice date before '
'including an invoice in Payment Order for processing.',
)

def validate(self):
for mandate in self:
if not mandate.delay_days:
raise UserError('Delay days must be specified, and greater than 0.')

super(AccountBankingMandate, self).validate()

def set_payment_modes_on_partner(self):
"""
Set the payment modes on the Partner if they don't already exist.
"""
payment_modes = {}

if self.partner_id.customer and not self.partner_id.customer_payment_mode_id:
customer_mode = self.env['account.payment.mode'].search([
('payment_type', '=', 'inbound'),
('company_id', '=', self.company_id.id),
], limit=1)
if customer_mode:
payment_modes['customer_payment_mode_id'] = customer_mode.id
if self.partner_id.supplier and not self.partner_id.supplier_payment_mode_id:
supplier_mode = self.env['account.payment.mode'].search([
('payment_type', '=', 'outbound'),
('company_id', '=', self.company_id.id),
], limit=1)
if supplier_mode:
payment_modes['supplier_payment_mode_id'] = supplier_mode.id

if payment_modes:
self.partner_id.write(payment_modes)

@api.model
def create(self, vals):
mandate = super(AccountBankingMandate, self).create(vals)
self.set_payment_modes_on_partner()
return mandate
27 changes: 27 additions & 0 deletions account_banking_ach_base/models/account_invoice.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from datetime import date, timedelta
from odoo import api, fields, models
from odoo.exceptions import UserError


class AccountInvoice(models.Model):
_inherit = 'account.invoice'

@api.multi
def create_account_payment_line(self):
today = date.today()

for invoice in self:
mandate = invoice.mandate_id
if not mandate:
continue

invoice_date = fields.Date.from_string(invoice.date_invoice)
delay_expired = invoice_date + timedelta(days=mandate.delay_days)

if today < delay_expired:
raise UserError(
'To satisfy payment mandate, cannot add invoice %s to Debit Order until %s!' %
(invoice.number, delay_expired.strftime('%Y-%m-%d'))
)

return super(AccountInvoice, self).create_account_payment_line()
138 changes: 138 additions & 0 deletions account_banking_ach_base/models/account_payment_order.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
from odoo import api, models, fields, _
from odoo.exceptions import UserError
from string import ascii_uppercase
from ach.builder import AchFile


CREDIT_AUTOMATED_RETURN = '21'
CREDIT_AUTOMATED_DEPOSIT = '22'
CREDIT_PRENOTE_DNE_ENR = '23'
CREDIT_ZERO_DOLLAR_ENTRY_WITH_ADDENDA = '24'

DEBIT_AUTOMATED_RETURN = '26'
DEBIT_AUTOMATED_PAYMENT = '27'
DEBIT_PRENOTE_DNE_ENR = '28'
DEBIT_ZERO_DOLLAR_ENTRY_WITH_ADDENDA = '29'


class AccountPaymentOrder(models.Model):
_inherit = 'account.payment.order'

def get_file_id_mod(self):
"""
ACH file_id_mod should be 'A' for the first of the day,
'B' for the second and so on.
"""
ach_transactions_today = self.env['account.payment.order'].search_count([
('create_date', '>=', fields.Date.today()),
('company_partner_bank_id', '=', self.company_partner_bank_id.id),
('state', 'in', ['generated', 'uploaded']),
('payment_mode_id.payment_method_id.code', 'in', ['ACH-In', 'ACH-Out']),
])

return ascii_uppercase[ach_transactions_today]

def ach_settings(self):
bank = self.company_partner_bank_id.bank_id
routing_number = bank.routing_number
legal_id_number = self.company_id.legal_id_number

if not legal_id_number:
raise UserError(
'%s does not have an EIN / SSN / BN assigned!' % self.company_id.name
)

if not routing_number:
raise UserError(
'%s does not have a Routing Number assigned!' % bank.name
)

return {
'immediate_dest': self.company_partner_bank_id.acc_number,
'immediate_org': routing_number,
'immediate_dest_name': bank.name,
'immediate_org_name': self.company_id.name,
'company_id': legal_id_number,
}

def validate_banking(self, line):
if not line.partner_bank_id.bank_id:
raise UserError(
_('%s account number has no Bank assigned' % line.partner_bank_id.acc_number)
)

if not line.partner_bank_id.bank_id.routing_number:
raise UserError(
_('%s has no routing number specified' % line.partner_bank_id.bank_id.name)
)

def validate_mandates(self, line):
"""Ensure that mandates are correctly set"""
if not line.mandate_id:
raise UserError(
_("Missing ACH Direct Debit mandate on the "
"bank payment line with partner '%s' "
"(reference '%s').")
% (line.partner_id.name, line.name))
if line.mandate_id.state != 'valid':
raise Warning(
_("The ACH Direct Debit mandate with reference '%s' "
"for partner '%s' has expired.")
% (line.mandate_id.unique_mandate_reference,
line.mandate_id.partner_id.name))
if line.mandate_id.type == 'oneoff':
if line.mandate_id.last_debit_date:
raise Warning(
_("The mandate with reference '%s' for partner "
"'%s' has type set to 'One-Off' and it has a "
"last debit date set to '%s', so we can't use "
"it.")
% (line.mandate_id.unique_mandate_reference,
line.mandate_id.partner_id.name,
line.mandate_id.last_debit_date))

def get_transaction_type(self, amount):
if not amount:
return DEBIT_ZERO_DOLLAR_ENTRY_WITH_ADDENDA if self.payment_type == 'inbound' \
else CREDIT_ZERO_DOLLAR_ENTRY_WITH_ADDENDA

return DEBIT_AUTOMATED_PAYMENT if self.payment_type == 'inbound' \
else CREDIT_AUTOMATED_DEPOSIT

@api.multi
def generate_ach_file(self):
self.ensure_one()

inbound_payment = self.payment_type == 'inbound'

file_mod = self.get_file_id_mod()
ach_file = AchFile(file_id_mod=file_mod, settings=self.ach_settings())
filename = '{today}_{bank}_{file_mod}.txt'.format(
today=fields.Date.today(), bank=self.company_partner_bank_id.id, file_mod=file_mod,
)

entries = []

for line in self.bank_line_ids:

if inbound_payment:
self.validate_mandates(line)

self.validate_banking(line)

amount = line.amount_currency
entries.append({
'type': self.get_transaction_type(amount=amount),
'routing_number': line.partner_bank_id.bank_id.routing_number,
'account_number': line.partner_bank_id.acc_number,
'amount': str(amount),
'name': line.partner_id.name,
'addenda': [{
'payment_related_info': line.communication,
}],
})

credits = self.payment_type == 'outbound'
ach_file.add_batch('PPD', entries, credits=credits, debits=inbound_payment)

return ach_file.render_to_string(), filename
42 changes: 42 additions & 0 deletions account_banking_ach_base/models/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from odoo import api, models, fields
from odoo.exceptions import UserError
from stdnum.us import ssn, ein
from stdnum.ca import bn


class LegalIDNumber(models.AbstractModel):
"""
Odoo's VAT field validation prevents it from being used
for EIN / GST / SSN, etc.
Use generic ID and apply validation depending on the Country field.
"""
_name = 'countinghouse.legal_id_number'

legal_id_number = fields.Char(
string='Legal ID',
required=False,
help='''For US entities, enter valid EIN or Social Security Number.
Canadian entities, enter Canadian Business Number.
'''
)

@api.constrains('legal_id_number')
def validate_legal_id_number(self):
if not self.legal_id_number:
return

valid = False

for v in (ssn, ein, bn):
try:
v.validate(self.legal_id_number)
valid = True
break
except Exception:
continue

if not valid:
raise UserError(
'%s is not a valid EIN / SSN / Canadian Business Number' % self.legal_id_number
)
30 changes: 30 additions & 0 deletions account_banking_ach_base/models/res_bank.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from odoo import api, models, fields
from odoo.exceptions import ValidationError
from stdnum.us import rtn


class ResBank(models.Model):
_inherit = 'res.bank'

routing_number = fields.Char(string='Routing Number', required=False)

@api.constrains('routing_number')
def validate_routing_number(self):
if not self.routing_number or not self.country:
return

country_code = self.country.code

if country_code == 'US':
try:
rtn.validate(self.routing_number)
except Exception:
raise ValidationError(
'%s is not a valid US routing number!' % self.routing_number
)

elif country_code == 'CA':
if len(self.routing_number) != 8 or not not self.routing_number.is_digit():
raise ValidationError(
'%s is not a valid Canadian routing number!' % self.routing_number
)
12 changes: 12 additions & 0 deletions account_banking_ach_base/models/res_company.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from odoo import fields, models


class ResCompany(models.Model):
_name = 'res.company'
_inherit = ['countinghouse.legal_id_number', 'res.company']

mandate_url = fields.Char(string='Mandate URL', required=False,
help='Full URL to download ACH Mandate / Authorization form. Useful '
'to include in email templates for customer to access and '
'complete the Mandate form.'
)
6 changes: 6 additions & 0 deletions account_banking_ach_base/models/res_partner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from odoo import models


class ResPartner(models.Model):
_name = 'res.partner'
_inherit = ['countinghouse.legal_id_number', 'res.partner']
2 changes: 2 additions & 0 deletions account_banking_ach_base/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
stdnum
python-ach
Loading

0 comments on commit 9f242cf

Please sign in to comment.