[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
This commit is contained in:
OpenERP instance user
2011-12-14 15:35:02 +01:00
parent 4af0d711f1
commit cab64a4ebb
9 changed files with 268 additions and 98 deletions

View File

@@ -751,6 +751,26 @@ class payment_line(osv.osv):
return res 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() payment_line()

View File

@@ -174,6 +174,10 @@ class mem_bank_transaction(object):
# An error message for interaction with the user # An error message for interaction with the user
# Only used when mem_transaction.valid returns False. # Only used when mem_transaction.valid returns False.
'error_message', '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. # PERIODIC_ORDER An automated payment by the bank on your behalf.
# Always outgoing. # Always outgoing.
# Will be selected for matching. # 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. # Perhaps more will follow.
# #

View File

@@ -77,10 +77,10 @@ class banking_import(osv.osv_memory):
retval.type = 'general' retval.type = 'general'
if partial: if partial:
move_line.reconcile_partial_id = reconcile_obj.create( reconcile_obj.create(
cursor, uid, { cursor, uid, {
'type': 'auto', 'type': 'auto',
'line_partial_ids': [(4, 0, [move_line.id])] 'line_partial_ids': [(4, 0, [move_line.id])],
} }
) )
else: else:
@@ -90,19 +90,56 @@ class banking_import(osv.osv_memory):
] ]
else: else:
partial_ids = [] partial_ids = []
move_line.reconcile_id = reconcile_obj.create( reconcile_obj.create(
cursor, uid, { cursor, uid, {
'type': 'auto', 'type': 'auto',
'line_id': [ 'line_id': [(6, 0, [move_line.id] + partial_ids)],
(4, x, False) for x in [move_line.id] + partial_ids 'line_partial_ids': [(6, 0, [])],
],
'line_partial_ids': [
(3, x, False) for x in partial_ids
]
} }
) )
return retval 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( def _link_debit_order(
self, cr, uid, trans, account_info, log, context=None): self, cr, uid, trans, account_info, log, context=None):
@@ -309,6 +346,7 @@ class banking_import(osv.osv_memory):
] ]
move_line = False move_line = False
if candidates and len(candidates) > 0: if candidates and len(candidates) > 0:
# Now a possible selection of invoices has been found, check the # Now a possible selection of invoices has been found, check the
# amounts expected and received. # amounts expected and received.
@@ -753,6 +791,9 @@ class banking_import(osv.osv_memory):
if transaction.type == bt.DIRECT_DEBIT: if transaction.type == bt.DIRECT_DEBIT:
move_info = self._link_debit_order( move_info = self._link_debit_order(
cursor, uid, transaction, account_info, results.log, context) 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 # Allow inclusion of generated bank invoices
if transaction.type == bt.BANK_COSTS: if transaction.type == bt.BANK_COSTS:
lines = self._link_costs( lines = self._link_costs(
@@ -864,7 +905,12 @@ class banking_import(osv.osv_memory):
) )
if move_info: if move_info:
values.type = move_info.type 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_id = move_info.partner_id
values.partner_bank_id = move_info.partner_bank_id values.partner_bank_id = move_info.partner_bank_id
else: else:

View File

@@ -54,8 +54,6 @@ class transaction_message(object):
# 'remote_owner', 'remote_account', 'transfer_type', 'reference', # 'remote_owner', 'remote_account', 'transfer_type', 'reference',
] ]
ref_expr = re.compile('REF[\*:]([0-9A-Z-z_-]+)')
def __init__(self, values, subno): def __init__(self, values, subno):
''' '''
Initialize own dict with attributes and coerce values to right type 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', attrnames = ['local_account', 'remote_account',
'remote_owner', 'transferred_amount', 'remote_owner', 'transferred_amount',
'execution_date', 'effective_date', 'transfer_type', 'execution_date', 'effective_date', 'transfer_type',
'reference', 'id', 'id', #'reference',
] ]
""" """
@@ -112,6 +110,9 @@ class transaction(models.mem_bank_transaction):
'NO': bt.STORNO, # Storno '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): def __init__(self, line, *args, **kwargs):
''' '''
Initialize own dict with read values. Initialize own dict with read values.
@@ -119,15 +120,15 @@ class transaction(models.mem_bank_transaction):
super(transaction, self).__init__(*args, **kwargs) super(transaction, self).__init__(*args, **kwargs)
# Copy attributes from auxiliary class to self. # Copy attributes from auxiliary class to self.
for attr in self.attrnames: for attr in self.attrnames:
if attr == 'reference': #if attr == 'reference':
setattr(self, 'reference', False) # setattr(self, 'reference', False)
else: #else:
setattr(self, attr, getattr(line, attr)) setattr(self, attr, getattr(line, attr))
# self.message = '' # self.message = ''
# Decompose structured messages # Decompose structured messages
self.parse_message() self.parse_message()
# Adaptations to direct debit orders ands stornos # 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) res = self.ref_expr.search(self.remote_owner)
if res: if res:
self.transfer_type = 'NO' self.transfer_type = 'NO'
@@ -139,7 +140,15 @@ class transaction(models.mem_bank_transaction):
self.transfer_type = 'NO' self.transfer_type = 'NO'
self.reference = res.group(1) self.reference = res.group(1)
if self.transfer_type == 'IC': 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 self.remote_owner = False
def is_valid(self): def is_valid(self):

View File

@@ -36,6 +36,7 @@
'view/account_payment.xml', 'view/account_payment.xml',
'view/account_invoice.xml', 'view/account_invoice.xml',
'workflow/account_invoice.xml', 'workflow/account_invoice.xml',
'data/account_payment_term.xml',
], ],
'demo_xml': [], 'demo_xml': [],
'description': ''' 'description': '''

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<openerp>
<data noupdate="1">
<record id="payment_term_direct_debit" model="account.payment.term">
<field name="name">Direct debit</field>
<field name="note">Direct debit in 14 days</field>
</record>
<record id="payment_term_line_direct_debit" model="account.payment.term.line">
<field name="name">Direct debit in 14 days</field>
<field name="value">balance</field>
<field eval="14" name="days"/>
<field eval="0" name="days2"/>
<field eval="payment_term_direct_debit" name="payment_id"/>
</record>
</data>
</openerp>

View File

@@ -19,6 +19,21 @@ class payment_mode(osv.osv):
help=('Journal to write payment entries when confirming ' + help=('Journal to write payment entries when confirming ' +
'a debit order of this mode'), '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() payment_mode()
@@ -77,14 +92,17 @@ class payment_order(osv.osv):
for line in order_line.debit_move_line_id.move_id.line_id: for line in order_line.debit_move_line_id.move_id.line_id:
if line.account_id.type == 'other' and not line.reconcile_id: if line.account_id.type == 'other' and not line.reconcile_id:
line_ids.append(line.id) line_ids.append(line.id)
import pdb
pdb.set_trace()
if is_zero(order.line_ids[0].debit_move_line_id, if is_zero(order.line_ids[0].debit_move_line_id,
get_balance(line_ids) - amount): get_balance(line_ids) - amount):
reconcile_id = self.pool.get('account.move.reconcile').create( reconcile_id = self.pool.get('account.move.reconcile').create(
cr, uid, cr, uid,
{'type': 'auto', 'line_id': [(6, 0, line_ids)]}, {'type': 'auto', 'line_id': [(6, 0, line_ids)]},
context) 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 return reconcile_id
def action_sent(self, cr, uid, ids, context=None): def action_sent(self, cr, uid, ids, context=None):
@@ -165,86 +183,99 @@ payment_order()
class payment_line(osv.osv): class payment_line(osv.osv):
_inherit = 'payment.line' _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 The processing of a storno is triggered by a debit
been canceled by the bank or by the user: transfer on one of the company's bank accounts.
- Undo the reconciliation of the payment line with the move This method offers to re-reconcile the original debit
line that it originated from, and re-reconciliated with payment. For this purpose, we have registered that
the credit payment in the bank journal of the same amount and payment move on the payment line.
on the same account.
- Mark the payment line for being reversed. Return the (now incomplete) reconcile id. The caller MUST
re-reconcile this reconcile with the bank transfer and
:param payment_line_id: the single id of the canceled payment line re-open the associated invoice.
:param storno_move_line_id: the credit payment in the bank journal
: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') move_line_obj = self.pool.get('account.move.line')
payment_line = self.browse(cr, uid, payment_line_id, context=context) reconcile_obj = self.pool.get('account.move.reconcile')
line = self.browse(cr, uid, payment_line_id)
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_id = False reconcile_id = False
if (payment_line.move_line_id.reconcile_partial_id and if (line.debit_move_line_id and not line.storno and
debit_move_line_id.id in self.pool.get('res.currency').is_zero(
payment_line.move_line_id.reconcile_partial_id.line_partial_ids): cr, uid, currency_id, (
reconcile_id = payment_line.move_line_id.reconcile_partial_id (line.debit_move_line_id.credit or 0.0) -
vals = { (line.debit_move_line_id.debit or 0.0) + amount))):
'line_partial_ids': # Two different cases, full and partial
[(3, debit_move_line_id.id), (4, torec_move_line.id)], # Both cases differ subtly in the procedure to follow
} # Needs refractoring, but why is this not in the OpenERP API?
elif (payment_line.move_line_id.reconcile_id and if line.debit_move_line_id.reconcile_partial_id:
debit_move_line_id.id in reconcile_id = line.debit_move_line_id.reconcile_partial_id.id
payment_line.move_line_id.reconcile_id.line_id): attribute = 'reconcile_partial_id'
reconcile_id = payment_line.move_line_id.reconcile_id if len(line.debit_move_line_id.reconcile_id.line_partial_ids) == 2:
vals = { # reuse the simple reconcile for the storno transfer
'line_id': reconcile_obj.write(
[(3, debit_move_line_id.id), (4, torec_move_line.id)] cr, uid, reconcile_id, {
} 'line_id': [(6, 0, line.debit_move_line_id.id)],
if not reconcile_id: 'line_partial_ids': [(6, 0, [])],
raise osv.except_osv( }, context=context)
_('Can not perform storno'), else:
_('Debit order line %s does not occur in the list of ' # split up the original reconcile in a partial one
'reconciliation move lines of its origin') % # and a new one for reconciling the storno transfer
debit_move_line_id.name) reconcile_obj.write(
reconcile_obj.write(cr, uid, reconcile_id, vals, context=context) cr, uid, reconcile_id, {
self.write(cr, uid, payment_line_id, {'storno': True}, context=context) 'line_partial_ids': [(3, line.debit_move_line_id.id)],
#for line_id in line_ids: }, context=context)
# netsvc.LocalService("workflow").trg_trigger( reconcile_id = reconcile_obj.create(
# uid, 'account.move.line', line_id, cr) 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): 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) payment = self.pool.get('payment.order').browse(cr, uid, context['active_id'], context=context)
# Search for move line to pay: # Search for move line to pay:
if payment.payment_order_type == 'debit': 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: 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)] # domain = [('reconcile_id', '=', False), ('account_id.type', '=', 'payable'), ('amount_to_pay', '>', 0)]
### end account_direct_debit ### ### end account_direct_debit ###

View File

@@ -45,6 +45,10 @@
icon="gtk-find" icon="gtk-find"
/> />
</xpath> </xpath>
<xpath expr="//tree[@string='Payment Line']" position="inside">
<!-- the attrs do not work like this, apparently -->
<field name="storno" attrs="{'invisible': [(parent.payment_order_type, '!=', 'debit')]}"/>
</xpath>
</data> </data>
</field> </field>
</record> </record>
@@ -59,8 +63,22 @@
<field name="type" position="after"> <field name="type" position="after">
<field name="transfer_account_id"/> <field name="transfer_account_id"/>
<field name="transfer_journal_id"/> <field name="transfer_journal_id"/>
<field name="reference_filter"/>
</field> </field>
</field> </field>
</record> </record>
<record id="view_payment_line_tree" model="ir.ui.view">
<field name="name">Payment Lines</field>
<field name="model">payment.line</field>
<field name="type">tree</field>
<field name="inherit_id" ref="account_payment.view_payment_line_tree"/>
<field eval="4" name="priority"/>
<field name="arch" type="xml">
<field name="name" position="after">
<field name="storno"/>
</field>
</field>
</record>
</data> </data>
</openerp> </openerp>

View File

@@ -16,6 +16,7 @@
<record id="debit_denied_to_open" model="workflow.transition"> <record id="debit_denied_to_open" model="workflow.transition">
<field name="act_from" ref="act_debit_denied"/> <field name="act_from" ref="act_debit_denied"/>
<field name="act_to" ref="account.act_open_test"/> <field name="act_to" ref="account.act_open_test"/>
<field name="signal">open_test</field>
</record> </record>
</data> </data>
</openerp> </openerp>