From cab64a4ebbb5461aeda8142ba3d0b2bebd856da9 Mon Sep 17 00:00:00 2001 From: OpenERP instance user Date: Wed, 14 Dec 2011 15:35:02 +0100 Subject: [PATCH] [FIX] Bank import writes to browse object [ADD] direct debit order: process storno during bank import [ADD] bank import: add hooks for processing debit orders and stornos [ADD] direct debit order: pre-select move lines on reference substring configured in payment mode [ADD] payment term for direct debit invoices [ADD] payment line views: add storno field [RFR] standardize storno and debit order processing during bank import --- account_banking/account_banking.py | 20 ++ account_banking/parsers/models.py | 8 + account_banking/wizard/bank_import.py | 66 +++++- account_banking_nl_ing/ing.py | 27 ++- account_direct_debit/__openerp__.py | 1 + .../data/account_payment_term.xml | 16 ++ account_direct_debit/model/account_payment.py | 209 +++++++++++------- account_direct_debit/view/account_payment.xml | 18 ++ .../workflow/account_invoice.xml | 1 + 9 files changed, 268 insertions(+), 98 deletions(-) create mode 100644 account_direct_debit/data/account_payment_term.xml diff --git a/account_banking/account_banking.py b/account_banking/account_banking.py index 351ea78b8..c452796c1 100644 --- a/account_banking/account_banking.py +++ b/account_banking/account_banking.py @@ -751,6 +751,26 @@ class payment_line(osv.osv): return res + def debit_storno(self, cr, uid, payment_line_id, amount, + currency_id, storno_retry=True, context=None): + """ + Hook for handling a canceled item of a direct debit order. + Presumably called from a bank statement import routine. + + Decide on the direction that the invoice's workflow needs to take. + You may optionally return an incomplete reconcile for the caller + to reconcile the now void payment. + + :param payment_line_id: the single payment line id + :param amount: the (negative) amount debited from the bank account + :param currency_id: the bank account's currency id + :param boolean storno_retry: whether the storno is considered fatal \ + or not. + :return: an incomplete reconcile for the caller to fill + :rtype: database id of an account.move.reconcile resource. + """ + + return False payment_line() diff --git a/account_banking/parsers/models.py b/account_banking/parsers/models.py index 2af0b30cd..d0451f228 100644 --- a/account_banking/parsers/models.py +++ b/account_banking/parsers/models.py @@ -174,6 +174,10 @@ class mem_bank_transaction(object): # An error message for interaction with the user # Only used when mem_transaction.valid returns False. 'error_message', + + # Storno attribute. When True, make the cancelled debit eligible for + # a next direct debit run + 'storno_retry', ] @@ -206,6 +210,10 @@ class mem_bank_transaction(object): # PERIODIC_ORDER An automated payment by the bank on your behalf. # Always outgoing. # Will be selected for matching. + # STORNO A failed or reversed attempt at direct debit. + # Either due to an action on the payer's side + # or a failure observed by the bank (lack of + # credit for instance) # # Perhaps more will follow. # diff --git a/account_banking/wizard/bank_import.py b/account_banking/wizard/bank_import.py index 7d096fe4f..999348a37 100644 --- a/account_banking/wizard/bank_import.py +++ b/account_banking/wizard/bank_import.py @@ -77,10 +77,10 @@ class banking_import(osv.osv_memory): retval.type = 'general' if partial: - move_line.reconcile_partial_id = reconcile_obj.create( + reconcile_obj.create( cursor, uid, { 'type': 'auto', - 'line_partial_ids': [(4, 0, [move_line.id])] + 'line_partial_ids': [(4, 0, [move_line.id])], } ) else: @@ -90,19 +90,56 @@ class banking_import(osv.osv_memory): ] else: partial_ids = [] - move_line.reconcile_id = reconcile_obj.create( + 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 - ] + 'line_id': [(6, 0, [move_line.id] + partial_ids)], + 'line_partial_ids': [(6, 0, [])], } ) return retval + def _link_storno( + self, cr, uid, trans, account_info, log, context=None): + payment_line_obj = self.pool.get('payment.line') + move_line_obj = self.pool.get('account.move.line') + line_ids = payment_line_obj.search( + cr, uid, [ + ('order_id.payment_order_type', '=', 'debit'), + ('order_id.state', 'in', ['sent', 'done']), + ('communication', '=', trans.reference) + ], context=context) + if len(line_ids) == 1: + reconcile_id = payment_line_obj.debit_storno( + cr, uid, line_ids[0], trans.transferred_amount, + account_info.currency_id, trans.storno_retry, context=None) + if reconcile_id: + # we need to retrieve the move line as per consistency + # but it is only used to retrieve the account_id to book + # the transfer to. By necessity, we can use any of the + # move lines from the reconcile as all of them should have + # the same account. + move_line_ids = move_line_obj.search( + cr, uid, + [ + '|', ('reconcile_id', '=', reconcile_id), + ('reconcile_partial_id', '=', reconcile_id), + ] + , context=context) + if move_line_ids: + move_line=move_line_obj.browse( + cr, uid, move_line_ids[0], context=context) + return struct( + move_line=move_line, + partner_id=False, + partner_bank_id=False, + reference=False, + type='general', + ) + # TODO log the reason why there is no result for transfers marked + # as storno + return False + def _link_debit_order( self, cr, uid, trans, account_info, log, context=None): @@ -309,6 +346,7 @@ class banking_import(osv.osv_memory): ] move_line = False + if candidates and len(candidates) > 0: # Now a possible selection of invoices has been found, check the # amounts expected and received. @@ -753,6 +791,9 @@ class banking_import(osv.osv_memory): if transaction.type == bt.DIRECT_DEBIT: move_info = self._link_debit_order( cursor, uid, transaction, account_info, results.log, context) + if transaction.type == bt.STORNO: + move_info = self._link_storno( + cursor, uid, transaction, account_info, results.log, context) # Allow inclusion of generated bank invoices if transaction.type == bt.BANK_COSTS: lines = self._link_costs( @@ -864,7 +905,12 @@ class banking_import(osv.osv_memory): ) if move_info: values.type = move_info.type - values.reconcile_id = move_info.move_line.reconcile_id.id + values.reconcile_id = ( + move_info.move_line.reconcile_id and + move_info.move_line.reconcile_id.id or + move_info.move_line.reconcile_partial_id and + move_info.move_line.reconcile_partial_id.id + ) values.partner_id = move_info.partner_id values.partner_bank_id = move_info.partner_bank_id else: diff --git a/account_banking_nl_ing/ing.py b/account_banking_nl_ing/ing.py index 498935471..1bddd8824 100644 --- a/account_banking_nl_ing/ing.py +++ b/account_banking_nl_ing/ing.py @@ -54,8 +54,6 @@ class transaction_message(object): # 'remote_owner', 'remote_account', 'transfer_type', 'reference', ] - ref_expr = re.compile('REF[\*:]([0-9A-Z-z_-]+)') - def __init__(self, values, subno): ''' Initialize own dict with attributes and coerce values to right type @@ -85,7 +83,7 @@ class transaction(models.mem_bank_transaction): attrnames = ['local_account', 'remote_account', 'remote_owner', 'transferred_amount', 'execution_date', 'effective_date', 'transfer_type', - 'reference', 'id', + 'id', #'reference', ] """ @@ -112,6 +110,9 @@ class transaction(models.mem_bank_transaction): 'NO': bt.STORNO, # Storno } + # global expression for matching storno references + ref_expr = re.compile('REF[\*:]([0-9A-Z-z_-]+)') + def __init__(self, line, *args, **kwargs): ''' Initialize own dict with read values. @@ -119,15 +120,15 @@ class transaction(models.mem_bank_transaction): super(transaction, self).__init__(*args, **kwargs) # Copy attributes from auxiliary class to self. for attr in self.attrnames: - if attr == 'reference': - setattr(self, 'reference', False) - else: - setattr(self, attr, getattr(line, attr)) + #if attr == 'reference': + # setattr(self, 'reference', False) + #else: + setattr(self, attr, getattr(line, attr)) # self.message = '' # Decompose structured messages self.parse_message() # Adaptations to direct debit orders ands stornos - if self.transfer_type == 'DV': + if self.transfer_type == 'DV' and self.transferred_amount < 0: res = self.ref_expr.search(self.remote_owner) if res: self.transfer_type = 'NO' @@ -139,7 +140,15 @@ class transaction(models.mem_bank_transaction): self.transfer_type = 'NO' self.reference = res.group(1) if self.transfer_type == 'IC': - self.reference = self.remote_owner + if self.transferred_amount > 0: + self.reference = self.remote_owner + else: + self.transfer_type = 'NO' + self.message = self.remote_owner + self.message + res = self.ref_expr.search(self.message) + if res: + self.reference = res.group(1) + self.storno_retry = True self.remote_owner = False def is_valid(self): diff --git a/account_direct_debit/__openerp__.py b/account_direct_debit/__openerp__.py index df50a2161..8ae7e8973 100644 --- a/account_direct_debit/__openerp__.py +++ b/account_direct_debit/__openerp__.py @@ -36,6 +36,7 @@ 'view/account_payment.xml', 'view/account_invoice.xml', 'workflow/account_invoice.xml', + 'data/account_payment_term.xml', ], 'demo_xml': [], 'description': ''' diff --git a/account_direct_debit/data/account_payment_term.xml b/account_direct_debit/data/account_payment_term.xml new file mode 100644 index 000000000..1efc35545 --- /dev/null +++ b/account_direct_debit/data/account_payment_term.xml @@ -0,0 +1,16 @@ + + + + + Direct debit + Direct debit in 14 days + + + Direct debit in 14 days + balance + + + + + + diff --git a/account_direct_debit/model/account_payment.py b/account_direct_debit/model/account_payment.py index 93dad0a01..3601948ea 100644 --- a/account_direct_debit/model/account_payment.py +++ b/account_direct_debit/model/account_payment.py @@ -19,6 +19,21 @@ class payment_mode(osv.osv): help=('Journal to write payment entries when confirming ' + 'a debit order of this mode'), ), + 'reference_filter': fields.char( + 'Reference filter', size=16, + help=( + 'Optional substring filter on move line references. ' + + 'You can use this in combination with a specific journal ' + + 'for items that you want to handle with this mode. Use ' + + 'a separate sequence for the journal with a distinguished ' + + 'prefix or suffix and enter that character string here.'), + ), +# 'payment_term_ids': fields.many2many( +# 'account.payment.term', 'account_payment_order_terms_rel', +# 'mode_id', 'term_id', 'Payment terms', +# help=('Limit selected invoices to invoices with these payment ' + +# 'terms') +# ), } payment_mode() @@ -77,14 +92,17 @@ class payment_order(osv.osv): for line in order_line.debit_move_line_id.move_id.line_id: if line.account_id.type == 'other' and not line.reconcile_id: line_ids.append(line.id) - import pdb - pdb.set_trace() if is_zero(order.line_ids[0].debit_move_line_id, get_balance(line_ids) - amount): reconcile_id = self.pool.get('account.move.reconcile').create( cr, uid, {'type': 'auto', 'line_id': [(6, 0, line_ids)]}, context) + # set direct debit order to finished state + wf_service = netsvc.LocalService('workflow') + wf_service.trg_validate( + uid, 'payment.order', payment_order_id, 'done', cr) + return reconcile_id def action_sent(self, cr, uid, ids, context=None): @@ -165,86 +183,99 @@ payment_order() class payment_line(osv.osv): _inherit = 'payment.line' - def debit_storno(self, cr, uid, payment_line_id, storno_move_line_id, context=None): + def debit_storno(self, cr, uid, payment_line_id, amount, + currency_id, storno_retry=True, context=None): """ - Process a payment line from a direct debit order which has - been canceled by the bank or by the user: - - Undo the reconciliation of the payment line with the move - line that it originated from, and re-reconciliated with - the credit payment in the bank journal of the same amount and - on the same account. - - Mark the payment line for being reversed. - - :param payment_line_id: the single id of the canceled payment line - :param storno_move_line_id: the credit payment in the bank journal + The processing of a storno is triggered by a debit + transfer on one of the company's bank accounts. + This method offers to re-reconcile the original debit + payment. For this purpose, we have registered that + payment move on the payment line. + + Return the (now incomplete) reconcile id. The caller MUST + re-reconcile this reconcile with the bank transfer and + re-open the associated invoice. + + :param payment_line_id: the single payment line id + :param amount: the (signed) amount debited from the bank account + :param currency_id: the bank account's currency id + :param boolean storno_retry: when True, attempt to reopen the invoice, \ + set the invoice to 'Debit denied' otherwise. + :return: an incomplete reconcile for the caller to fill + :rtype: database id of an account.move.reconcile resource. """ - if isinstance(payment_line_id, (list, tuple)): - payment_line_id = payment_line_id[0] - reconcile_obj = self.pool.get('account.move.reconcile') move_line_obj = self.pool.get('account.move.line') - payment_line = self.browse(cr, uid, payment_line_id, context=context) - - debit_move_line = payment_line.debit_move_line_id - if (not debit_move_line): - raise osv.except_osv( - _('Can not process storno'), - _('No move line for line %s') % payment_line.name) - if payment_line.storno: - raise osv.except_osv( - _('Can not process storno'), - _('Cancelation of payment line \'%s\' has already been ' + - 'processed') % payment_line.name) - - def is_zero(total): - return self.pool.get('res.currency').is_zero( - cr, uid, debit_move_line.company_id.currency_id, total) - - # check validity of the proposed move line - torec_move_line = move_line_obj.browse( - cr, uid, storno_move_line_id, context=context) - if not (is_zero(torec_move_line.debit - debit_move_line.debit) and - is_zero(torec_move_line.credit - debit_move_line.credit) and - torec_move_line.account_id.id == debit_move_line.account_id.id): - raise osv.except_osv( - _('Can not process storno'), - _('%s is not a drop-in replacement for %s') % ( - torec_move_line.name, debit_move_line.name)) - if payment_line.storno: - raise osv.except_osv( - _('Can not process storno'), - _('Debit order line %s has already been cancelled') % ( - payment_line.name)) - - # replace move line in reconciliation + reconcile_obj = self.pool.get('account.move.reconcile') + line = self.browse(cr, uid, payment_line_id) reconcile_id = False - if (payment_line.move_line_id.reconcile_partial_id and - debit_move_line_id.id in - payment_line.move_line_id.reconcile_partial_id.line_partial_ids): - reconcile_id = payment_line.move_line_id.reconcile_partial_id - vals = { - 'line_partial_ids': - [(3, debit_move_line_id.id), (4, torec_move_line.id)], - } - elif (payment_line.move_line_id.reconcile_id and - debit_move_line_id.id in - payment_line.move_line_id.reconcile_id.line_id): - reconcile_id = payment_line.move_line_id.reconcile_id - vals = { - 'line_id': - [(3, debit_move_line_id.id), (4, torec_move_line.id)] - } - if not reconcile_id: - raise osv.except_osv( - _('Can not perform storno'), - _('Debit order line %s does not occur in the list of ' - 'reconciliation move lines of its origin') % - debit_move_line_id.name) - reconcile_obj.write(cr, uid, reconcile_id, vals, context=context) - self.write(cr, uid, payment_line_id, {'storno': True}, context=context) - #for line_id in line_ids: - # netsvc.LocalService("workflow").trg_trigger( - # uid, 'account.move.line', line_id, cr) + if (line.debit_move_line_id and not line.storno and + self.pool.get('res.currency').is_zero( + cr, uid, currency_id, ( + (line.debit_move_line_id.credit or 0.0) - + (line.debit_move_line_id.debit or 0.0) + amount))): + # Two different cases, full and partial + # Both cases differ subtly in the procedure to follow + # Needs refractoring, but why is this not in the OpenERP API? + if line.debit_move_line_id.reconcile_partial_id: + reconcile_id = line.debit_move_line_id.reconcile_partial_id.id + attribute = 'reconcile_partial_id' + if len(line.debit_move_line_id.reconcile_id.line_partial_ids) == 2: + # reuse the simple reconcile for the storno transfer + reconcile_obj.write( + cr, uid, reconcile_id, { + 'line_id': [(6, 0, line.debit_move_line_id.id)], + 'line_partial_ids': [(6, 0, [])], + }, context=context) + else: + # split up the original reconcile in a partial one + # and a new one for reconciling the storno transfer + reconcile_obj.write( + cr, uid, reconcile_id, { + 'line_partial_ids': [(3, line.debit_move_line_id.id)], + }, context=context) + reconcile_id = reconcile_obj.create( + cr, uid, { + 'type': 'auto', + 'line_id': [(6, 0, line.debit_move_line_id.id)], + }, context=context) + elif line.debit_move_line_id.reconcile_id: + reconcile_id = line.debit_move_line_id.reconcile_id.id + if len(line.debit_move_line_id.reconcile_id.line_id) == 2: + # reuse the simple reconcile for the storno transfer + reconcile_obj.write( + cr, uid, reconcile_id, { + 'line_id': [(6, 0, [line.debit_move_line_id.id])] + }, context=context) + else: + # split up the original reconcile in a partial one + # and a new one for reconciling the storno transfer + partial_ids = [ + x.id for x in line.debit_move_line_id.reconcile_id.line_id + if x.id != line.debit_move_line_id.id + ] + reconcile_obj.write( + cr, uid, reconcile_id, { + 'line_partial_ids': [(6, 0, partial_ids)], + 'line_id': [(6, 0, [])], + }, context=context) + reconcile_id = reconcile_obj.create( + cr, uid, { + 'type': 'auto', + 'line_id': [(6, 0, line.debit_move_line_id.id)], + }, context=context) + # mark the payment line for storno processed + if reconcile_id: + self.write(cr, uid, [payment_line_id], + {'storno': True}, context=context) + # put forth the invoice workflow + if line.move_line_id.invoice: + activity = (storno_retry and 'open_test' + or 'invoice_debit_denied') + netsvc.LocalService("workflow").trg_validate( + uid, 'account.invoice', line.move_line_id.invoice.id, + activity, cr) + return reconcile_id def debit_reconcile(self, cr, uid, payment_line_id, context=None): """ @@ -356,9 +387,29 @@ class payment_order_create(osv.osv_memory): payment = self.pool.get('payment.order').browse(cr, uid, context['active_id'], context=context) # Search for move line to pay: if payment.payment_order_type == 'debit': - domain = [('reconcile_id', '=', False), ('account_id.type', '=', 'receivable'), ('amount_to_receive', '>', 0)] + domain = [ + ('reconcile_id', '=', False), + ('account_id.type', '=', 'receivable'), + ('amount_to_receive', '>', 0), + ] + # cannot filter on properties of (searchable) + # function fields. Needs work in expression.expression.parse() + # Currently gives an SQL error. +# # apply payment term filter +# if payment.mode.payment_term_ids: +# term_ids = [term.id for term in payment.mode.payment_term_ids] +# domain = domain + [ +# '|', ('invoice', '=', False), +# ('invoice.payment_term', 'in', term_ids), +# ] else: - domain = [('reconcile_id', '=', False), ('account_id.type', '=', 'payable'), ('amount_to_pay', '>', 0)] + domain = [ + ('reconcile_id', '=', False), + ('account_id.type', '=', 'payable'), + ('amount_to_pay', '>', 0) + ] + if payment.mode.reference_filter: + domain.append(('ref', 'ilike', payment.mode.reference_filter)) # domain = [('reconcile_id', '=', False), ('account_id.type', '=', 'payable'), ('amount_to_pay', '>', 0)] ### end account_direct_debit ### diff --git a/account_direct_debit/view/account_payment.xml b/account_direct_debit/view/account_payment.xml index 64accde3b..cd70c3ca7 100644 --- a/account_direct_debit/view/account_payment.xml +++ b/account_direct_debit/view/account_payment.xml @@ -45,6 +45,10 @@ icon="gtk-find" /> + + + + @@ -59,8 +63,22 @@ + + + + Payment Lines + payment.line + tree + + + + + + + + diff --git a/account_direct_debit/workflow/account_invoice.xml b/account_direct_debit/workflow/account_invoice.xml index c6fe8396d..080d1187f 100644 --- a/account_direct_debit/workflow/account_invoice.xml +++ b/account_direct_debit/workflow/account_invoice.xml @@ -16,6 +16,7 @@ + open_test