diff --git a/account_banking/__init__.py b/account_banking/__init__.py index 3cd966b9c..fd44236e4 100644 --- a/account_banking/__init__.py +++ b/account_banking/__init__.py @@ -24,6 +24,8 @@ # along with this program. If not, see . # ############################################################################## +import sepa +import record import account_banking import parsers import wizard diff --git a/account_banking/__terp__.py b/account_banking/__terp__.py index 4f3f6e253..00b4eddb1 100644 --- a/account_banking/__terp__.py +++ b/account_banking/__terp__.py @@ -25,12 +25,12 @@ ############################################################################## { 'name': 'Account Banking', - 'version': '0.1', + 'version': '0.1.9', 'license': 'GPL-3', 'author': 'EduSense BV', 'website': 'http://www.edusense.nl', 'category': 'Account Banking', - 'depends': ['base', 'base_iban', 'account', 'account_payment'], + 'depends': ['base', 'account', 'account_payment'], 'init_xml': [], 'update_xml': [ #'security/ir.model.access.csv', @@ -42,8 +42,7 @@ 'description': ''' Module to do banking. - Note: This module is depending on BeautifulSoup when using the Dutch - online database. Make sure it is installed. + Note: This module is depending on BeautifulSoup. This modules tries to combine all current banking import and export schemes. Rationale for this is that it is quite common to have foreign @@ -70,6 +69,7 @@ + Each bank can have its own pace in introducing SEPA into their communication with their customers. + National online databases can be used to convert BBAN's to IBAN's. + + The SWIFT database is consulted for bank information. * Adds dropin extensible import facility for bank communication in: - Drop-in input parser development. diff --git a/account_banking/account_banking.py b/account_banking/account_banking.py index 83897b5a2..62282e3fd 100644 --- a/account_banking/account_banking.py +++ b/account_banking/account_banking.py @@ -57,8 +57,11 @@ Modifications are extensive: default behavior is to flag the orders as 'sent', not as 'done'. ''' import time +import sys +import sepa from osv import osv, fields from tools.translate import _ +from wizard.banktools import get_or_create_bank class account_banking_account_settings(osv.osv): '''Default Journal for Bank Account''' @@ -351,7 +354,7 @@ class account_bank_statement_line(osv.osv): _description = 'Bank Transaction' def _get_period(self, cursor, uid, context={}): - date = context.get('date') and context['date'] or None + date = context.get('date', None) periods = self.pool.get('account.period').find(cursor, uid, dt=date) return periods and periods[0] or False @@ -381,7 +384,7 @@ class account_bank_statement_line(osv.osv): states={'draft': [('readonly', False)]}), 'ref': fields.char('Ref.', size=32, readonly=True, states={'draft': [('readonly', False)]}), - 'name': fields.char('Name', size=64, required=True, readonly=True, + 'name': fields.char('Name', size=64, required=False, readonly=True, states={'draft': [('readonly', False)]}), 'date': fields.date('Date', required=True, readonly=True, states={'draft': [('readonly', False)]}), @@ -750,4 +753,211 @@ class payment_order(osv.osv): payment_order() +class res_partner_bank(osv.osv): + ''' + This is a hack to circumvent the ugly account/base_iban dependency. The + usage of __mro__ requires inside information of inheritence. This code is + tested and works - it bypasses base_iban altogether. Be sure to use + 'super' for inherited classes from here though. + + Extended functionality: + 1. BBAN and IBAN are considered equal + 2. Online databases are checked when available + 3. Banks are created on the fly when using IBAN + 4. Storage is uppercase, not lowercase + 5. Presentation is formal IBAN + 6. BBAN's are generated from IBAN when possible + ''' + _inherit = 'res.partner.bank' + _columns = { + 'iban': fields.char('IBAN', size=34, readonly=True, + help="International Bank Account Number" + ), + } + + def create(self, cursor, uid, vals, context={}): + ''' + Create dual function IBAN account for SEPA countries + Note: No check on validity IBAN/Country + ''' + if 'iban' in vals and vals['iban']: + iban = sepa.IBAN(vals['iban']) + vals['iban'] = str(iban) + vals['acc_number'] = iban.localized_BBAN + return self.__class__.__mro__[4].create(self, cursor, uid, vals, + context + ) + + def write(self, cursor, uid, ids, vals, context={}): + ''' + Create dual function IBAN account for SEPA countries + Note: No check on validity IBAN/Country + ''' + if 'iban' in vals and vals['iban']: + iban = sepa.IBAN(vals['iban']) + vals['iban'] = str(iban) + vals['acc_number'] = iban.localized_BBAN + return self.__class__.__mro__[4].write(self, cursor, uid, ids, + vals, context + ) + + def read(self, *args, **kwargs): + records = self.__class__.__mro__[4].read(self, *args, **kwargs) + for record in records: + if 'iban' in record and record['iban']: + record['iban'] = unicode(sepa.IBAN(record['iban'])) + return records + + def search(self, cr, uid, args, offset=0, limit=None, order=None, + context=None, count=False + ): + ''' + Extend the search method to search not only on + bank type == basic account number, + but also on + type == iban + ''' + res = self.__class__.__mro__[4].search(self, + cr, uid, args, offset, limit, order, context=context, count=count + ) + if filter(lambda x:x[0]=='acc_number' ,args): + # get the value of the search + iban_value = filter(lambda x: x[0] == 'acc_number', args)[0][2] + # get the other arguments of the search + args1 = filter(lambda x:x[0]!='acc_number' ,args) + # add the new criterion + args1 += [('iban', 'ilike', + iban_value.replace(' ','').replace('-','').replace('/','') + )] + # append the results to the older search + res += super(res_partner_bank, self).search( + cr, uid, args1, offset, limit, order, context=context, + count=count + ) + return res + + def check_iban(self, cursor, uid, ids): + ''' + Check IBAN number + ''' + for bank_acc in self.browse(cursor, uid, ids): + if not bank_acc.iban: + continue + iban = sepa.IBAN(bank_acc.iban) + if not iban.valid: + return False + return True + + def get_bban_from_iban(self, cursor, uid, context={}): + ''' + Get the local BBAN from the IBAN + ''' + for record in self.browse(cursor, uid, ids, context): + if record.iban: + res[record.id] = record.iban.localized_BBAN + else: + res[record.id] = False + return res + + def onchange_iban(self, cursor, uid, ids, iban, acc_number, context={}): + ''' + Trigger to auto complete other fields. + ''' + acc_number = acc_number.strip() + country_obj = self.pool.get('res.country') + partner_obj = self.pool.get('res.partner') + bic = None + country_ids = [] + + if not iban: + # Pre fill country based on company address + user_obj = self.pool.get('res.users') + user = user_obj.browse(cursor, uid, uid, context) + country = partner_obj.browse(cursor, uid, + user.company_id.partner_id.id + ).country + country_ids = [country.id] + + # Complete data with online database when available + if country.code in sepa.IBAN.countries: + info = sepa.online.account_info(country.code, acc_number) + if info: + bic = info.bic + iban = info.iban + else: + return {} + + iban_acc = sepa.IBAN(iban) + if iban_acc.valid: + bank_id, country_id = get_or_create_bank( + self.pool, cursor, uid, bic or iban_acc.BIC_searchkey + ) + return { + 'value': { + 'acc_number': iban_acc.localized_BBAN, + 'iban': unicode(iban_acc), + 'country': + country_id or + country_ids and country_ids[0] or + False, + 'bank': + bank_id or bank_ids and bank_id[0] or False, + } + } + raise osv.except_osv(_('Invalid IBAN account number!'), + _("The IBAN number doesn't seem to be correct") + ) + + _constraints = [ + (check_iban, "The IBAN number doesn't seem to be correct", ["iban"]) + ] + _defaults = { + 'acc_number': get_bban_from_iban, + } + +res_partner_bank() + +class res_bank(osv.osv): + ''' + Add a on_change trigger to automagically fill bank details from the + online SWIFT database. Allow hand filled names to overrule SWIFT names. + ''' + _inherit = 'res.bank' + def onchange_bic(self, cursor, uid, ids, bic, name, context={}): + ''' + Trigger to auto complete other fields. + ''' + if not bic: + return {} + + info, address = sepa.online.bank_info(bic) + if not info: + return {} + + if address and address.country_id: + country_id = self.pool.get('res.country').search( + cursor, uid, [('code','=',address.country_id)] + ) + country_id = country_id and country_id[0] or False + else: + country_id = False + + return { + 'value': { + # Only the first eight positions of BIC are used for bank + # transfers, so ditch the rest. + 'bic': info.bic[:8], + 'code': info.code, + 'street': address.street, + 'street2': + address.has_key('street2') and address.street2 or False, + 'zip': address.zip, + 'city': address.city, + 'country': country_id, + 'name': name and name or info.name, + } + } + +res_bank() + # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: diff --git a/account_banking/account_banking_view.xml b/account_banking/account_banking_view.xml index 1606c0cae..8b48d0a9c 100644 --- a/account_banking/account_banking_view.xml +++ b/account_banking/account_banking_view.xml @@ -207,5 +207,66 @@ + + + res.partner.bank.form.account_banking.inherit + res.partner.bank + + form + + + + + + + + res.partner.bank.form.account_banking.inherit + res.partner.bank + + form + + + + + + + + + + res.partner.form.account_banking.inherit-1 + res.partner + + form + + + + + + + + res.partner.form.account_banking.inherit-2 + res.partner + + form + + + + + + + + + + res.bank.form.account_banking.inherit-1 + res.bank + + form + + + + + + + diff --git a/account_banking/sepa/__init__.py b/account_banking/sepa/__init__.py new file mode 100644 index 000000000..93f2dc0fe --- /dev/null +++ b/account_banking/sepa/__init__.py @@ -0,0 +1,23 @@ +# -*- encoding: utf-8 -*- +############################################################################## +# +# Copyright (C) 2009 EduSense BV (). +# All Rights Reserved +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +############################################################################## +import iban +import online +IBAN = iban.IBAN diff --git a/account_banking/sepa.py b/account_banking/sepa/iban.py similarity index 100% rename from account_banking/sepa.py rename to account_banking/sepa/iban.py diff --git a/account_banking/sepa/online.py b/account_banking/sepa/online.py new file mode 100644 index 000000000..9d175ad33 --- /dev/null +++ b/account_banking/sepa/online.py @@ -0,0 +1,186 @@ +# -*- encoding: utf-8 -*- +############################################################################## +# +# Copyright (C) 2009 EduSense BV (). +# All Rights Reserved +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +############################################################################## +''' +This module provides online bank databases for conversion between BBAN and +IBAN numbers and for consulting. +''' +import re +import urllib, urllib2 +from BeautifulSoup import BeautifulSoup +from account_banking.sepa import postalcode +from account_banking.sepa.urlagent import URLAgent, SoupForm +from account_banking.struct import struct + +__all__ = [ + 'account_info', + 'bank_info', +] + +IBANlink_NL = 'http://www.ibannl.org/iban_check.php' + +def get_iban_bic_NL(bank_acc): + ''' + Consult the Dutch online banking database to check both the account number + and the bank to which it belongs. Will not work offline, is limited to + banks operating in the Netherlands and will only convert Dutch local + account numbers. + ''' + data = urllib.urlencode(dict(number=bank_acc, method='POST')) + request = urllib2.Request(IBANlink_NL, data) + response = urllib2.urlopen(request) + soup = BeautifulSoup(response) + result = struct() + for _pass, td in enumerate(soup.findAll('td')): + if _pass % 2 == 1: + result[attr] = unicode(td.find('font').contents[0]) + else: + attr = td.find('strong').contents[0][:4].strip().lower() + if result: + result.account = bank_acc + result.country_id = result.bic[4:6] + # Nationalized bank code + result.code = result.bic[:6] + # All Dutch banks use generic channels + # result.bic += 'XXX' + return result + return None + +_account_info = { + # TODO: Add more online data banks + 'NL': get_iban_bic_NL, +} + +def account_info(iso, bank_acc): + ''' + Consult the online database for this country or return None + ''' + if iso in _account_info: + return _account_info[iso](bank_acc) + return None + +bic_re = re.compile("[^']+'([^']*)'.*") +SWIFTlink = 'http://www.swift.com/bsl/freequery.do' + +def bank_info(bic): + ''' + Consult the free online SWIFT service to obtain the name and address of a + bank. This call may take several seconds to complete, due to the number of + requests to make. In total three HTTP requests are made per function call. + In theory one request could be stripped, but the SWIFT terms of use prevent + automated usage, so user like behavior is required. + ''' + def harvest(soup): + retval = struct() + for trsoup in soup('tr'): + for stage, tdsoup in enumerate(trsoup('td')): + if stage == 0: + attr = tdsoup.contents[0].strip().replace(' ','_') + elif stage == 2: + if tdsoup.contents: + retval[attr] = tdsoup.contents[0].strip() + else: + retval[attr] = '' + return retval + + # Get form + agent = URLAgent() + request = agent.open(SWIFTlink) + soup = BeautifulSoup(request) + + # Parse request form. As this form is intertwined with a table, use the parent + # as root to search for form elements. + form = SoupForm(soup.find('form', {'id': 'frmFreeSearch1'}), parent=True) + + # Fill form fields + form['selected_bic'] = bic + + # Get intermediate response + response = agent.submit(form) + + # Parse response + soup = BeautifulSoup(response) + + # Isolate the full 11 BIC - there may be more, but we only use the first + bic_button = soup.find('a', {'class': 'bigbuttonblack'}) + if not bic_button: + return None, None + + # Overwrite the location with 'any' ('XXX') to narrow the results to one or less. + # Assume this regexp will never fail... + full_bic = bic_re.match(bic_button.get('href')).groups()[0][:8] + 'XXX' + + # Get the detail form + form = SoupForm(soup.find('form', {'id': 'frmDetail'})) + + # Fill detail fields + form['selected_bic11'] = full_bic + + # Get final response + response = agent.submit(form) + soup = BeautifulSoup(response) + + # Now parse the results + tables = soup.find('div', {'id':'Middle'}).findAll('table') + if not tables: + return None, None + tablesoup = tables[2]('table') + if not tablesoup: + return None, None + + codes = harvest(tablesoup[0]) + if not codes: + return None, None + + bankinfo = struct( + # Most banks use the first four chars of the BIC as an identifier for + # their 'virtual bank' accross the world, containing all national + # banks world wide using the same name. + # The concatenation with the two character country code is for most + # national branches sufficient as a unique identifier. + code = full_bic[:6], + bic = full_bic, + name = codes.Institution_name, + ) + + address = harvest(tablesoup[1]) + # The address in the SWIFT database includes a postal code. + # We need to split it into two fields... + if not address.Zip_Code: + if address.Location: + address.Zip_Code, address.Location = \ + postalcode.split(full_bic[4:6], address.Location) + + bankaddress = struct( + street = address.Address.title(), + city = address.Location.strip().title(), + zip = address.Zip_Code, + country = address.Country.title(), + country_id = full_bic[4:6], + ) + if ' ' in bankaddress.street: + bankaddress.street, bankaddress.street2 = [ + x.strip() for x in bankaddress.street.split(' ', 1) + ] + else: + bankaddress.street2 = '' + + return bankinfo, bankaddress + diff --git a/account_banking/sepa/postalcode.py b/account_banking/sepa/postalcode.py new file mode 100644 index 000000000..6fad63bc6 --- /dev/null +++ b/account_banking/sepa/postalcode.py @@ -0,0 +1,159 @@ +# -*- encoding: utf-8 -*- +############################################################################## +# +# Copyright (C) 2009 EduSense BV (). +# All Rights Reserved +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +############################################################################## + +''' +This module provides a utility class to extract postal codes from address +strings. +''' +import re + +__all__ = ['split', 'get', 'PostalCode'] + +class PostalCode(object): + ''' + The PostalCode class is a wrapper around PostCodeFormat and an internal + database of postalcode formats. It provides the class methods split() and + get(), both of which must be called with the two character iso country + code as first parameter. + ''' + + class PostalCodeFormat(object): + ''' + Utility class of PostalCode. + Allows finding and splitting of postalcode in strings + ''' + def __init__(self, format): + ''' + Create regexp patterns for matching + ''' + # Sort formats on length, longest first + formats = [(len(x), x) for x in format.split('|')] + formats.sort() + formats.reverse() + formats = [x[1] for x in formats] + self.res = [re.compile(x.replace('#', '\\d').replace('@','[A-Z]')) + for x in formats + ] + + def get(self, str_): + ''' + Return the postal code from the string str_ + ''' + for re_ in self.res: + retval = re_.findall(str_) + if retval: + break + return retval and retval[0] or '' + + def split(self, str_): + ''' + Split str_ into (postalcode, remainder) + ''' + for re_ in self.res: + pos = re_.search(str_) + if pos: + break + if pos: + return (pos.group(), str_[pos.end():]) + return ('', str_) + + _formats = { + 'AF': '', 'AX': '', 'AL': '', 'DZ': '#####', 'AS': '', 'AD': 'AD###', + 'AO': '', 'AI': '', 'AQ': '', 'AG': '', 'AR': '@####@@@', + 'AM': '######', 'AW': '', 'AU': '####', 'AT': '####', 'AZ': 'AZ ####', + 'BS': '', 'BH': '####|###', 'BD': '####', 'BB': 'BB#####', + 'BY': '######', 'BE': '####', 'BZ': '', 'BJ': '', 'BM': '@@ ##', + 'BT': '', 'BO': '', 'BA': '#####', 'BW': '', 'BV': '', + 'BR': '#####-###', 'IO': '', 'BN': '@@####', 'BG': '####', 'BF': '', + 'BI': '', 'KH': '#####', 'CM': '', 'CA': '@#@ #@#', 'CV': '####', + 'KY': '', 'CF': '', 'TD': '', 'CL': '#######', 'CN': '######', + 'CX': '####', 'CC': '', 'CO': '', 'KM': '', 'CG': '', 'CD': '', + 'CK': '', 'CR': '####', 'CI': '', 'HR': 'HR-#####', 'CU': 'CP #####', + 'CY': '####', 'CZ': '### ##', 'DK': '####', 'DJ': '', 'DM': '', + 'DO': '#####', 'EC': '@####@', 'EG': '#####', 'SV': 'CP ####', + 'GQ': '', 'ER': '', 'EE': '#####', 'ET': '####', 'FK': '', + 'FO': 'FO-###', 'FJ': '', 'FI': 'FI-#####', 'FR': '#####', + 'GF': '#####', 'PF': '#####', 'TF': '', 'GA': '', 'GM': '', + 'GE': '####', 'DE': '#####', 'GH': '', 'GI': '', 'GR': '### ##', + 'GL': '####', 'GD': '', 'GP': '#####', 'GU': '969##', 'GT': '#####', + 'GG': '@# #@@|@## #@@|@@# #@@|@@## #@@|@#@ #@@|@@#@ #@@|GIR0AA', + 'GN': '', 'GW': '####', 'GY': '', 'HT': 'HT####', 'HM': '', 'VA': '', + 'HN': '@@####', 'HK': '', 'HU': '####', 'IS': '###', 'IN': '######', + 'ID': '#####', 'IR': '##########', 'IQ': '#####', 'IE': '', + 'IM': '@# #@@|@## #@@|@@# #@@|@@## #@@|@#@ #@@|@@#@ #@@|GIR0AA', + 'IL': '#####', 'IT': '####', 'JM': '', 'JP': '###-####', + 'JE': '@# #@@|@## #@@|@@# #@@|@@## #@@|@#@ #@@|@@#@ #@@|GIR0AA', + 'JO': '#####', 'KZ': '######', 'KE': '#####', 'KI': '', 'KP': '###-###', + 'KR': 'SEOUL ###-###', 'KW': '#####', 'KG': '######', 'LA': '#####', + 'LV': 'LV-####', 'LB': '#### ####|####', 'LS': '###', 'LR': '####', + 'LY': '', 'LI': '####', 'LT': 'LT-#####', 'LU': '####', 'MO': '', + 'MK': '####', 'MG': '###', 'MW': '', 'MY': '#####', 'MV': '#####', + 'ML': '', 'MT': '@@@ ###|@@@ ##', 'MH': '', 'MQ': '#####', 'MR': '', + 'MU': '', 'YT': '#####', 'MX': '#####', 'FM': '#####', 'MD': 'MD-####', + 'MC': '#####', 'MN': '######', 'ME': '#####', 'MS': '', 'MA': '#####', + 'MZ': '####', 'MM': '#####', 'NA': '', 'NR': '', 'NP': '#####', + 'NL': '#### @@', 'AN': '', 'NC': '#####', 'NZ': '####', + 'NI': '###-###-#', 'NE': '####', 'NG': '######', 'NU': '', 'NF': '', + 'MP': '', 'NO': '####', 'OM': '###', 'PK': '#####', 'PW': '96940', + 'PS': '', 'PA': '', 'PG': '###', 'PY': '####', 'PE': '', 'PH': '####', + 'PN': '', 'PL': '##-###', 'PT': '####-###', 'PR': '#####-####', + 'QA': '', 'RE': '#####', 'RO': '######', 'RU': '######', 'RW': '', + 'BL': '### ###', 'SH': 'STHL 1ZZ', 'KN': '', 'LC': '', 'MF': '### ###', + 'PM': '', 'VC': '', 'WS': '', 'SM': '4789#', 'ST': '', 'SA': '#####', + 'SN': '#####', 'RS': '######', 'SC': '', 'SL': '', 'SG': '######', + 'SK': '### ##', 'SI': 'SI- ####', 'SB': '', 'SO': '@@ #####', + 'ZA': '####', 'GS': '', 'ES': '#####', 'LK': '#####', 'SD': '#####', + 'SR': '', 'SJ': '', 'SZ': '@###', 'SE': 'SE-### ##', 'CH': '####', + 'SY': '', 'TW': '#####', 'TJ': '######', 'TZ': '', 'TH': '#####', + 'TL': '', 'TG': '', 'TK': '', 'TO': '', 'TT': '', 'TN': '####', + 'TR': '#####', 'TM': '######', 'TC': 'TKCA 1ZZ', 'TV': '', 'UG': '', + 'UA': '#####', 'AE': '', + 'GB': '@# #@@|@## #@@|@@# #@@|@@## #@@|@#@ #@@|@@#@ #@@|GIR0AA', + 'US': '#####-####', 'UM': '', 'UY': '#####', 'UZ': '######', 'VU': '', + 'VE': '####', 'VN': '######', 'VG': '', 'VI': '', 'WF': '', 'EH': '', + 'YE': '', 'ZM': '#####', 'ZW': '' + } + for iso, formatstr in _formats.iteritems(): + _formats[iso] = PostalCodeFormat(formatstr) + + @classmethod + def split(cls, iso, str_): + ''' + Split string in (postalcode, remainder) following the specs of + country . + Returns both the postal code and the remaining part of + ''' + if iso in cls._formats: + return cls._formats[iso].split(str_) + return ('', str_) + + @classmethod + def get(cls, iso, str_): + ''' + Extracts the postal code from str_ following the specs of country + . + ''' + if iso in cls._formats: + return cls._formats[iso].get(str_) + return '' + +get = PostalCode.get +split = PostalCode.split diff --git a/account_banking/sepa/urlagent.py b/account_banking/sepa/urlagent.py new file mode 100644 index 000000000..05f867cdb --- /dev/null +++ b/account_banking/sepa/urlagent.py @@ -0,0 +1,221 @@ +# -*- encoding: utf-8 -*- +############################################################################## +# +# Copyright (C) 2009 EduSense BV (). +# All Rights Reserved +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +############################################################################## + +''' +This module presents a browser like class to browse the web, fill and submit +forms and to parse the results back in. It is heavily based on BeautifulSoup. +''' + +import urllib +from BeautifulSoup import BeautifulSoup + +__all__ = ['urlsplit', 'urljoin', 'pathbase', 'urlbase', 'SoupForm', + 'URLAgent' + ] + +def urlsplit(url): + ''' + Split an URL into scheme, host and path parts. Helper function. + ''' + if ':' in url: + parts = url.split(':') + scheme = parts[0] + url = ':'.join(parts[1:]) + else: + scheme = '' + host, path = urllib.splithost(url) + return (scheme, host, path) + +def urljoin(scheme, host, path, args = None): + ''' + Join scheme, host and path to a full URL. + Optional: add urlencoded args. + Helper function. + ''' + url = '%s://%s/%s' % (scheme or 'http', host, path) + if args: + url += '?%s' % urllib.urlencode(args) + return url + +def pathbase(path): + ''' + Return the base for the path in order to satisfy relative paths. + Helper function. + ''' + if path and '/' in path: + return path[:path.rfind('/') +1] + return path + +def urlbase(url): + ''' + Return the base URL for url in order to satisfy relative paths. + Helper function. + ''' + scheme, host, path = urlsplit(url) + return urljoin(scheme, host, pathbase(path)) + +class SoupForm(object): + ''' + A SoupForm is a representation of a HTML Form in BeautifulSoup terms. + It has a helper method __setitem__ to set or replace form fields. + It gets initiated from a soup object. + ''' + def __init__(self, soup, parent=False): + ''' + Parse the form attributes and fields from the soup. Make sure + to get the action right. When parent is set, then the parent + element is used as anchor for the search for form elements. + ''' + self._extra_args = {} + self.soup = soup + + # Make sure to use base strings, not unicode + for attr, value in soup.attrMap.iteritems(): + setattr(self, str(attr), str(value)) + + # Set right anchor point for harvest + if parent: + self.soup = soup.parent + + # Harvest input elements + self._args = {} + for item in self.soup.findAll('input'): + self._args[str(item.get('name'))] = item.get('value') + + # Harvest url + self.scheme, self.host, self.action = urlsplit(self.action) + self.action, args = urllib.splitquery(self.action) + if args: + args = args.split('&') + for arg in args: + attr, value = urllib.splitvalue(arg) + self._extra_args[str(attr)] = value + + def __setitem__(self, name, value, force=False): + ''' + Set values for the form attributes when present + ''' + if name in self._args or force: + self._extra_args[name] = value + else: + raise AttributeError('No such attribute: %s' % name) + + def __getitem__(self, name): + ''' + Get a value. Set values overrule got values. + ''' + if name in self._extra_args: + return self._extra_args[name] + if name in self._args: + return self._args[name] + raise AttributeError('No attribute with name "%s" found.' % name) + + def set(self, **kwargs): + ''' + Forcibly sets an attribute to the supplied value, even if it is not + part of the parsed form. + Can be useful in situations where forms are deliberatly chunked in + order to make it difficult to automate form requests, e.g. the + SWIFT BIC service, which uses JavaScript to add form attributes to an + emtpy base form. + ''' + for name, value in kwargs.iteritems(): + self.__setitem__(name, value, force=True) + + def args(self): + ''' + Return the field values as attributes, updated with the modified + values. + ''' + args = dict(self._args) + args.update(self._extra_args) + return args + +class URLAgent(object): + ''' + Assistent object to ease HTTP(S) requests. + Mimics a normal web browser. + ''' + + def __init__(self, *args, **kwargs): + super(URLAgent, self).__init__(*args, **kwargs) + self._extra_headers = {} + self.headers = { + 'User-Agent': 'Mozilla/5.0 (X11; U; Linux x86_64; us; rv:1.9.0.10) Gecko/2009042708 Fedora/3.0.10-1.fc9 Firefox/3.0.10', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-us;q=1.0', + 'Accept-Charset': 'UTF-8,*', + 'Cache-Control': 'max-age=0' + } + + def add_headers(self, **kwargs): + self._extra_headers.update(**kwargs) + + def open(self, URL): + ''' + Open a URL and set some vars based on the used URL. + Meant to be used on a single server. + ''' + self.scheme, self.host, self.path = urlsplit(URL) + + # Create agent + self.agent = urllib.URLopener() + headers = self._extra_headers.copy() + headers.update(self.headers) + for key, value in headers.iteritems(): + self.agent.addheader(key, value) + + # Open webpage + request = self.agent.open(URL) + + # Get and set cookies for next actions + attributes = request.info() + if attributes.has_key('set-cookie'): + self.agent.addheader('Cookie', attributes['set-cookie']) + + # Add referer + self.agent.addheader('Referer', URL) + + # Return request + return request + + def submit(self, form, action=None, method=None, **kwargs): + ''' + Submit a SoupForm. Override missing attributes in action from our own + initial URL. + ''' + if action: + scheme, host, path = urlsplit(action) + else: + scheme = form.scheme or self.scheme + host = form.host or self.host + action = form.action + method = (method or form.method).lower() + args = urllib.urlencode(kwargs or form.args()) + + if not action.startswith('/'): + # Relative path + action = pathbase(self.path) + action + + function = getattr(self.agent, 'open_%s' % scheme) + if method == 'post': + return function('//%s%s' % (host, action), args) + return function('//%s%s?%s' % (host, action, args)) diff --git a/account_banking/struct.py b/account_banking/struct.py new file mode 100644 index 000000000..cd10f2248 --- /dev/null +++ b/account_banking/struct.py @@ -0,0 +1,55 @@ +# -*- encoding: utf-8 -*- +############################################################################## +# +# Copyright (C) 2009 EduSense BV (). +# All Rights Reserved +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +############################################################################## +''' +Define a struct class which behaves like a dict, but allows using +object.attr alongside object['attr']. +''' + +__all__ = ['struct'] + +class struct(dict): + ''' + Ease working with dicts. Allow dict.key alongside dict['key'] + ''' + def __setattr__(self, item, value): + self.__setitem__(item, value) + + def __getattr__(self, item): + return self.__getitem__(item) + + def show(self, indent=0, align=False, ralign=False): + ''' + PrettyPrint method. Aligns keys right (ralign) or left (align). + ''' + if align or ralign: + width = 0 + for key in self.iterkeys(): + width = max(width, len(key)) + alignment = '' + if not ralign: + alignment = '-' + fmt = '%*.*s%%%s%d.%ds: %%s' % ( + indent, indent, '', alignment, width, width + ) + else: + fmt = '%*.*s%%s: %%s' % (indent, indent, '') + for item in self.iteritems(): + print fmt % item diff --git a/account_banking/wizard/banktools.py b/account_banking/wizard/banktools.py index 0417867f4..c93e13cb3 100644 --- a/account_banking/wizard/banktools.py +++ b/account_banking/wizard/banktools.py @@ -19,7 +19,13 @@ # ############################################################################## +import sys +import datetime +import re from tools.translate import _ +from account_banking.parsers import convert +from account_banking import sepa +from account_banking.struct import struct __all__ = [ 'get_period', @@ -27,42 +33,8 @@ __all__ = [ 'get_or_create_partner', 'get_company_bank_account', 'create_bank_account', - 'struct', ] -class struct(dict): - ''' - Ease working with dicts. Allow dict.key alongside dict['key'] - ''' - def __setattr__(self, item, value): - self.__setitem__(item, value) - - def __getattr__(self, item): - return self.__getitem__(item) - - def show(self, indent=0, align=False, ralign=False): - ''' - PrettyPrint method. Aligns keys right (ralign) or left (align). - ''' - if align or ralign: - width = 0 - for key in self.iterkeys(): - width = max(width, len(key)) - alignment = '' - if not ralign: - alignment = '-' - fmt = '%*.*s%%%s%d.%ds: %%s' % ( - indent, indent, '', alignment, width, width - ) - else: - fmt = '%*.*s%%s: %%s' % (indent, indent, '') - for item in self.iteritems(): - print fmt % item - -import datetime -from account_banking import sepa -from account_banking.parsers.convert import * - def get_period(pool, cursor, uid, date, company, log): ''' Get a suitable period for the given date range and the given company. @@ -70,9 +42,9 @@ def get_period(pool, cursor, uid, date, company, log): fiscalyear_obj = pool.get('account.fiscalyear') period_obj = pool.get('account.period') if not date: - date = date2str(datetime.datetime.today()) + date = convert.date2str(datetime.datetime.today()) - search_date = date2str(date) + search_date = convert.date2str(date) fiscalyear_ids = fiscalyear_obj.search(cursor, uid, [ ('date_start','<=', search_date), ('date_stop','>=', search_date), ('state','=','draft'), ('company_id','=',company.id) @@ -192,41 +164,56 @@ def get_company_bank_account(pool, cursor, uid, account_number, results.default_credit_account_id = settings.default_credit_account_id return results -def get_iban_bic_NL(bank_acc): +def get_or_create_bank(pool, cursor, uid, bic, online=True): ''' - Consult the Dutch online banking database to check both the account number - and the bank to which it belongs. Will not work offline, is limited to - banks operating in the Netherlands and will only convert Dutch local - account numbers. + Find or create the bank with the provided BIC code. + When online, the SWIFT database will be consulted in order to + provide for missing information. ''' - import urllib, urllib2 - from BeautifulSoup import BeautifulSoup + bank_obj = pool.get('res.bank') + if len(bic) < 8: + # search key + bank_ids = bank_obj.search( + cursor, uid, [ + ('bic', 'ilike', bic + '%') + ]) + else: + bank_ids = bank_obj.search( + cursor, uid, [ + ('bic', '=', bic) + ]) + if bank_ids and len(bank_ids) == 1: + return bank_ids[0], bank_ids[0].country - IBANlink = 'http://www.ibannl.org/iban_check.php' - data = urllib.urlencode(dict(number=bank_acc, method='POST')) - request = urllib2.Request(IBANlink, data) - response = urllib2.urlopen(request) - soup = BeautifulSoup(response) - result = struct() - for _pass, td in enumerate(soup.findAll('td')): - if _pass % 2 == 1: - result[attr] = td.find('font').contents[0] + country_obj = pool.get('res.country') + country_ids = country_obj.search( + cursor, uid, [('code', '=', bic[4:6])] + ) + if online: + info, address = sepa.online.bank_info(bic) + if info: + bank_id = bank_obj.create(cursor, uid, dict( + code = info.code, + name = info.name, + street = address.street, + street2 = address.street2, + zip = address.zip, + city = address.city, + country = country_ids and country_ids[0] or False, + bic = info.bic, + )) else: - attr = td.find('strong').contents[0][:4].strip().lower() - if result: - result.account = bank_acc - result.country_id = result.bic[4:6] - # Nationalized bank code - result.code = result.bic[:6] - # All Dutch banks use generic channels - result.bic += 'XXX' - return result - return None + bank_id = False -online_account_info = { - # TODO: Add more online data banks - 'NL': get_iban_bic_NL, -} + country_id = country_ids and country_ids[0] or False + if not online or not bank_id: + bank_id = bank_obj.create(cursor, uid, dict( + code = info.code, + name = _('Unknown Bank'), + country = country_id, + bic = bic, + )) + return bank_id, country_id def create_bank_account(pool, cursor, uid, partner_id, account_number, holder_name, log @@ -238,6 +225,9 @@ def create_bank_account(pool, cursor, uid, partner_id, partner_id = partner_id, owner_name = holder_name, ) + bankcode = None + bic = None + # Are we dealing with IBAN? iban = sepa.IBAN(account_number) if iban.valid: @@ -250,29 +240,25 @@ def create_bank_account(pool, cursor, uid, partner_id, cursor, uid, partner_id).country_id values.state = 'bank' values.acc_number = account_number - if country.code in sepa.IBAN.countries \ - and country.code in online_account_info \ - : - account_info = online_account_info[country.code](values.acc_number) - if account_info and iban in account_info: + if country.code in sepa.IBAN.countries: + account_info = sepa.online.account_info(country.code, + values.acc_number + ) + if account_info: values.iban = iban = account_info.iban values.state = 'iban' bankcode = account_info.code bic = account_info.bic - else: - bankcode = None - bic = None - if bankcode: + if bic: + values.bank_id = get_or_create_bank(pool, cursor, uid, bic) + + elif bankcode: # Try to link bank bank_obj = pool.get('res.bank') bank_ids = bank_obj.search(cursor, uid, [ ('code', 'ilike', bankcode) ]) - if not bank_ids and bic: - bank_ids = bank_obj.search(cursor, uid, [ - ('bic', 'ilike', bic) - ]) if bank_ids: # Check BIC on existing banks values.bank_id = bank_ids[0] @@ -283,7 +269,9 @@ def create_bank_account(pool, cursor, uid, partner_id, # New bank - create values.bank_id = bank_obj.create(cursor, uid, dict( code = account_info.code, - bic = account_info.bic, + # Only the first eight positions of BIC are used for bank + # transfers, so ditch the rest. + bic = account_info.bic[:8], name = account_info.bank, country_id = country.id, )) diff --git a/account_banking_nl_clieop/wizard/clieop.py b/account_banking_nl_clieop/wizard/clieop.py index 24425e873..38b7a2cf3 100644 --- a/account_banking_nl_clieop/wizard/clieop.py +++ b/account_banking_nl_clieop/wizard/clieop.py @@ -21,15 +21,15 @@ from account_banking import record -__all__ = ['IncassoBatch', 'BetalingsBatch', 'Incasso', 'Betaling', - 'IncassoFile', 'BetalingsFile', 'SalarisFile', - 'SalarisbetalingsOpdracht', 'BetalingsOpdracht', 'IncassoOpdracht', - 'OpdrachtenFile', +__all__ = ['DirectDebitBatch', 'PaymentsBatch', 'DirectDebit', 'Payment', + 'DirectDebitFile', 'PaymentsFile', 'SalaryPaymentsFile', + 'SalaryPaymentOrder', 'PaymentOrder', 'DirectDebitOrder', + 'OrdersFile', ] -def elfproef(s): +def eleven_test(s): ''' - Dutch elfproef for validating 9-long local bank account numbers. + Dutch eleven-test for validating 9-long local bank account numbers. ''' r = 0 l = len(s) @@ -42,19 +42,19 @@ class HeaderRecord(record.Record): #{{{ _fields = [ record.Filler('recordcode', 4, '0001'), record.Filler('variantcode', 1, 'A'), - record.DateField('aanmaakdatum', '%d%m%y', auto=True), - record.Filler('bestandsnaam', 8, 'CLIEOP03'), - record.Field('inzender_id', 5), - record.Field('bestands_id', 4), - record.Field('duplicaatcode', 1), + record.DateField('creation_date', '%d%m%y', auto=True), + record.Filler('filename', 8, 'CLIEOP03'), + record.Field('sender_id', 5), + record.Field('file_id', 4), + record.Field('duplicatecode', 1), record.Filler('filler', 21), ] def __init__(self, id='1', volgnr=1, duplicate=False): super(HeaderRecord, self).__init__() - self.inzender_id = id - self.bestands_id = '%02d%02d' % (self.aanmaakdatum.day, volgnr) - self.duplicaatcode = duplicate and '2' or '1' + self.sender_id = id + self.file_id = '%02d%02d' % (self.creation_date.day, volgnr) + self.duplicatecode = duplicate and '2' or '1' #}}} class FooterRecord(record.Record): @@ -70,10 +70,10 @@ class BatchHeaderRecord(record.Record): _fields = [ record.Filler('recordcode', 4, '0010'), record.Field('variantcode', 1), - record.Field('transactiegroep', 2), - record.NumberField('rekeningnr_opdrachtgever', 10), - record.NumberField('batchvolgnummer', 4), - record.Filler('aanlevermuntsoort', 3, 'EUR'), + record.Field('transactiongroup', 2), + record.NumberField('accountno_sender', 10), + record.NumberField('batch_tracer', 4), + record.Filler('currency_order', 3, 'EUR'), record.Field('batch_id', 16), record.Filler('filler', 10), ] @@ -83,112 +83,113 @@ class BatchFooterRecord(record.Record): _fields = [ record.Filler('recordcode', 4, '9990'), record.Filler('variantcode', 1, 'A'), - record.NumberField('totaalbedrag', 18), - record.NumberField('totaal_rekeningnummers', 10), - record.NumberField('aantal_posten', 7), + record.NumberField('total_amount', 18), + record.NumberField('total_accountnos', 10), + record.NumberField('nr_posts', 7), record.Filler('filler', 10), ] -class VasteOmschrijvingRecord(record.Record): +class FixedMessageRecord(record.Record): '''Fixed message''' _fields = [ record.Filler('recordcode', 4, '0020'), record.Filler('variantcode', 1, 'A'), - record.Field('vaste_omschrijving', 32), + record.Field('fixed_message', 32), record.Filler('filler', 13), ] -class OpdrachtgeverRecord(record.Record): +class SenderRecord(record.Record): '''Ordering party''' _fields = [ record.Filler('recordcode', 4, '0030'), record.Filler('variantcode', 1, 'B'), + # NAW = Name, Address, Residence record.Field('NAWcode', 1), - record.DateField('gewenste_verwerkingsdatum', '%d%m%y', auto=True), - record.Field('naam_opdrachtgever', 35), + record.DateField('preferred_execution_date', '%d%m%y', auto=True), + record.Field('name_sender', 35), record.Field('testcode', 1), record.Filler('filler', 2), ] -class TransactieRecord(record.Record): +class TransactionRecord(record.Record): '''Transaction''' _fields = [ record.Filler('recordcode', 4, '0100'), record.Filler('variantcode', 1, 'A'), - record.NumberField('transactiesoort', 4), - record.NumberField('bedrag', 12), - record.NumberField('rekeningnr_betaler', 10), - record.NumberField('rekeningnr_begunstigde', 10), + record.NumberField('transactiontype', 4), + record.NumberField('amount', 12), + record.NumberField('accountno_payer', 10), + record.NumberField('accountno_beneficiary', 10), record.Filler('filler', 9), ] -class NaamBetalerRecord(record.Record): +class NamePayerRecord(record.Record): '''Name payer''' _fields = [ record.Filler('recordcode', 4, '0110'), record.Filler('variantcode', 1, 'B'), - record.Field('naam', 35), + record.Field('name', 35), record.Filler('filler', 10), ] -class BetalingskenmerkRecord(record.Record): +class PaymentReferenceRecord(record.Record): '''Payment reference''' _fields = [ record.Filler('recordcode', 4, '0150'), record.Filler('variantcode', 1, 'A'), - record.Field('betalingskenmerk', 16), + record.Field('paymentreference', 16), record.Filler('filler', 29), ] -class OmschrijvingRecord(record.Record): +class DescriptionRecord(record.Record): '''Description''' _fields = [ record.Filler('recordcode', 4, '0160'), record.Filler('variantcode', 1, 'B'), - record.Field('omschrijving', 32), + record.Field('description', 32), record.Filler('filler', 13), ] -class NaamBegunstigdeRecord(record.Record): +class NameBeneficiaryRecord(record.Record): '''Name receiving party''' _fields = [ record.Filler('recordcode', 4, '0170'), record.Filler('variantcode', 1, 'B'), - record.Field('naam', 35), + record.Field('name', 35), record.Filler('filler', 10), ] -class OpdrachtRecord(record.Record): +class OrderRecord(record.Record): '''Order details''' _fields = [ record.Filler('recordcode', 6, 'KAE092'), - record.Field('naam_transactiecode', 18), - record.NumberField('totaalbedrag', 13), - record.Field('rekeningnr_opdrachtgever', 10), - record.NumberField('totaal_rekeningnummers', 5), - record.NumberField('aantal_posten', 6), - record.Field('identificatie', 6), - record.DateField('gewenste_verwerkingsdatum', '%y%m%d'), + record.Field('name_transactioncode', 18), + record.NumberField('total_amount', 13), + record.Field('accountno_sender', 10), + record.NumberField('total_accountnos', 5), + record.NumberField('nr_posts', 6), + record.Field('identification', 6), + record.DateField('preferred_execution_date', '%y%m%d'), record.Field('batch_medium', 18), - record.Filler('muntsoort', 3, 'EUR'), + record.Filler('currency', 3, 'EUR'), record.Field('testcode', 1), ] def __init__(self, *args, **kwargs): - super(OpdrachtRecord, self).__init__(*args, **kwargs) + super(OrderRecord, self).__init__(*args, **kwargs) self.batch_medium = 'DATACOM' - self.naam_transactiecode = self._transactiecode + self.name_transactioncode = self._transactioncode -class SalarisbetalingsOpdracht(OpdrachtRecord): +class SalaryPaymentOrder(OrderRecord): '''Salary payment batch record''' - _transactiecode = 'SALARIS' + _transactioncode = 'SALARIS' -class BetalingsOpdracht(OpdrachtRecord): +class PaymentOrder(OrderRecord): '''Payment batch record''' - _transactiecode = 'CREDBET' + _transactioncode = 'CREDBET' -class IncassoOpdracht(OpdrachtRecord): +class DirectDebitOrder(OrderRecord): '''Direct debit payments batch record''' - _transactiecode = 'INCASSO' + _transactioncode = 'INCASSO' class Optional(object): '''Auxilliary class to handle optional records''' @@ -218,135 +219,135 @@ class Optional(object): '''Make sure to adapt''' return self._guts.__iter__() -class OpdrachtenFile(object): +class OrdersFile(object): '''A payment orders file''' def __init__(self, *args, **kwargs): - self.opdrachten = [] + self.orders = [] @property def rawdata(self): ''' Return a writeable file content object ''' - return '\r\n'.join(self.opdrachten) + return '\r\n'.join(self.orders) -class Transactie(object): +class Transaction(object): '''Generic transaction class''' - def __init__(self, soort=0, naam=None, referentie=None, omschrijvingen=[], - rekeningnr_begunstigde=None, rekeningnr_betaler=None, - bedrag=0 + def __init__(self, type_=0, name=None, reference=None, messages=[], + accountno_beneficiary=None, accountno_payer=None, + amount=0 ): - self.transactie = TransactieRecord() - self.betalingskenmerk = Optional(BetalingskenmerkRecord) - self.omschrijving = Optional(OmschrijvingRecord, 4) - self.transactie.transactiesoort = soort - self.transactie.rekeningnr_begunstigde = rekeningnr_begunstigde - self.transactie.rekeningnr_betaler = rekeningnr_betaler - self.transactie.bedrag = int(bedrag * 100) - if referentie: - self.betalingskenmerk.betalingskenmerk = referentie - for oms in omschrijvingen: - self.omschrijving.omschrijving = oms - self.naam.naam = naam + self.transaction = TransactionRecord() + self.paymentreference = Optional(PaymentReferenceRecord) + self.description = Optional(DescriptionRecord, 4) + self.transaction.transactiontype = type_ + self.transaction.accountno_beneficiary = accountno_beneficiary + self.transaction.accountno_payer = accountno_payer + self.transaction.amount = int(amount * 100) + if reference: + self.paymentreference.paymentreference = reference + for msg in messages: + self.description.description = msg + self.name.name = name -class Incasso(Transactie): +class DirectDebit(Transaction): '''Direct Debit Payment transaction''' def __init__(self, *args, **kwargs): - reknr = kwargs['rekeningnr_betaler'] - kwargs['soort'] = len(reknr.lstrip('0')) <= 7 and 1002 or 1001 - self.naam = NaamBetalerRecord() - super(Incasso, self).__init__(*args, **kwargs) + reknr = kwargs['accountno_payer'] + kwargs['type_'] = len(reknr.lstrip('0')) <= 7 and 1002 or 1001 + self.name = NamePayerRecord() + super(DirectDebit, self).__init__(*args, **kwargs) @property def rawdata(self): ''' Return self as writeable file content object ''' - items = [str(self.transactie)] - if self.naam: - items.append(str(self.naam)) - for kenmerk in self.betalingskenmerk: + items = [str(self.transaction)] + if self.name: + items.append(str(self.name)) + for kenmerk in self.paymentreference: items.append(str(kenmerk)) - for omschrijving in self.omschrijving: - items.append(str(omschrijving)) + for description in self.description: + items.append(str(description)) return '\r\n'.join(items) -class Betaling(Transactie): +class Payment(Transaction): '''Payment transaction''' def __init__(self, *args, **kwargs): - reknr = kwargs['rekeningnr_begunstigde'] + reknr = kwargs['accountno_beneficiary'] if len(reknr.lstrip('0')) > 7: - if not elfproef(reknr): + if not eleven_test(reknr): raise ValueError, '%s is not a valid bank account' % reknr - kwargs['soort'] = 5 - self.naam = NaamBegunstigdeRecord() - super(Betaling, self).__init__(*args, **kwargs) + kwargs['type_'] = 5 + self.name = NameBeneficiaryRecord() + super(Payment, self).__init__(*args, **kwargs) @property def rawdata(self): ''' Return self as writeable file content object ''' - items = [str(self.transactie)] - for kenmerk in self.betalingskenmerk: + items = [str(self.transaction)] + for kenmerk in self.paymentreference: items.append(str(kenmerk)) - if self.naam: - items.append(str(self.naam)) - for omschrijving in self.omschrijving: - items.append(str(omschrijving)) + if self.name: + items.append(str(self.name)) + for description in self.description: + items.append(str(description)) return '\r\n'.join(items) -class SalarisBetaling(Betaling): +class SalaryPayment(Payment): '''Salary Payment transaction''' def __init__(self, *args, **kwargs): - reknr = kwargs['rekeningnr_begunstigde'] - kwargs['soort'] = len(reknr.lstrip('0')) <= 7 and 3 or 8 - super(SalarisBetaling, self).__init__(*args, **kwargs) + reknr = kwargs['accountno_beneficiary'] + kwargs['type_'] = len(reknr.lstrip('0')) <= 7 and 3 or 8 + super(SalaryPayment, self).__init__(*args, **kwargs) class Batch(object): '''Generic batch class''' - transactieclass = None + transactionclass = None - def __init__(self, opdrachtgever, rekeningnr, verwerkingsdatum=None, - test=True, omschrijvingen=[], transactiegroep=None, - batchvolgnummer=1, batch_id='' + def __init__(self, sender, rekeningnr, execution_date=None, + test=True, messages=[], transactiongroup=None, + batch_tracer=1, batch_id='' ): self.header = BatchHeaderRecord() - self.vaste_omschrijving = Optional(VasteOmschrijvingRecord, 4) - self.opdrachtgever = OpdrachtgeverRecord() + self.fixed_message = Optional(FixedMessageRecord, 4) + self.sender = SenderRecord() self.footer = BatchFooterRecord() self.header.variantcode = batch_id and 'C' or 'B' - self.header.transactiegroep = transactiegroep - self.header.batchvolgnummer = batchvolgnummer + self.header.transactiongroup = transactiongroup + self.header.batch_tracer = batch_tracer self.header.batch_id = batch_id - self.header.rekeningnr_opdrachtgever = rekeningnr - self.opdrachtgever.naam_opdrachtgever = opdrachtgever - self.opdrachtgever.gewenste_verwerkingsdatum = verwerkingsdatum - self.opdrachtgever.NAWcode = 1 - self.opdrachtgever.testcode = test and 'T' or 'P' - self.transacties = [] - for omschrijving in omschrijvingen: - self.vaste_omschrijving.omschrijving = omschrijving + self.header.accountno_sender = rekeningnr + self.sender.name_sender = sender + self.sender.preferred_execution_date = execution_date + self.sender.NAWcode = 1 + self.sender.testcode = test and 'T' or 'P' + self.transactions = [] + for message in messages: + self.fixed_message.fixed_message = message @property - def aantal_posten(self): + def nr_posts(self): '''nr of posts''' - return len(self.transacties) + return len(self.transactions) @property - def totaalbedrag(self): + def total_amount(self): '''total amount transferred''' - return reduce(lambda x,y: x + int(y.transactie.bedrag), - self.transacties, 0 + return reduce(lambda x,y: x + int(y.transaction.amount), + self.transactions, 0 ) @property - def totaal_rekeningnummers(self): + def total_accountnos(self): '''check number on account numbers''' return reduce(lambda x,y: - x + int(y.transactie.rekeningnr_betaler) + \ - int(y.transactie.rekeningnr_begunstigde), - self.transacties, 0 + x + int(y.transaction.accountno_payer) + \ + int(y.transaction.accountno_beneficiary), + self.transactions, 0 ) @property @@ -354,49 +355,49 @@ class Batch(object): ''' Return self as writeable file content object ''' - self.footer.aantal_posten = self.aantal_posten - self.footer.totaalbedrag = self.totaalbedrag - self.footer.totaal_rekeningnummers = self.totaal_rekeningnummers + self.footer.nr_posts = self.nr_posts + self.footer.total_amount = self.total_amount + self.footer.total_accountnos = self.total_accountnos lines = [str(self.header)] - for oms in self.vaste_omschrijving: - lines.append(str(oms)) + for msg in self.fixed_message: + lines.append(str(msg)) lines += [ - str(self.opdrachtgever), - '\r\n'.join([x.rawdata for x in self.transacties]), + str(self.sender), + '\r\n'.join([x.rawdata for x in self.transactions]), str(self.footer) ] return '\r\n'.join(lines) - def transactie(self, *args, **kwargs): + def transaction(self, *args, **kwargs): '''generic factory method''' - retval = self.transactieclass(*args, **kwargs) - self.transacties.append(retval) + retval = self.transactionclass(*args, **kwargs) + self.transactions.append(retval) return retval -class IncassoBatch(Batch): +class DirectDebitBatch(Batch): '''Direct Debig Payment batch''' - transactieclass = Incasso + transactionclass = DirectDebit -class BetalingsBatch(Batch): +class PaymentsBatch(Batch): '''Payment batch''' - transactieclass = Betaling + transactionclass = Payment class SalarisBatch(Batch): '''Salary payment class''' - transactieclass = SalarisBetaling + transactionclass = SalaryPayment class ClieOpFile(object): '''The grand unifying class''' - def __init__(self, identificatie='1', uitvoeringsdatum=None, - naam_opdrachtgever='', rekeningnr_opdrachtgever='', + def __init__(self, identification='1', execution_date=None, + name_sender='', accountno_sender='', test=False, **kwargs): - self.header = HeaderRecord(id=identificatie,) + self.header = HeaderRecord(id=identification,) self.footer = FooterRecord() self.batches = [] - self._uitvoeringsdatum = uitvoeringsdatum - self._identificatie = identificatie - self._naam_opdrachtgever = naam_opdrachtgever - self._reknr_opdrachtgever = rekeningnr_opdrachtgever + self._execution_date = execution_date + self._identification = identification + self._name_sender = name_sender + self._accno_sender = accountno_sender self._test = test @property @@ -410,51 +411,51 @@ class ClieOpFile(object): def batch(self, *args, **kwargs): '''Create batch''' - kwargs['transactiegroep'] = self.transactiegroep - kwargs['batchvolgnummer'] = len(self.batches) +1 - kwargs['verwerkingsdatum'] = self._uitvoeringsdatum + kwargs['transactiongroup'] = self.transactiongroup + kwargs['batch_tracer'] = len(self.batches) +1 + kwargs['execution_date'] = self._execution_date kwargs['test'] = self._test - args = (self._naam_opdrachtgever, self._reknr_opdrachtgever) + args = (self._name_sender, self._accno_sender) retval = self.batchclass(*args, **kwargs) self.batches.append(retval) return retval @property - def opdracht(self): + def order(self): '''Produce an order to go with the file''' - totaal_rekeningnummers = 0 - totaalbedrag = 0 - aantal_posten = 0 + total_accountnos = 0 + total_amount = 0 + nr_posts = 0 for batch in self.batches: - totaal_rekeningnummers += batch.totaal_rekeningnummers - totaalbedrag += batch.totaalbedrag - aantal_posten += batch.aantal_posten - retval = self.opdrachtclass() - retval.identificatie = self._identificatie - retval.rekeningnr_opdrachtgever = self._reknr_opdrachtgever - retval.gewenste_verwerkingsdatum = self._uitvoeringsdatum + total_accountnos += batch.total_accountnos + total_amount += batch.total_amount + nr_posts += batch.nr_posts + retval = self.orderclass() + retval.identification = self._identification + retval.accountno_sender = self._accno_sender + retval.preferred_execution_date = self._execution_date retval.testcode = self._test and 'T' or 'P' - retval.totaalbedrag = totaalbedrag - retval.aantal_posten = aantal_posten - retval.totaal_rekeningnummers = totaal_rekeningnummers + retval.total_amount = total_amount + retval.nr_posts = nr_posts + retval.total_accountnos = total_accountnos return retval -class IncassoFile(ClieOpFile): +class DirectDebitFile(ClieOpFile): '''Direct Debit Payments file''' - transactiegroep = '10' - batchclass = IncassoBatch - opdrachtclass = IncassoOpdracht + transactiongroup = '10' + batchclass = DirectDebitBatch + orderclass = DirectDebitOrder -class BetalingsFile(ClieOpFile): +class PaymentsFile(ClieOpFile): '''Payments file''' - transactiegroep = '00' - batchclass = BetalingsBatch - opdrachtclass = BetalingsOpdracht + transactiongroup = '00' + batchclass = PaymentsBatch + orderclass = PaymentOrder -class SalarisFile(BetalingsFile): +class SalaryPaymentsFile(PaymentsFile): '''Salary Payments file''' batchclass = SalarisBatch - opdrachtclass = SalarisbetalingsOpdracht + orderclass = SalaryPaymentOrder # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: diff --git a/account_banking_nl_clieop/wizard/export_clieop.py b/account_banking_nl_clieop/wizard/export_clieop.py index 21a115a92..d32bb4885 100644 --- a/account_banking_nl_clieop/wizard/export_clieop.py +++ b/account_banking_nl_clieop/wizard/export_clieop.py @@ -22,9 +22,10 @@ import wizard import pooler import base64 +from datetime import datetime, date, timedelta +from account_banking import sepa #from osv import osv from tools.translate import _ -from datetime import datetime, date, timedelta #import pdb; pdb.set_trace() import clieop @@ -102,6 +103,7 @@ file_form = ''' + ''' @@ -252,60 +254,80 @@ def _create_clieop(self, cursor, uid, data, context): payment_orders = payment_order_obj.browse(cursor, uid, data['ids']) for payment_order in payment_orders: if not clieopfile: + # Just once: create clieop file our_account_owner = payment_order.mode.bank_id.owner_name our_account_nr = payment_order.mode.bank_id.acc_number - clieopfile = {'CLIEOPPAY': clieop.BetalingsFile, - 'CLIEOPINC': clieop.IncassoFile, - 'CLIEOPSAL': clieop.SalarisFile, + if not our_account_nr and payment_order.mode.bank_id.iban: + our_account_nr = sepa.IBAN( + payment_order.mode.bank_id.iban + ).localized_BBAN + if not our_account_nr: + raise wizard.except_wizard( + _('Error'), + _('Your bank account has to have a valid account number') + ) + clieopfile = {'CLIEOPPAY': clieop.PaymentsFile, + 'CLIEOPINC': clieop.DirectDebitFile, + 'CLIEOPSAL': clieop.SalaryPaymentsFile, }[form['batchtype']]( - identificatie = form['reference'], - uitvoeringsdatum = form['execution_date'], - naam_opdrachtgever = our_account_owner, - rekeningnr_opdrachtgever = our_account_nr, + identification = form['reference'], + execution_date = form['execution_date'], + name_sender = our_account_owner, + accountno_sender = our_account_nr, test = form['test'] ) + # As payment_orders can have multiple transactions, create a new batch + # for each payment_order if form['fixed_message']: - omschrijvingen = [form['fixed_message']] + messages = [form['fixed_message']] else: - omschrijvingen = [] + messages = [] batch = clieopfile.batch( - #our_account_owner, - #our_account_nr, - #verwerkingsdatum = strpdate(form['execution_date']), - #test = form['test'], - omschrijvingen = omschrijvingen, + messages = messages, batch_id = payment_order.reference ) + for line in payment_order.line_ids: kwargs = dict( - naam = line.bank_id.owner_name, - bedrag = line.amount_currency, - referentie = line.communication or None, + name = line.bank_id.owner_name, + amount = line.amount_currency, + reference = line.communication or None, ) if line.communication2: - kwargs['omschrijvingen'] = [line.communication2] - if form['batchtype'] in ['CLIEOPPAY', 'CLIEOPSAL']: - kwargs['rekeningnr_begunstigde'] = line.bank_id.acc_number - kwargs['rekeningnr_betaler'] = our_account_nr + kwargs['messages'] = [line.communication2] + other_account_nr = line.bank_id.acc_number + iban = sepa.IBAN(other_account_nr) + if iban.valid: + if iban.countrycode != 'NL': + raise wizard.except_wizard( + _('Error'), + _('You cannot send international bank transfers ' + 'through ClieOp3!') + ) + other_account_nr = iban.localized_BBAN + if form['batchtype'] == 'CLIEOPINC': + kwargs['accountno_beneficiary'] = our_account_nr + kwargs['accountno_payer'] = other_account_nr else: - kwargs['rekeningnr_begunstigde'] = our_account_nr - kwargs['rekeningnr_betaler'] = line.bank_id.acc_number - transaction = batch.transactie(**kwargs) + kwargs['accountno_beneficiary'] = other_account_nr + kwargs['accountno_payer'] = our_account_nr + transaction = batch.transaction(**kwargs) - opdracht = clieopfile.opdracht + # Generate the specifics of this clieopfile + order = clieopfile.order values = dict( - filetype = opdracht.naam_transactiecode, - identification = opdracht.identificatie, - prefered_date = strfdate(opdracht.gewenste_verwerkingsdatum), - total_amount = int(opdracht.totaalbedrag) / 100.0, - check_no_accounts = opdracht.totaal_rekeningnummers, - no_transactions = opdracht.aantal_posten, - testcode = opdracht.testcode, + filetype = order.name_transactioncode, + identification = order.identification, + prefered_date = strfdate(order.preferred_execution_date), + total_amount = int(order.total_amount) / 100.0, + check_no_accounts = order.total_accountnos, + no_transactions = order.nr_posts, + testcode = order.testcode, file = base64.encodestring(clieopfile.rawdata), ) form.update(values) - values['daynumber'] = int(clieopfile.header.bestands_id[2:]) + values['daynumber'] = int(clieopfile.header.file_id[2:]) values['payment_order_ids'] = ','.join(map(str, data['ids'])) data['file_id'] = pool.get('banking.export.clieop').create(cursor, uid, values) data['clieop'] = clieopfile @@ -313,11 +335,17 @@ def _create_clieop(self, cursor, uid, data, context): return form def _cancel_clieop(self, cursor, uid, data, context): + ''' + Cancel the ClieOp: just drop the file + ''' pool = pooler.get_pool(cursor.dbname) pool.get('banking.export.clieop').unlink(cursor, uid, data['file_id']) return {'state': 'end'} def _save_clieop(self, cursor, uid, data, context): + ''' + Save the ClieOp: mark all payments in the file as 'sent'. + ''' pool = pooler.get_pool(cursor.dbname) clieop_obj = pool.get('banking.export.clieop') payment_order_obj = pool.get('payment.order')