Files
enterprise-addons/account_accountant/tests/test_reconciliation_matching_rules.py
T

1995 lines
105 KiB
Python

# -*- coding: utf-8 -*-
from freezegun import freeze_time
from contextlib import closing
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.exceptions import RedirectWarning
from odoo.tests import Form, HttpCase, tagged
from odoo import Command
from odoo.addons.account_accountant.models.account_reconcile_model_line import split_amount_str
from odoo.tests.common import new_test_user
@tagged('post_install', '-at_install')
class TestReconciliationMatchingRules(AccountTestInvoicingCommon, HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
#################
# Company setup #
#################
cls.other_currency = cls.setup_other_currency('EUR')
cls.other_currency_2 = cls.setup_other_currency('CAD', rates=[('2016-01-01', 10.0), ('2017-01-01', 20.0)])
cls.account_rec = cls.company_data['default_account_receivable']
cls.account_pay = cls.company_data['default_account_payable']
cls.current_assets_account = cls.env['account.account'].search([
('account_type', '=', 'asset_current'),
('company_ids', '=', cls.company.id)], limit=1)
cls.bank_journal = cls.env['account.journal'].search([('type', '=', 'bank'), ('company_id', '=', cls.company.id)], limit=1)
cls.cash_journal = cls.env['account.journal'].create({'type': 'cash', 'name': 'Cash'})
cls.tax_account = cls.env['account.account'].create({
'name': 'TAX_ACC',
'code': 'TACC',
'account_type': 'liability_current',
'reconcile': False,
})
cls.tax21 = cls.env['account.tax'].create({
'name': '21%',
'type_tax_use': 'purchase',
'amount': 21,
'invoice_repartition_line_ids': [
(0, 0, {'repartition_type': 'base'}),
(0, 0, {
'repartition_type': 'tax',
'account_id': cls.tax_account.id,
}),
],
'refund_repartition_line_ids': [
(0, 0, {'repartition_type': 'base'}),
(0, 0, {
'repartition_type': 'tax',
'account_id': cls.tax_account.id,
}),
],
})
cls.partner_1 = cls.env['res.partner'].create({'name': 'partner_1', 'company_id': cls.company.id})
cls.partner_2 = cls.env['res.partner'].create({'name': 'partner_2', 'company_id': cls.company.id})
cls.partner_3 = cls.env['res.partner'].create({'name': 'partner_3', 'company_id': cls.company.id})
cls.partner_agrolait = cls.env['res.partner'].create({'name': 'Agrolait', 'company_id': cls.company.id})
###############
# Rules setup #
###############
cls.rule_1 = cls.env['account.reconcile.model'].create({
'name': 'button that shouldn\'t be proposed',
'sequence': 1,
'match_partner_ids': [],
'line_ids': [Command.create({'account_id': cls.current_assets_account.id})],
})
cls.rule_2 = cls.env['account.reconcile.model'].create({
'name': 'button to be completed by each test',
'sequence': 2,
'match_journal_ids': [(6, 0, [cls.bank_journal.id])],
'match_label': 'contains',
'match_label_param': 'fees',
'line_ids': [Command.create({'account_id': cls.current_assets_account.id})],
})
cls.mapping_partner_rule = cls.env['account.reconcile.model'].create({
'name': 'mapping agrolait',
'sequence': 100,
'match_label': 'contains',
'match_label_param': 'agrolait',
'line_ids': [Command.create({'partner_id': cls.partner_agrolait.id})],
})
def _create_and_post_payment(self, amount=100, memo=None, post=True, partner=True, **kwargs):
payment = self.env['account.payment'].create({
'payment_type': 'inbound',
'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id,
'partner_type': 'customer',
'partner_id': self.partner_a.id if partner else False,
'amount': amount,
'journal_id': self.company_data['default_journal_bank'].id,
'memo': memo,
**kwargs,
})
if post:
payment.action_post()
return payment
@classmethod
def _create_invoice_line(cls, amount, partner, move_type, currency=None, ref=None, name=None, inv_date='2019-09-01'):
''' Create an invoice on the fly.'''
invoice_form = Form(cls.env['account.move'].with_context(default_move_type=move_type, default_invoice_date=inv_date, default_date=inv_date))
invoice_form.partner_id = partner
if currency:
invoice_form.currency_id = currency
if ref:
invoice_form.ref = ref
if name:
invoice_form.name = name
with invoice_form.invoice_line_ids.new() as invoice_line_form:
invoice_line_form.name = 'xxxx'
invoice_line_form.quantity = 1
invoice_line_form.price_unit = amount
invoice_line_form.tax_ids.clear()
invoice = invoice_form.save()
invoice.action_post()
lines = invoice.line_ids
return lines.filtered(lambda l: l.account_id.account_type in ('asset_receivable', 'liability_payable'))
@classmethod
def _create_st_line(cls, amount=1000.0, date='2019-01-01', payment_ref='turlututu', **kwargs):
st_line = cls.env['account.bank.statement.line'].create({
'journal_id': kwargs.get('journal_id', cls.bank_journal.id),
'amount': amount,
'date': date,
'payment_ref': payment_ref,
'partner_id': cls.partner_a.id,
**kwargs,
})
return st_line
@classmethod
def _create_reconcile_model(cls, **kwargs):
return cls.env['account.reconcile.model'].create({
'name': "test",
**kwargs,
'line_ids': [
Command.create({
'account_id': cls.company_data['default_account_revenue'].id,
'amount_type': 'percentage',
'label': f"test {i}",
**line_vals,
})
for i, line_vals in enumerate(kwargs.get('line_ids', []))
],
})
@freeze_time('2020-01-01')
def _check_st_line_matching(self, st_line, expected_values, reconciled_amls=None):
reconciled_amls = reconciled_amls or []
self.assertRecordValues(st_line.move_id.line_ids, expected_values)
if not reconciled_amls:
return
for inv_line, rec_aml in zip(st_line.move_id.line_ids.filtered(lambda x: x.account_id.account_type in ('asset_receivable', 'liability_payable')), reconciled_amls):
if inv_line.debit:
self.assertEqual(inv_line.matched_credit_ids.credit_move_id, rec_aml)
else:
self.assertEqual(inv_line.matched_debit_ids.debit_move_id, rec_aml)
def test_matching_buttons(self):
bank_line_1, bank_line_2, cash_line_1 = self.env['account.bank.statement.line'].with_context(auto_statement_processing=True).create([
{
'journal_id': self.bank_journal.id,
'date': '2020-01-01',
'payment_ref': 'invoice 2020-01-01',
'amount': 100,
'sequence': 1,
},
{
'journal_id': self.bank_journal.id,
'date': '2020-01-01',
'payment_ref': 'xxxxxfeesxxx',
'partner_id': self.partner_1.id,
'amount': 30,
'sequence': 2,
},
{
'journal_id': self.cash_journal.id,
'date': '2020-01-01',
'payment_ref': 'yyyyyfees',
'amount': -1000,
'sequence': 1,
},
])
# no need to call the cron since we passed the contextual key to process the lines automatically
self._check_st_line_matching(bank_line_1, [
{'account_id': self.bank_journal.default_account_id.id, 'reconcile_model_id': False},
{'account_id': self.bank_journal.suspense_account_id.id, 'reconcile_model_id': False},
], reconciled_amls=False)
self._check_st_line_matching(bank_line_2, [
{'account_id': self.bank_journal.default_account_id.id, 'reconcile_model_id': False},
{'account_id': self.bank_journal.suspense_account_id.id, 'reconcile_model_id': self.rule_2.id},
], reconciled_amls=False)
self._check_st_line_matching(cash_line_1, [
{'account_id': self.cash_journal.default_account_id.id, 'reconcile_model_id': False},
{'account_id': self.cash_journal.suspense_account_id.id, 'reconcile_model_id': False},
], reconciled_amls=False)
def test_button_with_taxes(self):
bank_line_1 = self.env['account.bank.statement.line'].create([
{
'journal_id': self.bank_journal.id,
'date': '2020-01-01',
'payment_ref': 'xxxagrolaitxxfeesxxx',
'amount': -121,
'sequence': 1,
},
])
rule_tax = self.env['account.reconcile.model'].create({
'name': 'button that includes some taxes on the lines to create',
'sequence': 1,
'match_partner_ids': [],
'line_ids': [Command.create({'account_id': self.current_assets_account.id, 'tax_ids': self.tax21.ids})],
})
# push the button
rule_tax._trigger_reconciliation_model(bank_line_1)
self._check_st_line_matching(bank_line_1, [
{'account_id': self.bank_journal.default_account_id.id, 'reconcile_model_id': False, 'partner_id': False, 'balance': -121.0},
{'account_id': self.current_assets_account.id, 'reconcile_model_id': rule_tax.id, 'partner_id': False, 'balance': 100},
{'account_id': self.tax_account.id, 'reconcile_model_id': False, 'partner_id': False, 'balance': 21},
], reconciled_amls=False)
def test_partner_mapping(self):
bank_line_1 = self.env['account.bank.statement.line'].with_context(auto_statement_processing=True).create([
{
'journal_id': self.bank_journal.id,
'date': '2020-01-01',
'payment_ref': 'xxxagrolaitxxfeesxxx',
'amount': 30,
'sequence': 2,
},
])
self._check_st_line_matching(bank_line_1, [
{'account_id': self.bank_journal.default_account_id.id, 'reconcile_model_id': False, 'partner_id': self.partner_agrolait.id},
{'account_id': self.bank_journal.suspense_account_id.id, 'reconcile_model_id': self.rule_2.id, 'partner_id': self.partner_agrolait.id},
], reconciled_amls=False)
def test_matching_algorithm(self):
self.env['ir.config_parameter'].set_param('account_accountant.bank_rec_payment_tolerance', '0.03')
invoice_line_1 = self._create_invoice_line(100, self.partner_1, 'out_invoice')
invoice_line_2 = self._create_invoice_line(200, self.partner_1, 'out_invoice')
invoice_line_3 = self._create_invoice_line(300, self.partner_1, 'in_refund', name="RBILL/2019/09/0013")
invoice_line_4 = self._create_invoice_line(1000, self.partner_2, 'in_invoice')
_invoice_line_5 = self._create_invoice_line(1000, self.partner_2, 'in_invoice')
_invoice_line_6 = self._create_invoice_line(1000, self.partner_2, 'in_invoice')
invoice_line_7 = self._create_invoice_line(100, self.partner_3, 'out_invoice')
invoice_line_8 = self._create_invoice_line(600, self.partner_3, 'out_invoice', ref="RF12 3456")
_invoice_line_9 = self._create_invoice_line(200, self.partner_3, 'out_invoice')
invoice_line_10 = self._create_invoice_line(200, self.partner_agrolait, 'out_invoice')
self._create_invoice_line(12345.67, self.partner_2, 'out_invoice')
bank_line_1, bank_line_2,\
bank_line_3, bank_line_4,\
bank_line_5 = self.env['account.bank.statement.line'].create([
{
'journal_id': self.bank_journal.id,
'date': '2020-01-01',
'payment_ref': 'all open invoices',
'partner_id': self.partner_1.id,
'amount': 600,
'sequence': 1,
},
{
'journal_id': self.bank_journal.id,
'date': '2020-01-01',
'payment_ref': invoice_line_4.move_id.name,
'partner_id': self.partner_2.id,
'amount': -1600,
'sequence': 2,
},
{
'journal_id': self.bank_journal.id,
'date': '2020-01-01',
'payment_ref': 'approx due amount',
'narration': 'Communication: RF12 3456',
'partner_id': self.partner_3.id,
'amount': 97,
'sequence': 3,
},
{
'journal_id': self.bank_journal.id,
'date': '2020-01-01',
'payment_ref': 'RF12 3456',
'partner_id': self.partner_3.id,
'amount': 100,
'sequence': 4,
},
{
'journal_id': self.bank_journal.id,
'date': '2020-01-01',
'transaction_details': {'no partner?': 'yes: agrolait'},
'ref': 'RF12 3456',
'amount': 200,
'sequence': 5,
},
])
self.env['account.bank.statement.line']._cron_try_auto_reconcile_statement_lines(batch_size=100)
# the total residual of the partner matches the line amount
self._check_st_line_matching(bank_line_1, [
{'account_id': self.bank_journal.default_account_id.id, 'balance': 600.0},
{'account_id': self.account_rec.id, 'balance': -100.0},
{'account_id': self.account_rec.id, 'balance': -200.0},
{'account_id': self.account_pay.id, 'balance': -300.0},
], reconciled_amls=[invoice_line_1, invoice_line_2, invoice_line_3])
# amount isn't the same, but partner and ref are
self._check_st_line_matching(bank_line_2, [
{'account_id': self.bank_journal.default_account_id.id, 'balance': -1600.0},
{'account_id': self.account_pay.id, 'balance': 1000.0},
{'account_id': self.bank_journal.suspense_account_id.id, 'balance': 600.0}, # always the suspense account because it's our way to know the reconciliation isn't over
], reconciled_amls=[invoice_line_4, False])
# partner and amount almost equals the invoice (3% diff allowed)
self._check_st_line_matching(bank_line_3, [
{'account_id': self.bank_journal.default_account_id.id, 'balance': 97.0},
{'account_id': self.account_rec.id, 'balance': -97.0},
], reconciled_amls=[invoice_line_7])
# amount isn't the same, but partner and ref are
self._check_st_line_matching(bank_line_4, [
{'account_id': self.bank_journal.default_account_id.id, 'balance': 100.0},
{'account_id': self.account_rec.id, 'balance': -100.0},
], reconciled_amls=[invoice_line_8])
# mapping of agrolait first, then because we have the partner the correct invoice can be found
self._check_st_line_matching(bank_line_5, [
{'account_id': self.bank_journal.default_account_id.id, 'balance': 200.0, 'partner_id': self.partner_agrolait.id},
{'account_id': self.account_rec.id, 'balance': -200.0, 'partner_id': self.partner_agrolait.id},
], reconciled_amls=[invoice_line_10])
def test_matching_algorithm_for_multiple_invoices(self):
"""Test matching algorithm for multiple invoices with positive statement balance"""
invoice_line_1 = self._create_invoice_line(800, self.partner_1, 'out_invoice')
invoice_line_2 = self._create_invoice_line(900, self.partner_1, 'out_invoice')
invoice_line_3 = self._create_invoice_line(1100, self.partner_1, 'out_invoice')
invoice_line_4 = self._create_invoice_line(1200, self.partner_1, 'out_invoice')
invoice_line_5 = self._create_invoice_line(200, self.partner_1, 'out_invoice')
invoice_line_6 = self._create_invoice_line(200, self.partner_1, 'out_invoice')
invoice_line_7 = self._create_invoice_line(500, self.partner_1, 'out_invoice')
invoice_line_8 = self._create_invoice_line(300, self.partner_1, 'out_invoice')
invoice_line_9 = self._create_invoice_line(150, self.partner_1, 'out_invoice')
invoice_line_10 = self._create_invoice_line(160, self.partner_1, 'out_invoice')
invoice_line_11 = self._create_invoice_line(370, self.partner_1, 'out_invoice')
bank_line_1, bank_line_2,\
bank_line_3, bank_line_4,\
bank_line_5 = self.env['account.bank.statement.line'].create([
{
'journal_id': self.bank_journal.id,
'date': '2020-01-01',
'payment_ref': f"{invoice_line_1.move_name} {invoice_line_2.move_name}",
'partner_id': self.partner_1.id,
'amount': 1700,
'sequence': 1,
},
{
'journal_id': self.bank_journal.id,
'date': '2020-01-01',
'payment_ref': f"{invoice_line_3.move_name} and {invoice_line_4.move_name}",
'partner_id': self.partner_1.id,
'amount': 2100,
'sequence': 2,
},
{
'journal_id': self.bank_journal.id,
'date': '2020-01-01',
'payment_ref': f"{invoice_line_5.move_name} and {invoice_line_6.move_name}",
'partner_id': self.partner_1.id,
'amount': 200,
'sequence': 3,
},
{
'journal_id': self.bank_journal.id,
'date': '2020-01-01',
'payment_ref': f"Partner is not set {invoice_line_7.move_name} {invoice_line_8.move_name}",
'amount': 800,
'sequence': 4,
},
{
'journal_id': self.bank_journal.id,
'date': '2020-01-01',
'payment_ref': f"{invoice_line_9.move_name} {invoice_line_10.move_name} {invoice_line_11.move_name}",
'partner_id': self.partner_1.id,
'amount': 300,
'sequence': 5,
},
])
self.env['account.bank.statement.line']._cron_try_auto_reconcile_statement_lines(batch_size=100)
# payment reference contains invoice numbers of multiple invoices from the same partner, and the total amount matches
self._check_st_line_matching(bank_line_1, [
{'account_id': self.bank_journal.default_account_id.id, 'balance': 1700.0, 'partner_id': self.partner_1.id},
{'account_id': self.account_rec.id, 'balance': -800.0, 'partner_id': self.partner_1.id},
{'account_id': self.account_rec.id, 'balance': -900.0, 'partner_id': self.partner_1.id},
], reconciled_amls=[invoice_line_1, invoice_line_2])
# payment reference contains invoice numbers, but the total amount does not match
self._check_st_line_matching(bank_line_2, [
{'account_id': self.bank_journal.default_account_id.id, 'balance': 2100.0, 'partner_id': self.partner_1.id},
{'account_id': self.account_rec.id, 'balance': -1100.0, 'partner_id': self.partner_1.id},
{'account_id': self.account_rec.id, 'balance': -1000.0, 'partner_id': self.partner_1.id},
], reconciled_amls=[invoice_line_3, invoice_line_4])
# Amount is not enough to reconcile both invoices, so only the first one is reconciled
self._check_st_line_matching(bank_line_3, [
{'account_id': self.bank_journal.default_account_id.id, 'balance': 200.0, 'partner_id': self.partner_1.id},
{'account_id': self.account_rec.id, 'balance': -200.0, 'partner_id': self.partner_1.id},
], reconciled_amls=[invoice_line_5])
# payment reference contains invoice number, total amount matches, but the partner is not set
self._check_st_line_matching(bank_line_4, [
{'account_id': self.bank_journal.default_account_id.id, 'balance': 800.0, 'partner_id': self.partner_1.id},
{'account_id': self.account_rec.id, 'balance': -500.0, 'partner_id': self.partner_1.id},
{'account_id': self.account_rec.id, 'balance': -300.0, 'partner_id': self.partner_1.id},
], reconciled_amls=[invoice_line_7, invoice_line_8])
# The payment amount is not enough to fully reconcile all invoices: the first invoice is fully reconciled, the second invoice is partially reconciled, and the third invoice is not reconciled at all.
self._check_st_line_matching(bank_line_5, [
{'account_id': self.bank_journal.default_account_id.id, 'balance': 300.0, 'partner_id': self.partner_1.id},
{'account_id': self.account_rec.id, 'balance': -150.0, 'partner_id': self.partner_1.id},
{'account_id': self.account_rec.id, 'balance': -150.0, 'partner_id': self.partner_1.id},
], reconciled_amls=[invoice_line_9, invoice_line_10])
def test_matching_algorithm_for_multiple_invoices_for_multi_currency(self):
"""Test matching algorithm for multiple invoices with multi currency"""
invoice_line_1 = self._create_invoice_line(2000, self.partner_1, 'out_invoice', currency=self.other_currency)
invoice_line_2 = self._create_invoice_line(3000, self.partner_1, 'out_invoice', currency=self.company_data['currency'])
# Handle when invoices have different currency
bank_line_1 = self.env['account.bank.statement.line'].create([
{
'journal_id': self.bank_journal.id,
'date': '2020-01-01',
'payment_ref': f"{invoice_line_1.move_name} {invoice_line_2.move_name}",
'partner_id': self.partner_1.id,
'amount': 5000,
'currency_id': self.company_data['currency'].id,
}
])
self.env['account.bank.statement.line']._cron_try_auto_reconcile_statement_lines(batch_size=100)
# proposal should only be in the same currency
self._check_st_line_matching(bank_line_1, [
{'account_id': self.bank_journal.default_account_id.id, 'amount_currency': 5000.0, 'partner_id': self.partner_1.id, 'currency_id': self.company_data['currency'].id, 'balance': 5000},
{'account_id': self.account_rec.id, 'amount_currency': -3000.0, 'partner_id': self.partner_1.id, 'currency_id': self.company_data['currency'].id, 'balance': -3000},
{'account_id': self.bank_journal.suspense_account_id.id, 'amount_currency': -2000.0, 'partner_id': self.partner_1.id, 'currency_id': self.company_data['currency'].id, 'balance': -2000},
], reconciled_amls=[invoice_line_2])
# add manually the invoice_line_1
bank_line_1.set_line_bank_statement_line(invoice_line_1.id)
# Handle when invoices have different currency
self._check_st_line_matching(bank_line_1, [
{'account_id': self.bank_journal.default_account_id.id, 'amount_currency': 5000.0, 'partner_id': self.partner_1.id, 'currency_id': self.company_data['currency'].id, 'balance': 5000},
{'account_id': self.account_rec.id, 'amount_currency': -3000.0, 'partner_id': self.partner_1.id, 'currency_id': self.company_data['currency'].id, 'balance': -3000},
{'account_id': self.account_rec.id, 'amount_currency': -2000.0, 'partner_id': self.partner_1.id, 'currency_id': self.other_currency.id, 'balance': -1000},
{'account_id': self.bank_journal.suspense_account_id.id, 'amount_currency': -1000.0, 'partner_id': self.partner_1.id, 'currency_id': self.company_data['currency'].id, 'balance': -1000},
], reconciled_amls=[invoice_line_2, invoice_line_1])
def test_matching_algorithm_for_multiple_invoices_for_negative_amount(self):
"""Test matching algorithm for multiple invoices with negative statement balance"""
invoice_line_1 = self._create_invoice_line(111, self.partner_1, 'in_invoice')
invoice_line_2 = self._create_invoice_line(300, self.partner_1, 'in_invoice')
bank_line_1 = self.env['account.bank.statement.line'].create([
{
'journal_id': self.bank_journal.id,
'date': '2020-01-01',
'payment_ref': f"{invoice_line_1.move_name} {invoice_line_2.move_name}",
'partner_id': self.partner_1.id,
'amount': -411,
}
])
self.env['account.bank.statement.line']._cron_try_auto_reconcile_statement_lines(batch_size=100)
# Payment reference contains invoice numbers of multiple invoices from the same partner, and the total amount matches but amount is negative
self._check_st_line_matching(bank_line_1, [
{'account_id': self.bank_journal.default_account_id.id, 'balance': -411.0, 'partner_id': self.partner_1.id},
{'account_id': self.account_pay.id, 'balance': 111.0, 'partner_id': self.partner_1.id},
{'account_id': self.account_pay.id, 'balance': 300.0, 'partner_id': self.partner_1.id}
], reconciled_amls=[invoice_line_1, invoice_line_2])
def test_matching_rules_with_payment_memo(self):
"""Test matching algorithm for multiple invoices with same ref"""
invoice_1 = self._create_invoice_line(100, self.partner_a, 'out_invoice', ref="INV Admin - SO2025/127326425")
invoice_2 = self._create_invoice_line(100, self.partner_a, 'out_invoice', ref="INV Admin - SO2025/12237246-13")
invoice_3 = self._create_invoice_line(100, self.partner_a, 'out_invoice', ref="PAY Admin - SO2025/127326425")
bank_line_1 = self._create_st_line(amount=200, payment_ref='SO2025/127326425 and SO2025/12237246-13')
bank_line_1._try_auto_reconcile_statement_lines()
# Both invoice_1 and invoice_3 refs match the st_line label, so only invoice_2 is reconciled
self._check_st_line_matching(bank_line_1, [
{'account_id': bank_line_1.journal_id.default_account_id.id, 'balance': 200.0, 'reconciled': False},
{'account_id': invoice_2.account_id.id, 'balance': -100.0, 'reconciled': True},
{'account_id': bank_line_1.journal_id.suspense_account_id.id, 'balance': -100.0, 'reconciled': False},
], reconciled_amls=[invoice_2])
invoice_3.move_id.button_draft()
invoice_3.move_id.unlink()
bank_line_1._try_auto_reconcile_statement_lines()
# Now that invoice_3 is no more there, invoice_1 is the only one to match the rules
self._check_st_line_matching(bank_line_1, [
{'account_id': bank_line_1.journal_id.default_account_id.id, 'balance': 200.0, 'reconciled': False},
{'account_id': invoice_2.account_id.id, 'balance': -100.0, 'reconciled': True},
{'account_id': invoice_1.account_id.id, 'balance': -100.0, 'reconciled': True},
], reconciled_amls=[invoice_2, invoice_1])
def test_matching_rules_with_wrong_payment_memo(self):
"""Test matching algorithm for multiple invoices with wrong payment_ref."""
invoice_1 = self._create_invoice_line(100, self.partner_a, 'out_invoice', ref="INV Admin - SO2025/127326425")
invoice_2 = self._create_invoice_line(100, self.partner_a, 'out_invoice', ref="INV Admin - SO2025/12237246-13")
self._create_invoice_line(100, self.partner_a, 'out_invoice', ref="INV Admin - paymentSO2025/127326425")
self._create_invoice_line(100, self.partner_a, 'out_invoice', ref="INVAdminSO2025/127326425")
self._create_invoice_line(100, self.partner_a, 'out_invoice', ref="INV Admin - SO2024/127326425")
self._create_invoice_line(100, self.partner_a, 'out_invoice', ref="INV Admin - SO/2025/127326425")
self._create_invoice_line(100, self.partner_a, 'out_invoice', ref="INV Admin SO2025/127326425")
bank_line_1 = self._create_st_line(amount=200, payment_ref='SO2025/127326425 & SO2025/12237246-13')
bank_line_1._try_auto_reconcile_statement_lines()
# Only payment 1 and 2 match the rules, others shouldn't
self._check_st_line_matching(bank_line_1, [
{'account_id': bank_line_1.journal_id.default_account_id.id, 'balance': 200.0, 'reconciled': False},
{'account_id': invoice_1.account_id.id, 'balance': -100.0, 'reconciled': True},
{'account_id': invoice_2.account_id.id, 'balance': -100.0, 'reconciled': True},
], reconciled_amls=[invoice_2, invoice_1])
def test_matching_rules_with_duplicate_payment_memo(self):
"""Test that if an invoice contains a string identifying another invoice, we don't reconcile"""
self._create_invoice_line(100, self.partner_a, 'out_invoice', ref="INV Admin - SO2025/127326425")
self._create_invoice_line(100, self.partner_a, 'out_invoice', ref="INV Admin - SO2025/12237246-13")
self._create_invoice_line(200, self.partner_a, 'out_invoice', ref="INV Admin - SO2025/127326425 - SO2025/12237246-13")
self._create_invoice_line(200, self.partner_a, 'out_invoice', ref="Test payment ref")
self._create_invoice_line(200, self.partner_a, 'out_invoice', ref="Test payment")
bank_line_1 = self._create_st_line(amount=200, payment_ref='SO2025/127326425 & SO2025/12237246-13')
bank_line_1._try_auto_reconcile_statement_lines()
bank_line_2 = self._create_st_line(amount=200, payment_ref='Test payment ref')
bank_line_2._try_auto_reconcile_statement_lines()
# We don't have matches, invoice 3 ref is conflicting with invoice 1 and 2
self.assertRecordValues(bank_line_1.line_ids, [
{'account_id': bank_line_1.journal_id.default_account_id.id, 'balance': 200.0, 'reconciled': False},
{'account_id': bank_line_1.journal_id.suspense_account_id.id, 'balance': -200.0, 'reconciled': False},
])
# 2 invoices match, don't reconcile
self.assertRecordValues(bank_line_2.line_ids, [
{'account_id': bank_line_2.journal_id.default_account_id.id, 'balance': 200.0, 'reconciled': False},
{'account_id': bank_line_2.journal_id.suspense_account_id.id, 'balance': -200.0, 'reconciled': False},
])
def test_matching_rules_with_invoice_having_the_same_reference_and_payment_reference(self):
""" A statement line without a partner should be able to match an invoice even when the reference and the
payement reference of the invoice are the same.
"""
lines = self._create_invoice_line(1000, self.partner_a, 'out_invoice', ref="SO2025/127326425")
invoice = lines.move_id
invoice.payment_reference = 'SO2025/127326425'
bank_line_1 = self._create_st_line(amount=1000, payment_ref='SO2025/127326425', partner_id=False)
bank_line_1._try_auto_reconcile_statement_lines()
self.assertTrue(bank_line_1.is_reconciled, "The statement line should have match the invoice")
self.assertIn(invoice, bank_line_1.line_ids.full_reconcile_id.reconciled_line_ids.move_id)
self.assertEqual(invoice.payment_state, 'paid')
def test_matching_rules_with_same_ref_on_st_line_and_aml(self):
"""Test reconciliation if move_id of st_line have the same ref"""
payment = self._create_and_post_payment(amount=100, memo="Test label ref")
bank_line_1 = self._create_st_line(amount=100, payment_ref='Test label ref')
bank_line_1.move_id.ref = "Test label ref"
bank_line_1._try_auto_reconcile_statement_lines()
self.assertRecordValues(bank_line_1.line_ids, [
{'account_id': bank_line_1.journal_id.default_account_id.id, 'balance': 100.0, 'reconciled': False},
{'account_id': payment.outstanding_account_id.id, 'balance': -100.0, 'reconciled': True},
])
def test_matching_rules_for_full_match_on_aml_ref(self):
"""Test reconciliation full match on aml ref"""
self._create_and_post_payment(amount=100, memo="000100000", partner=False)
bank_line_1 = self._create_st_line(amount=100, payment_ref='000000001000000000123')
bank_line_1._try_auto_reconcile_statement_lines()
# As aml.ref is only a part of a word in st_line.payment_ref, we can't reconcile
self.assertRecordValues(bank_line_1.line_ids, [
{'account_id': bank_line_1.journal_id.default_account_id.id, 'balance': 100.0, 'reconciled': False},
{'account_id': bank_line_1.journal_id.suspense_account_id.id, 'balance': -100.0, 'reconciled': False},
])
def test_matching_rules_with_structured_ref(self):
"""Test reconciliation with structured ref, to be sure special char like '+' are well escaped in the query regex"""
payment = self._create_and_post_payment(amount=100, memo="+++123/456/7890+++")
self._create_and_post_payment(amount=100, memo="+++123/456/7891+++")
bank_line_1 = self._create_st_line(amount=100, payment_ref='+++123/456/7890+++')
bank_line_1._try_auto_reconcile_statement_lines()
self.assertRecordValues(bank_line_1.line_ids, [
{'account_id': bank_line_1.journal_id.default_account_id.id, 'balance': 100.0, 'reconciled': False},
{'account_id': payment.outstanding_account_id.id, 'balance': -100.0, 'reconciled': True},
])
def test_matching_rules_with_multiple_payments_with_same_ref(self):
"""Test reconciliation with structured ref, to be sure special char like '+' are well escaped in the query regex"""
self._create_and_post_payment(amount=100, memo="+++123/456/7890+++")
self._create_and_post_payment(amount=100, memo="+++123/456/7890+++")
self._create_and_post_payment(amount=100, memo="+++123/456/7891+++")
self._create_and_post_payment(amount=100, memo="+++123/456/7891+++")
bank_line_1 = self._create_st_line(amount=200, payment_ref='+++123/456/7890+++')
bank_line_1._try_auto_reconcile_statement_lines()
self.assertRecordValues(bank_line_1.line_ids, [
{'account_id': bank_line_1.journal_id.default_account_id.id, 'balance': 200.0, 'reconciled': False},
{'account_id': bank_line_1.journal_id.suspense_account_id.id, 'balance': -200.0, 'reconciled': False},
])
def test_matching_rules_with_long_ref(self):
"""Test reconciliation with long ref. As it's long, it shouldn't match with the first rule but the second"""
payment = self._create_and_post_payment(amount=100, memo="SO/2025/127/326/425/222/11-01")
self._create_and_post_payment(amount=100, memo="SO/2025/127/326/425/222/11-02")
bank_line_1 = self._create_st_line(amount=100, payment_ref='SO/2025/127/326/425/222/11-01')
bank_line_1._try_auto_reconcile_statement_lines()
self.assertRecordValues(bank_line_1.line_ids, [
{'account_id': bank_line_1.journal_id.default_account_id.id, 'balance': 100.0, 'reconciled': False},
{'account_id': payment.outstanding_account_id.id, 'balance': -100.0, 'reconciled': True},
])
def test_perfect_match_on_move_name(self):
"""Test reconciliation for perfect match on move name"""
payment = self._create_and_post_payment(amount=100, partner=False)
bank_line_1 = self._create_st_line(amount=100, payment_ref=f"{payment.move_id.name}23")
bank_line_1._try_auto_reconcile_statement_lines()
self.assertRecordValues(bank_line_1.line_ids, [
{'account_id': bank_line_1.journal_id.default_account_id.id, 'balance': 100.0, 'reconciled': False},
{'account_id': bank_line_1.journal_id.suspense_account_id.id, 'balance': -100.0, 'reconciled': False},
])
def test_matching_rules_payment_regex(self):
"""Test if the regex matching statement lines & invoices is working properly"""
# Good format
invoice_1 = self._create_invoice_line(100, self.partner_a, 'out_invoice', ref="INV Admin - SO2025/127326425")
invoice_2 = self._create_invoice_line(100, self.partner_a, 'out_invoice', ref="INV Admin - SO2025/12237246-13")
invoice_3 = self._create_invoice_line(100, self.partner_a, 'out_invoice', ref="INV Admin - SO/2025/127326426")
invoice_4 = self._create_invoice_line(100, self.partner_a, 'out_invoice', ref="INV/2025/127326425 - Bob")
invoice_5 = self._create_invoice_line(100, self.partner_a, 'out_invoice', ref="Invoice for Bob with id - py_aesadasea123asdb")
# Wrong format
self._create_invoice_line(100, self.partner_a, 'out_invoice', ref="Admin SaleOrder2025/127326425")
self._create_invoice_line(100, self.partner_a, 'out_invoice', ref="Admin SO2025/127326425")
self._create_invoice_line(100, self.partner_a, 'out_invoice', ref="Admin SO2025/63173462/234/567/890-11")
self._create_invoice_line(100, self.partner_a, 'out_invoice', ref="Invoice for Bob with id py_aesadasea123asdb")
bank_line_1 = self._create_st_line(amount=100, payment_ref='SO2025/127326425 - Odoo Partner: Admin')
bank_line_2 = self._create_st_line(amount=100, payment_ref='Odoo Partner: Admin - SO2025/12237246-13')
bank_line_3 = self._create_st_line(amount=100, payment_ref='SO/2025/127326426 For admin')
bank_line_4 = self._create_st_line(amount=100, payment_ref='INV/2025/127326425 paid on 2025')
bank_line_5 = self._create_st_line(amount=100, payment_ref='py_aesadasea123asdb')
self.env['account.bank.statement.line']._cron_try_auto_reconcile_statement_lines(batch_size=100)
# Everything should be reconciled
self._check_st_line_matching(bank_line_1, [
{'account_id': bank_line_1.journal_id.default_account_id.id, 'balance': 100.0, 'reconciled': False},
{'account_id': invoice_1.account_id.id, 'balance': -100.0, 'reconciled': True},
], reconciled_amls=[invoice_1])
self._check_st_line_matching(bank_line_2, [
{'account_id': bank_line_2.journal_id.default_account_id.id, 'balance': 100.0, 'reconciled': False},
{'account_id': invoice_2.account_id.id, 'balance': -100.0, 'reconciled': True},
], reconciled_amls=[invoice_2])
self._check_st_line_matching(bank_line_3, [
{'account_id': bank_line_3.journal_id.default_account_id.id, 'balance': 100.0, 'reconciled': False},
{'account_id': invoice_3.account_id.id, 'balance': -100.0, 'reconciled': True},
], reconciled_amls=[invoice_3])
self._check_st_line_matching(bank_line_4, [
{'account_id': bank_line_4.journal_id.default_account_id.id, 'balance': 100.0, 'reconciled': False},
{'account_id': invoice_4.account_id.id, 'balance': -100.0, 'reconciled': True},
], reconciled_amls=[invoice_4])
self._check_st_line_matching(bank_line_5, [
{'account_id': bank_line_5.journal_id.default_account_id.id, 'balance': 100.0, 'reconciled': False},
{'account_id': invoice_5.account_id.id, 'balance': -100.0, 'reconciled': True},
], reconciled_amls=[invoice_5])
def test_auto_rule_creation_and_matching(self):
account_a = self.env['account.account'].create({
'name': "Custom Account A",
'code': "010101",
'account_type': "asset_current",
})
account_b = self.env['account.account'].create({
'name': "Custom Account B",
'code': "020202",
'account_type': "asset_current",
})
bank_stmt_line_1 = self._create_st_line(amount=10, payment_ref='VISA PAYMENT RENT ON 2020-01-01 FOR JAN')
bank_stmt_line_2 = self._create_st_line(amount=100, payment_ref='VISA PAYMENT RENT ON 2020-02-01 FOR FEB')
bank_stmt_line_1.set_account_bank_statement_line(bank_stmt_line_1.line_ids[-1].id, account_a.id)
bank_stmt_line_2.set_account_bank_statement_line(bank_stmt_line_2.line_ids[-1].id, account_a.id)
# Assert that the reconciliation model has been created with the correct parameters.
reco_model = self.env['account.reconcile.model'].search([
('created_automatically', '=', True),
('match_label', '=', 'contains'),
('match_label_param', '=', 'VISA PAYMENT RENT ON 2020-0'),
('match_partner_ids', '=', self.partner_a.ids),
('line_ids.account_id', '=', account_a.id),
])
self.assertTrue(reco_model.exists())
bank_stmt_line_3 = self._create_st_line(amount=1000, payment_ref='VISA PAYMENT RENT ON 2020-03-01 FOR MAR')
bank_stmt_line_3._try_auto_reconcile_statement_lines()
# Assert that the created model will be used as a suggestion.
self.assertEqual(bank_stmt_line_3.line_ids[-1].reconcile_model_id.id, reco_model.id)
# Assert that the rule is deleted when another account is selected while having a suggestion.
bank_stmt_line_3.set_account_bank_statement_line(bank_stmt_line_3.line_ids[-1].id, account_b.id)
self.assertFalse(reco_model.exists())
def test_auto_rule_creation_and_matching_case_insensitive_label_less_than_10(self):
account_a = self.env['account.account'].create({
'name': "Custom Account A",
'code': "010101",
'account_type': "asset_current",
})
bank_stmt_line_1 = self._create_st_line(amount=100, payment_ref='Rent')
bank_stmt_line_2 = self._create_st_line(amount=200, payment_ref='RENT')
bank_stmt_line_1.set_account_bank_statement_line(bank_stmt_line_1.line_ids[-1].id, account_a.id)
bank_stmt_line_2.set_account_bank_statement_line(bank_stmt_line_2.line_ids[-1].id, account_a.id)
# Assert that the reconciliation model has been created even if the label is not exactly 10 characters long.
reco_model = self.env['account.reconcile.model'].search([
('created_automatically', '=', True),
('match_label', '=', 'contains'),
('match_label_param', '=', 'RENT'),
])
self.assertTrue(reco_model.exists())
bank_stmt_line_3 = self._create_st_line(amount=300, payment_ref='rent')
bank_stmt_line_3._try_auto_reconcile_statement_lines()
# Assert that the created model will be used as a suggestion.
self.assertEqual(bank_stmt_line_3.line_ids[-1].reconcile_model_id.id, reco_model.id)
def test_auto_rule_creation_and_matching_less_than_10(self):
"""If the length of the common substring is less than 10 it shouldn't create a new rule"""
account_a = self.env['account.account'].create({
'name': "Custom Account A",
'code': "010101",
'account_type': "asset_current",
})
bank_stmt_line_1 = self._create_st_line(amount=100, payment_ref='This is len nine.')
bank_stmt_line_2 = self._create_st_line(amount=200, payment_ref='That isn\'t len nine.')
bank_stmt_line_1.set_account_bank_statement_line(bank_stmt_line_1.line_ids[-1].id, account_a.id)
bank_stmt_line_2.set_account_bank_statement_line(bank_stmt_line_2.line_ids[-1].id, account_a.id)
reco_model = self.env['account.reconcile.model'].search([
('created_automatically', '=', True),
('match_label', '=', 'contains'),
('match_label_param', '=', 'LEN NINE.'),
])
self.assertFalse(reco_model.exists())
def test_auto_rule_creation_and_matching_length_10(self):
"""If the length of the common substring is equal to or more than 10 it should create a new rule"""
account_a = self.env['account.account'].create({
'name': "Custom Account A",
'code': "010101",
'account_type': "asset_current",
})
bank_stmt_line_1 = self._create_st_line(amount=100, payment_ref='This is a len ten.')
bank_stmt_line_2 = self._create_st_line(amount=200, payment_ref='That isn\'t a len ten.')
bank_stmt_line_1.set_account_bank_statement_line(bank_stmt_line_1.line_ids[-1].id, account_a.id)
bank_stmt_line_2.set_account_bank_statement_line(bank_stmt_line_2.line_ids[-1].id, account_a.id)
reco_model = self.env['account.reconcile.model'].search([
('created_automatically', '=', True),
('match_label', '=', 'contains'),
('match_label_param', '=', 'A LEN TEN.'),
])
self.assertTrue(reco_model.exists())
bank_stmt_line_3 = self._create_st_line(amount=300, payment_ref='look who\'s there, a len ten.')
bank_stmt_line_3._try_auto_reconcile_statement_lines()
self.assertEqual(bank_stmt_line_3.line_ids[-1].reconcile_model_id.id, reco_model.id)
def test_auto_rule_creation_and_matching_amounts_sign(self):
"""Checks that the rule creation takes the amount sign into considiration when creating the auto rules"""
account_a = self.env['account.account'].create({
'name': "Custom Account A",
'code': "010101",
'account_type': "asset_current",
})
account_b = self.env['account.account'].create({
'name': "Custom Account B",
'code': "020202",
'account_type': "asset_current",
})
bank_stmt_line_1 = self._create_st_line(amount=100, payment_ref='Rent')
bank_stmt_line_2 = self._create_st_line(amount=200, payment_ref='RENT')
bank_stmt_line_1.set_account_bank_statement_line(bank_stmt_line_1.line_ids[-1].id, account_a.id)
bank_stmt_line_2.set_account_bank_statement_line(bank_stmt_line_2.line_ids[-1].id, account_a.id)
reco_model = self.env['account.reconcile.model'].search([
('created_automatically', '=', True),
('match_label', '=', 'contains'),
('match_label_param', '=', 'RENT'),
('match_amount', '=', 'greater'),
])
self.assertTrue(reco_model.exists())
bank_stmt_line_3 = self._create_st_line(amount=300, payment_ref='rent')
bank_stmt_line_3._try_auto_reconcile_statement_lines()
self.assertTrue(bank_stmt_line_3.line_ids[-1].reconcile_model_id.id)
bank_stmt_line_4 = self._create_st_line(amount=-400, payment_ref='rent')
bank_stmt_line_4._try_auto_reconcile_statement_lines()
self.assertFalse(bank_stmt_line_4.line_ids[-1].reconcile_model_id.id)
bank_stmt_line_5 = self._create_st_line(amount=-500, payment_ref='rent')
bank_stmt_line_4.set_account_bank_statement_line(bank_stmt_line_4.line_ids[-1].id, account_b.id)
bank_stmt_line_5.set_account_bank_statement_line(bank_stmt_line_5.line_ids[-1].id, account_b.id)
reco_model = self.env['account.reconcile.model'].search([
('created_automatically', '=', True),
('match_label', '=', 'contains'),
('match_label_param', '=', 'RENT'),
('match_amount', '=', 'lower'),
])
self.assertTrue(reco_model.exists())
bank_stmt_line_6 = self._create_st_line(amount=-600, payment_ref='rent')
bank_stmt_line_6._try_auto_reconcile_statement_lines()
self.assertTrue(bank_stmt_line_6.line_ids[-1].reconcile_model_id.id)
def test_auto_rule_creation_and_alter_common_substring(self):
"""Checks if the existing auto rule's substring gets altered when a new statement line is added
with the same account as the rule and has a common substring with the existing rule"""
account_a = self.env['account.account'].create({
'name': "Custom Account A",
'code': "010101",
'account_type': "asset_current",
})
bank_stmt_line_1 = self._create_st_line(amount=200, payment_ref='SALARY BALANCE AUGUST 2025 TRANSMITTED ON 31/08/25 VIA BUSINESS DASHBOARD CBC SALARY BALANCE AUGUST')
bank_stmt_line_2 = self._create_st_line(amount=200, payment_ref='SALARY BALANCE SEPTEMBER 2025 TRANSMITTED ON 30/09/25 VIA BUSINESS DASHBOARD CBC SALARY BALANCE SEPTEMBER')
bank_stmt_line_3 = self._create_st_line(amount=200, payment_ref='SALARY BALANCE OCTOBER 2025 TRANSMITTED ON 31/10/25 VIA BUSINESS DASHBOARD CBC SALARY BALANCE OCTOBER')
bank_stmt_line_4 = self._create_st_line(amount=200, payment_ref='BONUS YEAR END 2025 TRANSMITTED ON 29/12/25 VIA BUSINESS DASHBOARD CBC BONUS YEAR END')
bank_stmt_line_1.set_account_bank_statement_line(bank_stmt_line_1.line_ids[-1].id, account_a.id)
bank_stmt_line_2.set_account_bank_statement_line(bank_stmt_line_2.line_ids[-1].id, account_a.id)
reco_model = self.env['account.reconcile.model'].search([
('created_automatically', '=', True),
('match_label', '=', 'contains'),
('match_label_param', '=', '/25 VIA BUSINESS DASHBOARD CBC SALARY BALANCE'),
])
self.assertTrue(reco_model.exists())
bank_stmt_line_3.set_account_bank_statement_line(bank_stmt_line_3.line_ids[-1].id, account_a.id)
reco_model = self.env['account.reconcile.model'].search([
('created_automatically', '=', True),
('match_label', '=', 'contains'),
('match_label_param', '=', '/25 VIA BUSINESS DASHBOARD CBC SALARY BALANCE'),
])
self.assertTrue(reco_model.exists())
bank_stmt_line_4.set_account_bank_statement_line(bank_stmt_line_4.line_ids[-1].id, account_a.id)
reco_model = self.env['account.reconcile.model'].search([
('created_automatically', '=', True),
('match_label', '=', 'contains'),
('match_label_param', '=', '/25 VIA BUSINESS DASHBOARD CBC'),
])
self.assertTrue(reco_model.exists())
def test_auto_rule_creation_and_common_substring_used(self):
"""Checks that when a new statement line is added with the same account as the rule and has a common substring with the existing rule that already
exists in another rule the existing rule isn't changed and a new rule isn't created"""
account_a = self.env['account.account'].create({
'name': "Custom Account A",
'code': "010101",
'account_type': "asset_current",
})
account_b = self.env['account.account'].create({
'name': "Custom Account B",
'code': "020202",
'account_type': "asset_current",
})
bank_stmt_line_1 = self._create_st_line(amount=200, payment_ref='SALARY BALANCE AUGUST 2025 TRANSMITTED ON 31/08/25 VIA BUSINESS DASHBOARD CBC SALARY BALANCE AUGUST')
bank_stmt_line_2 = self._create_st_line(amount=200, payment_ref='SALARY BALANCE SEPTEMBER 2025 TRANSMITTED ON 30/09/25 VIA BUSINESS DASHBOARD CBC SALARY BALANCE SEPTEMBER')
bank_stmt_line_3 = self._create_st_line(amount=200, payment_ref='BONUS YEAR END 2025 TRANSMITTED ON 29/12/25 VIA BUSINESS DASHBOARD CBC BONUS YEAR END')
bank_stmt_line_4 = self._create_st_line(amount=200, payment_ref='ON 29/10/25 VIA BUSINESS DASHBOARD CBC')
bank_stmt_line_5 = self._create_st_line(amount=200, payment_ref='SALARY BALANCE OCTOBER 2025 TRANSMITTED ON 31/10/25 VIA BUSINESS DASHBOARD CBC SALARY BALANCE OCTOBER')
bank_stmt_line_1.set_account_bank_statement_line(bank_stmt_line_1.line_ids[-1].id, account_a.id)
bank_stmt_line_2.set_account_bank_statement_line(bank_stmt_line_2.line_ids[-1].id, account_a.id)
reco_model = self.env['account.reconcile.model'].search([
('created_automatically', '=', True),
('match_label', '=', 'contains'),
('match_label_param', '=', '/25 VIA BUSINESS DASHBOARD CBC SALARY BALANCE'),
])
self.assertTrue(reco_model.exists())
bank_stmt_line_3.set_account_bank_statement_line(bank_stmt_line_3.line_ids[-1].id, account_b.id)
bank_stmt_line_4.set_account_bank_statement_line(bank_stmt_line_4.line_ids[-1].id, account_b.id)
reco_model = self.env['account.reconcile.model'].search([
('created_automatically', '=', True),
('match_label', '=', 'contains'),
('match_label_param', '=', '/25 VIA BUSINESS DASHBOARD CBC SALARY BALANCE'),
])
self.assertTrue(reco_model.exists())
reco_model = self.env['account.reconcile.model'].search([
('created_automatically', '=', True),
('match_label', '=', 'contains'),
('match_label_param', '=', '/25 VIA BUSINESS DASHBOARD CBC'),
])
self.assertTrue(reco_model.exists())
# The new common substring is already used for another rule so it won't be used to alter the existing rule and the existing rule that has
# the common substring should be deleted because a statement line got matched with it and it used an account other than the one in the rule
bank_stmt_line_5.set_account_bank_statement_line(bank_stmt_line_5.line_ids[-1].id, account_a.id)
reco_model = self.env['account.reconcile.model'].search([
('created_automatically', '=', True),
('match_label', '=', 'contains'),
('match_label_param', '=', '/25 VIA BUSINESS DASHBOARD CBC SALARY BALANCE'),
])
self.assertTrue(reco_model.exists())
reco_model = self.env['account.reconcile.model'].search([
('created_automatically', '=', True),
('match_label', '=', 'contains'),
('match_label_param', '=', '/25 VIA BUSINESS DASHBOARD CBC'),
])
self.assertFalse(reco_model.exists())
reco_model = self.env['account.reconcile.model'].search([
('created_automatically', '=', True),
('match_label', '=', 'contains'),
])
self.assertEqual(len(reco_model), 1)
def test_auto_rule_creation_unreconciled_lines_to_match(self):
account_a = self.env['account.account'].create({
'name': "Custom Account A",
'code': "010101",
'account_type': "asset_current",
})
line_to_match_1 = self._create_st_line(amount=100, payment_ref='Payment')
line_to_match_2 = self._create_st_line(amount=200, payment_ref='Payment')
unreconciled_line = self._create_st_line(amount=300, payment_ref='Payment') # To stay unreconciled.
line_to_match_1.set_account_bank_statement_line(line_to_match_1.line_ids[-1].id, account_a.id)
# Assert that the reconciliation model has been created and set_account_bank_statement_line returns the existing
# unreconciled lines that can now match with the new rule.
lines_to_reload = line_to_match_2.set_account_bank_statement_line(line_to_match_2.line_ids[-1].id, account_a.id)
self.assertEqual(lines_to_reload, line_to_match_2 + unreconciled_line)
def test_common_substring_handles_none_safely(self):
"""Test that the common_substring function handles None values safely."""
account_a, account_b = self.env['account.account'].create([{
'name': "Edge Case Account A",
'code': "040404",
'account_type': "asset_current",
}, {
'name': "Temporary Account B",
'code': "050505",
'account_type': "asset_current",
}])
payment_refs = [
'Hello',
'Test',
'Demo',
]
lines = []
for ref in payment_refs:
line = self._create_st_line(amount=1.0, payment_ref=ref)
lines.append(line)
for i in range(2):
lines[i].set_account_bank_statement_line(lines[i].line_ids[-1].id, account_a.id)
lines[2].set_account_bank_statement_line(lines[2].line_ids[-1].id, account_b.id)
lines[2].delete_reconciled_line(lines[2].line_ids[-1].id)
lines[2].set_account_bank_statement_line(lines[2].line_ids[-1].id, account_a.id)
# Assert that the reconciliation model has not been created and no crash occurs.
reco_model = self.env['account.reconcile.model'].search([
('match_label', '=', 'contains'),
('match_label_param', '=', ''),
])
self.assertFalse(reco_model.exists())
def test_auto_rule_creation_and_matching_for_lines_without_payment_ref(self):
""" Assert bank statement lines without a payment reference will not affect the matching algorithm """
account_a = self.env['account.account'].create({
'name': "Custom Account A",
'code': "010101",
'account_type': "asset_current",
})
bank_stmt_line_1 = self._create_st_line(amount=100, payment_ref=None)
bank_stmt_line_2 = self._create_st_line(amount=200, payment_ref=None)
bank_stmt_line_3 = self._create_st_line(amount=300, payment_ref='VISA PAYMENT 300 EUR')
bank_stmt_line_4 = self._create_st_line(amount=400, payment_ref='VISA PAYMENT 400 EUR')
bank_stmt_line_1.set_account_bank_statement_line(bank_stmt_line_1.line_ids[-1].id, account_a.id)
bank_stmt_line_2.set_account_bank_statement_line(bank_stmt_line_2.line_ids[-1].id, account_a.id)
bank_stmt_line_3.set_account_bank_statement_line(bank_stmt_line_3.line_ids[-1].id, account_a.id)
bank_stmt_line_4.set_account_bank_statement_line(bank_stmt_line_4.line_ids[-1].id, account_a.id)
# Assert that the reconciliation model has been created even when not all the recent bank statement lines have
# a payment reference.
reco_model = self.env['account.reconcile.model'].search([
('match_label', '=', 'contains'),
('match_label_param', '=', 'VISA PAYMENT'),
])
self.assertTrue(reco_model.exists())
def test_multiple_auto_rule_creation_with_same_account_for_different_payment_ref(self):
""" Test that when setting same account on statement lines with different payment ref will create multiple reco model """
account_a = self.env['account.account'].create({
'name': "Custom Account A",
'code': "010101",
'account_type': "asset_current",
})
bank_stmt_line_1 = self._create_st_line(amount=100, payment_ref='DINNER PAYMENT 100 EUR')
bank_stmt_line_2 = self._create_st_line(amount=200, payment_ref='DINNER PAYMENT 200 EUR')
bank_stmt_line_3 = self._create_st_line(amount=300, payment_ref='VISA PAYMENT 300 EUR')
bank_stmt_line_4 = self._create_st_line(amount=400, payment_ref='VISA PAYMENT 400 EUR')
bank_stmt_line_1.set_account_bank_statement_line(bank_stmt_line_1.line_ids[-1].id, account_a.id)
bank_stmt_line_2.set_account_bank_statement_line(bank_stmt_line_2.line_ids[-1].id, account_a.id)
bank_stmt_line_3.set_account_bank_statement_line(bank_stmt_line_3.line_ids[-1].id, account_a.id)
bank_stmt_line_4.set_account_bank_statement_line(bank_stmt_line_4.line_ids[-1].id, account_a.id)
reco_models = self.env['account.reconcile.model'].search([
('match_label', '=', 'contains'),
('match_label_param', 'in', ['DINNER PAYMENT', 'VISA PAYMENT']),
])
self.assertTrue(reco_models.exists())
self.assertEqual(len(reco_models), 2, "Reco model should be created for both type of payment refs")
def test_only_one_auto_rule_creation_for_same_payment_ref(self):
""" Test that when setting same account on more than two statement lines with same payment ref will not create more than one
reco model """
account_a = self.env['account.account'].create({
'name': "Custom Account A",
'code': "010101",
'account_type': "asset_current",
})
bank_stmt_line_1 = self._create_st_line(amount=100, payment_ref='OFFICE RENT PAYMENT 100 EUR')
bank_stmt_line_2 = self._create_st_line(amount=200, payment_ref='OFFICE RENT PAYMENT 200 EUR')
bank_stmt_line_3 = self._create_st_line(amount=300, payment_ref='OFFICE RENT PAYMENT 300 EUR')
bank_stmt_line_4 = self._create_st_line(amount=400, payment_ref='OFFICE RENT PAYMENT 400 EUR')
bank_stmt_line_1.set_account_bank_statement_line(bank_stmt_line_1.line_ids[-1].id, account_a.id)
bank_stmt_line_2.set_account_bank_statement_line(bank_stmt_line_2.line_ids[-1].id, account_a.id)
bank_stmt_line_3.set_account_bank_statement_line(bank_stmt_line_3.line_ids[-1].id, account_a.id)
bank_stmt_line_4.set_account_bank_statement_line(bank_stmt_line_4.line_ids[-1].id, account_a.id)
reco_models = self.env['account.reconcile.model'].search([
('match_label', '=', 'contains'),
('match_label_param', 'in', ['OFFICE RENT PAYMENT']),
])
self.assertTrue(reco_models.exists())
self.assertEqual(len(reco_models), 1, "Only one Reco model should be created for same payment refs")
def test_discount_amount(self):
_invoice_line_1 = self._create_invoice_line(100, self.partner_1, 'out_invoice')
invoice_line_2 = self._create_invoice_line(100, self.partner_1, 'out_invoice')
bank_line_1 = self.env['account.bank.statement.line'].with_context(auto_statement_processing=True).create([
{
'journal_id': self.bank_journal.id,
'date': '2020-02-01',
'payment_ref': 'no clear identification of the paid invoice',
'partner_id': self.partner_1.id,
'amount': 95,
'sequence': 1,
}
])
bank_line_1._try_auto_reconcile_statement_lines()
# no proposal, since there are several possibilities
self._check_st_line_matching(bank_line_1, [
{'account_id': self.bank_journal.default_account_id.id, 'balance': 95.0},
{'account_id': self.bank_journal.suspense_account_id.id, 'balance': -95.0},
], reconciled_amls=[])
# add early payment discount info
invoice_line_2.discount_date = '2020-01-15'
invoice_line_2.discount_balance = 95.0
bank_line_1._try_auto_reconcile_statement_lines()
# early payment not matching because payment date is after the discount date
self._check_st_line_matching(bank_line_1, [
{'account_id': self.bank_journal.default_account_id.id, 'balance': 95.0},
{'account_id': self.bank_journal.suspense_account_id.id, 'balance': -95.0},
], reconciled_amls=[])
# add early payment discount info
bank_line_1.date = '2020-01-14'
bank_line_1._try_auto_reconcile_statement_lines()
self._check_st_line_matching(bank_line_1, [
{'account_id': self.bank_journal.default_account_id.id, 'balance': 95.0},
{'account_id': self.account_rec.id, 'balance': -95.0},
], reconciled_amls=[invoice_line_2])
def test_early_payment_discount_partial_payment(self):
invoice = self.init_invoice(move_type='out_invoice', partner=self.partner_a, invoice_date='2019-09-01', amounts=[100])
invoice.invoice_payment_term_id = self.env.ref('account.account_payment_term_30days_early_discount')
invoice.action_post()
st_line = self._create_st_line(amount=50, payment_ref=invoice.name)
st_line._try_auto_reconcile_statement_lines()
self.assertEqual(invoice.payment_state, 'partial')
self._check_st_line_matching(st_line, [
{'account_id': self.bank_journal.default_account_id.id, 'balance': 50.0},
{'account_id': self.account_rec.id, 'balance': -50.0}
])
def test_no_partner_ambiguity(self):
_invoice_line_1 = self._create_invoice_line(600, self.partner_1, 'out_invoice', ref="RF12 3456")
_invoice_line_2 = self._create_invoice_line(600, self.partner_2, 'out_invoice', ref="RF12 3456")
bank_line = self.env['account.bank.statement.line'].with_context(auto_statement_processing=True).create([
{
'journal_id': self.bank_journal.id,
'date': '2020-01-01',
'payment_ref': 'RF12 3456',
'amount': 600,
'sequence': 1,
},
])
# no proposal, since there are several possibilities
self._check_st_line_matching(bank_line, [
{'account_id': self.bank_journal.default_account_id.id, 'balance': 600.0},
{'account_id': self.bank_journal.suspense_account_id.id, 'balance': -600.0},
], reconciled_amls=[])
def test_move_name_caba_tax_account(self):
self.env.company.tax_exigibility = True
tax_account = self.company_data['default_account_tax_sale']
tax_account.reconcile = True
caba_tax = self.env['account.tax'].create({
'name': "CABA",
'amount_type': 'percent',
'amount': 20.0,
'tax_exigibility': 'on_payment',
'cash_basis_transition_account_id': self.safe_copy(tax_account).id,
'invoice_repartition_line_ids': [
Command.create({
'repartition_type': 'base',
}),
Command.create({
'repartition_type': 'tax',
'account_id': tax_account.id,
}),
],
})
invoice = self.env['account.move'].create({
'move_type': 'out_invoice',
'partner_id': self.partner_a.id,
'invoice_date': '2021-07-01',
'invoice_line_ids': [
Command.create({
'name': "test",
'price_unit': 100,
'tax_ids': [Command.set(caba_tax.ids)],
}),
]
})
invoice.action_post()
self.env['account.bank.statement.line'].with_context(auto_statement_processing=True).create({
'amount': 100.0,
'date': '2019-01-01',
'payment_ref': invoice.name,
'journal_id': self.company_data['default_journal_bank'].id,
})
caba_move = invoice.tax_cash_basis_created_move_ids
self.assertEqual(caba_move.line_ids[0].move_name, caba_move.name)
def test_widget_available_for_line(self):
"""
Tests what the reconcileModelPerStatementLineId (js side) will receive
A reco model is valid for a statement line if the value to filter is valid on the statement line or if
it is not specified on the model
Used to know which reconcile model to show in the list on the statement line
"""
self.env['account.reconcile.model'].search([]).unlink() # Don't want it to appear in suggestion
bank_line_1, bank_line_2, bank_line_3 = self.env['account.bank.statement.line'].create([
{
'journal_id': self.bank_journal.id,
'date': '2020-01-01',
'payment_ref': 'Line 1',
'amount': -121,
},
{
'journal_id': self.bank_journal.id,
'date': '2020-01-01',
'payment_ref': 'Line 2',
'partner_id': self.partner_1.id,
'amount': -121,
},
{
'journal_id': self.bank_journal.id,
'date': '2020-01-01',
'payment_ref': 'xxxagrolaitxxfeesxxx',
'partner_id': self.partner_1.id,
'amount': 500,
},
])
model_everywhere = self.env['account.reconcile.model'].create(
{
'name': "Shown everywhere",
'line_ids': [
Command.create({'account_id': self.current_assets_account.id}),
Command.create({'account_id': self.account_pay.id}),
],
},
)
self.env['account.reconcile.model'].create(
{
'name': "Never shown because no Counterpart wit account",
'line_ids': [Command.create({'partner_id': self.partner_1.id})],
},
)
model_partner_a = self.env['account.reconcile.model'].create(
{
'name': "Needs partner 1",
'match_partner_ids': self.partner_1.ids,
'line_ids': [Command.create({'account_id': self.current_assets_account.id})],
},
)
model_amount = self.env['account.reconcile.model'].create(
{
'name': "Needs amount",
'match_amount': 'greater',
'match_amount_min': 200,
'line_ids': [Command.create({'account_id': self.current_assets_account.id})],
},
)
model_partner_a_amount = self.env['account.reconcile.model'].create(
{
'name': "Needs amount and partner 1",
'match_amount': 'greater',
'match_amount_min': 200,
'match_partner_ids': self.partner_1.ids,
'line_ids': [Command.create({'account_id': self.current_assets_account.id})],
},
)
model_label = self.env['account.reconcile.model'].create(
{
'name': "Needs label",
'match_label': 'contains',
'match_label_param': 'fees',
'line_ids': [Command.create({'account_id': self.current_assets_account.id})],
},
)
models_per_line = self.env['account.reconcile.model'].with_context(lang='en_US').get_available_reconcile_model_per_statement_line(
(bank_line_1 + bank_line_2 + bank_line_3).ids
)
self.assertEqual(
models_per_line[bank_line_1.id],
[
{'id': model_everywhere.id, 'display_name': "Shown everywhere"},
],
"Does not match the amounts or partners"
)
self.assertEqual(
models_per_line[bank_line_2.id],
[
{'id': model_everywhere.id, 'display_name': "Shown everywhere"},
{'id': model_partner_a.id, 'display_name': "Needs partner 1"},
],
"Only match the partner"
)
self.assertEqual(
models_per_line[bank_line_3.id],
[
{'id': model_everywhere.id, 'display_name': "Shown everywhere"},
{'id': model_partner_a.id, 'display_name': "Needs partner 1"},
{'id': model_amount.id, 'display_name': 'Needs amount'},
{'id': model_partner_a_amount.id, 'display_name': 'Needs amount and partner 1'},
{'id': model_label.id, 'display_name': 'Needs label'},
],
"Match the partner, the amount and the label"
)
model_label.active = False
bank_line_3_models = self.env['account.reconcile.model'].with_context(lang='en_US') \
.get_available_reconcile_model_per_statement_line((bank_line_3).ids)
self.assertNotIn(
model_label.id,
bank_line_3_models[bank_line_3.id],
"Should not display archived reconcile models"
)
def test_modify_reco_model_apply_on_statement_line(self):
"""
This test will check that modifying a reco model will change the suggestion on statement lines
"""
bank_line_1, bank_line_2 = self.env['account.bank.statement.line'].with_context(auto_statement_processing=True).create([
{
'journal_id': self.bank_journal.id,
'date': '2020-01-01',
'payment_ref': 'fees',
'amount': 100,
},
{
'journal_id': self.bank_journal.id,
'date': '2020-01-01',
'payment_ref': 'blblbl',
'amount': 100,
},
])
self._check_st_line_matching(bank_line_1, [
{'account_id': self.bank_journal.default_account_id.id, 'reconcile_model_id': False},
{'account_id': self.bank_journal.suspense_account_id.id, 'reconcile_model_id': self.rule_2.id},
], reconciled_amls=False)
self.rule_2.match_label_param = 'blblbl'
self._check_st_line_matching(bank_line_1, [
{'account_id': self.bank_journal.default_account_id.id, 'reconcile_model_id': False},
{'account_id': self.bank_journal.suspense_account_id.id, 'reconcile_model_id': False},
], reconciled_amls=False)
self._check_st_line_matching(bank_line_2, [
{'account_id': self.bank_journal.default_account_id.id, 'reconcile_model_id': False},
{'account_id': self.bank_journal.suspense_account_id.id, 'reconcile_model_id': self.rule_2.id},
], reconciled_amls=False)
def test_create_reco_model_apply_on_statement_line(self):
bank_line = self.env['account.bank.statement.line'].with_context(auto_statement_processing=True).create([{
'journal_id': self.bank_journal.id,
'date': '2020-01-01',
'payment_ref': 'blblbl',
'amount': 100,
}]
)
self._check_st_line_matching(bank_line, [
{'account_id': self.bank_journal.default_account_id.id, 'reconcile_model_id': False},
{'account_id': self.bank_journal.suspense_account_id.id, 'reconcile_model_id': False},
], reconciled_amls=False)
new_rule = self.env['account.reconcile.model'].create({
'name': 'new rule',
'sequence': 3,
'match_journal_ids': [Command.set([self.bank_journal.id])],
'match_label': 'contains',
'match_label_param': 'blblbl',
'line_ids': [Command.create({'account_id': self.current_assets_account.id})],
})
self._check_st_line_matching(bank_line, [
{'account_id': self.bank_journal.default_account_id.id, 'reconcile_model_id': False},
{'account_id': self.bank_journal.suspense_account_id.id, 'reconcile_model_id': new_rule.id},
], reconciled_amls=False)
def test_archive_reco_model(self):
bank_line = self.env['account.bank.statement.line'].with_context(auto_statement_processing=True).create([{
'journal_id': self.bank_journal.id,
'date': '2020-01-01',
'payment_ref': 'blblbl',
'amount': 100,
}]
)
self._check_st_line_matching(bank_line, [
{'account_id': self.bank_journal.default_account_id.id, 'reconcile_model_id': False},
{'account_id': self.bank_journal.suspense_account_id.id, 'reconcile_model_id': False},
], reconciled_amls=False)
new_rule = self.env['account.reconcile.model'].create({
'name': 'new rule',
'match_journal_ids': [Command.set(self.bank_journal.ids)],
'match_label': 'contains',
'match_label_param': 'blblbl',
'line_ids': [Command.create({'account_id': self.current_assets_account.id})],
})
self._check_st_line_matching(bank_line, [
{'account_id': self.bank_journal.default_account_id.id, 'reconcile_model_id': False},
{'account_id': self.bank_journal.suspense_account_id.id, 'reconcile_model_id': new_rule.id},
], reconciled_amls=False)
new_rule.action_archive()
self._check_st_line_matching(bank_line, [
{'account_id': self.bank_journal.default_account_id.id, 'reconcile_model_id': False},
{'account_id': self.bank_journal.suspense_account_id.id, 'reconcile_model_id': False},
], reconciled_amls=False)
def test_copy_reco_model_created_automatically(self):
rule = self.env['account.reconcile.model'].create({
'name': 'new rule',
'match_journal_ids': [Command.set(self.bank_journal.ids)],
'match_label': 'contains',
'match_label_param': 'blblbl',
'line_ids': [Command.create({'account_id': self.current_assets_account.id})],
'created_automatically': True,
})
copied_rule = rule.copy()
self.assertFalse(copied_rule.created_automatically)
def test_get_common_substring_of_labels(self):
"""
Test the _get_common_substring helper method.
It should return the longest common substring if len>10,
or if all normalised labels are identical, the normalised label.
Else, return None.
"""
# Long (>=10) common substring
long_common_refs = [x + ' is Gamora.' for x in ('Where', 'Who', 'Why')]
long_common_bl = [self._create_st_line(payment_ref=x) for x in long_common_refs]
self.assertEqual(long_common_bl[0]._get_common_substring([x.payment_ref for x in long_common_bl]), 'IS GAMORA.')
# Short (<10) but identical normalised string
short_normalised_refs = ['Odoo' for x in (18, 19, 9000)]
short_normalised_bl = [self._create_st_line(payment_ref=x) for x in short_normalised_refs]
self.assertEqual(short_normalised_bl[0]._get_common_substring([x.payment_ref for x in short_normalised_bl]), 'ODOO')
# Short (<10) non-identical normalised string
short_common_refs = ['Great ' + x for x in ('power', 'responsibility')]
short_common_bl = [self._create_st_line(payment_ref=x) for x in short_common_refs]
self.assertEqual(short_common_bl[0]._get_common_substring([x.payment_ref for x in short_common_bl]), None)
def test_apply_reco_model_with_bad_counterpart_regex(self):
bad_regex_model = self.env['account.reconcile.model'].create({
'name': 'new rule',
'line_ids': [
Command.create({
'account_id': self.current_assets_account.id,
'amount_type': 'regex',
'amount_string': r'missing parentheses: \d+',
'label': 'Should raise a (caught) IndexError',
}),
Command.create({
'account_id': self.current_assets_account.id,
'amount_type': 'regex',
'amount_string': r'does not capture a float value: ([a-z]+)',
'label': 'Should raise a (caught) AttributeError',
}),
Command.create({
'account_id': self.current_assets_account.id,
'amount_type': 'regex',
'amount_string': r'can capture an empty value: ([\d]*)',
'label': 'Can raise a (caught) AttributeError',
}),
],
})
bank_line_1 = self.env['account.bank.statement.line'].create([
{
'journal_id': self.bank_journal.id,
'date': '2020-01-01',
'payment_ref': 'missing parentheses: 1234',
'amount': 1234,
},
])
bank_line_2 = self.env['account.bank.statement.line'].create([
{
'journal_id': self.bank_journal.id,
'date': '2020-01-01',
'payment_ref': 'does not capture a float value: notafloatvalue',
'amount': 1234,
},
])
bank_line_3 = self.env['account.bank.statement.line'].create([
{
'journal_id': self.bank_journal.id,
'date': '2020-01-01',
'payment_ref': 'can capture an empty value: ',
'amount': 1234,
},
])
with self.assertRaises(RedirectWarning, msg="The regex has to capture the amount in parenthesis"):
bad_regex_model._trigger_reconciliation_model(bank_line_1)
with self.assertRaises(RedirectWarning, msg="The regex has to capture a float value"):
bad_regex_model._trigger_reconciliation_model(bank_line_2)
with self.assertRaises(RedirectWarning, msg="The regex cannot capture an empty value"):
bad_regex_model._trigger_reconciliation_model(bank_line_3)
def test_no_reco_model_on_receivable_payable_button(self):
bank_stmt_line_1 = self._create_st_line(amount=10, payment_ref='This is a test')
bank_stmt_line_2 = self._create_st_line(amount=100, payment_ref='This is a test')
bank_stmt_line_1.set_account_bank_statement_line(bank_stmt_line_1.line_ids[-1].id, self.company_data['default_account_receivable'].id)
bank_stmt_line_2.set_account_bank_statement_line(bank_stmt_line_2.line_ids[-1].id, self.company_data['default_account_receivable'].id)
bank_stmt_line_3 = self._create_st_line(amount=1000, payment_ref='This is a test')
# Check that no reconciliation model is set on the suspense line as we set a receivable account manually
self.assertFalse(bank_stmt_line_3.line_ids[-1].reconcile_model_id.id)
def test_apply_reco_model_other_currency(self):
new_journal = self.env['account.journal'].create({
'name': 'test',
'code': 'TBNK',
'type': 'bank',
'currency_id': self.other_currency.id,
})
bank_stmt_line = self._create_st_line(amount=100, journal_id=new_journal.id)
reco_model = self.env['account.reconcile.model'].create({
'name': 'New reco model',
'match_journal_ids': [Command.set([new_journal.id])],
'match_label': 'contains',
'match_label_param': 'turlututu',
'line_ids': [Command.create({
'account_id': self.current_assets_account.id,
'amount_type': 'percentage',
'amount': 100,
'label': 'test',
})],
})
self._check_st_line_matching(bank_stmt_line, [
{'account_id': new_journal.default_account_id.id, 'reconcile_model_id': False},
{'account_id': new_journal.suspense_account_id.id, 'reconcile_model_id': reco_model.id},
], reconciled_amls=False)
reco_model._trigger_reconciliation_model(bank_stmt_line)
self.assertRecordValues(bank_stmt_line.line_ids, [
{'account_id': bank_stmt_line.journal_id.default_account_id.id, 'balance': 50, 'amount_currency': 100, 'reconciled': False},
{'account_id': self.current_assets_account.id, 'balance': -50.0, 'amount_currency': -100, 'reconciled': False},
])
def test_reco_model_with_activities(self):
activity_type_id = self.env.ref('mail.mail_activity_data_todo').id
reco_model = self.env['account.reconcile.model'].create({
'name': 'new rule',
'match_label': 'contains',
'match_label_param': 'fees',
'next_activity_type_id': activity_type_id,
})
st_line = self.env['account.bank.statement.line'].create({
'journal_id': self.bank_journal.id,
'date': '2020-01-01',
'payment_ref': 'fees',
'amount': 100,
})
reco_model._trigger_reconciliation_model(st_line)
self.assertEqual(st_line.activity_ids.activity_type_id.id, activity_type_id)
def test_match_amount_in_between_neg_amount(self):
bank_stmt_line = self._create_st_line(amount=-1500)
between_rule = self.env['account.reconcile.model'].create(
{
'name': "Between neg amount",
'match_amount': 'between',
'match_amount_min': -1000,
'match_amount_max': -2000,
'line_ids': [Command.create({'account_id': self.current_assets_account.id})],
},
)
self._check_st_line_matching(bank_stmt_line, [
{'account_id': self.bank_journal.default_account_id.id, 'reconcile_model_id': False},
{'account_id': self.bank_journal.suspense_account_id.id, 'reconcile_model_id': between_rule.id},
], reconciled_amls=False)
# Check if it works both ways
between_rule.update({
'match_amount_min': -2000,
'match_amount_max': -1000,
})
self._check_st_line_matching(bank_stmt_line, [
{'account_id': self.bank_journal.default_account_id.id, 'reconcile_model_id': False},
{'account_id': self.bank_journal.suspense_account_id.id, 'reconcile_model_id': between_rule.id},
], reconciled_amls=False)
def test_create_multiple_reco_model_with_same_account(self):
account_a = self.env['account.account'].create({
'name': "Custom Account A",
'code': "010101",
'account_type': "asset_current",
})
st_line_1 = self._create_st_line(amount=100, payment_ref='This is a test')
st_line_2 = self._create_st_line(amount=100, payment_ref='This is a test')
st_line_3 = self._create_st_line(amount=1000, payment_ref='This is a test')
st_line_1.set_account_bank_statement_line(st_line_1.line_ids[-1].id, account_a.id)
st_line_2.set_account_bank_statement_line(st_line_2.line_ids[-1].id, account_a.id)
# Check that a reco model has been created with the right name
self.assertEqual(st_line_3.line_ids[-1].reconcile_model_id.name, "Custom Account A")
st_line_4 = self._create_st_line(amount=100, payment_ref='Rent bla bla bla')
st_line_5 = self._create_st_line(amount=100, payment_ref='Rent bla bla bla')
st_line_6 = self._create_st_line(amount=100, payment_ref='Rent bla bla bla')
st_line_4.set_account_bank_statement_line(st_line_4.line_ids[-1].id, account_a.id)
st_line_5.set_account_bank_statement_line(st_line_5.line_ids[-1].id, account_a.id)
# Check that a reco model has been created with the right name
self.assertEqual(st_line_6.line_ids[-1].reconcile_model_id.name, "Custom Account A")
def test_matching_outstanding_accounts(self):
another_journal_id = self.env['account.journal'].create({'name': 'another journal', 'type': 'bank', 'code': 'BNKX'}).id
self._create_and_post_payment(amount=100, memo="SO2025/127326425", journal_id=another_journal_id)
bank_stmt_line = self._create_st_line(amount=100, payment_ref='SO2025/127326425')
bank_stmt_line._try_auto_reconcile_statement_lines()
# no proposal, since the journal of the outstanding payment is different than the one from the statement line
self.assertRecordValues(bank_stmt_line.line_ids, [
{'account_id': self.bank_journal.default_account_id.id, 'balance': 100.0, 'reconciled': False},
{'account_id': self.bank_journal.suspense_account_id.id, 'balance': -100.0, 'reconciled': False},
])
payment_2 = self._create_and_post_payment(amount=100, memo="SO2025/127326425 - INV/2025/05/0656")
bank_stmt_line._try_auto_reconcile_statement_lines()
# matching done, since a part of the commmunication (split on ' - ') of the outstanding payment can be found in the transaction
self.assertRecordValues(bank_stmt_line.line_ids, [
{'account_id': self.bank_journal.default_account_id.id, 'balance': 100.0, 'reconciled': False},
{'account_id': payment_2.outstanding_account_id.id, 'balance': -100.0, 'reconciled': True},
])
def test_matching_different_journal_with_outstanding_accounts(self):
another_journal = self.env['account.journal'].create({
'name': 'another journal',
'type': 'bank',
'code': 'BNKX',
})
another_journal.inbound_payment_method_line_ids[0].payment_account_id = self.inbound_payment_method_line.payment_account_id.copy()
self._create_and_post_payment(amount=100, memo="SO2025/127326425", journal_id=another_journal.id)
bank_stmt_line = self._create_st_line(amount=100, payment_ref='SO2025/127326425')
bank_stmt_line._try_auto_reconcile_statement_lines()
# still no proposal, since the journal of the outstanding payment is different than the one from the statement line
self.assertRecordValues(bank_stmt_line.line_ids, [
{'account_id': self.bank_journal.default_account_id.id, 'balance': 100.0, 'reconciled': False},
{'account_id': self.bank_journal.suspense_account_id.id, 'balance': -100.0, 'reconciled': False},
])
bank_stmt_line_2 = self._create_st_line(amount=200, payment_ref='some random reference')
payment = self._create_and_post_payment(amount=200, memo="SO2026/127326426")
bank_stmt_line_2._try_auto_reconcile_statement_lines()
# matching done via outstanding account, journal and amount
self.assertRecordValues(bank_stmt_line_2.line_ids, [
{'account_id': self.bank_journal.default_account_id.id, 'balance': 200.0, 'reconciled': False},
{'account_id': payment.outstanding_account_id.id, 'balance': -200.0, 'reconciled': True},
])
def test_matching_with_outstanding_accounts_partial_payment_invoice_user(self):
""" Test that a basic accounting user can automatically match a bank statement
line with an outstanding payment linked to an invoice without triggering
an incorrect over-reconciliation.
"""
invoicing_banks_user = new_test_user(self.env, 'inv_bank', groups='account.group_account_basic')
invoice = self._create_invoice_line(1100, self.partner_1, 'out_invoice').move_id
payment = self._create_and_post_payment(amount=500, memo=invoice.name, partner_id=self.partner_1.id)
bank_stmt_line = self._create_st_line(amount=500, payment_ref=invoice.name, partner_id=self.partner_1.id)
bank_stmt_line.with_user(invoicing_banks_user.id)._try_auto_reconcile_statement_lines()
self.assertRecordValues(bank_stmt_line.line_ids, [
{'account_id': self.bank_journal.default_account_id.id, 'balance': 500.0, 'reconciled': False, 'account_name': 'Bank'},
{'account_id': payment.outstanding_account_id.id, 'balance': -500.0, 'reconciled': True, 'account_name': 'Outstanding Receipts'},
])
def test_not_matching_when_outstanding_account_is_liquidity(self):
bank_stmt_line = self._create_st_line(amount=200, payment_ref='some random reference')
bank_stmt_line.journal_id.inbound_payment_method_line_ids[0].payment_account_id = bank_stmt_line.journal_id.default_account_id
self._create_and_post_payment(amount=200, memo="SO2026/127326426")
bank_stmt_line._try_auto_reconcile_statement_lines()
# no match because the outstanding account is the liquidity account
self.assertRecordValues(bank_stmt_line.line_ids, [
{'account_id': self.bank_journal.default_account_id.id, 'balance': 200.0, 'reconciled': False},
{'account_id': self.bank_journal.suspense_account_id.id, 'balance': -200.0, 'reconciled': False},
])
def test_ref_included_in_another(self):
_invoice_line_1 = self._create_invoice_line(600, self.partner_1, 'out_invoice', ref="INV/2025/08/10")
invoice_line_2 = self._create_invoice_line(600, self.partner_2, 'out_invoice', ref="INV/2025/08/101")
bank_line = self.env['account.bank.statement.line'].with_context(auto_statement_processing=True).create([
{
'journal_id': self.bank_journal.id,
'date': '2020-01-01',
'payment_ref': 'INV/2025/08/101',
'amount': 600,
'sequence': 1,
},
])
# _invoice_line_1 and invoice_line_2 both found, but the ref of _invoice_line_1 isn't a complete word in
# payment_ref, so it should be rejected and only invoice_line_2 kept and reconciled since there's no
# ambiguity anymore
self._check_st_line_matching(bank_line, [
{'account_id': bank_line.journal_id.default_account_id.id, 'balance': 600.0, 'reconciled': False},
{'account_id': invoice_line_2.account_id.id, 'balance': -600.0, 'reconciled': True},
], reconciled_amls=[invoice_line_2])
def test_negative_contains_matching_rule(self):
rule = self.env['account.reconcile.model'].create({
'name': 'Not contains rule',
'sequence': 3,
'match_label': 'not_contains',
'match_label_param': 'test',
'line_ids': [Command.create({
'account_id': self.current_assets_account.id,
'amount_type': 'percentage',
'amount': 100,
'label': 'Counterpart',
})],
})
st_line = self.env['account.bank.statement.line'].with_context(auto_statement_processing=True).create({
'journal_id': self.bank_journal.id,
'date': '2020-01-01',
'payment_ref': 'some payment ref',
'amount': 100,
})
self._check_st_line_matching(st_line, [
{'account_id': self.bank_journal.default_account_id.id, 'reconcile_model_id': False},
{'account_id': self.bank_journal.suspense_account_id.id, 'reconcile_model_id': rule.id},
], reconciled_amls=False)
def test_reconcile_model_multiple_statement_line(self):
reco_model = self.env['account.reconcile.model'].create({
'name': 'test reco model',
'line_ids': [Command.create({'account_id': self.company_data['default_account_revenue'].id})],
})
st_line_1 = self._create_st_line(500.0)
st_line_2 = self._create_st_line(500.0)
reco_model.trigger_reconciliation_model((st_line_1 + st_line_2).ids)
self.assertRecordValues(st_line_1.line_ids, [
{'account_id': st_line_1.journal_id.default_account_id.id, 'amount_currency': 500.0, 'balance': 500.0, 'reconciled': False},
{'account_id': self.company_data['default_account_revenue'].id, 'amount_currency': -500.0, 'balance': -500.0, 'reconciled': False},
])
self.assertRecordValues(st_line_2.line_ids, [
{'account_id': st_line_2.journal_id.default_account_id.id, 'amount_currency': 500.0, 'balance': 500.0, 'reconciled': False},
{'account_id': self.company_data['default_account_revenue'].id, 'amount_currency': -500.0, 'balance': -500.0, 'reconciled': False},
])
def test_reconciliation_model_preserves_payment_ref_as_label(self):
""" Ensure that both automatically generated and manual reconciliation models
preserve the statement line's payment reference as the label ('name')
on the created counterpart move lines.
"""
account_a = self.env['account.account'].create({
'name': "Custom Account A",
'code': "010101",
'account_type': "asset_current",
})
bank_account = self.bank_journal.default_account_id
# Automatic reconciliation model
st_line_1 = self._create_st_line(amount=100, payment_ref='This is a test')
st_line_2 = self._create_st_line(amount=100, payment_ref='This is a test')
st_line_3 = self._create_st_line(amount=1000, payment_ref='This is a test')
(st_line_1 + st_line_2).set_account_bank_statement_line([st_line_1.line_ids[-1].id, st_line_2.line_ids[-1].id], account_a.id)
# Check that a reco model has been created with the right name
self.assertEqual(st_line_3.line_ids[-1].reconcile_model_id.name, "Custom Account A")
st_line_3.line_ids[-1].reconcile_model_id._trigger_reconciliation_model(st_line_3)
self.assertRecordValues(st_line_3.line_ids, [
{'account_id': bank_account.id, 'name': 'This is a test'},
{'account_id': account_a.id, 'name': 'This is a test'},
])
# Manual reconciliation model
new_rule = self.env['account.reconcile.model'].create({
'name': 'Manual Rule',
'match_label': 'contains',
'match_label_param': 'manual',
'line_ids': [Command.create({'account_id': account_a.id})],
})
st_line_4 = self._create_st_line(amount=100, payment_ref='This is a manual test')
new_rule._trigger_reconciliation_model(st_line_4)
self.assertRecordValues(st_line_4.line_ids, [
{'account_id': bank_account.id, 'name': 'This is a manual test'},
{'account_id': account_a.id, 'name': 'This is a manual test'},
])
def test_reconciliation_model_extracts_decimal_amount_from_regex(self):
salary_account = self.env['account.account'].create({
'name': "Salary Account",
'code': "501010",
'account_type': "liability_current",
})
bank_fees_account = self.env['account.account'].create({
'name': "Bank Fees Account",
'code': "510101",
'account_type': "expense",
})
model = self.env['account.reconcile.model'].create({
'name': 'new rule',
'match_label': 'contains',
'match_label_param': 'uid 01870912',
'line_ids': [
Command.create({
'account_id': salary_account.id,
'amount_type': 'regex',
'amount_string': r'uid 01870912\s+0*(\d+?)(\d{2})(?=\s)',
}),
Command.create({
'account_id': bank_fees_account.id,
'amount_type': 'regex',
'amount_string': r'uid 01870912\s+\d+\s+0*(\d+?)(\d{2})(?=\s)',
}),
],
})
bank_stm_line = self.env['account.bank.statement.line'].create([
{
'journal_id': self.bank_journal.id,
'date': '2020-01-01',
'payment_ref': '001 uid 01870912 0000009065 000000115 00000 27 09',
'amount': 89.50,
},
])
model._trigger_reconciliation_model(bank_stm_line)
self.assertRecordValues(bank_stm_line.line_ids, [
{'account_id': self.bank_journal.default_account_id.id, 'balance': 89.50, 'amount_currency': 89.50},
{'account_id': salary_account.id, 'balance': -90.65, 'amount_currency': -90.65},
{'account_id': bank_fees_account.id, 'balance': 1.15, 'amount_currency': 1.15},
])
# EU format test
bank_stm_line2 = self.env['account.bank.statement.line'].create({
'journal_id': self.bank_journal.id,
'date': '2020-01-01',
'payment_ref': 'Salary BRUT: 1.344,00 COMM 7,71',
'amount': 1336.29,
})
model2 = self.env['account.reconcile.model'].create({
'name': 'format rule',
'match_label': 'contains',
'match_label_param': 'BRUT',
'line_ids': [
Command.create({
'account_id': salary_account.id,
'amount_type': 'regex',
'amount_string': r'BRUT:\s*(\d[\d.,]*)',
}),
Command.create({
'account_id': bank_fees_account.id,
'amount_type': 'regex',
'amount_string': r'COMM\s*(\d[\d.,]*)',
}),
],
})
model2._trigger_reconciliation_model(bank_stm_line2)
self.assertRecordValues(bank_stm_line2.line_ids, [
{'account_id': self.bank_journal.default_account_id.id, 'balance': 1336.29, 'amount_currency': 1336.29},
{'account_id': salary_account.id, 'balance': -1344.00, 'amount_currency': -1344.00},
{'account_id': bank_fees_account.id, 'balance': 7.71, 'amount_currency': 7.71},
])
def test_reconciliation_model_regex_amount_currency_conversion(self):
salary_account = self.env['account.account'].create({
'name': "Salary Account",
'code': "501010",
'account_type': "liability_current",
})
bank_fees_account = self.env['account.account'].create({
'name': "Bank Fees Account",
'code': "510101",
'account_type': "expense",
})
other_currency_bank = self.bank_journal.copy({'currency_id': self.other_currency_2.id})
bank_stm_line = self.env['account.bank.statement.line'].create({
'journal_id': other_currency_bank.id,
'date': '2020-01-01',
'payment_ref': 'Salary BRUT: 1.344,00 COMM 7,71',
'amount': 1336.29,
})
model = self.env['account.reconcile.model'].create({
'name': 'format rule',
'match_label': 'contains',
'match_label_param': 'BRUT',
'line_ids': [
Command.create({
'account_id': salary_account.id,
'amount_type': 'regex',
'amount_string': r'BRUT:\s*(\d[\d.,]*)',
}),
Command.create({
'account_id': bank_fees_account.id,
'amount_type': 'regex',
'amount_string': r'COMM\s*(\d[\d.,]*)',
}),
],
})
model._trigger_reconciliation_model(bank_stm_line)
self.assertRecordValues(bank_stm_line.line_ids, [
{'account_id': other_currency_bank.default_account_id.id, 'balance': 66.81, 'amount_currency': 1336.29},
{'account_id': salary_account.id, 'balance': -67.2, 'amount_currency': -1344.00},
{'account_id': bank_fees_account.id, 'balance': 0.39, 'amount_currency': 7.71},
])
def test_split_amount_string(self):
test_cases = [
# Plain integer
('1334', ('1334', '0')),
('', ('0', '0')),
('1 334', ('1334', '0')),
('1 334,56', ('1334', '56')),
('1 334.56', ('1334', '56')),
("1'334'567,89", ('1334567', '89')),
("1'334'567", ('1334567', '0')),
("1'334'567.89", ('1334567', '89')),
# European format: dot=thousands, comma=decimal
('1.334,00', ('1334', '00')),
('1.334,07', ('1334', '07')), # leading zero preserved
('1.334.567,89', ('1334567', '89')),
# US format: comma=thousands, dot=decimal
('1,334.00', ('1334', '00')),
('1,334,567.89', ('1334567', '89')),
# Unambiguous decimals
('1.50', ('1', '50')),
('1,5', ('1', '5')),
('1.0', ('1', '0')),
('1.00', ('1', '00')),
# ambiguous decimals but we assume the best scenario
('1.000', ('1000', '0')),
('1,000', ('1000', '0')),
# edge cases
('0.123', ('0', '123')),
('0,1234', ('0', '1234')),
('1234.567', ('1234', '567')),
('1234,567', ('1234', '567')),
('1 234.567', ('1234', '567')),
("1'234.567", ('1234', '567')),
('1.2.3', ('123', '0')),
('1.2.3,4,5.6', ('0', '0')),
]
for value, expected in test_cases:
with self.subTest(value=value):
self.assertEqual(split_amount_str(value), expected)
def test_matching_rules_parent_and_branch_companies(self):
branch_a = self.setup_other_company(name='Test Branch A', parent_id=self.company_data['company'].id)
invoice_1 = self._create_invoice_one_line(
price_unit=100,
quantity=1,
date='2019-09-01',
move_name='INV/2019/0010',
company_id=branch_a['company'].id,
)
invoice_line_1 = invoice_1.line_ids.filtered(lambda l: l.account_id.account_type in ('asset_receivable', 'liability_payable'))
statement_line = self._create_st_line(amount=100, date='2019-09-01', payment_ref='INV/2019/0010')
statement_line._try_auto_reconcile_statement_lines()
self._check_st_line_matching(statement_line, [
{'account_id': self.bank_journal.default_account_id.id, 'balance': 100.0},
{'account_id': self.account_rec.id, 'balance': -100.0},
], reconciled_amls=[invoice_line_1])
def test_matching_rules_parent_and_branch_companies_for_payment_with_outstanding_account(self):
branch_a = self.setup_other_company(name='Test Branch A', parent_id=self.company_data['company'].id)
payment = self._create_and_post_payment(amount=100, memo="INV/2019/0010", company_id=branch_a['company'].id)
statement_line = self._create_st_line(amount=100, date='2019-09-01', payment_ref='INV/2019/0010')
statement_line._try_auto_reconcile_statement_lines()
self.assertRecordValues(statement_line.line_ids, [
{'account_id': statement_line.journal_id.default_account_id.id, 'balance': 100.0, 'reconciled': False},
{'account_id': payment.outstanding_account_id.id, 'balance': -100.0, 'reconciled': True},
])
def test_bank_reconciliation_ui_context_keys(self):
"""
Verify that the bank reconciliation view sets the required context keys
when accessed directly (e.g. via URL or bookmark), bypassing
`_action_open_bank_reconciliation_widget` which normally provides them:
- `auto_statement_processing`: triggers auto-reconciliation on statement creation.
- `bank_statements_source`: hides the upload button for synchronized journals.
"""
self.env['account.reconcile.model'].create({
'name': 'Contains test',
'sequence': 1,
'match_label': 'contains',
'match_label_param': 'test',
'line_ids': [Command.create({
'account_id': self.current_assets_account.id,
'amount_type': 'percentage',
'amount': 100,
'label': 'Counterpart',
})],
'trigger': 'auto_reconcile'
})
online_bank = self.bank_journal.copy({
'bank_statements_source': 'online_sync'
})
self.start_tour(f"/odoo/accounting/{online_bank.id}/reconciliation", 'account_accountant_bank_reconciliation_ui_context_keys', login=self.env.user.login)
def test_sanitize_payment_ref(self):
invoice = self._create_invoice_line(100, self.partner_a, 'out_invoice', ref="INV Admin - SO2025/127326425")
st_line = self._create_st_line(amount=100, payment_ref='SO2025127326425')
st_line._try_auto_reconcile_statement_lines()
self._check_st_line_matching(st_line, [
{'account_id': st_line.journal_id.default_account_id.id, 'balance': 100.0, 'reconciled': False},
{'account_id': invoice.account_id.id, 'balance': -100.0, 'reconciled': True},
], reconciled_amls=[invoice])
def test_sanitize_payment_ref_structure_reference(self):
invoice = self._create_invoice_line(100, self.partner_a, 'out_invoice', ref="INV Admin - +++123/1234/123+++")
st_line = self._create_st_line(amount=100, payment_ref='bla bla bla 1231234123')
st_line._try_auto_reconcile_statement_lines()
self._check_st_line_matching(st_line, [
{'account_id': st_line.journal_id.default_account_id.id, 'balance': 100.0, 'reconciled': False},
{'account_id': invoice.account_id.id, 'balance': -100.0, 'reconciled': True},
], reconciled_amls=[invoice])