mirror of
https://github.com/OCA/bank-payment.git
synced 2025-02-02 10:37:31 +02:00
356 lines
14 KiB
Python
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:
|