mirror of
https://github.com/OCA/bank-payment.git
synced 2025-02-02 10:37:31 +02:00
989 lines
43 KiB
Python
989 lines
43 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 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 <http://www.gnu.org/licenses/>.
|
|
#
|
|
##############################################################################
|
|
# Many thanks to our contributors
|
|
#
|
|
# Kaspars Vilkens (KNdati): lenghty discussions, bugreports and bugfixes
|
|
# Stefan Rijnhart (Therp): bugreport and bugfix
|
|
#
|
|
'''
|
|
This module contains the business logic of the wizard account_banking_import.
|
|
The parsing is done in the parser modules. Every parser module is required to
|
|
use parser.models as a mean of communication with the business logic.
|
|
'''
|
|
from osv import osv, fields
|
|
import time
|
|
import netsvc
|
|
import base64
|
|
import datetime
|
|
from tools import config
|
|
from tools.translate import _
|
|
from account_banking.parsers import models
|
|
from account_banking.parsers.convert import *
|
|
from account_banking.struct import struct
|
|
from account_banking import sepa
|
|
from banktools import *
|
|
import decimal_precision as dp
|
|
|
|
bt = models.mem_bank_transaction
|
|
|
|
# This variable is used to match supplier invoices with an invoice date after
|
|
# the real payment date. This can occur with online transactions (web shops).
|
|
payment_window = datetime.timedelta(days=10)
|
|
|
|
def parser_types(*args, **kwargs):
|
|
'''Delay evaluation of parser types until start of wizard, to allow
|
|
depending modules to initialize and add their parsers to the list
|
|
'''
|
|
return models.parser_type.get_parser_types()
|
|
|
|
class banking_import(osv.osv_memory):
|
|
_name = 'account.banking.bank.import'
|
|
|
|
def _get_move_info(self, cursor, uid, move_line, partner_bank_id=False,
|
|
partial=False):
|
|
reconcile_obj = self.pool.get('account.move.reconcile')
|
|
type_map = {
|
|
'out_invoice': 'customer',
|
|
'in_invoice': 'supplier',
|
|
'out_refund': 'customer',
|
|
'in_refund': 'supplier',
|
|
}
|
|
retval = struct(move_line=move_line, partner_id=move_line.partner_id.id,
|
|
partner_bank_id=partner_bank_id,
|
|
reference=move_line.ref
|
|
)
|
|
if move_line.invoice:
|
|
retval.invoice = move_line.invoice
|
|
retval.type = type_map[move_line.invoice.type]
|
|
else:
|
|
retval.type = 'general'
|
|
|
|
if partial:
|
|
move_line.reconcile_partial_id = reconcile_obj.create(
|
|
cursor, uid, {
|
|
'type': 'auto',
|
|
'line_partial_ids': [(4, 0, [move_line.id])]
|
|
}
|
|
)
|
|
else:
|
|
if move_line.reconcile_partial_id:
|
|
partial_ids = [x.id for x in
|
|
move_line.reconcile_partial_id.line_partial_ids
|
|
]
|
|
else:
|
|
partial_ids = []
|
|
move_line.reconcile_id = reconcile_obj.create(
|
|
cursor, uid, {
|
|
'type': 'auto',
|
|
'line_id': [
|
|
(4, x, False) for x in [move_line.id] + partial_ids
|
|
],
|
|
'line_partial_ids': [
|
|
(3, x, False) for x in partial_ids
|
|
]
|
|
}
|
|
)
|
|
return retval
|
|
|
|
def _link_payment(self, cursor, uid, trans, payment_lines,
|
|
partner_ids, bank_account_ids, log, linked_payments):
|
|
'''
|
|
Find the payment order belonging to this reference - if there is one
|
|
This is the easiest part: when sending payments, the returned bank info
|
|
should be identical to ours.
|
|
'''
|
|
# TODO: Not sure what side effects are created when payments are done
|
|
# for credited customer invoices, which will be matched later on too.
|
|
digits = dp.get_precision('Account')(cursor)[1]
|
|
candidates = [x for x in payment_lines
|
|
if x.communication == trans.reference
|
|
and round(x.amount, digits) == -round(trans.transferred_amount, digits)
|
|
and trans.remote_account in (x.bank_id.acc_number,
|
|
x.bank_id.iban)
|
|
]
|
|
if len(candidates) == 1:
|
|
candidate = candidates[0]
|
|
# Check cache to prevent multiple matching of a single payment
|
|
if candidate.id not in linked_payments:
|
|
linked_payments[candidate.id] = True
|
|
payment_line_obj = self.pool.get('payment.line')
|
|
payment_line_obj.write(cursor, uid, [candidate.id], {
|
|
'export_state': 'done',
|
|
'date_done': trans.effective_date.strftime('%Y-%m-%d')}
|
|
)
|
|
|
|
return self._get_move_info(cursor, uid, candidate.move_line_id)
|
|
|
|
return False
|
|
|
|
def _link_invoice(self, cursor, uid, trans, move_lines,
|
|
partner_ids, bank_account_ids, log, linked_invoices):
|
|
'''
|
|
Find the invoice belonging to this reference - if there is one
|
|
Use the sales journal to check.
|
|
|
|
Challenges we're facing:
|
|
1. The sending or receiving party is not necessarily the same as the
|
|
partner the payment relates to.
|
|
2. References can be messed up during manual encoding and inexact
|
|
matching can link the wrong invoices.
|
|
3. Amounts can or can not match the expected amount.
|
|
4. Multiple invoices can be paid in one transaction.
|
|
.. There are countless more, but these we'll try to address.
|
|
|
|
Assumptions for matching:
|
|
1. There are no payments for invoices not sent. These are dealt with
|
|
later on.
|
|
2. Debit amounts are either customer invoices or credited supplier
|
|
invoices.
|
|
3. Credit amounts are either supplier invoices or credited customer
|
|
invoices.
|
|
4. Payments are either below expected amount or only slightly above
|
|
(abs).
|
|
5. Payments from partners that are matched, pay their own invoices.
|
|
|
|
Worst case scenario:
|
|
1. No match was made.
|
|
No harm done. Proceed with manual matching as usual.
|
|
2. The wrong match was made.
|
|
Statements are encoded in draft. You will have the opportunity to
|
|
manually correct the wrong assumptions.
|
|
|
|
Return values:
|
|
move_info: the move_line information belonging to the matched
|
|
invoice
|
|
new_trans: the new transaction when the current one was split.
|
|
This can happen when multiple invoices were paid with a single
|
|
bank transaction.
|
|
'''
|
|
def eyecatcher(invoice):
|
|
'''
|
|
Return the eyecatcher for an invoice
|
|
'''
|
|
return invoice.type.startswith('in_') and invoice.name or \
|
|
invoice.number
|
|
|
|
def has_id_match(invoice, ref, msg):
|
|
'''
|
|
Aid for debugging - way more comprehensible than complex
|
|
comprehension filters ;-)
|
|
|
|
Match on ID of invoice (reference, name or number, whatever
|
|
available and sensible)
|
|
'''
|
|
if invoice.reference:
|
|
# Reference always comes first, as it is manually set for a
|
|
# reason.
|
|
iref = invoice.reference.upper()
|
|
if iref in ref or iref in msg:
|
|
return True
|
|
if invoice.type.startswith('in_'):
|
|
# Internal numbering, no likely match on number
|
|
if invoice.name:
|
|
iname = invoice.name.upper()
|
|
if iname in ref or iname in msg:
|
|
return True
|
|
elif invoice.type.startswith('out_'):
|
|
# External id's possible and likely
|
|
inum = invoice.number.upper()
|
|
if inum in ref or inum in msg:
|
|
return True
|
|
|
|
return False
|
|
|
|
def _cached(move_line):
|
|
'''Check if the move_line has been cached'''
|
|
return move_line.id in linked_invoices
|
|
|
|
def _cache(move_line, remaining=0.0):
|
|
'''Cache the move_line'''
|
|
linked_invoices[move_line.id] = remaining
|
|
|
|
def _remaining(move_line):
|
|
'''Return the remaining amount for a previously matched move_line
|
|
'''
|
|
return linked_invoices[move_line.id]
|
|
|
|
def _sign(invoice):
|
|
'''Return the direction of an invoice'''
|
|
return {'in_invoice': -1,
|
|
'in_refund': 1,
|
|
'out_invoice': 1,
|
|
'out_refund': -1
|
|
}[invoice.type]
|
|
|
|
digits = dp.get_precision('Account')(cursor)[1]
|
|
partial = False
|
|
|
|
# Search invoice on partner
|
|
if partner_ids:
|
|
candidates = [x for x in move_lines
|
|
if x.partner_id.id in partner_ids and
|
|
str2date(x.date, '%Y-%m-%d') <= (trans.execution_date + payment_window)
|
|
and (not _cached(x) or _remaining(x))
|
|
]
|
|
else:
|
|
candidates = []
|
|
|
|
# Next on reference/invoice number. Mind that this uses the invoice
|
|
# itself, as the move_line references have been fiddled with on invoice
|
|
# creation. This also enables us to search for the invoice number in the
|
|
# reference instead of the other way around, as most human interventions
|
|
# *add* text.
|
|
if len(candidates) > 1 or not candidates:
|
|
ref = trans.reference.upper()
|
|
msg = trans.message.upper()
|
|
# The manual usage of the sales journal creates moves that
|
|
# are not tied to invoices. Thanks to Stefan Rijnhart for
|
|
# reporting this.
|
|
candidates = [x for x in candidates or move_lines
|
|
if x.invoice and has_id_match(x.invoice, ref, msg)
|
|
and str2date(x.invoice.date_invoice, '%Y-%m-%d')
|
|
<= (trans.execution_date + payment_window)
|
|
and (not _cached(x) or _remaining(x))
|
|
]
|
|
|
|
# Match on amount expected. Limit this kind of search to known
|
|
# partners.
|
|
if not candidates and partner_ids:
|
|
candidates = [x for x in move_lines
|
|
if round(abs(x.credit or x.debit), digits) ==
|
|
round(abs(trans.transferred_amount), digits)
|
|
and str2date(x.date, '%Y-%m-%d') <=
|
|
(trans.execution_date + payment_window)
|
|
and (not _cached(x) or _remaining(x))
|
|
]
|
|
|
|
move_line = False
|
|
if candidates and len(candidates) > 0:
|
|
# Now a possible selection of invoices has been found, check the
|
|
# amounts expected and received.
|
|
#
|
|
# TODO: currency coercing
|
|
best = [x for x in candidates
|
|
if round(abs(x.credit or x.debit), digits) ==
|
|
round(abs(trans.transferred_amount), digits)
|
|
and str2date(x.date, '%Y-%m-%d') <=
|
|
(trans.execution_date + payment_window)
|
|
]
|
|
if len(best) == 1:
|
|
# Exact match
|
|
move_line = best[0]
|
|
invoice = move_line.invoice
|
|
if _cached(move_line):
|
|
partial = True
|
|
expected = _remaining(move_line)
|
|
else:
|
|
_cache(move_line)
|
|
|
|
elif len(candidates) > 1:
|
|
# Before giving up, check cache for catching duplicate
|
|
# transfers first
|
|
paid = [x for x in move_lines
|
|
if x.invoice and has_id_match(x.invoice, ref, msg)
|
|
and str2date(x.invoice.date_invoice, '%Y-%m-%d')
|
|
<= trans.execution_date
|
|
and (_cached(x) and not _remaining(x))
|
|
]
|
|
if paid:
|
|
log.append(
|
|
_('Unable to link transaction id %(trans)s '
|
|
'(ref: %(ref)s) to invoice: '
|
|
'invoice %(invoice)s was already paid') % {
|
|
'trans': '%s.%s' % (trans.statement_id, trans.id),
|
|
'ref': trans.reference,
|
|
'invoice': eyecatcher(paid[0].invoice)
|
|
})
|
|
else:
|
|
# Multiple matches
|
|
log.append(
|
|
_('Unable to link transaction id %(trans)s (ref: %(ref)s) to invoice: '
|
|
'%(no_candidates)s candidates found; can\'t choose.') % {
|
|
'trans': '%s.%s' % (trans.statement_id, trans.id),
|
|
'ref': trans.reference,
|
|
'no_candidates': len(best) or len(candidates)
|
|
})
|
|
log.append(' ' +
|
|
_('Candidates: %(candidates)s') % {
|
|
'candidates': ', '.join([x.invoice.number
|
|
for x in best or candidates
|
|
])
|
|
})
|
|
move_line = False
|
|
partial = False
|
|
|
|
elif len(candidates) == 1:
|
|
# Mismatch in amounts
|
|
move_line = candidates[0]
|
|
invoice = move_line.invoice
|
|
expected = round(_sign(invoice) * invoice.residual, digits)
|
|
partial = True
|
|
|
|
trans2 = None
|
|
if move_line and partial:
|
|
found = round(trans.transferred_amount, digits)
|
|
if abs(expected) == abs(found):
|
|
partial = False
|
|
# Last partial payment will not flag invoice paid without
|
|
# manual assistence
|
|
invoice_obj = self.pool.get('account.invoice')
|
|
invoice_obj.write(cursor, uid, [invoice.id], {
|
|
'state': 'paid'
|
|
})
|
|
elif abs(expected) > abs(found):
|
|
# Partial payment, reuse invoice
|
|
_cache(move_line, expected - found)
|
|
elif abs(expected) < abs(found):
|
|
# Possible combined payments, need to split transaction to
|
|
# verify
|
|
_cache(move_line)
|
|
trans2 = trans.copy()
|
|
trans2.transferred_amount -= expected
|
|
trans.transferred_amount = expected
|
|
trans.id += 'a'
|
|
trans2.id += 'b'
|
|
# NOTE: the following is debatable. By copying the
|
|
# eyecatcher of the invoice itself, we enhance the
|
|
# tracability of the invoices, but we degrade the
|
|
# tracability of the bank transactions. When debugging, it
|
|
# is wise to disable this line.
|
|
trans.reference = eyecatcher(move_line.invoice)
|
|
|
|
if move_line:
|
|
account_ids = [
|
|
x.id for x in bank_account_ids
|
|
if x.partner_id.id == move_line.partner_id.id
|
|
]
|
|
|
|
return (
|
|
self._get_move_info(cursor, uid, move_line,
|
|
account_ids and account_ids[0] or False,
|
|
partial=(partial and not trans2)
|
|
),
|
|
trans2
|
|
)
|
|
|
|
|
|
return (False, False)
|
|
|
|
def _link_canceled_debit(self, cursor, uid, trans, payment_lines,
|
|
partner_ids, bank_account_ids, log):
|
|
'''
|
|
Direct debit transfers can be canceled by the remote owner within a
|
|
legaly defined time period. These 'payments' are most likely
|
|
already marked 'done', which makes them harder to match. Also the
|
|
reconciliation has to be reversed.
|
|
'''
|
|
# TODO: code _link_canceled_debit
|
|
return False
|
|
|
|
def _link_costs(self, cursor, uid, trans, period_id, account_info, log):
|
|
'''
|
|
Get or create a costs invoice for the bank and return it with
|
|
the payment as seen in the transaction (when not already done).
|
|
'''
|
|
if not account_info.costs_account_id:
|
|
return []
|
|
|
|
digits = dp.get_precision('Account')(cursor)[1]
|
|
amount = round(abs(trans.transferred_amount), digits)
|
|
# Make sure to be able to pinpoint our costs invoice for later
|
|
# matching
|
|
reference = '%s.%s: %s' % (trans.statement_id, trans.id, trans.reference)
|
|
|
|
# search supplier invoice
|
|
invoice_obj = self.pool.get('account.invoice')
|
|
invoice_ids = invoice_obj.search(cursor, uid, [
|
|
'&',
|
|
('type', '=', 'in_invoice'),
|
|
('partner_id', '=', account_info.bank_partner_id.id),
|
|
('company_id', '=', account_info.company_id.id),
|
|
('date_invoice', '=', date2str(trans.effective_date)),
|
|
('reference', '=', reference),
|
|
('amount_total', '=', amount),
|
|
]
|
|
)
|
|
if invoice_ids and len(invoice_ids) == 1:
|
|
invoice = invoice_obj.browse(cursor, uid, invoice_ids)[0]
|
|
elif not invoice_ids:
|
|
# create supplier invoice
|
|
partner_obj = self.pool.get('res.partner')
|
|
invoice_lines = [(0,0,dict(
|
|
amount = 1,
|
|
price_unit = amount,
|
|
name = trans.message or trans.reference,
|
|
account_id = account_info.costs_account_id.id
|
|
))]
|
|
invoice_address_id = partner_obj.address_get(
|
|
cursor, uid, [account_info.bank_partner_id.id], ['invoice']
|
|
)
|
|
invoice_id = invoice_obj.create(cursor, uid, dict(
|
|
type = 'in_invoice',
|
|
company_id = account_info.company_id.id,
|
|
partner_id = account_info.bank_partner_id.id,
|
|
address_invoice_id = invoice_address_id['invoice'],
|
|
period_id = period_id,
|
|
journal_id = account_info.invoice_journal_id.id,
|
|
account_id = account_info.bank_partner_id.property_account_payable.id,
|
|
date_invoice = date2str(trans.effective_date),
|
|
reference_type = 'none',
|
|
reference = reference,
|
|
name = trans.reference or trans.message,
|
|
check_total = amount,
|
|
invoice_line = invoice_lines,
|
|
))
|
|
invoice = invoice_obj.browse(cursor, uid, invoice_id)
|
|
# Create workflow
|
|
invoice_obj.button_compute(cursor, uid, [invoice_id],
|
|
{'type': 'in_invoice'}, set_total=True)
|
|
wf_service = netsvc.LocalService('workflow')
|
|
# Move to state 'open'
|
|
wf_service.trg_validate(uid, 'account.invoice', invoice.id,
|
|
'invoice_open', cursor)
|
|
|
|
# return move_lines to mix with the rest
|
|
return [x for x in invoice.move_id.line_id if x.account_id.reconcile]
|
|
|
|
def import_statements_file(self, cursor, uid, ids, context):
|
|
'''
|
|
Import bank statements / bank transactions file.
|
|
This method represents the business logic, the parser modules
|
|
represent the decoding logic.
|
|
'''
|
|
banking_import = self.browse(cursor, uid, ids, context)[0]
|
|
statements_file = banking_import.file
|
|
data = base64.decodestring(statements_file)
|
|
|
|
company_obj = self.pool.get('res.company')
|
|
user_obj = self.pool.get('res.user')
|
|
partner_bank_obj = self.pool.get('res.partner.bank')
|
|
journal_obj = self.pool.get('account.journal')
|
|
move_line_obj = self.pool.get('account.move.line')
|
|
payment_line_obj = self.pool.get('payment.line')
|
|
statement_obj = self.pool.get('account.bank.statement')
|
|
statement_line_obj = self.pool.get('account.bank.statement.line')
|
|
statement_file_obj = self.pool.get('account.banking.imported.file')
|
|
payment_order_obj = self.pool.get('payment.order')
|
|
currency_obj = self.pool.get('res.currency')
|
|
|
|
# get the parser to parse the file
|
|
parser_code = banking_import.parser
|
|
parser = models.create_parser(parser_code)
|
|
if not parser:
|
|
raise osv.except_osv(
|
|
_('ERROR!'),
|
|
_('Unable to import parser %(parser)s. Parser class not found.') %
|
|
{'parser': parser_code}
|
|
)
|
|
|
|
# Get the company
|
|
company = (banking_import.company or
|
|
user_obj.browse(cursor, uid, uid, context).company_id)
|
|
|
|
# Parse the file
|
|
statements = parser.parse(data)
|
|
|
|
if any([x for x in statements if not x.is_valid()]):
|
|
raise osv.except_osv(
|
|
_('ERROR!'),
|
|
_('The imported statements appear to be invalid! Check your file.')
|
|
)
|
|
|
|
# Create the file now, as the statements need to be linked to it
|
|
import_id = statement_file_obj.create(cursor, uid, dict(
|
|
company_id = company.id,
|
|
file = statements_file,
|
|
state = 'unfinished',
|
|
format = parser.name,
|
|
))
|
|
|
|
# Results
|
|
results = struct(
|
|
stat_loaded_cnt = 0,
|
|
trans_loaded_cnt = 0,
|
|
stat_skipped_cnt = 0,
|
|
trans_skipped_cnt = 0,
|
|
trans_matched_cnt = 0,
|
|
bank_costs_invoice_cnt = 0,
|
|
error_cnt = 0,
|
|
log = [],
|
|
)
|
|
|
|
# Caching
|
|
error_accounts = {}
|
|
info = {}
|
|
imported_statement_ids = []
|
|
linked_payments = {}
|
|
linked_invoices = {}
|
|
payment_lines = []
|
|
|
|
if statements:
|
|
# Get default defaults
|
|
def_pay_account_id = company.partner_id.property_account_payable.id
|
|
def_rec_account_id = company.partner_id.property_account_receivable.id
|
|
|
|
# Get interesting journals once
|
|
journal_ids = journal_obj.search(cursor, uid, [
|
|
('type', 'in', ('sale','purchase',
|
|
'purchase_refund','sale_refund')),
|
|
('company_id', '=', company.id),
|
|
])
|
|
# Get all unreconciled moves predating the last statement in one big
|
|
# swoop. Assumption: the statements in the file are sorted in ascending
|
|
# order of date.
|
|
move_line_ids = move_line_obj.search(cursor, uid, [
|
|
('reconcile_id', '=', False),
|
|
('journal_id', 'in', journal_ids),
|
|
('account_id.reconcile', '=', True),
|
|
('date', '<=', date2str(statements[-1].date)),
|
|
])
|
|
if move_line_ids:
|
|
move_lines = move_line_obj.browse(cursor, uid, move_line_ids)
|
|
else:
|
|
move_lines = []
|
|
|
|
# Get all unreconciled sent payment lines in one big swoop.
|
|
# No filtering can be done, as empty dates carry value for C2B
|
|
# communication. Most likely there are much less sent payments
|
|
# than reconciled and open/draft payments.
|
|
cursor.execute("SELECT l.id FROM payment_order o, payment_line l "
|
|
"WHERE l.order_id = o.id AND "
|
|
"o.state = 'sent' AND "
|
|
"l.date_done IS NULL"
|
|
)
|
|
payment_line_ids = [x[0] for x in cursor.fetchall()]
|
|
if payment_line_ids:
|
|
payment_lines = payment_line_obj.browse(cursor, uid, payment_line_ids)
|
|
|
|
for statement in statements:
|
|
if statement.local_account in error_accounts:
|
|
# Don't repeat messages
|
|
results.stat_skipped_cnt += 1
|
|
results.trans_skipped_cnt += len(statement.transactions)
|
|
continue
|
|
|
|
# Create fallback currency code
|
|
currency_code = statement.local_currency or company.currency_id.name
|
|
|
|
# Check cache for account info/currency
|
|
if statement.local_account in info and \
|
|
currency_code in info[statement.local_account]:
|
|
account_info = info[statement.local_account][currency_code]
|
|
|
|
else:
|
|
# Pull account info/currency
|
|
account_info = get_company_bank_account(
|
|
self.pool, cursor, uid, statement.local_account,
|
|
statement.local_currency, company, results.log
|
|
)
|
|
if not account_info:
|
|
results.log.append(
|
|
_('Statements found for unknown account %(bank_account)s') %
|
|
{'bank_account': statement.local_account}
|
|
)
|
|
error_accounts[statement.local_account] = True
|
|
results.error_cnt += 1
|
|
continue
|
|
if 'journal_id' not in account_info:
|
|
results.log.append(
|
|
_('Statements found for account %(bank_account)s, '
|
|
'but no default journal was defined.'
|
|
) % {'bank_account': statement.local_account}
|
|
)
|
|
error_accounts[statement.local_account] = True
|
|
results.error_cnt += 1
|
|
continue
|
|
|
|
# Get required currency code
|
|
currency_code = account_info.currency_id.name
|
|
|
|
# Cache results
|
|
if not statement.local_account in info:
|
|
info[statement.local_account] = {
|
|
currency_code: account_info
|
|
}
|
|
else:
|
|
info[statement.local_account][currency_code] = account_info
|
|
|
|
# Final check: no coercion of currencies!
|
|
if statement.local_currency \
|
|
and account_info.currency_id.name != statement.local_currency:
|
|
# TODO: convert currencies?
|
|
results.log.append(
|
|
_('Statement %(statement_id)s for account %(bank_account)s'
|
|
' uses different currency than the defined bank journal.'
|
|
) % {
|
|
'bank_account': statement.local_account,
|
|
'statement_id': statement.id
|
|
}
|
|
)
|
|
error_accounts[statement.local_account] = True
|
|
results.error_cnt += 1
|
|
continue
|
|
|
|
# Check existence of previous statement
|
|
statement_ids = statement_obj.search(cursor, uid, [
|
|
('name', '=', statement.id),
|
|
('date', '=', date2str(statement.date)),
|
|
])
|
|
if statement_ids:
|
|
results.log.append(
|
|
_('Statement %(id)s known - skipped') % {
|
|
'id': statement.id
|
|
}
|
|
)
|
|
continue
|
|
|
|
statement_id = statement_obj.create(cursor, uid, dict(
|
|
name = statement.id,
|
|
journal_id = account_info.journal_id.id,
|
|
date = date2str(statement.date),
|
|
balance_start = statement.start_balance,
|
|
balance_end_real = statement.end_balance,
|
|
balance_end = statement.end_balance,
|
|
state = 'draft',
|
|
user_id = uid,
|
|
banking_id = import_id,
|
|
))
|
|
imported_statement_ids.append(statement_id)
|
|
|
|
# move each transaction to the right period and try to match it with an
|
|
# invoice or payment
|
|
subno = 0
|
|
injected = []
|
|
i = 0
|
|
max_trans = len(statement.transactions)
|
|
while i < max_trans:
|
|
move_info = False
|
|
if injected:
|
|
# Force FIFO behavior
|
|
transaction = injected.pop(0)
|
|
else:
|
|
transaction = statement.transactions[i]
|
|
# Keep a tracer for identification of order in a statement in case
|
|
# of missing transaction ids.
|
|
subno += 1
|
|
|
|
# Link accounting period
|
|
period_id = get_period(self.pool, cursor, uid,
|
|
transaction.effective_date, company,
|
|
results.log)
|
|
if not period_id:
|
|
results.trans_skipped_cnt += 1
|
|
if not injected:
|
|
i += 1
|
|
continue
|
|
|
|
# When bank costs are part of transaction itself, split it.
|
|
if transaction.type != bt.BANK_COSTS and transaction.provision_costs:
|
|
# Create new transaction for bank costs
|
|
costs = transaction.copy()
|
|
costs.type = bt.BANK_COSTS
|
|
costs.id = '%s-prov' % transaction.id
|
|
costs.transferred_amount = transaction.provision_costs
|
|
costs.remote_currency = transaction.provision_costs_currency
|
|
costs.message = transaction.provision_costs_description
|
|
injected.append(costs)
|
|
|
|
# Remove bank costs from current transaction
|
|
# Note that this requires that the transferred_amount
|
|
# includes the bank costs and that the costs itself are
|
|
# signed correctly.
|
|
transaction.transferred_amount -= transaction.provision_costs
|
|
transaction.provision_costs = None
|
|
transaction.provision_costs_currency = None
|
|
transaction.provision_costs_description = None
|
|
|
|
# Allow inclusion of generated bank invoices
|
|
if transaction.type == bt.BANK_COSTS:
|
|
lines = self._link_costs(
|
|
cursor, uid, transaction, period_id, account_info,
|
|
results.log
|
|
)
|
|
results.bank_costs_invoice_cnt += bool(lines)
|
|
for line in lines:
|
|
if not [x for x in move_lines if x.id == line.id]:
|
|
move_lines.append(line)
|
|
partner_ids = [account_info.bank_partner_id.id]
|
|
partner_banks = []
|
|
|
|
else:
|
|
# Link remote partner, import account when needed
|
|
partner_banks = get_bank_accounts(
|
|
self.pool, cursor, uid, transaction.remote_account,
|
|
results.log, fail=True
|
|
)
|
|
if partner_banks:
|
|
partner_ids = [x.partner_id.id for x in partner_banks]
|
|
elif transaction.remote_owner:
|
|
iban = sepa.IBAN(transaction.remote_account)
|
|
if iban.valid:
|
|
country_code = iban.countrycode
|
|
elif transaction.remote_owner_country_code:
|
|
country_code = transaction.remote_owner_country_code
|
|
elif hasattr(parser, 'country_code') and parser.country_code:
|
|
country_code = parser.country_code
|
|
else:
|
|
country_code = None
|
|
partner_id = get_or_create_partner(
|
|
self.pool, cursor, uid, transaction.remote_owner,
|
|
transaction.remote_owner_address,
|
|
transaction.remote_owner_postalcode,
|
|
transaction.remote_owner_city,
|
|
country_code, results.log
|
|
)
|
|
if transaction.remote_account:
|
|
partner_bank_id = create_bank_account(
|
|
self.pool, cursor, uid, partner_id,
|
|
transaction.remote_account,
|
|
transaction.remote_owner,
|
|
transaction.remote_owner_address,
|
|
transaction.remote_owner_city,
|
|
country_code, results.log
|
|
)
|
|
partner_banks = partner_bank_obj.browse(
|
|
cursor, uid, [partner_bank_id]
|
|
)
|
|
else:
|
|
partner_bank_id = None
|
|
partner_banks = []
|
|
partner_ids = [partner_id]
|
|
else:
|
|
partner_ids = []
|
|
partner_banks = []
|
|
|
|
# Credit means payment... isn't it?
|
|
if transaction.transferred_amount < 0 and payment_lines:
|
|
# Link open payment - if any
|
|
move_info = self._link_payment(
|
|
cursor, uid, transaction,
|
|
payment_lines, partner_ids,
|
|
partner_banks, results.log, linked_payments,
|
|
)
|
|
|
|
# Second guess, invoice -> may split transaction, so beware
|
|
if not move_info:
|
|
# Link invoice - if any. Although bank costs are not an
|
|
# invoice, automatic invoicing on bank costs will create
|
|
# these, and invoice matching still has to be done.
|
|
move_info, remainder = self._link_invoice(
|
|
cursor, uid, transaction, move_lines, partner_ids,
|
|
partner_banks, results.log, linked_invoices,
|
|
)
|
|
if remainder:
|
|
injected.append(remainder)
|
|
|
|
if not move_info:
|
|
# Use the default settings, but allow individual partner
|
|
# settings to overrule this. Note that you need to change
|
|
# the internal type of these accounts to either 'payable'
|
|
# or 'receivable' to enable usage like this.
|
|
if transaction.transferred_amount < 0:
|
|
if len(partner_banks) == 1:
|
|
account_id = partner_banks[0].partner_id.property_account_payable
|
|
if len(partner_banks) != 1 or not account_id or account_id.id == def_pay_account_id:
|
|
account_id = account_info.default_credit_account_id
|
|
else:
|
|
if len(partner_banks) == 1:
|
|
account_id = partner_banks[0].partner_id.property_account_receivable
|
|
if len(partner_banks) != 1 or not account_id or account_id.id == def_rec_account_id:
|
|
account_id = account_info.default_debit_account_id
|
|
else:
|
|
account_id = move_info.move_line.account_id
|
|
results.trans_matched_cnt += 1
|
|
|
|
values = struct(
|
|
name = '%s.%s' % (statement.id, transaction.id or subno),
|
|
date = transaction.effective_date,
|
|
amount = transaction.transferred_amount,
|
|
account_id = account_id.id,
|
|
statement_id = statement_id,
|
|
note = transaction.message,
|
|
ref = transaction.reference,
|
|
period_id = period_id,
|
|
currency = account_info.currency_id.id,
|
|
)
|
|
if move_info:
|
|
values.type = move_info.type
|
|
values.reconcile_id = move_info.move_line.reconcile_id
|
|
values.partner_id = move_info.partner_id
|
|
values.partner_bank_id = move_info.partner_bank_id
|
|
else:
|
|
values.partner_id = values.partner_bank_id = False
|
|
if not values.partner_id and partner_ids and len(partner_ids) == 1:
|
|
values.partner_id = partner_ids[0]
|
|
if not values.partner_bank_id and partner_banks and \
|
|
len(partner_banks) == 1:
|
|
values.partner_bank_id = partner_banks[0].id
|
|
|
|
statement_line_id = statement_line_obj.create(cursor, uid, values)
|
|
results.trans_loaded_cnt += 1
|
|
# Only increase index when all generated transactions are
|
|
# processed as well
|
|
if not injected:
|
|
i += 1
|
|
|
|
results.stat_loaded_cnt += 1
|
|
|
|
if payment_lines:
|
|
# As payments lines are treated as individual transactions, the
|
|
# batch as a whole is only marked as 'done' when all payment lines
|
|
# have been reconciled.
|
|
cursor.execute(
|
|
"SELECT DISTINCT o.id "
|
|
"FROM payment_order o, payment_line l "
|
|
"WHERE o.state = 'sent' "
|
|
"AND o.id = l.order_id "
|
|
"AND o.id NOT IN ("
|
|
"SELECT DISTINCT order_id AS id "
|
|
"FROM payment_line "
|
|
"WHERE date_done IS NULL "
|
|
"AND id IN (%s)"
|
|
")" % (','.join([str(x) for x in payment_line_ids]))
|
|
)
|
|
order_ids = [x[0] for x in cursor.fetchall()]
|
|
if order_ids:
|
|
# Use workflow logics for the orders. Recode logic from
|
|
# account_payment, in order to increase efficiency.
|
|
payment_order_obj.set_done(cursor, uid, order_ids,
|
|
{'state': 'done'}
|
|
)
|
|
wf_service = netsvc.LocalService('workflow')
|
|
for id in order_ids:
|
|
wf_service.trg_validate(uid, 'payment.order', id, 'done',
|
|
cursor
|
|
)
|
|
|
|
# Original code. Didn't take workflow logistics into account...
|
|
#
|
|
#cursor.execute(
|
|
# "UPDATE payment_order o "
|
|
# "SET state = 'done', "
|
|
# "date_done = '%s' "
|
|
# "FROM payment_line l "
|
|
# "WHERE o.state = 'sent' "
|
|
# "AND o.id = l.order_id "
|
|
# "AND l.id NOT IN ("
|
|
# "SELECT DISTINCT id FROM payment_line "
|
|
# "WHERE date_done IS NULL "
|
|
# "AND id IN (%s)"
|
|
# ")" % (
|
|
# time.strftime('%Y-%m-%d'),
|
|
# ','.join([str(x) for x in payment_line_ids])
|
|
# )
|
|
#)
|
|
|
|
report = [
|
|
'%s: %s' % (_('Total number of statements'),
|
|
results.stat_skipped_cnt + results.stat_loaded_cnt),
|
|
'%s: %s' % (_('Total number of transactions'),
|
|
results.trans_skipped_cnt + results.trans_loaded_cnt),
|
|
'%s: %s' % (_('Number of errors found'),
|
|
results.error_cnt),
|
|
'%s: %s' % (_('Number of statements skipped due to errors'),
|
|
results.stat_skipped_cnt),
|
|
'%s: %s' % (_('Number of transactions skipped due to errors'),
|
|
results.trans_skipped_cnt),
|
|
'%s: %s' % (_('Number of statements loaded'),
|
|
results.stat_loaded_cnt),
|
|
'%s: %s' % (_('Number of transactions loaded'),
|
|
results.trans_loaded_cnt),
|
|
'%s: %s' % (_('Number of transactions matched'),
|
|
results.trans_matched_cnt),
|
|
'%s: %s' % (_('Number of bank costs invoices created'),
|
|
results.bank_costs_invoice_cnt),
|
|
'',
|
|
'%s:' % ('Error report'),
|
|
'',
|
|
]
|
|
text_log = '\n'.join(report + results.log)
|
|
state = results.error_cnt and 'error' or 'ready'
|
|
statement_file_obj.write(cursor, uid, import_id, dict(
|
|
state = state, log = text_log,
|
|
), context)
|
|
if not imported_statement_ids:
|
|
# file state can be 'ready' while import state is 'error'
|
|
state = 'error'
|
|
self.write(cursor, uid, [ids[0]], dict(
|
|
import_id = import_id, log = text_log, state = state,
|
|
statement_ids = [[6, 0, imported_statement_ids]],
|
|
), context)
|
|
return {
|
|
'name': _('Import Bank Transactions File'),
|
|
'view_type': 'form',
|
|
'view_mode': 'form',
|
|
'view_id': False,
|
|
'res_model': self._name,
|
|
'domain': [],
|
|
'context': dict(context, active_ids=ids),
|
|
'type': 'ir.actions.act_window',
|
|
'target': 'new',
|
|
'res_id': ids[0] or False,
|
|
}
|
|
|
|
_columns = {
|
|
'company': fields.many2one(
|
|
'res.company', 'Company', required=True,
|
|
states={
|
|
'ready': [('readonly', True)],
|
|
'error': [('readonly', True)],
|
|
},
|
|
),
|
|
'file': fields.binary(
|
|
'Statements File', required=True,
|
|
help = ('The Transactions File to import. Please note that while it is '
|
|
'perfectly safe to reload the same file multiple times or to load in '
|
|
'timeframe overlapping statements files, there are formats that may '
|
|
'introduce different sequencing, which may create double entries.\n\n'
|
|
'To stay on the safe side, always load bank statements files using the '
|
|
'same format.'),
|
|
states={
|
|
'ready': [('readonly', True)],
|
|
'error': [('readonly', True)],
|
|
},
|
|
),
|
|
'parser': fields.selection(
|
|
parser_types, 'File Format', required=True,
|
|
states={
|
|
'ready': [('readonly', True)],
|
|
'error': [('readonly', True)],
|
|
},
|
|
),
|
|
'log': fields.text('Log', readonly=True),
|
|
'state': fields.selection(
|
|
[('init', 'init'), ('ready', 'ready'),
|
|
('error', 'error')],
|
|
'State', readonly=True),
|
|
'import_id': fields.many2one(
|
|
'account.banking.imported.file', 'Import File'),
|
|
# osv_memory does not seem to support one2many
|
|
'statement_ids': fields.many2many(
|
|
'account.bank.statement', 'rel_wiz_statements', 'wizard_id',
|
|
'statement_id', 'Imported Bank Statements'),
|
|
}
|
|
|
|
_defaults = {
|
|
'state': 'init',
|
|
}
|
|
banking_import()
|
|
|
|
# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
|