Files
bank-payment/account_banking_nl_girotel/girotel.py

356 lines
14 KiB
Python

# -*- encoding: utf-8 -*-
##############################################################################
#
# Copyright (C) 2009 EduSense BV (<http://www.edusense.nl>).
# All Rights Reserved
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero 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 Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
##############################################################################
'''
This parser follows the Dutch Girotel specifications which are
empirically recreated in this module.
There is very little information for validating the format or the content
within.
Dutch Girotel uses no concept of 'Afschrift' or Bank Statement.
To overcome a lot of problems, this module generates reproducible Bank
Staments per months period.
Transaction ID's are missing, but generated on the fly based on transaction
date and sequence position therein.
Assumptions:
1. transactions are sorted in ascending order of date.
2. new transactions are appended after previously known transactions of
the same date
3. banks maintain order in transaction lists within a single date
4. the data comes from the SWIFT-network (limited ASCII)
Assumption 4 seems not always true, leading to wrong character conversions.
As a counter measure, all imported data is converted to SWIFT-format before usage.
'''
from account_banking.parsers import models
from account_banking.parsers.convert import str2date, to_swift
from tools.translate import _
import re
import csv
bt = models.mem_bank_transaction
__all__ = ['parser']
class transaction_message(object):
'''
A auxiliary class to validate and coerce read values
'''
attrnames = [
'local_account', 'date', 'transfer_type', 'u1',
'remote_account', 'remote_owner', 'u2', 'transferred_amount',
'direction', 'u3', 'message', 'remote_currency',
]
# Attributes with possible non-ASCII string content
strattrs = [
'remote_owner', 'message'
]
ids = {}
def __setattribute__(self, attr, value):
'''
Convert values for string content to SWIFT-allowable content
'''
if attr != 'strattrs' and attr in self.strattrs:
value = to_swift(value)
super(transaction_message, self).__setattribute__(attr, value)
def __getattribute__(self, attr):
'''
Convert values from string content to SWIFT-allowable content
'''
retval = super(transaction_message, self).__getattribute__(attr)
return attr != 'strattrs' and attr in self.strattrs and to_swift(retval) or retval
def genid(self):
'''
Generate a new id when not assigned before
'''
if (not hasattr(self, 'id')) or not self.id:
if self.date in self.ids:
self.ids[self.date] += 1
else:
self.ids[self.date] = 1
self.id = self.date.strftime('%%Y%%m%%d%04d' % self.ids[self.date])
def __init__(self, values):
'''
Initialize own dict with attributes and coerce values to right type
'''
if len(self.attrnames) != len(values):
raise ValueError, \
_('Invalid transaction line: expected %d columns, found %d') \
% (len(self.attrnames), len(values))
self.__dict__.update(dict(zip(self.attrnames, values)))
self.date = str2date(self.date, '%Y%m%d')
if self.direction == 'A':
self.transferred_amount = -float(self.transferred_amount)
if (self.transfer_type == 'VZ'
and (not self.remote_account or self.remote_account == '0')
and (not self.message or re.match('^\s*$', self.message))
and self.remote_owner.startswith('TOTAAL ')):
self.transfer_type = 'PB'
self.message = self.remote_owner
self.remove_owner = False
else:
self.transferred_amount = float(self.transferred_amount)
self.local_account = self.local_account.zfill(10)
if self.transfer_type != 'DV':
self.remote_account = self.remote_account.zfill(10)
else:
self.remote_account = False
self.execution_date = self.effective_date = self.date
self.remote_owner = self.remote_owner.rstrip()
self.message = self.message.rstrip()
self.genid()
@property
def statement_id(self):
'''Return calculated statement id'''
return self.id[:6]
class transaction(models.mem_bank_transaction):
'''
Implementation of transaction communication class for account_banking.
'''
attrnames = [ 'statement_id', 'remote_account', 'remote_owner',
'remote_currency', 'transferred_amount', 'execution_date',
'effective_date', 'transfer_type', 'message',
]
type_map = {
'BA': bt.PAYMENT_TERMINAL,
'BT': bt.ORDER,
'DV': bt.BANK_COSTS,
'GM': bt.BANK_TERMINAL,
'GT': bt.ORDER,
'IC': bt.DIRECT_DEBIT,
'OV': bt.ORDER,
'VZ': bt.ORDER,
'PB': bt.PAYMENT_BATCH,
}
def __init__(self, line, *args, **kwargs):
'''
Initialize own dict with read values.
'''
super(transaction, self).__init__(*args, **kwargs)
for attr in self.attrnames:
setattr(self, attr, getattr(line, attr))
self.id = line.id.replace(line.statement_id, '')
self.reference = self.message[:32].rstrip()
self.parse_message()
def is_valid(self):
'''
There are a few situations that can be signaled as 'invalid' but are
valid nontheless:
1. Invoices from the bank itself are communicated through statements.
These too have no remote_account and no remote_owner. They have a
transfer_type set to 'DV'.
2. Transfers sent through the 'International Transfers' system get
their feedback rerouted through a statement, which is not designed to
hold the extra fields needed. These transfers have their transfer_type
set to 'BT'.
3. Cash payments with debit cards are not seen as a transfer between
accounts, but as a cash withdrawal. These withdrawals have their
transfer_type set to 'BA'.
4. Cash withdrawals from banks are too not seen as a transfer between
two accounts - the cash exits the banking system. These withdrawals
have their transfer_type set to 'GM'.
5. Aggregated payment batches. These transactions have transfer type
'VZ' natively but are changed to 'PB' while parsing. These transactions
have no remote account.
'''
return bool(self.transferred_amount and self.execution_date and (
self.remote_account or
self.transfer_type in [
'DV', 'PB', 'BT', 'BA', 'GM',
]))
def refold_message(self, message):
'''
Refold a previously chopped and fixed length message back into one
line
'''
msg, message = message.rstrip(), None
parts = [msg[i:i+32].rstrip() for i in range(0, len(msg), 32)]
return '\n'.join(parts)
def parse_message(self):
'''
Parse the message as sent by the bank. Most messages are composed
of chunks of 32 characters, but there are exceptions.
'''
if self.transfer_type == 'VZ':
# Credit bank costs (interest) gets a special treatment.
if self.remote_owner.startswith('RC AFREK. REK. '):
self.transfer_type = 'DV'
if self.transfer_type == 'DV':
# Bank costs.
# Title of action is in remote_owner, message contains additional
# info
self.reference = self.remote_owner.rstrip()
parts = [self.message[i:i+32].rstrip()
for i in range(0, len(self.message), 32)
]
if len(parts) > 3:
self.reference = parts[-1]
self.message = '\n'.join(parts[:-1])
else:
self.message = '\n'.join(parts)
self.remote_owner = ''
elif self.transfer_type == 'BA':
# Payment through bank terminal
# Id of terminal and some owner info is part of message
if self.execution_date < str2date('20091130', '%Y%m%d'):
parts = self.remote_owner.split('>')
else:
parts = self.remote_owner.split('>\\')
self.remote_owner = ' '.join(parts[0].split()[1:])
if len(parts) > 1 and len(parts[1]) > 2:
self.remote_owner_city = parts[1]
self.message = self.refold_message(self.message)
self.reference = '%s %s' % (self.remote_owner,
' '.join(self.message.split()[2:4])
)
elif self.transfer_type == 'IC':
# Direct debit - remote_owner containts reference, while
# remote_owner is part of the message, most often as
# first part of the message.
# Sometimes this misfires, as with the tax office collecting road
# taxes, but then a once-only manual correction is sufficient.
parts = [self.message[i:i+32].rstrip()
for i in range(0, len(self.message), 32)
]
self.reference = self.remote_owner
if not parts:
return
if self.reference.startswith('KN: '):
self.reference = self.reference[4:]
if parts[0] == self.reference:
parts = parts[1:]
# The tax administration office seems to be the notorious exception
# to the rule
if parts[-1] == 'BELASTINGDIENST':
self.remote_owner = parts[-1].capitalize()
parts = parts[:-1]
else:
self.remote_owner = parts[0]
parts = parts[1:]
# Leave the message, to assist in manual correction of misfires
self.message = '\n'.join(parts)
elif self.transfer_type == 'GM':
# Cash withdrawal from a bank terminal
# Structured remote_owner message containing bank info and location
if self.remote_owner.startswith('OPL. CHIPKNIP'):
# Transferring cash to debit card
self.remote_account = self.local_account
self.message = '%s: %s' % (self.remote_owner, self.message)
else:
if self.execution_date < str2date('20091130', '%Y%m%d'):
parts = self.remote_owner.split('>')
else:
parts = self.remote_owner.split('>\\')
if len(parts) > 1:
self.reference = ' '.join([x.rstrip() for x in parts])
else:
self.reference = 'ING BANK NV %s' % parts[0].split(' ')[0]
self.remote_owner = ''
elif self.transfer_type == 'GT':
# Normal transaction, but remote_owner can contain city, depending
# on length of total. As there is no clear pattern, leave it as
# is.
self.message = self.refold_message(self.message)
else:
# Final default: reconstruct message from chopped fixed length
# message parts.
self.message = self.refold_message(self.message)
class statement(models.mem_bank_statement):
'''
Implementation of bank_statement communication class of account_banking
'''
def __init__(self, msg, start_balance=0.0, *args, **kwargs):
'''
Set decent start values based on first transaction read
'''
super(statement, self).__init__(*args, **kwargs)
self.id = msg.statement_id
self.local_account = msg.local_account
self.date = msg.date
self.start_balance = self.end_balance = start_balance
self.import_transaction(msg)
def import_transaction(self, msg):
'''
Import a transaction and keep some house holding in the mean time.
'''
trans = transaction(msg)
self.end_balance += trans.transferred_amount
self.transactions.append(trans)
class parser(models.parser):
code = 'NLGT'
name = _('Dutch Girotel - Kommagescheiden')
country_code = 'NL'
doc = _('''\
The Dutch Girotel - Kommagescheiden format is basicly a MS Excel CSV format.
''')
def parse(self, cr, data):
result = []
stmnt = None
dialect = csv.excel()
dialect.quotechar = '"'
dialect.delimiter = ','
lines = data.split('\n')
start_balance = 0.0
for line in csv.reader(lines, dialect=dialect):
# Skip empty (last) lines
if not line:
continue
msg = transaction_message(line)
if stmnt and stmnt.id != msg.statement_id:
start_balance = stmnt.end_balance
result.append(stmnt)
stmnt = None
if not stmnt:
stmnt = statement(msg, start_balance=start_balance)
else:
stmnt.import_transaction(msg)
result.append(stmnt)
return result
# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: