From caeb782841fc5a7ad71a196e2c9ee67644ef9074 Mon Sep 17 00:00:00 2001 From: Laurent Smet Date: Tue, 29 Oct 2019 12:21:27 +0000 Subject: [PATCH] [IMP] account,*: Improve bank statements/payments workflow - Create journal entries as soon as bank/cash statement lines are created, temporary booked on a suspense account set on the journal. - Simplify the management of "blue" lines in the reconciliation widget. A "blue" line is now a journal item using a temporary liquidity account (outstanding payment/receipt accounts, set on the journal). - Adapt and simplify the bank reconciliation report. - Remove the bank reconciliation threshold date. The reconciliation report will show the not already reconciled journal entries using a liquidity account and the not already reconciled journal entries using a temporary liquidity account. Without accounting, an account.payment will involve directly the liquidity account and then, will be considered as a statement line directly. - Remove the post_at bank reconciliation feature. The "paid" state will be set on the invoices only if reconciled with a journal entry involving the journal's liquidity account. With invoicing, the payment will do that so the "in_payment" state should never be shown up. With accounting, only the statement lines have the power to move an invoice to the "paid" state. - Fix various corner cases about the management of multi-currency in bank statement lines. - Fix the conversion dates in multi-currency: Since the bank/cash is always used on the statement lines, it will use always the real "bank" date instead of the fictive payment one. - Ensure the 'reconcile' method will raise an error if the involved moves are not posted. related enterprise PR odoo/enterprise#7019 closes odoo/odoo#41301 --task: 2092096 Related: odoo/upgrade#1018 Signed-off-by: Quentin De Paoli (qdp) --- addons/account/__manifest__.py | 1 + addons/account/data/account_data.xml | 52 - addons/account/models/__init__.py | 4 +- addons/account/models/account.py | 393 ++++- .../account/models/account_bank_statement.py | 1432 ++++++++++------- .../models/account_journal_dashboard.py | 83 +- addons/account/models/account_move.py | 401 +++-- addons/account/models/account_payment.py | 1329 ++++++++------- .../account/models/account_reconcile_model.py | 258 ++- addons/account/models/chart_template.py | 28 +- addons/account/models/company.py | 6 +- addons/account/models/res_config_settings.py | 5 - .../account/report/account_invoice_report.py | 2 +- .../static/src/xml/account_payment.xml | 2 +- addons/account/tests/__init__.py | 6 +- addons/account/tests/common.py | 102 +- .../tests/test_account_bank_statement.py | 1103 ++++++++++++- addons/account/tests/test_account_journal.py | 44 +- .../account/tests/test_account_move_entry.py | 1 + .../tests/test_account_move_in_invoice.py | 8 +- .../tests/test_account_move_in_refund.py | 8 +- .../tests/test_account_move_out_invoice.py | 14 +- .../tests/test_account_move_out_refund.py | 8 +- addons/account/tests/test_account_payment.py | 587 +++++-- .../tests/test_account_payment_register.py | 424 +++++ .../test_bank_statement_reconciliation.py | 111 -- addons/account/tests/test_payment.py | 546 ------- addons/account/tests/test_reconciliation.py | 569 ++----- .../tests/test_reconciliation_heavy_load.py | 79 - .../test_reconciliation_matching_rules.py | 47 +- .../views/account_journal_dashboard_view.xml | 40 +- addons/account/views/account_move_views.xml | 27 +- addons/account/views/account_payment_view.xml | 228 ++- addons/account/views/account_view.xml | 170 +- addons/account/views/report_invoice.xml | 4 +- .../report_payment_receipt_templates.xml | 14 +- addons/account/views/report_statement.xml | 8 +- .../views/res_config_settings_views.xml | 10 - addons/account/wizard/__init__.py | 1 + addons/account/wizard/account_invoice_send.py | 2 +- .../wizard/account_payment_register.py | 504 ++++++ .../wizard/account_payment_register_views.xml | 82 + addons/account/wizard/setup_wizards.py | 2 +- .../models/account_payment.py | 90 +- .../tests/test_print_check.py | 146 +- .../views/account_payment_views.xml | 11 +- .../wizard/print_prenumbered_checks.py | 2 +- .../data/facturx_templates.xml | 6 +- addons/account_facturx/models/account_move.py | 2 +- .../views/account_portal_templates.xml | 2 +- .../tests/test_sepa_qr.py | 6 +- .../tests/test_delivery_stock_move.py | 4 +- addons/hr_expense/models/hr_expense.py | 2 +- .../hr_expense_sheet_register_payment.py | 10 +- .../data/account_pcmn_belgium_data.xml | 1 + .../l10n_be/models/account_chart_template.py | 5 - addons/l10n_be_edi/models/account_invoice.py | 2 +- addons/l10n_ch/models/account_invoice.py | 32 +- addons/l10n_ch/report/isr_report.xml | 12 +- addons/l10n_ch/report/swissqr_report.xml | 14 +- addons/l10n_ch/tests/test_ch_qr_code.py | 8 +- addons/l10n_ch/views/account_invoice_view.xml | 2 +- addons/l10n_cl/views/account_move_view.xml | 2 +- .../demo/account_bank_statement_demo.xml | 28 +- addons/l10n_in/demo/account_payment_demo.xml | 5 +- .../l10n_in/report/account_payment_report.py | 4 +- .../l10n_it_edi/data/invoice_it_template.xml | 6 +- addons/l10n_it_edi/models/account_invoice.py | 4 +- .../l10n_lu/models/account_chart_template.py | 5 - addons/l10n_pe/models/__init__.py | 1 - addons/l10n_pe/models/account_journal.py | 31 - addons/membership/tests/test_membership.py | 16 +- addons/payment/models/account_payment.py | 4 +- addons/payment/models/payment_acquirer.py | 40 +- addons/payment/wizards/payment_link_wizard.py | 2 +- .../models/account_bank_statement.py | 9 +- addons/point_of_sale/models/pos_order.py | 2 +- addons/point_of_sale/models/pos_session.py | 23 +- addons/point_of_sale/tests/common.py | 6 +- .../tests/test_point_of_sale_flow.py | 6 +- .../views/account_statement_view.xml | 2 +- addons/purchase/models/account_invoice.py | 6 +- addons/purchase/models/purchase.py | 8 +- addons/sale/models/account_invoice.py | 2 +- addons/sale/models/sale.py | 8 +- addons/sale/tests/test_sale_transaction.py | 2 +- .../sale/wizard/sale_make_invoice_advance.py | 4 +- .../wizard/account_invoice_send.py | 2 +- .../static/tests/tours/main_flow.js | 7 +- .../addons/test_main_flows/tests/test_flow.py | 2 - 90 files changed, 5621 insertions(+), 3718 deletions(-) create mode 100644 addons/account/tests/test_account_payment_register.py delete mode 100644 addons/account/tests/test_bank_statement_reconciliation.py delete mode 100644 addons/account/tests/test_payment.py delete mode 100644 addons/account/tests/test_reconciliation_heavy_load.py create mode 100644 addons/account/wizard/account_payment_register.py create mode 100644 addons/account/wizard/account_payment_register_views.xml delete mode 100644 addons/l10n_pe/models/account_journal.py diff --git a/addons/account/__manifest__.py b/addons/account/__manifest__.py index 203a93617cb..1df984fb746 100644 --- a/addons/account/__manifest__.py +++ b/addons/account/__manifest__.py @@ -27,6 +27,7 @@ You could use this simplified accounting in case you work with an (external) acc 'wizard/account_unreconcile_view.xml', 'wizard/account_move_reversal_view.xml', 'wizard/account_resequence_views.xml', + 'wizard/account_payment_register_views.xml', 'views/account_move_views.xml', 'wizard/setup_wizards_view.xml', 'wizard/pos_box.xml', diff --git a/addons/account/data/account_data.xml b/addons/account/data/account_data.xml index 49b7646bcba..891eb4b6cde 100644 --- a/addons/account/data/account_data.xml +++ b/addons/account/data/account_data.xml @@ -127,58 +127,6 @@ outbound - - - Payments customer invoices sequence - account.payment.customer.invoice - CUST.IN/%(range_year)s/ - - - - - 4 - - - Payments customer credit notes sequence - account.payment.customer.refund - CUST.OUT/%(range_year)s/ - - - - - 4 - - - Payments supplier invoices sequence - account.payment.supplier.invoice - SUPP.OUT/%(range_year)s/ - - - - - 4 - - - Payments supplier credit notes sequence - account.payment.supplier.refund - SUPP.IN/%(range_year)s/ - - - - - 4 - - - Payments transfer sequence - account.payment.transfer - TRANS/%(range_year)s/ - - - - - 4 - - Taxes diff --git a/addons/account/models/__init__.py b/addons/account/models/__init__.py index 7aeedf8b24d..df3fd69cb05 100644 --- a/addons/account/models/__init__.py +++ b/addons/account/models/__init__.py @@ -4,11 +4,9 @@ from . import sequence_mixin from . import partner from . import account from . import account_reconcile_model -from . import account_payment from . import account_payment_term -from . import account_bank_statement from . import account_move -from . import account_payment_term +from . import account_payment from . import account_bank_statement from . import chart_template from . import account_analytic_line diff --git a/addons/account/models/account.py b/addons/account/models/account.py index eaea0e6f09c..a92668cb249 100644 --- a/addons/account/models/account.py +++ b/addons/account/models/account.py @@ -1,15 +1,12 @@ # -*- coding: utf-8 -*- -import time import math -import re +import logging from odoo.osv import expression from odoo.tools.float_utils import float_round as round, float_compare -from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT from odoo.exceptions import UserError, ValidationError from odoo import api, fields, models, _, tools -from odoo.tests.common import Form TYPE_TAX_USE = [ ('sale', 'Sales'), @@ -17,6 +14,8 @@ TYPE_TAX_USE = [ ('none', 'None'), ] +_logger = logging.getLogger(__name__) + class AccountAccountType(models.Model): _name = "account.account.type" @@ -415,6 +414,43 @@ class AccountAccount(models.Model): if ids: raise ValidationError(_('Some journal items already exist with this account but in other journals than the allowed ones.')) + @api.constrains('currency_id') + def _check_journal_consistency(self): + ''' Ensure the currency set on the journal is the same as the currency set on the + linked accounts. + ''' + if not self: + return + + self.env['account.account'].flush(['currency_id']) + self.env['account.journal'].flush([ + 'currency_id', + 'default_debit_account_id', + 'default_credit_account_id', + 'payment_debit_account_id', + 'payment_credit_account_id', + 'suspense_account_id', + ]) + self._cr.execute(''' + SELECT account.id, journal.id + FROM account_account account + JOIN res_company company ON company.id = account.company_id + JOIN account_journal journal ON + journal.default_debit_account_id = account.id + OR + journal.default_credit_account_id = account.id + WHERE account.id IN %s + AND journal.type IN ('bank', 'cash') + AND journal.currency_id IS NOT NULL + AND journal.currency_id != company.currency_id + AND account.currency_id != journal.currency_id + ''', [tuple(self.ids)]) + res = self._cr.fetchone() + if res: + account = self.env['account.account'].browse(res[0]) + journal = self.env['account.journal'].browse(res[1]) + raise ValidationError(_("The foreign currency set on the journal '%s' and the account '%s' must be the same.") % (journal.display_name, account.display_name)) + @api.constrains('company_id') def _check_company_consistency(self): if not self: @@ -949,14 +985,33 @@ class AccountJournal(models.Model): account_control_ids = fields.Many2many('account.account', 'journal_account_control_rel', 'journal_id', 'account_id', string='Accounts Allowed', check_company=True, domain="[('deprecated', '=', False), ('company_id', '=', company_id)]") - default_credit_account_id = fields.Many2one('account.account', string='Default Credit Account', - domain="[('deprecated', '=', False), ('company_id', '=', company_id)]", - help="It acts as a default account for credit amount", + default_credit_account_id = fields.Many2one('account.account', string='Default Credit Account', copy=False, + domain=[('deprecated', '=', False)], help="It acts as a default account for credit amount", check_company=True, ondelete='restrict') - default_debit_account_id = fields.Many2one('account.account', string='Default Debit Account', + default_debit_account_id = fields.Many2one('account.account', string='Default Debit Account', copy=False, check_company=True, domain="[('deprecated', '=', False), ('company_id', '=', company_id)]", help="It acts as a default account for debit amount", ondelete='restrict') + payment_debit_account_id = fields.Many2one('account.account', string='Outstanding Receipts Account', copy=False, + ondelete='restrict', + domain=[('deprecated', '=', False)], + help="Incoming payments entries triggered by invoices/refunds will be posted on the Outstanding Receipts Account " + "and displayed as blue lines in the bank reconciliation widget. During the reconciliation process, concerned " + "transactions will be reconciled with entries on the Outstanding Receipts Account instead of the " + "receivable account.") + payment_credit_account_id = fields.Many2one('account.account', string='Outstanding Payments Account', copy=False, + ondelete='restrict', + domain=[('deprecated', '=', False)], + help="Outgoing payments entries triggered by bills/credit notes will be posted on the Outstanding Payments Account " + "and displayed as blue lines in the bank reconciliation widget. During the reconciliation process, concerned " + "transactions will be reconciled with entries on the Outstanding Payments Account instead of the " + "payable account.") + suspense_account_id = fields.Many2one('account.account', string='Bank Suspense Account', + ondelete='restrict', readonly=False, store=True, require=True, + compute='_compute_suspense_account_id', + domain=[('deprecated', '=', False), ('reconcile', '=', True)], + help="Bank statements transactions will be posted on the suspense account until the final reconciliation " + "allowing finding the right account.") restrict_mode_hash_table = fields.Boolean(string="Lock Posted Entries with Hash", help="If ticked, the accounting entry or invoice receives a hash as soon as it is posted and cannot be modified anymore.") sequence = fields.Integer(help='Used to order Journals in the dashboard view', default=10) @@ -1009,7 +1064,6 @@ class AccountJournal(models.Model): bank_statements_source = fields.Selection(selection=_get_bank_statements_available_sources, string='Bank Feeds', default='undefined', help="Defines how the bank statements will be registered") bank_acc_number = fields.Char(related='bank_account_id.acc_number', readonly=False) bank_id = fields.Many2one('res.bank', related='bank_account_id.bank_id', readonly=False) - post_at = fields.Selection([('pay_val', 'Payment Validation'), ('bank_rec', 'Bank Reconciliation')], string="Post At", default='pay_val') # Sale journals fields sale_activity_type_id = fields.Many2one('mail.activity.type', string='Schedule Activity', default=False, help="Activity will be automatically scheduled on payment due date, improving collection process.") @@ -1035,6 +1089,18 @@ class AccountJournal(models.Model): ('code_company_uniq', 'unique (code, name, company_id)', 'The code and name of the journal must be unique per company !'), ] + @api.depends('company_id', 'type') + def _compute_suspense_account_id(self): + for journal in self: + if journal.type not in ('bank', 'cash'): + journal.suspense_account_id = False + elif journal.suspense_account_id: + journal.suspense_account_id = journal.suspense_account_id + elif journal.company_id.account_journal_suspense_account_id: + journal.suspense_account_id = journal.company_id.account_journal_suspense_account_id + else: + journal.suspense_account_id = False + def _compute_alias_domain(self): alias_domain = self.env["ir.config_parameter"].sudo().get_param("mail.catchall.domain") for record in self: @@ -1070,15 +1136,6 @@ class AccountJournal(models.Model): if self._cr.fetchone(): raise ValidationError(_('Some journal items already exist in this journal but with other accounts than the allowed ones.')) - @api.constrains('currency_id', 'default_credit_account_id', 'default_debit_account_id') - def _check_currency(self): - for journal in self: - if journal.currency_id: - if journal.default_credit_account_id and not journal.default_credit_account_id.currency_id.id == journal.currency_id.id: - raise ValidationError(_('The currency of the journal should be the same than the default credit account.')) - if journal.default_debit_account_id and not journal.default_debit_account_id.currency_id.id == journal.currency_id.id: - raise ValidationError(_('The currency of the journal should be the same than the default debit account.')) - @api.constrains('type', 'bank_account_id') def _check_bank_account(self): for journal in self: @@ -1106,6 +1163,43 @@ class AccountJournal(models.Model): if self._cr.fetchone(): raise UserError(_("You can't change the company of your journal since there are some journal entries linked to it.")) + @api.constrains('default_debit_account_id', 'default_credit_account_id', 'payment_debit_account_id', 'payment_credit_account_id') + def _check_journal_not_shared_accounts(self): + accounts = self.default_debit_account_id \ + + self.default_credit_account_id \ + + self.payment_debit_account_id \ + + self.payment_credit_account_id + + if not accounts: + return + + self.env['account.journal'].flush([ + 'default_debit_account_id', + 'default_credit_account_id', + 'payment_debit_account_id', + 'payment_credit_account_id', + ]) + self._cr.execute(''' + SELECT + account.name, + ARRAY_AGG(DISTINCT journal.name) AS journal_names + FROM account_account account + LEFT JOIN account_journal journal ON + journal.default_debit_account_id = account.id + OR + journal.default_credit_account_id = account.id + OR + journal.payment_debit_account_id = account.id + OR + journal.payment_credit_account_id = account.id + WHERE account.id IN %s + GROUP BY account.name + HAVING COUNT(DISTINCT journal.id) > 1 + ''', [tuple(accounts.ids)]) + res = self._cr.fetchone() + if res: + raise ValidationError(_("The account %s can't be shared between multiple journals: %s") % (res[0], ', '.join(res[1]))) + @api.constrains('type', 'default_credit_account_id', 'default_debit_account_id') def _check_type_default_credit_account_id_type(self): journals_to_check = self.filtered(lambda journal: journal.type in ('sale', 'purchase')) @@ -1183,10 +1277,6 @@ class AccountJournal(models.Model): 'partner_id': company.partner_id.id, }) if 'currency_id' in vals: - if not 'default_debit_account_id' in vals and journal.default_debit_account_id: - journal.default_debit_account_id.currency_id = vals['currency_id'] - if not 'default_credit_account_id' in vals and journal.default_credit_account_id: - journal.default_credit_account_id.currency_id = vals['currency_id'] if journal.bank_account_id: journal.bank_account_id.currency_id = vals['currency_id'] if 'bank_account_id' in vals: @@ -1205,16 +1295,15 @@ class AccountJournal(models.Model): raise UserError(_("You cannot modify the field %s of a journal that already has accounting entries.") % field_string) result = super(AccountJournal, self).write(vals) + for journal in self: + # Ensure the liquidity accounts are sharing the same foreign currency. + accounts = journal.default_debit_account_id + journal.default_credit_account_id + accounts.write({'currency_id': journal.currency_id.id}) + # Create the bank_account_id if necessary if 'bank_acc_number' in vals: for journal in self.filtered(lambda r: r.type == 'bank' and not r.bank_account_id): journal.set_bank_account(vals.get('bank_acc_number'), vals.get('bank_id')) - # Changing the 'post_at' option will post the draft payment moves and change the related invoices' state. - if 'post_at' in vals and vals['post_at'] != 'bank_rec': - draft_moves = self.env['account.move'].search([('journal_id', 'in', self.ids), ('state', '=', 'draft')]) - pending_payments = draft_moves.mapped('line_ids.payment_id') - pending_payments.mapped('move_line_ids.move_id').post() - pending_payments.mapped('reconciled_invoice_ids').filtered(lambda x: x.state == 'in_payment').write({'state': 'paid'}) for record in self: if record.restrict_mode_hash_table and not record.secure_sequence_id: record._create_secure_sequence(['secure_sequence_id']) @@ -1222,41 +1311,9 @@ class AccountJournal(models.Model): return result @api.model - def _prepare_liquidity_account(self, name, company, currency_id, type): - ''' - This function prepares the value to use for the creation of the default debit and credit accounts of a - liquidity journal (created through the wizard of generating COA from templates for example). - - :param name: name of the bank account - :param company: company for which the wizard is running - :param currency_id: ID of the currency in which is the bank account - :param type: either 'cash' or 'bank' - :return: mapping of field names and values - :rtype: dict - ''' - digits = 6 - acc = self.env['account.account'].search([('company_id', '=', company.id)], limit=1) - if acc: - digits = len(acc.code) - # Seek the next available number for the account code - if type == 'bank': - account_code_prefix = company.bank_account_code_prefix or '' - else: - account_code_prefix = company.cash_account_code_prefix or company.bank_account_code_prefix or '' - - liquidity_type = self.env.ref('account.data_account_type_liquidity') - return { - 'name': name, - 'currency_id': currency_id or False, - 'code': self.env['account.account']._search_new_account_code(company, digits, account_code_prefix), - 'user_type_id': liquidity_type and liquidity_type.id or False, - 'company_id': company.id, - } - - @api.model - def get_next_bank_cash_default_code(self, journal_type, company_id): + def get_next_bank_cash_default_code(self, journal_type, company): journal_code_base = (journal_type == 'cash' and 'CSH' or 'BNK') - journals = self.env['account.journal'].search([('code', 'like', journal_code_base + '%'), ('company_id', '=', company_id)]) + journals = self.env['account.journal'].search([('code', 'like', journal_code_base + '%'), ('company_id', '=', company.id)]) for num in range(1, 100): # journal_code has a maximal size of 5, hence we can enforce the boundary num < 100 journal_code = journal_code_base + str(num) @@ -1264,38 +1321,89 @@ class AccountJournal(models.Model): return journal_code @api.model - def create(self, vals): - company_id = vals.get('company_id', self.env.company.id) - if vals.get('type') in ('bank', 'cash'): - # For convenience, the name can be inferred from account number - if not vals.get('name') and 'bank_acc_number' in vals: - vals['name'] = vals['bank_acc_number'] + def _fill_missing_values(self, vals): + journal_type = vals.get('type') - # If no code provided, loop to find next available journal code - if not vals.get('code'): - vals['code'] = self.get_next_bank_cash_default_code(vals['type'], company_id) + # 'type' field is required. + if not journal_type: + return + + # === Fill missing company === + company = self.env['res.company'].browse(vals['company_id']) if vals.get('company_id') else self.env.company + vals['company_id'] = company.id + + # Don't get the digits on 'chart_template_id' since the chart template could be a custom one. + random_account = self.env['account.account'].search([('company_id', '=', company.id)], limit=1) + digits = len(random_account.code) if random_account else 6 + + liquidity_type = self.env.ref('account.data_account_type_liquidity') + current_assets_type = self.env.ref('account.data_account_type_current_assets') + + if journal_type in ('bank', 'cash'): + has_liquidity_accounts = vals.get('default_debit_account_id') or vals.get('default_credit_account_id') + has_payment_accounts = vals.get('payment_debit_account_id') or vals.get('payment_credit_account_id') + has_profit_account = vals.get('profit_account_id') + has_loss_account = vals.get('loss_account_id') + + if journal_type == 'bank': + liquidity_account_prefix = company.bank_account_code_prefix or '' + else: + liquidity_account_prefix = company.cash_account_code_prefix or company.bank_account_code_prefix or '' + + # === Fill missing name === + vals['name'] = vals.get('name') or vals.get('bank_acc_number') + + # === Fill missing code === + if 'code' not in vals: + vals['code'] = self.get_next_bank_cash_default_code(journal_type, company) if not vals['code']: raise UserError(_("Cannot generate an unused journal code. Please fill the 'Shortcode' field.")) + # === Fill missing accounts === + if not has_liquidity_accounts: + liquidity_account = self.env['account.account'].create({ + 'name': vals.get('name'), + 'code': self.env['account.account']._search_new_account_code(company, digits, liquidity_account_prefix), + 'user_type_id': liquidity_type.id, + 'currency_id': vals.get('currency_id'), + 'company_id': company.id, + }) - # Create a default debit/credit account if not given - default_account = vals.get('default_debit_account_id') or vals.get('default_credit_account_id') - company = self.env['res.company'].browse(company_id) - if not default_account: - account_vals = self._prepare_liquidity_account(vals.get('name'), company, vals.get('currency_id'), vals.get('type')) - default_account = self.env['account.account'].create(account_vals) - vals['default_debit_account_id'] = default_account.id - vals['default_credit_account_id'] = default_account.id - if vals['type'] == 'cash': - if not vals.get('profit_account_id'): - vals['profit_account_id'] = company.default_cash_difference_income_account_id.id - if not vals.get('loss_account_id'): - vals['loss_account_id'] = company.default_cash_difference_expense_account_id.id + vals.update({ + 'default_debit_account_id': liquidity_account.id, + 'default_credit_account_id': liquidity_account.id, + }) + if not has_payment_accounts: + vals['payment_debit_account_id'] = self.env['account.account'].create({ + 'name': _("Outstanding Receipts"), + 'code': self.env['account.account']._search_new_account_code(company, digits, liquidity_account_prefix), + 'reconcile': True, + 'user_type_id': current_assets_type.id, + 'company_id': company.id, + }).id + vals['payment_credit_account_id'] = self.env['account.account'].create({ + 'name': _("Outstanding Payments"), + 'code': self.env['account.account']._search_new_account_code(company, digits, liquidity_account_prefix), + 'reconcile': True, + 'user_type_id': current_assets_type.id, + 'company_id': company.id, + }).id + if journal_type == 'cash' and not has_profit_account: + vals['profit_account_id'] = company.default_cash_difference_income_account_id.id + if journal_type == 'cash' and not has_loss_account: + vals['loss_account_id'] = company.default_cash_difference_expense_account_id.id + # === Fill missing refund_sequence === if 'refund_sequence' not in vals: vals['refund_sequence'] = vals['type'] in ('sale', 'purchase') + @api.model + def create(self, vals): + # OVERRIDE + self._fill_missing_values(vals) + journal = super(AccountJournal, self.with_context(mail_create_nolog=True)).create(vals) + if 'alias_name' in vals: journal._update_mail_alias(vals) @@ -1401,6 +1509,123 @@ class AccountJournal(models.Model): if vals_write: journal.write(vals_write) + # ------------------------------------------------------------------------- + # REPORTING METHODS + # ------------------------------------------------------------------------- + + def _get_journal_bank_account_balance(self, domain=None): + ''' Get the bank balance of the current journal by filtering the journal items using the journal's accounts. + + /!\ The current journal is not part of the applied domain. This is the expected behavior since we only want + a logic based on accounts. + + :param domain: An additional domain to be applied on the account.move.line model. + :return: The balance expressed in the journal's currency. + ''' + self.ensure_one() + self.env['account.move.line'].check_access_rights('read') + + accounts = self.default_debit_account_id + self.default_credit_account_id + if not accounts: + return 0.0 + + domain = (domain or []) + [ + ('account_id', 'in', tuple(accounts.ids)), + ('display_type', 'not in', ('line_section', 'line_note')), + ('move_id.state', '!=', 'cancel'), + ] + query = self.env['account.move.line']._where_calc(domain) + tables, where_clause, where_params = query.get_sql() + + query = ''' + SELECT + COUNT(account_move_line.id) AS nb_lines, + COALESCE(SUM(account_move_line.balance), 0.0), + COALESCE(SUM(account_move_line.amount_currency), 0.0) + FROM ''' + tables + ''' + WHERE ''' + where_clause + ''' + ''' + self._cr.execute(query, where_params) + + company_currency = self.company_id.currency_id + journal_currency = self.currency_id if self.currency_id and self.currency_id != company_currency else False + + nb_lines, balance, amount_currency = self._cr.fetchone() + return amount_currency if journal_currency else balance, nb_lines + + def _get_journal_outstanding_payments_account_balance(self, domain=None, date=None): + ''' Get the outstanding payments balance of the current journal by filtering the journal items using the + journal's accounts. + + /!\ The current journal is not part of the applied domain. This is the expected behavior since we only want + a logic based on accounts. + + :param domain: An additional domain to be applied on the account.move.line model. + :param date: The date to be used when performing the currency conversions. + :return: The balance expressed in the journal's currency. + ''' + self.ensure_one() + self.env['account.move.line'].check_access_rights('read') + conversion_date = date or fields.Date.context_today(self) + + accounts = self.payment_debit_account_id + self.payment_credit_account_id + if not accounts: + return 0.0 + + domain = (domain or []) + [ + ('account_id', 'in', tuple(accounts.ids)), + ('display_type', 'not in', ('line_section', 'line_note')), + ('move_id.state', '!=', 'cancel'), + ('reconciled', '=', False), + ] + query = self.env['account.move.line']._where_calc(domain) + tables, where_clause, where_params = query.get_sql() + + self._cr.execute(''' + SELECT + COUNT(account_move_line.id) AS nb_lines, + account_move_line.currency_id, + account.reconcile AS is_account_reconcile, + SUM(account_move_line.amount_residual) AS amount_residual, + SUM(account_move_line.balance) AS balance, + SUM(account_move_line.amount_residual_currency) AS amount_residual_currency, + SUM(account_move_line.amount_currency) AS amount_currency + FROM ''' + tables + ''' + JOIN account_account account ON account.id = account_move_line.account_id + WHERE ''' + where_clause + ''' + GROUP BY account_move_line.currency_id, account.reconcile + ''', where_params) + + company_currency = self.company_id.currency_id + journal_currency = self.currency_id if self.currency_id and self.currency_id != company_currency else False + balance_currency = journal_currency or company_currency + + total_balance = 0.0 + nb_lines = 0 + for res in self._cr.dictfetchall(): + nb_lines += res['nb_lines'] + + amount_currency = res['amount_residual_currency'] if res['is_account_reconcile'] else res['amount_currency'] + balance = res['amount_residual'] if res['is_account_reconcile'] else res['balance'] + + if res['currency_id'] and journal_currency and res['currency_id'] == journal_currency.id: + total_balance += amount_currency + elif journal_currency: + total_balance += company_currency._convert(balance, balance_currency, self.company_id, conversion_date) + else: + total_balance += balance + return total_balance, nb_lines + + def _get_last_bank_statement(self, domain=None): + ''' Retrieve the last bank statement created using this journal. + :param domain: An additional domain to be applied on the account.bank.statement model. + :return: An account.bank.statement record or an empty recordset. + ''' + self.ensure_one() + last_statement_domain = (domain or []) + [('journal_id', '=', self.id)] + last_st_line = self.env['account.bank.statement.line'].search(last_statement_domain, order='date desc, id desc', limit=1) + return last_st_line.statement_id + class ResPartnerBank(models.Model): _inherit = "res.partner.bank" diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py index 27d99736bbc..6a9035ba299 100644 --- a/addons/account/models/account_bank_statement.py +++ b/addons/account/models/account_bank_statement.py @@ -107,11 +107,16 @@ class AccountBankStmtCloseCheck(models.TransientModel): def validate(self): bnk_stmt_id = self.env.context.get('active_id', False) if bnk_stmt_id: - self.env['account.bank.statement'].browse(bnk_stmt_id).button_confirm_bank() + self.env['account.bank.statement'].browse(bnk_stmt_id).button_validate() return {'type': 'ir.actions.act_window_close'} class AccountBankStatement(models.Model): + _name = "account.bank.statement" + _description = "Bank Statement" + _order = "date desc, name desc, id desc" + _inherit = ['mail.thread', 'sequence.mixin'] + _check_company_auto = True # Note: the reason why we did 2 separate function with the same dependencies (one for balance_start and one for balance_end_real) # is because if we create a bank statement with a default value for one of the field but not the other, the compute method @@ -163,19 +168,10 @@ class AccountBankStatement(models.Model): for statement in self: statement.currency_id = statement.journal_id.currency_id or statement.company_id.currency_id - @api.depends('line_ids.journal_entry_ids') - def _check_lines_reconciled(self): - for statement in self: - statement.all_lines_reconciled = all( - line.journal_entry_ids.ids or line.account_id.id - for line in statement.line_ids - if not statement.currency_id.is_zero(line.amount) - ) - @api.depends('move_line_ids') def _get_move_line_count(self): - for payment in self: - payment.move_line_count = len(payment.move_line_ids) + for statement in self: + statement.move_line_count = len(statement.move_line_ids) @api.model def _default_journal(self): @@ -187,23 +183,6 @@ class AccountBankStatement(models.Model): return journals[0] return self.env['account.journal'] - def _get_opening_balance(self, journal_id): - last_bnk_stmt = self.search([('journal_id', '=', journal_id)], limit=1) - if last_bnk_stmt: - return last_bnk_stmt.balance_end - return 0 - - def _set_opening_balance(self, journal_id): - self.balance_start = self._get_opening_balance(journal_id) - - @api.model - def _default_opening_balance(self): - #Search last bank statement and set current opening balance as closing balance of previous one - journal_id = self._context.get('default_journal_id', False) or self._context.get('journal_id', False) - if journal_id: - return self._get_opening_balance(journal_id) - return 0 - @api.depends('balance_start', 'previous_statement_id') def _compute_is_valid_balance_start(self): for bnk in self: @@ -223,23 +202,21 @@ class AccountBankStatement(models.Model): previous_statement = self.search(domain, limit=1) st.previous_statement_id = previous_statement.id - - _name = "account.bank.statement" - _description = "Bank Statement" - _order = "date desc, name desc, id desc" - _inherit = ['mail.thread', 'sequence.mixin'] - _check_company_auto = True - name = fields.Char(string='Reference', states={'open': [('readonly', False)]}, copy=False, readonly=True) reference = fields.Char(string='External Reference', states={'open': [('readonly', False)]}, copy=False, readonly=True, help="Used to hold the reference of the external mean that created this statement (name of imported file, reference of online synchronization...)") date = fields.Date(required=True, states={'confirm': [('readonly', True)]}, index=True, copy=False, default=fields.Date.context_today) date_done = fields.Datetime(string="Closed On") balance_start = fields.Monetary(string='Starting Balance', states={'confirm': [('readonly', True)]}, compute='_compute_starting_balance', readonly=False, store=True) balance_end_real = fields.Monetary('Ending Balance', states={'confirm': [('readonly', True)]}, compute='_compute_ending_balance', readonly=False, store=True) - accounting_date = fields.Date(string="Accounting Date", help="If set, the accounting entries created during the bank statement reconciliation process will be created at this date.\n" - "This is useful if the accounting period in which the entries should normally be booked is already closed.", - states={'open': [('readonly', False)]}, readonly=True) - state = fields.Selection([('open', 'New'), ('confirm', 'Validated')], string='Status', required=True, readonly=True, copy=False, default='open') + state = fields.Selection(string='Status', required=True, readonly=True, copy=False, selection=[ + ('open', 'New'), + ('posted', 'Processing'), + ('confirm', 'Validated'), + ], default='open', + help="The current state of your bank statement:" + "- New: Fully editable with draft Journal Entries." + "- Processing: No longer editable with posted Journal entries, ready for the reconciliation." + "- Validated: All lines are reconciled. There is nothing left to process.") currency_id = fields.Many2one('res.currency', compute='_compute_currency', string="Currency") journal_id = fields.Many2one('account.journal', string='Journal', required=True, states={'confirm': [('readonly', True)]}, default=_default_journal, check_company=True) journal_type = fields.Selection(related='journal_id.type', help="Technical field used for usability purposes") @@ -254,13 +231,16 @@ class AccountBankStatement(models.Model): move_line_ids = fields.One2many('account.move.line', 'statement_id', string='Entry lines', states={'confirm': [('readonly', True)]}) move_line_count = fields.Integer(compute="_get_move_line_count") - all_lines_reconciled = fields.Boolean(compute='_check_lines_reconciled') + all_lines_reconciled = fields.Boolean(compute='_compute_all_lines_reconciled', + help="Technical field indicating if all statement lines are fully reconciled.") user_id = fields.Many2one('res.users', string='Responsible', required=False, default=lambda self: self.env.user) cashbox_start_id = fields.Many2one('account.bank.statement.cashbox', string="Starting Cashbox") cashbox_end_id = fields.Many2one('account.bank.statement.cashbox', string="Ending Cashbox") is_difference_zero = fields.Boolean(compute='_is_difference_zero', string='Is zero', help="Check if difference is zero.") previous_statement_id = fields.Many2one('account.bank.statement', help='technical field to compute starting balance correctly', compute='_get_previous_statement', store=True) - is_valid_balance_start = fields.Boolean(string="Is Valid Balance Start", compute="_compute_is_valid_balance_start", help="technical field to display a warning message in case starting balance is different than previous ending balance") + is_valid_balance_start = fields.Boolean(string="Is Valid Balance Start", store=True, + compute="_compute_is_valid_balance_start", + help="Technical field to display a warning message in case starting balance is different than previous ending balance") def write(self, values): res = super(AccountBankStatement, self).write(values) @@ -296,7 +276,21 @@ class AccountBankStatement(models.Model): next_statements_to_recompute.modified(['previous_statement_id']) return res - def _balance_check(self): + @api.depends('line_ids.is_reconciled') + def _compute_all_lines_reconciled(self): + for statement in self: + statement.all_lines_reconciled = all(st_line.is_reconciled for st_line in statement.line_ids) + + @api.onchange('journal_id') + def onchange_journal_id(self): + for st_line in self.line_ids: + st_line.journal_id = self.journal_id + st_line.currency_id = self.journal_id.currency_id or self.company_id.currency_id + + def _check_balance_end_real_same_as_computed(self): + ''' Check the balance_end_real (encoded manually by the user) is equals to the balance_end (computed by odoo). + In case of a cash statement, the different is set automatically to a profit/loss account. + ''' for stmt in self: if not stmt.currency_id.is_zero(stmt.difference): if stmt.journal_type == 'cash': @@ -310,13 +304,14 @@ class AccountBankStatement(models.Model): if not account: raise UserError(_('Please go on the %s journal and define a %s Account. This account will be used to record cash difference.') % (stmt.journal_id.name, name)) - values = { + st_line_vals = { 'statement_id': stmt.id, - 'account_id': account.id, + 'journal_id': stmt.journal_id.id, 'amount': stmt.difference, - 'name': _("Cash difference observed during the counting (%s)") % name, + 'payment_ref': _("Cash difference observed during the counting (%s)") % name, + 'date': stmt.date, } - self.env['account.bank.statement.line'].create(values) + self.env['account.bank.statement.line'].with_context(counterpart_account_id=account.id).create(st_line_vals) else: balance_end_real = formatLang(self.env, stmt.balance_end_real, currency_obj=stmt.currency_id) balance_end = formatLang(self.env, stmt.balance_end, currency_obj=stmt.currency_id) @@ -337,6 +332,20 @@ class AccountBankStatement(models.Model): next_statement.previous_statement_id = statement.previous_statement_id return super(AccountBankStatement, self).unlink() + # ------------------------------------------------------------------------- + # CONSTRAINT METHODS + # ------------------------------------------------------------------------- + + @api.constrains('journal_id') + def _check_journal(self): + for statement in self: + if any(st_line.journal_id != statement.journal_id for st_line in statement.line_ids): + raise ValidationError(_('The journal of a bank statement line must always be the same as the bank statement one.')) + + # ------------------------------------------------------------------------- + # BUSINESS METHODS + # ------------------------------------------------------------------------- + def open_cashbox_id(self): self.ensure_one() context = dict(self.env.context or {}) @@ -362,37 +371,32 @@ class AccountBankStatement(models.Model): return action - def check_confirm_bank(self): - if self.journal_type == 'cash' and not self.currency_id.is_zero(self.difference): - action_rec = self.env['ir.model.data'].xmlid_to_object('account.action_view_account_bnk_stmt_check') - if action_rec: - action = action_rec.read([])[0] - return action - return self.button_confirm_bank() + def button_post(self): + ''' Move the bank statements from 'draft' to 'posted'. ''' + if any(statement.state != 'open' for statement in self): + raise UserError(_("Only new statements can be posted.")) - def button_confirm_bank(self): - self._balance_check() - statements = self.filtered(lambda r: r.state == 'open') - for statement in statements: - moves = self.env['account.move'] - # `line.journal_entry_ids` gets invalidated from the cache during the loop - # because new move lines are being created at each iteration. - # The below dict is to prevent the ORM to permanently refetch `line.journal_entry_ids` - line_journal_entries = {line: line.journal_entry_ids for line in statement.line_ids} - for st_line in statement.line_ids: - #upon bank statement confirmation, look if some lines have the account_id set. It would trigger a journal entry - #creation towards that account, with the wanted side-effect to skip that line in the bank reconciliation widget. - journal_entries = line_journal_entries[st_line] - st_line.fast_counterpart_creation() - if not st_line.account_id and not journal_entries.ids and not st_line.statement_id.currency_id.is_zero(st_line.amount): - raise UserError(_('All the account entries lines must be processed in order to close the statement.')) - moves = statement.mapped('line_ids.journal_entry_ids.move_id') - if moves: - moves.filtered(lambda m: m.state != 'posted').post() - statement.message_post(body=_('Statement %s confirmed, journal items were created.') % (statement.name,)) + self._check_balance_end_real_same_as_computed() + + for statement in self: + if not statement.name: + statement._set_next_sequence() + + self.write({'state': 'posted'}) + self.line_ids.move_id.post() + + def button_validate(self): + if any(statement.state != 'posted' or not statement.all_lines_reconciled for statement in self): + raise UserError(_('All the account entries lines must be processed in order to validate the statement.')) + + for statement in self: + + # Chatter. + statement.message_post(body=_('Statement %s confirmed.') % statement.name) + + # Bank statement report. if statement.journal_id.type == 'bank': - # Attach report to the Bank statement - content, content_type = self.env.ref('account.action_report_account_statement').render_qweb_pdf(statement.id) + content, content_type = self.env.ref('account.action_report_account_statement').render(statement.id) self.env['ir.attachment'].create({ 'name': statement.name and _("Bank Statement %s.pdf") % statement.name or _("Bank Statement.pdf"), 'type': 'binary', @@ -400,7 +404,26 @@ class AccountBankStatement(models.Model): 'res_model': statement._name, 'res_id': statement.id }) - statements.write({'state': 'confirm', 'date_done': time.strftime("%Y-%m-%d %H:%M:%S")}) + + self.write({'state': 'confirm', 'date_done': fields.Datetime.now()}) + + def button_validate_or_action(self): + if self.journal_type == 'cash' and not self.currency_id.is_zero(self.difference): + action_rec = self.env['ir.model.data'].xmlid_to_object('account.action_view_account_bnk_stmt_check') + if action_rec: + action = action_rec.read()[0] + return action + + return self.button_validate() + + def button_reopen(self): + ''' Move the bank statements back to the 'open' state. ''' + if any(statement.state == 'draft' for statement in self): + raise UserError(_("Only validated statements can be reset to new.")) + + self.write({'state': 'open'}) + self.line_ids.move_id.button_draft() + self.line_ids.button_undo_reconciliation() def button_journal_entries(self): return { @@ -409,22 +432,12 @@ class AccountBankStatement(models.Model): 'res_model': 'account.move', 'view_id': False, 'type': 'ir.actions.act_window', - 'domain': [('id', 'in', self.mapped('move_line_ids').mapped('move_id').ids)], + 'domain': [('id', 'in', self.line_ids.move_id.ids)], 'context': { 'journal_id': self.journal_id.id, } } - def button_open(self): - """ Changes statement state to Running.""" - for statement in self: - if not statement.name: - statement._set_next_sequence() - statement.state = 'open' - - def button_reopen(self): - self.state = 'open' - def _get_last_sequence_domain(self, relaxed=False): self.ensure_one() where_string = "WHERE journal_id = %(journal_id)s AND name != '/'" @@ -455,318 +468,795 @@ class AccountBankStatement(models.Model): return '%s%04d%s%02d%s%s%s' % (sequence.group('prefix1'), self.date.year, sequence.group('prefix2'), self.date.month, sequence.group('prefix3'), "0" * len(sequence.group('seq')), sequence.group('suffix')) # There was no pattern found, propose one - return "%s/%04d/%02d/0000" % (self.journal_id.code, self.date.year, self.date.month) + return "%s %s %04d/%02d/00000" % (self.journal_id.code, _('Statement'), self.date.year, self.date.month) class AccountBankStatementLine(models.Model): _name = "account.bank.statement.line" + _inherits = {'account.move': 'move_id'} _description = "Bank Statement Line" _order = "statement_id desc, date, sequence, id desc" + _check_company_auto = True + + # FIXME: Fields having the same name in both tables are confusing (partner_id & state). We don't change it because: + # - It's a mess to track/fix. + # - Some fields here could be simplified when the onchanges will be gone in account.move. + # Should be improved in the future. + + # == Business fields == + move_id = fields.Many2one( + comodel_name='account.move', + string='Journal Entry', required=True, readonly=True, ondelete='cascade', + check_company=True) + statement_id = fields.Many2one( + comodel_name='account.bank.statement', + string='Statement', index=True, required=True, ondelete='cascade', + check_company=True) - name = fields.Char(string='Label', required=True) - date = fields.Date(required=True, default=lambda self: self._context.get('date', fields.Date.context_today(self))) - amount = fields.Monetary(currency_field='journal_currency_id') - journal_currency_id = fields.Many2one('res.currency', string="Journal's Currency", related='statement_id.currency_id', - help='Utility field to express amount currency', readonly=True) - partner_id = fields.Many2one('res.partner', string='Partner') - account_number = fields.Char(string='Bank Account Number', help="Technical field used to store the bank account number before its creation, upon the line's processing") - bank_account_id = fields.Many2one('res.partner.bank', string='Bank Account', help="Bank account that was used in this transaction.") - account_id = fields.Many2one('account.account', string='Counterpart Account', domain="[('deprecated', '=', False), ('company_id', '=', company_id)]", - check_company=True, - help="This technical field can be used at the statement line creation/import time in order to avoid the reconciliation" - " process on it later on. The statement line will simply create a counterpart on this account") - statement_id = fields.Many2one('account.bank.statement', string='Statement', index=True, required=True, ondelete='cascade', check_company=True) - journal_id = fields.Many2one('account.journal', related='statement_id.journal_id', string='Journal', store=True, readonly=True) - partner_name = fields.Char(help="This field is used to record the third party name when importing bank statement in electronic format," - " when the partner doesn't exist yet in the database (or cannot be found).") - ref = fields.Char(string='Reference') - note = fields.Text(string='Notes') - transaction_type = fields.Char(string='Transaction Type') sequence = fields.Integer(index=True, help="Gives the sequence order when displaying a list of bank statement lines.", default=1) - company_id = fields.Many2one('res.company', related='statement_id.company_id', string='Company', store=True, readonly=True) - journal_entry_ids = fields.One2many('account.move.line', 'statement_line_id', 'Journal Items', copy=False, readonly=True) - amount_currency = fields.Monetary(help="The amount expressed in an optional other currency if it is a multi-currency entry.") - currency_id = fields.Many2one('res.currency', string='Currency', help="The optional other currency if it is a multi-currency entry.") + account_number = fields.Char(string='Bank Account Number', help="Technical field used to store the bank account number before its creation, upon the line's processing") + partner_name = fields.Char( + help="This field is used to record the third party name when importing bank statement in electronic format, " + "when the partner doesn't exist yet in the database (or cannot be found).") + transaction_type = fields.Char(string='Transaction Type') + payment_ref = fields.Char(string='Label', required=True) + amount = fields.Monetary(currency_field='currency_id') + amount_currency = fields.Monetary(currency_field='foreign_currency_id', + help="The amount expressed in an optional other currency if it is a multi-currency entry.") + foreign_currency_id = fields.Many2one('res.currency', string='Foreign Currency', + help="The optional other currency if it is a multi-currency entry.") + currency_id = fields.Many2one('res.currency', string='Journal Currency') + partner_id = fields.Many2one( + comodel_name='res.partner', + string='Partner', ondelete='restrict', + domain="['|', ('parent_id','=', False), ('is_company','=',True)]", + check_company=True) + payment_ids = fields.Many2many( + comodel_name='account.payment', + relation='account_payment_account_bank_statement_line_rel', + string='Auto-generated Payments', + help="Payments generated during the reconciliation of this bank statement lines.") + + # == Display purpose fields == + is_reconciled = fields.Boolean(string='Is Reconciled', store=True, + compute='_compute_is_reconciled', + help="Technical field indicating if the statement line is already reconciled.") state = fields.Selection(related='statement_id.state', string='Status', readonly=True) - move_name = fields.Char(string='Journal Entry Name', readonly=True, - default=False, copy=False, - help="Technical field holding the number given to the journal entry, automatically set when the statement line is reconciled then stored to set the same number again if the line is cancelled, set to draft and re-processed again.") - @api.constrains('amount') - def _check_amount(self): - for line in self: - # Allow to enter bank statement line with an amount of 0, - # so that user can enter/import the exact bank statement they have received from their bank in Odoo - currency = line.currency_id or line.journal_currency_id - if line.journal_id.type != 'bank' and currency.is_zero(line.amount): - raise ValidationError(_('The amount of a cash transaction cannot be 0.')) + # ------------------------------------------------------------------------- + # HELPERS + # ------------------------------------------------------------------------- - @api.constrains('amount', 'amount_currency') - def _check_amount_currency(self): - for line in self: - if line.amount_currency != 0 and line.amount == 0: - raise ValidationError(_('If "Amount Currency" is specified, then "Amount" must be as well.')) + def _seek_for_lines(self): + ''' Helper used to dispatch the journal items between: + - The lines using the liquidity account. + - The lines using the transfer account. + - The lines being not in one of the two previous categories. + :return: (liquidity_lines, suspense_lines, other_lines) + ''' + liquidity_lines = self.env['account.move.line'] + suspense_lines = self.env['account.move.line'] + other_lines = self.env['account.move.line'] - @api.constrains('currency_id', 'journal_id') - def _check_currency_id(self): - for line in self: - if not line.currency_id: - continue - - statement_currency = line.journal_id.currency_id or line.company_id.currency_id - if line.currency_id == statement_currency: - raise ValidationError(_('The currency of the bank statement line must be different than the statement currency.')) + for line in self.move_id.line_ids: + if line.account_id in (self.journal_id.default_debit_account_id, self.journal_id.default_credit_account_id): + liquidity_lines += line + elif line.account_id == self.journal_id.suspense_account_id: + suspense_lines += line + else: + other_lines += line + return liquidity_lines, suspense_lines, other_lines @api.model - def create(self, vals): - line = super(AccountBankStatementLine, self).create(vals) - # The most awesome fix you will ever see is below. - # Explanation: during a 'create', the 'convert_to_cache' method is not called. Moreover, at - # that point 'journal_currency_id' is not yet known since it is a related field. It means - # that the 'amount' field will not be properly rounded. The line below triggers a write on - # the 'amount' field, which will trigger the 'convert_to_cache' method, and ultimately round - # the field correctly. - # This is obviously an awful workaround, but at the time of writing, the ORM does not - # provide a clean mechanism to fix the issue. - line.amount = line.amount - return line + def _prepare_liquidity_move_line_vals(self): + ''' Prepare values to create a new account.move.line record corresponding to the + liquidity line (having the bank/cash account). + :return: The values to create a new account.move.line record. + ''' + self.ensure_one() - def unlink(self): - for line in self: - if line.journal_entry_ids.ids: - raise UserError(_('In order to delete a bank statement line, you must first cancel it to delete related journal items.')) - return super(AccountBankStatementLine, self).unlink() + statement = self.statement_id + journal = statement.journal_id + company_currency = journal.company_id.currency_id + journal_currency = journal.currency_id if journal.currency_id != company_currency else False - def button_cancel_reconciliation(self): - aml_to_unbind = self.env['account.move.line'] - aml_to_cancel = self.env['account.move.line'] - payment_to_unreconcile = self.env['account.payment'] - payment_to_cancel = self.env['account.payment'] - for st_line in self: - aml_to_unbind |= st_line.journal_entry_ids - for line in st_line.journal_entry_ids: - payment_to_unreconcile |= line.payment_id - if st_line.move_name and line.payment_id.payment_reference == st_line.move_name: - #there can be several moves linked to a statement line but maximum one created by the line itself - aml_to_cancel |= line - payment_to_cancel |= line.payment_id - aml_to_unbind = aml_to_unbind - aml_to_cancel - - if aml_to_unbind: - aml_to_unbind.write({'statement_line_id': False}) - - payment_to_unreconcile = payment_to_unreconcile - payment_to_cancel - if payment_to_unreconcile: - payment_to_unreconcile.unreconcile() - - if aml_to_cancel: - aml_to_cancel.remove_move_reconcile() - moves_to_cancel = aml_to_cancel.mapped('move_id') - moves_to_cancel.button_draft() - moves_to_cancel.button_cancel() - moves_to_cancel.with_context(force_delete=True).unlink() - if payment_to_cancel: - payment_to_cancel.unlink() - - #################################################### - # Reconciliation methods - #################################################### - - def _get_common_sql_query(self, overlook_partner = False, excluded_ids = None, split = False): - acc_type = "acc.reconcile = true" - select_clause = "SELECT aml.id " - from_clause = "FROM account_move_line aml JOIN account_account acc ON acc.id = aml.account_id " - account_clause = '' - if self.journal_id.default_credit_account_id and self.journal_id.default_debit_account_id: - account_clause = "(aml.statement_id IS NULL AND aml.account_id IN %(account_payable_receivable)s AND aml.payment_id IS NOT NULL) OR" - where_clause = """WHERE aml.company_id = %(company_id)s - AND ( - """ + account_clause + """ - ("""+acc_type+""" AND aml.reconciled = false) - )""" - where_clause = where_clause + ' AND aml.partner_id = %(partner_id)s' if self.partner_id else where_clause - where_clause = where_clause + ' AND aml.id NOT IN %(excluded_ids)s' if excluded_ids else where_clause - if split: - return select_clause, from_clause, where_clause - return select_clause + from_clause + where_clause - - def _prepare_reconciliation_move(self, move_ref): - """ Prepare the dict of values to create the move from a statement line. This method may be overridden to adapt domain logic - through model inheritance (make sure to call super() to establish a clean extension chain). - - :param char move_ref: will be used as the reference of the generated account move - :return: dict of value to create() the account.move - """ - ref = move_ref or '' - if self.ref: - ref = move_ref + ' - ' + self.ref if move_ref else self.ref - data = { - 'move_type': 'entry', - 'journal_id': self.statement_id.journal_id.id, - 'currency_id': self.statement_id.currency_id.id, - 'date': self.statement_id.accounting_date or self.date, - 'partner_id': self.partner_id.id, - 'ref': ref, - } - if self.move_name: - data.update(name=self.move_name) - return data - - def _prepare_reconciliation_move_line(self, move, amount): - """ Prepare the dict of values to balance the move. - - :param recordset move: the account.move to link the move line - :param dict move: a dict of vals of a account.move which will be created later - :param float amount: the amount of transaction that wasn't already reconciled - """ - company_currency = self.journal_id.company_id.currency_id - statement_currency = self.journal_id.currency_id or company_currency - st_line_currency = self.currency_id or statement_currency - amount_currency = False - st_line_currency_rate = self.currency_id and (self.amount_currency / self.amount) or False - if isinstance(move, dict): - amount_sum = sum(x[2].get('amount_currency', 0) for x in move['line_ids']) + if self.foreign_currency_id and journal_currency: + currency_id = journal_currency.id + if self.foreign_currency_id == company_currency: + amount_currency = self.amount + balance = self.amount_currency + else: + amount_currency = self.amount + balance = journal_currency._convert(amount_currency, journal.company_id.currency_id, journal.company_id, self.date) + elif self.foreign_currency_id and not journal_currency: + if self.foreign_currency_id == company_currency: + amount_currency = 0.0 + balance = self.amount + currency_id = False + else: + amount_currency = self.amount_currency + balance = self.amount + currency_id = self.foreign_currency_id.id + elif not self.foreign_currency_id and journal_currency: + currency_id = journal_currency.id + amount_currency = self.amount + balance = journal_currency._convert(amount_currency, journal.company_id.currency_id, journal.company_id, self.date) else: - amount_sum = sum(x.amount_currency for x in move.line_ids) - # We have several use case here to compare the currency and amount currency of counterpart line to balance the move: - if st_line_currency != company_currency and st_line_currency == statement_currency: - # company in currency A, statement in currency B and transaction in currency B - # counterpart line must have currency B and correct amount is inverse of already existing lines - amount_currency = -amount_sum - elif st_line_currency != company_currency and statement_currency == company_currency: - # company in currency A, statement in currency A and transaction in currency B - # counterpart line must have currency B and correct amount is inverse of already existing lines - amount_currency = -amount_sum - elif st_line_currency != company_currency and st_line_currency != statement_currency: - # company in currency A, statement in currency B and transaction in currency C - # counterpart line must have currency B and use rate between B and C to compute correct amount - amount_currency = -amount_sum/st_line_currency_rate - elif st_line_currency == company_currency and statement_currency != company_currency: - # company in currency A, statement in currency B and transaction in currency A - # counterpart line must have currency B and amount is computed using the rate between A and B - amount_currency = amount/st_line_currency_rate + currency_id = False + amount_currency = 0.0 + balance = self.amount - # last case is company in currency A, statement in currency A and transaction in currency A - # and in this case counterpart line does not need any second currency nor amount_currency + return { + 'name': self.payment_ref, + 'move_id': self.move_id.id, + 'partner_id': self.partner_id.id, + 'currency_id': currency_id if amount_currency else False, + 'account_id': journal.default_debit_account_id.id if balance >= 0 else journal.default_credit_account_id.id, + 'debit': balance > 0 and balance or 0.0, + 'credit': balance < 0 and -balance or 0.0, + 'amount_currency': amount_currency if currency_id else False, + } - # Check if default_debit or default_credit account are properly configured - account_id = amount >= 0 \ - and self.statement_id.journal_id.default_credit_account_id.id \ - or self.statement_id.journal_id.default_debit_account_id.id + @api.model + def _prepare_counterpart_move_line_vals(self, counterpart_vals, move_line=None): + ''' Prepare values to create a new account.move.line move_line. + By default, without specified 'counterpart_vals' or 'move_line', the counterpart line is + created using the suspense account. Otherwise, this method is also called during the + reconciliation to prepare the statement line's journal entry. In that case, + 'counterpart_vals' will be used to create a custom account.move.line (from the reconciliation widget) + and 'move_line' will be used to create the counterpart of an existing account.move.line to which + the newly created journal item will be reconciled. + :param counterpart_vals: A python dictionary containing: + 'balance': Optional amount to consider during the reconciliation. If a foreign currency is set on the + counterpart line in the same foreign currency as the statement line, then this amount is + considered as the amount in foreign currency. If not specified, the full balance is took. + This value must be provided if move_line is not. + **kwargs: Additional values that need to land on the account.move.line to create. + :param move_line: An optional account.move.line move_line representing the counterpart line to reconcile. + :return: The values to create a new account.move.line move_line. + ''' + self.ensure_one() - if not account_id: - raise UserError(_('No default debit and credit account defined on journal %s (ids: %s).' % (self.statement_id.journal_id.name, self.statement_id.journal_id.ids))) + statement = self.statement_id + journal = statement.journal_id + company_currency = journal.company_id.currency_id + journal_currency = journal.currency_id if journal.currency_id != company_currency else False + statement_line_rate = self.amount_currency / (self.amount or 1.0) - aml_dict = { - 'name': self.name, - 'partner_id': self.partner_id and self.partner_id.id or False, - 'account_id': account_id, - 'credit': amount < 0 and -amount or 0.0, - 'debit': amount > 0 and amount or 0.0, - 'statement_line_id': self.id, - 'currency_id': statement_currency != company_currency and statement_currency.id or (st_line_currency != company_currency and st_line_currency.id or False), + if 'currency_id' in counterpart_vals: + currency_id = counterpart_vals['currency_id'] + elif move_line: + currency_id = move_line.currency_id.id + else: + currency_id = self.foreign_currency_id.id + + if currency_id: + if self.foreign_currency_id and journal_currency: + + # Different currencies set on the company, the journal, the statement line and the counterpart line. + # Since we can express only one foreign currency on a journal item, only the foreign currency + # will be used on the journal item we are created. However, in any case, the rate used by the + # statement line is kept to convert from the journal's currency to the foreign's currency. + + if currency_id == self.foreign_currency_id.id: + + # The counterpart currency if the same as the foreign currency set on the statement line. + # There is also a foreign currency set on the journal so the journal item to create will + # use the foreign currency set on the statement line. + + amount_currency = counterpart_vals.pop('balance', -move_line.amount_residual_currency if move_line else 0.0) + balance = journal_currency._convert(amount_currency / statement_line_rate, company_currency, journal.company_id, self.date) + + elif currency_id == journal_currency.id and self.foreign_currency_id == company_currency: + + # The counterpart currency if the same as the foreign currency set on the journal. + # There is also a foreign currency set on the statement line that is the same as the company one. + # Then, the journal item to create will use the company's currency. + + amount_currency = counterpart_vals.pop('balance', -move_line.amount_residual_currency if move_line else 0.0) + balance = amount_currency * statement_line_rate + currency_id = False + amount_currency = 0.0 + + elif currency_id == journal_currency.id and self.foreign_currency_id != company_currency: + + # The counterpart currency if the same as the foreign currency set on the journal. + # There is also a foreign currency set on the statement line. + # The residual amount will be convert to the foreign currency set on the statement line. + + amount_currency = counterpart_vals.pop('balance', -move_line.amount_residual_currency if move_line else 0.0) + balance = journal_currency._convert(amount_currency, company_currency, journal.company_id, self.date) + amount_currency *= statement_line_rate + currency_id = self.foreign_currency_id.id + + else: + + # Whatever the currency set on the journal item passed as parameter, the counterpart line + # will be expressed in the foreign currency set on the statement line. + + balance = counterpart_vals.pop('balance', -move_line.amount_residual if move_line else 0.0) + amount_currency = company_currency._convert(balance, journal_currency, journal.company_id, self.date) + amount_currency *= statement_line_rate + currency_id = self.foreign_currency_id.id + + elif self.foreign_currency_id and not journal_currency: + + # Different currencies set on the company, the statement line and the counterpart line. + # In that case, the 'amount' set on the statement line is expressed in the company's currency + # and is used as conversion rate between the company's currency and the foreign currency. + + if currency_id == self.foreign_currency_id.id: + amount_currency = counterpart_vals.pop('balance', -move_line.amount_residual_currency if move_line else 0.0) + balance = amount_currency / statement_line_rate + else: + balance = counterpart_vals.pop('balance', -move_line.amount_residual if move_line else 0.0) + amount_currency = balance * statement_line_rate + currency_id = self.foreign_currency_id.id + + elif not self.foreign_currency_id and journal_currency: + + # Different currencies set on the company, the journal and the counterpart line. + # Everything will be expressed in the journal's currency. + + if currency_id == journal_currency.id: + amount_currency = counterpart_vals.pop('balance', -move_line.amount_residual_currency if move_line else 0.0) + balance = journal_currency._convert(amount_currency, company_currency, journal.company_id, self.date) + else: + balance = counterpart_vals.pop('balance', -move_line.amount_residual if move_line else 0.0) + amount_currency = company_currency._convert(balance, journal_currency, journal.company_id, self.date) + currency_id = journal_currency.id + + else: + + # Only a foreign currency set on the counterpart line. + # Ignore it and record the line using the company's currency. + + balance = counterpart_vals.pop('balance', -move_line.amount_residual if move_line else 0.0) + amount_currency = 0.0 + currency_id = False + + else: + balance = counterpart_vals.pop('balance', -move_line.amount_residual if move_line else 0.0) + + if self.foreign_currency_id and journal_currency: + + # Different currencies set on the company, the journal and the statement line. + # Obviously, the foreign currency set on the statement line will be set on the journal item + # to create. Again, keep the same conversion rate as the statement line when converting from + # the journal's currency to its foreign currency. + + if self.foreign_currency_id == company_currency: + amount_currency = 0.0 + else: + amount_currency = company_currency._convert(balance, journal_currency, journal.company_id, self.date) + amount_currency *= statement_line_rate + currency_id = self.foreign_currency_id.id + + elif self.foreign_currency_id and not journal_currency: + + # Different currencies set on the company and the statement line. + # Record the counterpart line using the foreign currency. + + amount_currency = balance * statement_line_rate + currency_id = self.foreign_currency_id.id + + elif not self.foreign_currency_id and journal_currency: + + # Different currencies set on the company and the journal. + # Record the counterpart line using the company's currency. + + amount_currency = company_currency._convert(balance, journal_currency, journal.company_id, self.date) + currency_id = journal_currency.id + + else: + + # Single currency. + + amount_currency = 0.0 + + return { + **counterpart_vals, + 'name': counterpart_vals.get('name', move_line.name if move_line else ''), + 'move_id': self.move_id.id, + 'partner_id': self.partner_id.id, + 'currency_id': currency_id if amount_currency else False, + 'account_id': counterpart_vals.get('account_id', move_line.account_id.id if move_line else False), + 'debit': balance if balance > 0 else 0.0, + 'credit': -balance if balance < 0 else 0.0, 'amount_currency': amount_currency, } - if isinstance(move, self.env['account.move'].__class__): - aml_dict['move_id'] = move.id - return aml_dict - def fast_counterpart_creation(self): - """This function is called when confirming a bank statement and will allow to automatically process lines without - going in the bank reconciliation widget. By setting an account_id on bank statement lines, it will create a journal - entry using that account to counterpart the bank account - """ - payment_list = [] - move_list = [] - account_type_receivable = self.env.ref('account.data_account_type_receivable') - already_done_stmt_line_ids = [a['statement_line_id'][0] for a in self.env['account.move.line'].read_group([('statement_line_id', 'in', self.ids)], ['statement_line_id'], ['statement_line_id'])] - managed_st_line = [] + @api.model + def _prepare_move_line_default_vals(self, counterpart_account_id=None): + ''' Prepare the dictionary to create the default account.move.lines for the current account.bank.statement.line + record. + :return: A list of python dictionary to be passed to the account.move.line's 'create' method. + ''' + self.ensure_one() + + if not counterpart_account_id: + counterpart_account_id = self.journal_id.suspense_account_id.id + + if not counterpart_account_id: + raise UserError(_( + "You can't create a new statement line without a suspense account set on the %s journal." + ) % self.journal_id.display_name) + + liquidity_line_vals = self._prepare_liquidity_move_line_vals() + + counterpart_vals = { + 'name': self.payment_ref, + 'account_id': counterpart_account_id, + } + + if liquidity_line_vals['currency_id']: + # Ensure the counterpart will have a balance exactly equals to the amount in journal currency. + # This avoid some rounding issues when the currency rate between two currencies is not symmetrical. + # E.g: + # A.convert(amount_a, B) = amount_b + # B.convert(amount_b, A) = amount_c != amount_a + + counterpart_vals.update({ + 'currency_id': liquidity_line_vals['currency_id'], + 'balance': -liquidity_line_vals['amount_currency'], + }) + else: + counterpart_vals['balance'] = liquidity_line_vals['credit'] - liquidity_line_vals['debit'] + + counterpart_line_vals = self._prepare_counterpart_move_line_vals(counterpart_vals) + return [liquidity_line_vals, counterpart_line_vals] + + # ------------------------------------------------------------------------- + # COMPUTE METHODS + # ------------------------------------------------------------------------- + + @api.depends('currency_id', 'amount', + 'move_id.line_ids', 'move_id.line_ids.matched_debit_ids', 'move_id.line_ids.matched_credit_ids') + def _compute_is_reconciled(self): + ''' Compute the field indicating if the statement lines are already reconciled with something. + This field is used for display purpose (e.g. display the 'cancel' button on the statement lines). + ''' for st_line in self: - # Technical functionality to automatically reconcile by creating a new move line - if st_line.account_id and not st_line.id in already_done_stmt_line_ids: - managed_st_line.append(st_line.id) - # Create payment vals - total = st_line.amount - payment_methods = (total > 0) and st_line.journal_id.inbound_payment_method_ids or st_line.journal_id.outbound_payment_method_ids - currency = st_line.journal_id.currency_id or st_line.company_id.currency_id - partner_type = 'customer' if st_line.account_id.user_type_id == account_type_receivable else 'supplier' - payment_list.append({ - 'payment_method_id': payment_methods and payment_methods[0].id or False, - 'payment_type': total > 0 and 'inbound' or 'outbound', - 'partner_id': st_line.partner_id.id, - 'partner_type': partner_type, - 'journal_id': st_line.statement_id.journal_id.id, - 'payment_date': st_line.date, - 'state': 'reconciled', - 'currency_id': currency.id, - 'amount': abs(total), - 'communication': st_line._get_communication(payment_methods[0] if payment_methods else False), - 'name': st_line.statement_id.name or _("Bank Statement %s") % st_line.date, + liquidity_lines, suspense_lines, other_lines = st_line._seek_for_lines() + + if not st_line.id or suspense_lines: + # New record: The journal items are not yet there. + st_line.is_reconciled = False + elif st_line.currency_id.is_zero(st_line.amount): + st_line.is_reconciled = True + else: + # The journal entry seems reconciled. + st_line.is_reconciled = True + + # ------------------------------------------------------------------------- + # CONSTRAINT METHODS + # ------------------------------------------------------------------------- + + @api.constrains('amount', 'amount_currency', 'currency_id', 'foreign_currency_id', 'journal_id') + def _check_amounts_currencies(self): + ''' Ensure the consistency the specified amounts and the currencies. ''' + for st_line in self: + if st_line.journal_id != st_line.statement_id.journal_id: + raise ValidationError(_('The journal of a statement line must always be the same as the bank statement one.')) + if st_line.currency_id.is_zero(st_line.amount): + raise ValidationError(_("The amount of a statement line can't be equal to zero.")) + if st_line.foreign_currency_id == st_line.currency_id: + raise ValidationError(_("The foreign currency must be different than the journal one: %s") % st_line.currency_id.name) + if st_line.foreign_currency_id and st_line.foreign_currency_id.is_zero(st_line.amount_currency): + raise ValidationError(_("The amount in foreign currency must be set if the amount is not equal to zero.")) + if not st_line.foreign_currency_id and st_line.amount_currency: + raise ValidationError(_("You can't provide an amount in foreign currency without specifying a foreign currency.")) + + # ------------------------------------------------------------------------- + # LOW-LEVEL METHODS + # ------------------------------------------------------------------------- + + @api.model_create_multi + def create(self, vals_list): + # OVERRIDE + counterpart_account_ids = [] + + for vals in vals_list: + statement = self.env['account.bank.statement'].browse(vals['statement_id']) + if statement.state != 'open' and self._context.get('check_move_validity', True): + raise UserError(_("You can only create statement line in open bank statements.")) + + # Force the move_type to avoid inconsistency with residual 'default_move_type' inside the context. + vals['move_type'] = 'entry' + + journal = statement.journal_id + # Ensure the journal is the same as the statement one. + vals['journal_id'] = journal.id + vals['currency_id'] = (journal.currency_id or journal.company_id.currency_id).id + if 'date' not in vals: + vals['date'] = statement.date + + # Hack to force different account instead of the suspense account. + counterpart_account_ids.append(vals.pop('counterpart_account_id', None)) + + st_lines = super().create(vals_list) + + for i, st_line in enumerate(st_lines): + counterpart_account_id = counterpart_account_ids[i] + + to_write = {'statement_line_id': st_line.id} + if 'line_ids' not in vals_list[i]: + to_write['line_ids'] = [(0, 0, line_vals) for line_vals in st_line._prepare_move_line_default_vals(counterpart_account_id=counterpart_account_id)] + + st_line.move_id.write(to_write) + + return st_lines + + def write(self, vals): + # OVERRIDE + res = super().write(vals) + self._synchronize_to_moves(set(vals.keys())) + return res + + def unlink(self): + # OVERRIDE to unlink the inherited account.move (move_id field) as well. + moves = self.with_context(force_delete=True).mapped('move_id') + res = super().unlink() + moves.unlink() + return res + + # ------------------------------------------------------------------------- + # SYNCHRONIZATION account.bank.statement.line <-> account.move + # ------------------------------------------------------------------------- + + def _synchronize_from_moves(self, changed_fields): + ''' Update the account.bank.statement.line regarding its related account.move. + Also, check both models are still consistent. + :param changed_fields: A set containing all modified fields on account.move. + ''' + if self._context.get('skip_account_move_synchronization'): + return + + for st_line in self.with_context(skip_account_move_synchronization=True): + move = st_line.move_id + move_vals_to_write = {} + st_line_vals_to_write = {} + + if 'state' in changed_fields: + if (st_line.state == 'open' and move.state != 'draft') or (st_line.state == 'posted' and move.state != 'posted'): + raise UserError(_( + "You can't manually change the state of journal entry %s, as it has been created by bank " + "statement %s." + ) % (st_line.move_id.display_name, st_line.statement_id.display_name)) + + if 'line_ids' in changed_fields: + liquidity_lines, suspense_lines, other_lines = st_line._seek_for_lines() + company_currency = st_line.journal_id.company_id.currency_id + journal_currency = st_line.journal_id.currency_id if st_line.journal_id.currency_id != company_currency else False + + if len(liquidity_lines) != 1: + raise UserError(_( + "The journal entry %s reached an invalid state regarding its related statement line.\n" + "To be consistent, the journal entry must always have exactly one journal item involving the " + "bank/cash account." + ) % st_line.move_id.display_name) + + st_line_vals_to_write.update({ + 'payment_ref': liquidity_lines.name, + 'partner_id': liquidity_lines.partner_id.id, }) - # Create move and move line vals - move_vals = st_line._prepare_reconciliation_move(st_line.statement_id.name) - aml_dict = { - 'name': st_line.name, - 'debit': st_line.amount < 0 and -st_line.amount or 0.0, - 'credit': st_line.amount > 0 and st_line.amount or 0.0, - 'account_id': st_line.account_id.id, - 'partner_id': st_line.partner_id.id, - 'statement_line_id': st_line.id, - } - st_line._prepare_move_line_for_currency(aml_dict, st_line.date or fields.Date.context_today()) - move_vals['line_ids'] = [(0, 0, aml_dict)] - balance_line = self._prepare_reconciliation_move_line( - move_vals, -aml_dict['debit'] if st_line.amount < 0 else aml_dict['credit']) - move_vals['line_ids'].append((0, 0, balance_line)) - move_list.append(move_vals) + # Update 'amount' according to the liquidity line. - # Creates - payment_ids = self.env['account.payment'].create(payment_list) - for payment_id, move_vals in zip(payment_ids, move_list): - for line in move_vals['line_ids']: - line[2]['payment_id'] = payment_id.id - move_ids = self.env['account.move'].create(move_list) - move_ids.post() + if journal_currency: + st_line_vals_to_write.update({ + 'amount': liquidity_lines.amount_currency, + }) + else: + st_line_vals_to_write.update({ + 'amount': liquidity_lines.balance, + }) - for move, st_line, payment in zip(move_ids, self.browse(managed_st_line), payment_ids): - st_line.write({'move_name': move.name}) - payment.write({'payment_reference': move.name}) + if len(suspense_lines) == 1: - def _get_communication(self, payment_method_id): - return self.name or '' + if journal_currency and suspense_lines.currency_id == journal_currency: - def _prepare_payment_vals(self, total): - """ Prepare the dict of values to create the payment from a statement line. This method may be overridden for update dict - through model inheritance (make sure to call super() to establish a clean extension chain). + # The suspense line is expressed in the journal's currency meaning the foreign currency + # set on the statement line is no longer needed. - :param float total: will be used as the amount of the generated payment - :return: dict of value to create() the account.payment - """ - self.ensure_one() - partner_type = False - if self.partner_id: - if total < 0: - partner_type = 'supplier' + st_line_vals_to_write.update({ + 'amount_currency': 0.0, + 'foreign_currency_id': False, + }) + + elif not suspense_lines.currency_id and st_line.foreign_currency_id == company_currency: + + # The suspense line has no foreign currency because the foreign currency set on the + # statement line is the same as the company one. In that case, don't erase the + # 'foreign_currency_id' field. + + st_line_vals_to_write.update({ + 'amount_currency': -suspense_lines.balance, + }) + + else: + + # Update the statement line regarding the foreign currency of the suspense line. + + st_line_vals_to_write.update({ + 'amount_currency': -suspense_lines.amount_currency, + 'foreign_currency_id': suspense_lines.currency_id.id, + }) + + move_vals_to_write.update({ + 'partner_id': liquidity_lines.partner_id.id, + 'currency_id': (st_line.foreign_currency_id or journal_currency or company_currency).id, + }) + + move.write(move._cleanup_write_orm_values(move, move_vals_to_write)) + st_line.write(move._cleanup_write_orm_values(st_line, st_line_vals_to_write)) + + def _synchronize_to_moves(self, changed_fields): + ''' Update the account.move regarding the modified account.bank.statement.line. + :param changed_fields: A list containing all modified fields on account.bank.statement.line. + ''' + if self._context.get('skip_account_move_synchronization'): + return + + if not any(field_name in changed_fields for field_name in ( + 'payment_ref', 'amount', 'amount_currency', + 'foreign_currency_id', 'currency_id', 'partner_id', + )): + return + + for st_line in self.with_context(skip_account_move_synchronization=True): + liquidity_lines, suspense_lines, other_lines = st_line._seek_for_lines() + company_currency = st_line.journal_id.company_id.currency_id + journal_currency = st_line.journal_id.currency_id if st_line.journal_id.currency_id != company_currency else False + + line_vals_list = self._prepare_move_line_default_vals() + line_ids_commands = [(1, liquidity_lines.id, line_vals_list[0])] + + if suspense_lines: + line_ids_commands.append((1, suspense_lines.id, line_vals_list[1])) else: - partner_type = 'customer' - if not partner_type and self.env.context.get('default_partner_type'): - partner_type = self.env.context['default_partner_type'] - currency = self.journal_id.currency_id or self.company_id.currency_id - payment_methods = (total > 0) and self.journal_id.inbound_payment_method_ids or self.journal_id.outbound_payment_method_ids - return { - 'payment_method_id': payment_methods and payment_methods[0].id or False, - 'payment_type': total > 0 and 'inbound' or 'outbound', - 'partner_id': self.partner_id.id, - 'partner_type': partner_type, - 'journal_id': self.statement_id.journal_id.id, - 'payment_date': self.date, - 'state': 'reconciled', - 'currency_id': currency.id, - 'amount': abs(total), - 'communication': self._get_communication(payment_methods[0] if payment_methods else False), - 'name': self.statement_id.name or _("Bank Statement %s") % self.date, - } + line_ids_commands.append((0, 0, line_vals_list[1])) + + for line in other_lines: + line_ids_commands.append((2, line.id)) + + st_line.move_id.write({ + 'partner_id': st_line.partner_id.id, + 'currency_id': (st_line.foreign_currency_id or journal_currency or company_currency).id, + 'line_ids': line_ids_commands, + }) + + # ------------------------------------------------------------------------- + # RECONCILIATION METHODS + # ------------------------------------------------------------------------- + + def _prepare_reconciliation(self, lines_vals_list, create_payment_for_invoice=False): + ''' Helper for the "reconcile" method used to get a full preview of the reconciliation result. This method is + quite useful to deal with reconcile models or the reconciliation widget because it ensures the values seen by + the user are exactly the values you get after reconciling. + + :param lines_vals_list: See the 'reconcile' method. + :param create_payment_for_invoice: A flag indicating the statement line must create payments on the fly during + the reconciliation. + :return: The diff to be applied on the statement line as a tuple + ( + lines_to_create: The values to create the account.move.line on the statement line. + payments_to_create: The values to create the account.payments. + open_balance_vals: A dictionary to create the open-balance line or None if the reconciliation is full. + existing_lines: The counterpart lines to which the reconciliation will be done. + ) + ''' + + self.ensure_one() + + liquidity_lines, suspense_lines, other_lines = self._seek_for_lines() + + # Ensure the statement line has not yet been already reconciled. + # If the move has 'to_check' enabled, it means the statement line has created some lines that + # need to be checked later and replaced by the real ones. + if not self.move_id.to_check and other_lines: + raise UserError(_("The statement line has already been reconciled.")) + + # A list of dictionary containing: + # - line_vals: The values to create the account.move.line on the statement line. + # - payment_vals: The optional values to create a bridge account.payment + # - counterpart_line: The optional counterpart line to reconcile with 'line'. + reconciliation_overview = [] + + total_balance = liquidity_lines.balance + + # Step 1: Split 'lines_vals_list' into two batches: + # - The existing account.move.lines that need to be reconciled with the statement line. + # => Will be managed at step 2. + # - The account.move.lines to be created from scratch. + # => Will be managed directly. + + to_browse_ids = [] + to_process_vals = [] + for vals in lines_vals_list: + # Don't modify the params directly. + vals = dict(vals) + + if 'id' in vals: + # Existing account.move.line. + to_browse_ids.append(vals.pop('id')) + to_process_vals.append(vals) + else: + # Newly created account.move.line from scratch. + line_vals = self._prepare_counterpart_move_line_vals(vals) + total_balance += line_vals['debit'] - line_vals['credit'] + + reconciliation_overview.append({ + 'line_vals': line_vals, + }) + + # Step 2: Browse counterpart lines all in one and process them. + + existing_lines = self.env['account.move.line'].browse(to_browse_ids) + for line, counterpart_vals in zip(existing_lines, to_process_vals): + line_vals = self._prepare_counterpart_move_line_vals(counterpart_vals, move_line=line) + balance = line_vals['debit'] - line_vals['credit'] + + reconciliation_vals = { + 'line_vals': line_vals, + 'counterpart_line': line, + } + + if create_payment_for_invoice and line.account_internal_type in ('receivable', 'payable'): + + # Prepare values to create a new account.payment. + payment_vals = self.env['account.payment.register']\ + .with_context(active_model='account.move.line', active_ids=line.ids)\ + .create({ + 'amount': abs(line_vals['amount_currency']) if line_vals['currency_id'] else abs(balance), + 'payment_date': self.date, + 'payment_type': 'inbound' if balance < 0.0 else 'outbound', + 'journal_id': self.journal_id.id, + 'currency_id': (self.foreign_currency_id or self.currency_id).id, + })\ + ._create_payment_vals_from_wizard() + + if payment_vals['payment_type'] == 'inbound': + liquidity_account = self.journal_id.payment_debit_account_id + else: + liquidity_account = self.journal_id.payment_credit_account_id + + # Preserve the rate of the statement line. + payment_vals['line_ids'] = [ + # Receivable / Payable line. + (0, 0, { + **line_vals, + }), + + # Liquidity line. + (0, 0, { + **line_vals, + 'amount_currency': -line_vals['amount_currency'], + 'debit': line_vals['credit'], + 'credit': line_vals['debit'], + 'account_id': liquidity_account.id, + }), + ] + + # Prepare the line to be reconciled with the payment. + if payment_vals['payment_type'] == 'inbound': + # Receive money. + line_vals['account_id'] = self.journal_id.payment_debit_account_id.id + elif payment_vals['payment_type'] == 'outbound': + # Send money. + line_vals['account_id'] = self.journal_id.payment_credit_account_id.id + + reconciliation_vals['payment_vals'] = payment_vals + + reconciliation_overview.append(reconciliation_vals) + + total_balance += balance + + # Step 3: If the journal entry is not yet balanced, create an open balance. + + if self.company_currency_id.round(total_balance): + if self.amount > 0: + open_balance_account = self.partner_id.with_company(self.company_id).property_account_receivable_id + else: + open_balance_account = self.partner_id.with_company(self.company_id).property_account_payable_id + + open_balance_vals = self._prepare_counterpart_move_line_vals({ + 'name': '%s: %s' % (self.payment_ref, _('Open Balance')), + 'account_id': open_balance_account.id, + 'balance': -total_balance, + 'currency_id': False, + }) + else: + open_balance_vals = None + + return reconciliation_overview, open_balance_vals + + def reconcile(self, lines_vals_list, to_check=False): + ''' Perform a reconciliation on the current account.bank.statement.line with some + counterpart account.move.line. + If the statement line entry is not fully balanced after the reconciliation, an open balance will be created + using the partner. + + :param lines_vals_list: A list of python dictionary containing: + 'id': Optional id of an existing account.move.line. + For each line having an 'id', a new line will be created in the current statement line. + 'balance': Optional amount to consider during the reconciliation. If a foreign currency is set on the + counterpart line in the same foreign currency as the statement line, then this amount is + considered as the amount in foreign currency. If not specified, the full balance is taken. + This value must be provided if 'id' is not. + **kwargs: Custom values to be set on the newly created account.move.line. + :param to_check: Mark the current statement line as "to_check" (see field for more details). + ''' + self.ensure_one() + liquidity_lines, suspense_lines, other_lines = self._seek_for_lines() + + reconciliation_overview, open_balance_vals = self._prepare_reconciliation(lines_vals_list) + + # ==== Manage res.partner.bank ==== + + if self.account_number and self.partner_id and not self.partner_bank_id: + self.partner_bank_id = self._find_or_create_bank_account() + + # ==== Check open balance ==== + + if open_balance_vals: + if not open_balance_vals.get('partner_id'): + raise UserError(_("Unable to create an open balance for a statement line without a partner set.")) + if not open_balance_vals.get('account_id'): + raise UserError(_("Unable to create an open balance for a statement line because the receivable " + "/ payable accounts are missing on the partner.")) + + # ==== Create & reconcile payments ==== + # When reconciling to a receivable/payable account, create an payment on the fly. + + pay_reconciliation_overview = [reconciliation_vals + for reconciliation_vals in reconciliation_overview + if reconciliation_vals.get('payment_vals')] + if pay_reconciliation_overview: + payment_vals_list = [reconciliation_vals['payment_vals'] for reconciliation_vals in pay_reconciliation_overview] + payments = self.env['account.payment'].create(payment_vals_list) + + payments.action_post() + + for reconciliation_vals, payment in zip(pay_reconciliation_overview, payments): + reconciliation_vals['payment'] = payment + + # Reconcile the newly created payment with the counterpart line. + (reconciliation_vals['counterpart_line'] + payment.line_ids)\ + .filtered(lambda line: line.account_id == reconciliation_vals['counterpart_line'].account_id)\ + .reconcile() + + # ==== Create & reconcile lines on the bank statement line ==== + + to_create_commands = [(0, 0, open_balance_vals)] if open_balance_vals else [] + to_delete_commands = [(2, line.id) for line in suspense_lines + other_lines] + + # Cleanup previous lines. + self.move_id.with_context(check_move_validity=False, skip_account_move_synchronization=True).write({ + 'line_ids': to_delete_commands + to_create_commands, + 'to_check': to_check, + }) + + line_vals_list = [reconciliation_vals['line_vals'] for reconciliation_vals in reconciliation_overview] + new_lines = self.env['account.move.line'].create(line_vals_list) + for reconciliation_vals, line in zip(reconciliation_overview, new_lines): + if reconciliation_vals.get('payment'): + accounts = (self.journal_id.payment_debit_account_id, self.journal_id.payment_credit_account_id) + counterpart_line = reconciliation_vals['payment'].line_ids.filtered(lambda line: line.account_id in accounts) + elif reconciliation_vals.get('counterpart_line'): + counterpart_line = reconciliation_vals['counterpart_line'] + else: + continue + + (line + counterpart_line).reconcile() + + # Update the payment date to match the current bank statement line's date. + if counterpart_line.payment_id: + counterpart_line.payment_id.payment_date = self.date + + # ------------------------------------------------------------------------- + # BUSINESS METHODS + # ------------------------------------------------------------------------- def _find_or_create_bank_account(self): bank_account = self.env['res.partner.bank'].search( @@ -779,201 +1269,15 @@ class AccountBankStatementLine(models.Model): }) return bank_account - def process_reconciliation(self, counterpart_aml_dicts=None, payment_aml_rec=None, new_aml_dicts=None): - """ Match statement lines with existing payments (eg. checks) and/or payables/receivables (eg. invoices and credit notes) and/or new move lines (eg. write-offs). - If any new journal item needs to be created (via new_aml_dicts or counterpart_aml_dicts), a new journal entry will be created and will contain those - items, as well as a journal item for the bank statement line. - Finally, mark the statement line as reconciled by putting the matched moves ids in the column journal_entry_ids. + def button_undo_reconciliation(self): + ''' Undo the reconciliation mades on the statement line and reset their journal items + to their original states. + ''' + self.line_ids.remove_move_reconcile() + self.payment_ids.unlink() - :param self: browse collection of records that are supposed to have no accounting entries already linked. - :param (list of dicts) counterpart_aml_dicts: move lines to create to reconcile with existing payables/receivables. - The expected keys are : - - 'name' - - 'debit' - - 'credit' - - 'move_line' - # The move line to reconcile (partially if specified debit/credit is lower than move line's credit/debit) - - :param (list of recordsets) payment_aml_rec: recordset move lines representing existing payments (which are already fully reconciled) - - :param (list of dicts) new_aml_dicts: move lines to create. The expected keys are : - - 'name' - - 'debit' - - 'credit' - - 'account_id' - - (optional) 'tax_ids' - - (optional) Other account.move.line fields like analytic_account_id or analytics_id - - (optional) 'reconcile_model_id' - - :returns: The journal entries with which the transaction was matched. If there was at least an entry in counterpart_aml_dicts or new_aml_dicts, this list contains - the move created by the reconciliation, containing entries for the statement.line (1), the counterpart move lines (0..*) and the new move lines (0..*). - """ - payable_account_type = self.env.ref('account.data_account_type_payable') - receivable_account_type = self.env.ref('account.data_account_type_receivable') - suspense_moves_mode = self._context.get('suspense_moves_mode') - counterpart_aml_dicts = counterpart_aml_dicts or [] - payment_aml_rec = payment_aml_rec or self.env['account.move.line'] - new_aml_dicts = new_aml_dicts or [] - - aml_obj = self.env['account.move.line'] - - company_currency = self.journal_id.company_id.currency_id - statement_currency = self.journal_id.currency_id or company_currency - st_line_currency = self.currency_id or statement_currency - - counterpart_moves = self.env['account.move'] - - # Check and prepare received data - if any(rec.statement_id for rec in payment_aml_rec): - raise UserError(_('A selected move line was already reconciled.')) - for aml_dict in counterpart_aml_dicts: - if aml_dict['move_line'].reconciled and not suspense_moves_mode: - raise UserError(_('A selected move line was already reconciled.')) - if isinstance(aml_dict['move_line'], int): - aml_dict['move_line'] = aml_obj.browse(aml_dict['move_line']) - - account_types = self.env['account.account.type'] - for aml_dict in (counterpart_aml_dicts + new_aml_dicts): - if aml_dict.get('tax_ids') and isinstance(aml_dict['tax_ids'][0], int): - # Transform the value in the format required for One2many and Many2many fields - aml_dict['tax_ids'] = [(4, id, None) for id in aml_dict['tax_ids']] - - user_type_id = self.env['account.account'].browse(aml_dict.get('account_id')).user_type_id - if user_type_id in [payable_account_type, receivable_account_type] and user_type_id not in account_types: - account_types |= user_type_id - if suspense_moves_mode: - if any(not line.journal_entry_ids for line in self): - raise UserError(_('Some selected statement line were not already reconciled with an account move.')) - else: - if any(line.journal_entry_ids for line in self): - raise UserError(_('A selected statement line was already reconciled with an account move.')) - - # Fully reconciled moves are just linked to the bank statement - total = self.amount - currency = self.currency_id or statement_currency - for aml_rec in payment_aml_rec: - balance = aml_rec.amount_currency if aml_rec.currency_id else aml_rec.balance - aml_currency = aml_rec.currency_id or aml_rec.company_currency_id - total -= aml_currency._convert(balance, currency, aml_rec.company_id, aml_rec.date) - aml_rec.with_context(check_move_validity=False).write({'statement_line_id': self.id}) - counterpart_moves = (counterpart_moves | aml_rec.move_id) - if aml_rec.journal_id.post_at == 'bank_rec' and aml_rec.payment_id and aml_rec.move_id.state == 'draft': - # In case the journal is set to only post payments when performing bank - # reconciliation, we modify its date and post it. - aml_rec.move_id.date = self.date - aml_rec.payment_id.payment_date = self.date - aml_rec.move_id.post() - # We check the paid status of the invoices reconciled with this payment - for invoice in aml_rec.payment_id.reconciled_invoice_ids: - self._check_invoice_state(invoice) - - # Create move line(s). Either matching an existing journal entry (eg. invoice), in which - # case we reconcile the existing and the new move lines together, or being a write-off. - if counterpart_aml_dicts or new_aml_dicts: - - # Create the move - self.sequence = self.statement_id.line_ids.ids.index(self.id) + 1 - move_vals = self._prepare_reconciliation_move(self.statement_id.name) - if suspense_moves_mode: - self.button_cancel_reconciliation() - move = self.env['account.move'].with_context(default_journal_id=move_vals['journal_id']).create(move_vals) - counterpart_moves = (counterpart_moves | move) - - # Create The payment - payment = self.env['account.payment'] - partner_id = self.partner_id or (aml_dict.get('move_line') and aml_dict['move_line'].partner_id) or self.env['res.partner'] - if abs(total)>0.00001: - payment_vals = self._prepare_payment_vals(total) - if not payment_vals['partner_id']: - payment_vals['partner_id'] = partner_id.id - if payment_vals['partner_id'] and len(account_types) == 1: - payment_vals['partner_type'] = 'customer' if account_types == receivable_account_type else 'supplier' - payment = payment.create(payment_vals) - - # Complete dicts to create both counterpart move lines and write-offs - to_create = (counterpart_aml_dicts + new_aml_dicts) - date = self.date or fields.Date.today() - for aml_dict in to_create: - aml_dict['move_id'] = move.id - aml_dict['partner_id'] = self.partner_id.id - aml_dict['statement_line_id'] = self.id - self._prepare_move_line_for_currency(aml_dict, date) - - # Create write-offs - for aml_dict in new_aml_dicts: - aml_dict['payment_id'] = payment and payment.id or False - aml_obj.with_context(check_move_validity=False).create(aml_dict) - - # Create counterpart move lines and reconcile them - for aml_dict in counterpart_aml_dicts: - if aml_dict['move_line'].payment_id and not aml_dict['move_line'].statement_line_id: - aml_dict['move_line'].write({'statement_line_id': self.id}) - if aml_dict['move_line'].partner_id.id: - aml_dict['partner_id'] = aml_dict['move_line'].partner_id.id - aml_dict['account_id'] = aml_dict['move_line'].account_id.id - aml_dict['payment_id'] = payment and payment.id or False - - counterpart_move_line = aml_dict.pop('move_line') - new_aml = aml_obj.with_context(check_move_validity=False).create(aml_dict) - - (new_aml | counterpart_move_line).reconcile() - - self._check_invoice_state(counterpart_move_line.move_id) - - # Balance the move - st_line_amount = -sum([x.balance for x in move.line_ids]) - aml_dict = self._prepare_reconciliation_move_line(move, st_line_amount) - aml_dict['payment_id'] = payment and payment.id or False - aml_obj.with_context(check_move_validity=False).create(aml_dict) - - move.post() - #record the move name on the statement line to be able to retrieve it in case of unreconciliation - self.write({'move_name': move.name}) - payment and payment.write({'payment_reference': move.name}) - elif self.move_name: - raise UserError(_('Operation not allowed. Since your statement line already received a number (%s), you cannot reconcile it entirely with existing journal entries otherwise it would make a gap in the numbering. You should book an entry and make a regular revert of it in case you want to cancel it.') % (self.move_name)) - - #create the res.partner.bank if needed - if self.account_number and self.partner_id and not self.bank_account_id: - # Search bank account without partner to handle the case the res.partner.bank already exists but is set - # on a different partner. - self.bank_account_id = self._find_or_create_bank_account() - - counterpart_moves._check_balanced() - return counterpart_moves - - def _prepare_move_line_for_currency(self, aml_dict, date): - self.ensure_one() - company_currency = self.journal_id.company_id.currency_id - statement_currency = self.journal_id.currency_id or company_currency - st_line_currency = self.currency_id or statement_currency - st_line_currency_rate = self.currency_id and (self.amount_currency / self.amount) or False - company = self.company_id - - if st_line_currency.id != company_currency.id: - aml_dict['amount_currency'] = aml_dict['debit'] - aml_dict['credit'] - aml_dict['currency_id'] = st_line_currency.id - if self.currency_id and statement_currency.id == company_currency.id and st_line_currency_rate: - # Statement is in company currency but the transaction is in foreign currency - aml_dict['debit'] = company_currency.round(aml_dict['debit'] / st_line_currency_rate) - aml_dict['credit'] = company_currency.round(aml_dict['credit'] / st_line_currency_rate) - elif self.currency_id and st_line_currency_rate: - # Statement is in foreign currency and the transaction is in another one - aml_dict['debit'] = statement_currency._convert(aml_dict['debit'] / st_line_currency_rate, company_currency, company, date) - aml_dict['credit'] = statement_currency._convert(aml_dict['credit'] / st_line_currency_rate, company_currency, company, date) - else: - # Statement is in foreign currency and no extra currency is given for the transaction - aml_dict['debit'] = st_line_currency._convert(aml_dict['debit'], company_currency, company, date) - aml_dict['credit'] = st_line_currency._convert(aml_dict['credit'], company_currency, company, date) - elif statement_currency.id != company_currency.id: - # Statement is in foreign currency but the transaction is in company currency - prorata_factor = (aml_dict['debit'] - aml_dict['credit']) / self.amount_currency - aml_dict['amount_currency'] = prorata_factor * self.amount - aml_dict['currency_id'] = statement_currency.id - - def _check_invoice_state(self, invoice): - if invoice.is_invoice(include_receipts=True): - invoice._compute_amount() - - def button_confirm_bank(self): - self.statement_id.button_confirm_bank() + for st_line in self: + st_line.write({ + 'to_check': False, + 'line_ids': [(5, 0)] + [(0, 0, line_vals) for line_vals in st_line._prepare_move_line_default_vals()], + }) diff --git a/addons/account/models/account_journal_dashboard.py b/addons/account/models/account_journal_dashboard.py index 991f35e18e4..8c2134b4789 100644 --- a/addons/account/models/account_journal_dashboard.py +++ b/addons/account/models/account_journal_dashboard.py @@ -100,7 +100,7 @@ class account_journal(models.Model): locale = get_lang(self.env).code #starting point of the graph is the last statement - last_stmt = BankStatement.search([('journal_id', '=', self.id), ('date', '<=', today.strftime(DF))], order='date desc, id desc', limit=1) + last_stmt = self._get_last_bank_statement(domain=[('move_id.state', '=', 'posted')]) last_balance = last_stmt and last_stmt.balance_end_real or 0 data.append(build_graph_data(today, last_balance)) @@ -109,15 +109,16 @@ class account_journal(models.Model): #(graph is drawn backward) date = today amount = last_balance - query = """SELECT l.date, sum(l.amount) as amount - FROM account_bank_statement_line l - RIGHT JOIN account_bank_statement st ON l.statement_id = st.id - WHERE st.journal_id = %s - AND l.date > %s - AND l.date <= %s - GROUP BY l.date - ORDER BY l.date desc - """ + query = ''' + SELECT move.date, sum(st_line.amount) as amount + FROM account_bank_statement_line st_line + JOIN account_move move ON move.id = st_line.move_id + WHERE move.journal_id = %s + AND move.date > %s + AND move.date <= %s + GROUP BY move.date + ORDER BY move.date desc + ''' self.env.cr.execute(query, (self.id, last_month, today)) query_result = self.env.cr.dictfetchall() for val in query_result: @@ -218,37 +219,37 @@ class account_journal(models.Model): def get_journal_dashboard_datas(self): currency = self.currency_id or self.company_id.currency_id - number_to_reconcile = number_to_check = last_balance = account_sum = 0 + number_to_reconcile = number_to_check = last_balance = 0 + has_at_least_one_statement = False + bank_account_balance = nb_lines_bank_account_balance = 0 + outstanding_pay_account_balance = nb_lines_outstanding_pay_account_balance = 0 title = '' number_draft = number_waiting = number_late = to_check_balance = 0 sum_draft = sum_waiting = sum_late = 0.0 - if self.type in ['bank', 'cash']: - last_bank_stmt = self.env['account.bank.statement'].search([('journal_id', 'in', self.ids)], order="date desc, id desc", limit=1) - last_balance = last_bank_stmt and last_bank_stmt[0].balance_end or 0 - #Get the number of items to reconcile for that bank journal - self.env.cr.execute("""SELECT COUNT(DISTINCT(line.id)) - FROM account_bank_statement_line AS line - LEFT JOIN account_bank_statement AS st - ON line.statement_id = st.id - WHERE st.journal_id IN %s AND st.state = 'open' AND line.amount != 0.0 AND line.account_id IS NULL - AND not exists (select 1 from account_move_line aml where aml.statement_line_id = line.id) - """, (tuple(self.ids),)) + if self.type in ('bank', 'cash'): + last_statement = self._get_last_bank_statement( + domain=[('move_id.state', '=', 'posted')]) + last_balance = last_statement.balance_end + has_at_least_one_statement = bool(last_statement) + bank_account_balance, nb_lines_bank_account_balance = self._get_journal_bank_account_balance( + domain=[('move_id.state', '=', 'posted')]) + outstanding_pay_account_balance, nb_lines_outstanding_pay_account_balance = self._get_journal_outstanding_payments_account_balance( + domain=[('move_id.state', '=', 'posted')]) + + self._cr.execute(''' + SELECT COUNT(st_line.id) + FROM account_bank_statement_line st_line + JOIN account_move st_line_move ON st_line_move.id = st_line.move_id + JOIN account_bank_statement st ON st_line.statement_id = st.id + WHERE st_line_move.journal_id IN %s + AND st.state = 'posted' + AND NOT st_line.is_reconciled + ''', [tuple(self.ids)]) number_to_reconcile = self.env.cr.fetchone()[0] + to_check_ids = self.to_check_ids() number_to_check = len(to_check_ids) to_check_balance = sum([r.amount for r in to_check_ids]) - # optimization to read sum of balance from account_move_line - account_ids = tuple(ac for ac in [self.default_debit_account_id.id, self.default_credit_account_id.id] if ac) - if account_ids: - amount_field = 'aml.balance' if (not self.currency_id or self.currency_id == self.company_id.currency_id) else 'aml.amount_currency' - query = """SELECT sum(%s) FROM account_move_line aml - LEFT JOIN account_move move ON aml.move_id = move.id - WHERE aml.account_id in %%s - AND move.date <= %%s AND move.state = 'posted';""" % (amount_field,) - self.env.cr.execute(query, (account_ids, fields.Date.context_today(self),)) - query_results = self.env.cr.dictfetchall() - if query_results and query_results[0].get('sum') != None: - account_sum = query_results[0].get('sum') #TODO need to check if all invoices are in the same currency than the journal!!!! elif self.type in ['sale', 'purchase']: title = _('Bills to pay') if self.type == 'purchase' else _('Invoices owed to you') @@ -293,17 +294,18 @@ class account_journal(models.Model): number_to_check = read[0]['__count'] to_check_balance = read[0]['amount_total'] - difference = currency.round(last_balance-account_sum) + 0.0 - is_sample_data = self.kanban_dashboard_graph and any(data.get('is_sample_data', False) for data in json.loads(self.kanban_dashboard_graph)) return { 'number_to_check': number_to_check, 'to_check_balance': formatLang(self.env, to_check_balance, currency_obj=currency), 'number_to_reconcile': number_to_reconcile, - 'account_balance': formatLang(self.env, currency.round(account_sum) + 0.0, currency_obj=currency), + 'account_balance': formatLang(self.env, currency.round(bank_account_balance), currency_obj=currency), + 'has_at_least_one_statement': has_at_least_one_statement, + 'nb_lines_bank_account_balance': nb_lines_bank_account_balance, + 'outstanding_pay_account_balance': formatLang(self.env, currency.round(outstanding_pay_account_balance), currency_obj=currency), + 'nb_lines_outstanding_pay_account_balance': nb_lines_outstanding_pay_account_balance, 'last_balance': formatLang(self.env, currency.round(last_balance) + 0.0, currency_obj=currency), - 'difference': formatLang(self.env, difference, currency_obj=currency) if difference else False, 'number_draft': number_draft, 'number_waiting': number_waiting, 'number_late': number_late, @@ -494,7 +496,10 @@ class account_journal(models.Model): [action] = self.env.ref(action_ref).read() action['context'] = dict(ast.literal_eval(action.get('context')), default_journal_id=self.id, search_default_journal_id=self.id) if payment_type == 'transfer': - action['context']['default_partner_id'] = self.company_id.partner_id.id + action['context'].update({ + 'default_partner_id': self.company_id.partner_id.id, + 'default_is_internal_transfer': True, + }) if mode == 'form': action['views'] = [[False, 'form']] return action diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index 64eccfdae57..c73f240da4a 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -49,6 +49,30 @@ class AccountMove(models.Model): def _sequence_fixed_regex(self): return self.journal_id.sequence_override_regex or super()._sequence_fixed_regex + @api.model + def _search_default_journal(self, journal_types): + company_id = self._context.get('default_company_id', self.env.company.id) + domain = [('company_id', '=', company_id), ('type', 'in', journal_types)] + + journal = None + if self._context.get('default_currency_id'): + currency_domain = domain + [('currency_id', '=', self._context['default_currency_id'])] + journal = self.env['account.journal'].search(currency_domain, limit=1) + + if not journal: + journal = self.env['account.journal'].search(domain, limit=1) + + if not journal: + company = self.env['res.company'].browse(company_id) + + error_msg = _("No journal could be found in company %s for any of those types: %s") % ( + company.display_name, + ', '.join(journal_types), + ) + raise UserError(error_msg) + + return journal + @api.model def _get_default_journal(self): ''' Get the default journal. @@ -56,36 +80,21 @@ class AccountMove(models.Model): either be determined by the default type. ''' move_type = self._context.get('default_move_type', 'entry') - journal_type = 'general' if move_type in self.get_sale_types(include_receipts=True): - journal_type = 'sale' + journal_types = ['sale'] elif move_type in self.get_purchase_types(include_receipts=True): - journal_type = 'purchase' + journal_types = ['purchase'] + else: + journal_types = self._context.get('default_move_journal_types', ['general']) if self._context.get('default_journal_id'): journal = self.env['account.journal'].browse(self._context['default_journal_id']) - if move_type != 'entry' and journal.type != journal_type: + if move_type != 'entry' and journal.type not in journal_types: raise UserError(_("Cannot create an invoice of type %s with a journal having %s as type.") % (move_type, journal.type)) else: - company_id = self._context.get('force_company', self._context.get('default_company_id', self.env.company.id)) - domain = [('company_id', '=', company_id), ('type', '=', journal_type)] + journal = self._search_default_journal(journal_types) - journal = None - if self._context.get('default_currency_id'): - currency_domain = domain + [('currency_id', '=', self._context['default_currency_id'])] - journal = self.env['account.journal'].search(currency_domain, limit=1) - - if not journal: - journal = self.env['account.journal'].search(domain, limit=1) - - if not journal: - error_msg = _('Please define an accounting miscellaneous journal in your company') - if journal_type == 'sale': - error_msg = _('Please define an accounting sale journal in your company') - elif journal_type == 'purchase': - error_msg = _('Please define an accounting purchase journal in your company') - raise UserError(error_msg) return journal @api.model @@ -154,6 +163,21 @@ class AccountMove(models.Model): commercial_partner_id = fields.Many2one('res.partner', string='Commercial Entity', store=True, readonly=True, compute='_compute_commercial_partner_id') country_code = fields.Char(related='company_id.country_id.code', readonly=True) + user_id = fields.Many2one(string='User', related='invoice_user_id', + help='Technical field used to fit the generic behavior in mail templates.') + is_move_sent = fields.Boolean(readonly=True, default=False, copy=False, + help="It indicates that the invoice/payment has been sent.") + partner_bank_id = fields.Many2one('res.partner.bank', string='Bank Account', + help='Bank Account Number to which the invoice will be paid. A Company bank account if this is a Customer Invoice or Vendor Credit Note, otherwise a Partner bank account number.', + check_company=True) + payment_reference = fields.Char(string='Payment Reference', index=True, copy=False, + help="The payment reference to set on journal items.") + payment_id = fields.Many2one( + comodel_name='account.payment', + string="Payment", copy=False, check_company=True) + statement_line_id = fields.Many2one( + comodel_name='account.bank.statement.line', + string="Statement Line", copy=False, check_company=True) # === Amount fields === amount_untaxed = fields.Monetary(string='Untaxed Amount', store=True, readonly=True, tracking=True, @@ -183,7 +207,7 @@ class AccountMove(models.Model): ('partial', 'Partially Paid'), ('reversed', 'Reversed'), ('invoicing_legacy', 'Invoicing App Legacy')], - string='Payment', store=True, readonly=True, copy=False, tracking=True, + string="Payment State", store=True, readonly=True, copy=False, tracking=True, compute='_compute_amount') # ==== Cash basis feature fields ==== @@ -215,17 +239,11 @@ class AccountMove(models.Model): invoice_user_id = fields.Many2one('res.users', copy=False, tracking=True, string='Salesperson', default=lambda self: self.env.user) - user_id = fields.Many2one(string='User', related='invoice_user_id', - help='Technical field used to fit the generic behavior in mail templates.') invoice_date = fields.Date(string='Invoice/Bill Date', readonly=True, index=True, copy=False, states={'draft': [('readonly', False)]}, default=_get_default_invoice_date) invoice_date_due = fields.Date(string='Due Date', readonly=True, index=True, copy=False, states={'draft': [('readonly', False)]}) - invoice_payment_ref = fields.Char(string='Payment Reference', index=True, copy=False, - help="The payment reference to set on journal items.") - invoice_sent = fields.Boolean(readonly=True, default=False, copy=False, - help="It indicates that the invoice has been sent.") invoice_origin = fields.Char(string='Origin', readonly=True, tracking=True, help="The document(s) that generated the invoice.") invoice_payment_term_id = fields.Many2one('account.payment.term', string='Payment Terms', @@ -236,9 +254,6 @@ class AccountMove(models.Model): copy=False, readonly=True, domain=[('exclude_from_invoice_tab', '=', False)], states={'draft': [('readonly', False)]}) - invoice_partner_bank_id = fields.Many2one('res.partner.bank', string='Bank Account', - help='Bank Account Number to which the invoice will be paid. A Company bank account if this is a Customer Invoice or Vendor Credit Note, otherwise a Partner bank account number.', - check_company=True) invoice_incoterm_id = fields.Many2one('account.incoterms', string='Incoterm', default=_get_default_invoice_incoterm, help='International Commercial Terms are a series of predefined commercial terms used in international transactions.') @@ -280,12 +295,38 @@ class AccountMove(models.Model): help="Technical field used to display a message when the invoice's accounting date is prior of the tax lock date.") # Technical field to hide Reconciled Entries stat button has_reconciled_entries = fields.Boolean(compute="_compute_has_reconciled_entries") + # ==== Hash Fields ==== restrict_mode_hash_table = fields.Boolean(related='journal_id.restrict_mode_hash_table') secure_sequence_number = fields.Integer(string="Inalteralbility No Gap Sequence #", readonly=True, copy=False) inalterable_hash = fields.Char(string="Inalterability Hash", readonly=True, copy=False) string_to_hash = fields.Char(compute='_compute_string_to_hash', readonly=True) + @api.model + def _field_will_change(self, record, vals, field_name): + if field_name not in vals: + return False + field = record._fields[field_name] + if field.type == 'many2one': + return record[field_name].id != vals[field_name] + if field.type == 'many2many': + current_ids = set(record[field_name].ids) + after_write_ids = set(record.new({field_name: vals[field_name]})[field_name].ids) + return current_ids != after_write_ids + if field.type == 'one2many': + return True + if field.type == 'monetary' and record[field.currency_field]: + return not record[field.currency_field].is_zero(record[field_name] - vals[field_name]) + return record[field_name] != vals[field_name] + + @api.model + def _cleanup_write_orm_values(self, record, vals): + cleaned_vals = dict(vals) + for field_name, value in vals.items(): + if not self._field_will_change(record, vals, field_name): + del cleaned_vals[field_name] + return cleaned_vals + # ------------------------------------------------------------------------- # ONCHANGE METHODS # ------------------------------------------------------------------------- @@ -349,7 +390,7 @@ class AccountMove(models.Model): line.account_id = new_term_account self._compute_bank_partner_id() - self.invoice_partner_bank_id = self.bank_partner_id.bank_ids and self.bank_partner_id.bank_ids[0] + self.partner_bank_id = self.bank_partner_id.bank_ids and self.bank_partner_id.bank_ids[0] # Find the new fiscal position. delivery_partner_id = self._get_invoice_delivery_partner_id() @@ -376,10 +417,10 @@ class AccountMove(models.Model): self._recompute_dynamic_lines(recompute_tax_base_amount=True) - @api.onchange('invoice_payment_ref') - def _onchange_invoice_payment_ref(self): + @api.onchange('payment_reference') + def _onchange_payment_reference(self): for line in self.line_ids.filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable')): - line.name = self.invoice_payment_ref + line.name = self.payment_reference @api.onchange('invoice_vendor_bill_id') def _onchange_invoice_vendor_bill(self): @@ -854,7 +895,7 @@ class AccountMove(models.Model): # Create new line. create_method = in_draft_mode and self.env['account.move.line'].new or self.env['account.move.line'].create candidate = create_method({ - 'name': self.invoice_payment_ref or '', + 'name': self.payment_reference or '', 'debit': balance < 0.0 and -balance or 0.0, 'credit': balance > 0.0 and balance or 0.0, 'quantity': 1.0, @@ -890,7 +931,7 @@ class AccountMove(models.Model): self.line_ids -= existing_terms_lines - new_terms_lines if new_terms_lines: - self.invoice_payment_ref = new_terms_lines[-1].name or '' + self.payment_reference = new_terms_lines[-1].name or '' self.invoice_date_due = new_terms_lines[-1].date_maturity def _recompute_dynamic_lines(self, recompute_all_taxes=False, recompute_tax_base_amount=False): @@ -1056,7 +1097,18 @@ class AccountMove(models.Model): else: move.bank_partner_id = move.company_id.partner_id + @api.model + def _get_invoice_in_payment_state(self): + ''' Hook to give the state when the invoice becomes fully paid. This is necessary because the users working + with only invoicing don't want to see the 'in_payment' state. Then, this method will be overridden in the + accountant module to enable the 'in_payment' state. ''' + return 'paid' + @api.depends( + 'line_ids.matched_debit_ids.debit_move_id.move_id.line_ids.amount_residual', + 'line_ids.matched_debit_ids.debit_move_id.move_id.line_ids.amount_residual_currency', + 'line_ids.matched_credit_ids.credit_move_id.move_id.line_ids.amount_residual', + 'line_ids.matched_credit_ids.credit_move_id.move_id.line_ids.amount_residual_currency', 'line_ids.debit', 'line_ids.credit', 'line_ids.currency_id', @@ -1066,30 +1118,6 @@ class AccountMove(models.Model): 'line_ids.payment_id.state', 'line_ids.full_reconcile_id') def _compute_amount(self): - invoice_ids = [move.id for move in self if move.id and move.is_invoice(include_receipts=True)] - self.env['account.payment'].flush(['state']) - if invoice_ids: - self._cr.execute( - ''' - SELECT move.id - FROM account_move move - JOIN account_move_line line ON line.move_id = move.id - JOIN account_partial_reconcile part ON part.debit_move_id = line.id OR part.credit_move_id = line.id - JOIN account_move_line rec_line ON - (rec_line.id = part.credit_move_id AND line.id = part.debit_move_id) - OR - (rec_line.id = part.debit_move_id AND line.id = part.credit_move_id) - JOIN account_payment payment ON payment.id = rec_line.payment_id - JOIN account_journal journal ON journal.id = rec_line.journal_id - WHERE payment.state IN ('posted', 'sent') - AND journal.post_at = 'bank_rec' - AND move.id IN %s - ''', [tuple(invoice_ids)] - ) - in_payment_set = set(res[0] for res in self._cr.fetchall()) - else: - in_payment_set = {} - for move in self: if move.payment_state == 'invoicing_legacy': @@ -1162,10 +1190,10 @@ class AccountMove(models.Model): if move.is_invoice(include_receipts=True) and move.state == 'posted': if currency.is_zero(move.amount_residual): - if move.id in in_payment_set: - new_pmt_state = 'in_payment' - else: + if all(payment.is_matched for payment in move._get_reconciled_payments()): new_pmt_state = 'paid' + else: + new_pmt_state = move._get_invoice_in_payment_state() elif currency.compare_amounts(total_to_pay, total_residual) != 0: new_pmt_state = 'partial' @@ -1252,7 +1280,7 @@ class AccountMove(models.Model): pay_term_line_ids = move.line_ids.filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable')) domain = [('account_id', 'in', pay_term_line_ids.mapped('account_id').ids), - '|', ('move_id.state', '=', 'posted'), '&', ('move_id.state', '=', 'draft'), ('journal_id.post_at', '=', 'bank_rec'), + ('move_id.state', '=', 'posted'), ('partner_id', '=', move.commercial_partner_id.id), ('reconciled', '=', False), '|', ('amount_residual', '!=', 0.0), ('amount_residual_currency', '!=', 0.0)] @@ -1320,7 +1348,7 @@ class AccountMove(models.Model): 'currency': self.currency_id.symbol, 'digits': [69, self.currency_id.decimal_places], 'position': self.currency_id.position, - 'date': counterpart_line.date, + 'date': counterpart_line.payment_id.date or counterpart_line.date, 'payment_id': counterpart_line.id, 'account_payment_id': counterpart_line.payment_id.id, 'payment_method_name': counterpart_line.payment_id.payment_method_id.name if counterpart_line.journal_id.type == 'bank' else None, @@ -1408,13 +1436,30 @@ class AccountMove(models.Model): move.tax_lock_date_message = False # ------------------------------------------------------------------------- - # CONSTRAINS METHODS + # BUSINESS MODELS SYNCHRONIZATION # ------------------------------------------------------------------------- - @api.constrains('line_ids', 'journal_id') - def _validate_move_modification(self): - if 'posted' in self.mapped('line_ids.payment_id.state'): - raise ValidationError(_("You cannot modify a journal entry linked to a posted payment.")) + def _synchronize_business_models(self, changed_fields): + ''' Ensure the consistency between: + account.payment & account.move + account.bank.statement.line & account.move + + The idea is to call the method performing the synchronization of the business + models regarding their related journal entries. To avoid cycling, the + 'skip_account_move_synchronization' key is used through the context. + + :param changed_fields: A set containing all modified fields on account.move. + ''' + if self._context.get('skip_account_move_synchronization'): + return + + self_sudo = self.sudo() + self_sudo.payment_id._synchronize_from_moves(changed_fields) + self_sudo.statement_line_id._synchronize_from_moves(changed_fields) + + # ------------------------------------------------------------------------- + # CONSTRAINT METHODS + # ------------------------------------------------------------------------- @api.constrains('name', 'journal_id', 'state') def _check_unique_sequence_number(self): @@ -1683,7 +1728,7 @@ class AccountMove(models.Model): res = True else: vals.pop('invoice_line_ids', None) - res = super(AccountMove, self.with_context(check_move_validity=False)).write(vals) + res = super(AccountMove, self.with_context(check_move_validity=False, skip_account_move_synchronization=True)).write(vals) # You can't change the date of a not-locked move to a locked period. # You can't post a new journal entry inside a locked period. @@ -1702,13 +1747,15 @@ class AccountMove(models.Model): if 'line_ids' in vals and self._context.get('check_move_validity', True): self._check_balanced() + self._synchronize_business_models(set(vals.keys())) + return res def unlink(self): for move in self: if move.posted_before and not self._context.get('force_delete'): raise UserError(_("You cannot delete an entry which has been posted once.")) - move.line_ids.unlink() + self.line_ids.unlink() return super(AccountMove, self).unlink() @api.depends('name', 'state') @@ -1929,10 +1976,54 @@ class AccountMove(models.Model): def _get_reconciled_payments(self): """Helper used to retrieve the reconciled payments on this journal entry""" - pay_term_line_ids = self.line_ids.filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable')) - reconciled_amls = pay_term_line_ids.mapped('matched_debit_ids.debit_move_id') + \ - pay_term_line_ids.mapped('matched_credit_ids.credit_move_id') - return reconciled_amls.mapped('payment_id') + reconciled_lines = self.line_ids.filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable')) + reconciled_amls = reconciled_lines.mapped('matched_debit_ids.debit_move_id') + \ + reconciled_lines.mapped('matched_credit_ids.credit_move_id') + return reconciled_amls.move_id.payment_id + + def _get_reconciled_statement_lines(self): + """Helper used to retrieve the reconciled payments on this journal entry""" + reconciled_lines = self.line_ids.filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable')) + reconciled_amls = reconciled_lines.mapped('matched_debit_ids.debit_move_id') + \ + reconciled_lines.mapped('matched_credit_ids.credit_move_id') + return reconciled_amls.move_id.statement_line_id + + def _get_reconciled_invoices(self): + """Helper used to retrieve the reconciled payments on this journal entry""" + reconciled_lines = self.line_ids.filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable')) + reconciled_amls = reconciled_lines.mapped('matched_debit_ids.debit_move_id') + \ + reconciled_lines.mapped('matched_credit_ids.credit_move_id') + return reconciled_amls.move_id.filtered(lambda move: move.is_invoice(include_receipts=True)) + + def _get_reconciled_invoices_partials(self): + ''' Helper to retrieve the details about reconciled invoices. + :return A list of tuple (amount, rate, invoice_line). + ''' + self.ensure_one() + + def compute_tuple(partial, payment_line, invoice_line): + if payment_line.currency_id == invoice_line.currency_id: + # Same currency. + exchange_rate = None + amount_paid = partial.amount_currency + elif invoice_line.currency_id: + # Different currency on the payment. + exchange_rate = payment_line.amount_currency / payment_line.balance + amount_paid = partial.company_currency_id._convert(partial.amount, invoice_line.currency_id, invoice_line.company_id, invoice_line.date) + else: + # No foreign currency on the payment, don't care about the invoice's currency. + exchange_rate = None + amount_paid = partial.amount + return (amount_paid, exchange_rate, invoice_line) + + receivable_lines = self.line_ids.filtered(lambda line: line.account_internal_type == 'receivable') + invoice_partials = [] + + for partial in receivable_lines.matched_debit_ids: + invoice_partials.append(compute_tuple(partial, partial.credit_move_id, partial.debit_move_id)) + for partial in receivable_lines.matched_credit_ids: + invoice_partials.append(compute_tuple(partial, partial.debit_move_id, partial.credit_move_id)) + return invoice_partials def _reverse_move_vals(self, default_values, cancel=True): ''' Reverse values passed as parameter being the copied values of the original journal entry. @@ -2177,6 +2268,7 @@ class AccountMove(models.Model): 'state': 'posted', 'posted_before': True, }) + for move in self: if move.auto_post and move.date > fields.Date.today(): raise UserError(_("This move is configured to be auto-posted on {}".format(move.date.strftime(get_lang(self.env).date_format)))) @@ -2186,21 +2278,17 @@ class AccountMove(models.Model): # Compute 'ref' for 'out_invoice'. if move._auto_compute_invoice_reference(): to_write = { - 'invoice_payment_ref': move._get_invoice_computed_reference(), + 'payment_reference': move._get_invoice_computed_reference(), 'line_ids': [] } for line in move.line_ids.filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable')): - to_write['line_ids'].append((1, line.id, {'name': to_write['invoice_payment_ref']})) + to_write['line_ids'].append((1, line.id, {'name': to_write['payment_reference']})) move.write(to_write) - if move == move.company_id.account_opening_move_id and not move.company_id.account_bank_reconciliation_start: - # For opening moves, we set the reconciliation date threshold - # to the move's date if it wasn't already set (we don't want - # to have to reconcile all the older payments -made before - # installing Accounting- with bank statements) - move.company_id.account_bank_reconciliation_start = move.date - - if move.is_sale_document() and move.journal_id.sale_activity_type_id and (move.journal_id.sale_activity_user_id or move.invoice_user_id).id not in (self.env.ref('base.user_root').id, False): + for move in self: + if move.is_sale_document() \ + and move.journal_id.sale_activity_type_id \ + and (move.journal_id.sale_activity_user_id or move.invoice_user_id).id not in (self.env.ref('base.user_root').id, False): move.activity_schedule( date_deadline=min((date for date in move.line_ids.mapped('date_maturity') if date), default=move.date), activity_type_id=move.journal_id.sale_activity_type_id.id, @@ -2219,14 +2307,13 @@ class AccountMove(models.Model): lambda m: m.is_invoice(include_receipts=True) and m.currency_id.is_zero(m.amount_total) ).action_invoice_paid() - def _auto_compute_invoice_reference(self): ''' Hook to be overridden to set custom conditions for auto-computed invoice references. :return True if the move should get a auto-computed reference else False :rtype bool ''' self.ensure_one() - return self.move_type == 'out_invoice' and not self.invoice_payment_ref + return self.move_type == 'out_invoice' and not self.payment_reference def action_reverse(self): action = self.env.ref('account.action_view_account_move_reversal').read()[0] @@ -2237,8 +2324,6 @@ class AccountMove(models.Model): return action def action_post(self): - if self.mapped('line_ids.payment_id') and any(post_at == 'bank_rec' for post_at in self.mapped('journal_id.post_at')): - raise UserError(_("A payment journal entry generated in a journal configured to post entries only when payments are reconciled with a bank statement cannot be manually posted. Those will be posted automatically after performing the bank reconciliation.")) return self.post() def js_assign_outstanding_line(self, line_id): @@ -2265,7 +2350,7 @@ class AccountMove(models.Model): move.mapped('line_ids.analytic_line_ids').unlink() self.mapped('line_ids').remove_move_reconcile() - self.write({'state': 'draft'}) + self.write({'state': 'draft', 'is_move_sent': False}) def button_cancel(self): self.write({'auto_post': False, 'state': 'cancel'}) @@ -2357,7 +2442,7 @@ class AccountMove(models.Model): if any(not move.is_invoice(include_receipts=True) for move in self): raise UserError(_("Only invoices could be printed.")) - self.filtered(lambda inv: not inv.invoice_sent).write({'invoice_sent': True}) + self.filtered(lambda inv: not inv.is_move_sent).write({'is_move_sent': True}) if self.user_has_groups('account.group_account_invoice'): return self.env.ref('account.account_invoices').report_action(self) else: @@ -2367,10 +2452,21 @@ class AccountMove(models.Model): ''' Hook to be overrided called when the invoice moves to the paid state. ''' pass - def action_invoice_register_payment(self): - return self.env['account.payment']\ - .with_context(active_ids=self.ids, active_model='account.move', active_id=self.id)\ - .action_register_payment() + def action_register_payment(self): + ''' Open the account.payment.register wizard to pay the selected journal entries. + :return: An action opening the account.payment.register wizard. + ''' + return { + 'name': _('Register Payment'), + 'res_model': 'account.payment.register', + 'view_mode': 'form', + 'context': { + 'active_model': 'account.move', + 'active_ids': self.ids, + }, + 'target': 'new', + 'type': 'ir.actions.act_window', + } def action_switch_invoice_into_refund_credit_note(self): if any(move.move_type not in ('in_invoice', 'out_invoice') for move in self): @@ -2394,7 +2490,7 @@ class AccountMove(models.Model): move.write({ 'move_type': move.move_type.replace('invoice', 'refund'), 'invoice_line_ids' : [(5, 0, 0)], - 'invoice_partner_bank_id': False, + 'partner_bank_id': False, }) move.write({'invoice_line_ids' : new_invoice_line_ids}) @@ -2498,12 +2594,12 @@ class AccountMove(models.Model): qr_code_method = self.qr_code_method if qr_code_method: # If the user set a qr code generator manually, we check that we can use it - if not self.invoice_partner_bank_id._eligible_for_qr_code(self.qr_code_method, self.partner_id, self.currency_id): + if not self.partner_bank_id._eligible_for_qr_code(self.qr_code_method, self.partner_id, self.currency_id): raise UserError(_("The chosen QR-code type is not eligible for this invoice.")) else: # Else we find one that's eligible and assign it to the invoice for candidate_method, candidate_name in self.env['res.partner.bank'].get_available_qr_methods_in_sequence(): - if self.invoice_partner_bank_id._eligible_for_qr_code(candidate_method, self.partner_id, self.currency_id): + if self.partner_bank_id._eligible_for_qr_code(candidate_method, self.partner_id, self.currency_id): qr_code_method = candidate_method break @@ -2512,7 +2608,7 @@ class AccountMove(models.Model): return None unstruct_ref = self.ref if self.ref else self.name - rslt = self.invoice_partner_bank_id.build_qr_code_url(self.amount_residual, unstruct_ref, self.invoice_payment_ref, self.currency_id, self.partner_id, qr_code_method, silent_errors=False) + rslt = self.partner_bank_id.build_qr_code_url(self.amount_residual, unstruct_ref, self.payment_reference, self.currency_id, self.partner_id, qr_code_method, silent_errors=False) # We only set qr_code_method after generating the url; otherwise, it # could be set even in case of a failure in the QR code generation @@ -2586,11 +2682,14 @@ class AccountMoveLine(models.Model): # ==== Origin fields ==== reconcile_model_id = fields.Many2one('account.reconcile.model', string="Reconciliation Model", copy=False, readonly=True, check_company=True) - payment_id = fields.Many2one('account.payment', string="Originator Payment", copy=False, check_company=True, - help="Payment that created this entry") - statement_line_id = fields.Many2one('account.bank.statement.line', - string='Bank statement line reconciled with this entry', - index=True, copy=False, readonly=True, check_company=True) + payment_id = fields.Many2one('account.payment', index=True, store=True, + string="Originator Payment", + related='move_id.payment_id', + help="The payment that created this entry") + statement_line_id = fields.Many2one('account.bank.statement.line', index=True, store=True, + string="Originator Statement Line", + related='move_id.statement_line_id', + help="The statement line that created this entry") statement_id = fields.Many2one(related='statement_line_id.statement_id', store=True, index=True, copy=False, help="The bank statement used for bank reconciliation") @@ -2823,12 +2922,12 @@ class AccountMoveLine(models.Model): res = {} # Compute 'price_subtotal'. - price_unit_wo_discount = price_unit * (1 - (discount / 100.0)) - subtotal = quantity * price_unit_wo_discount + line_discount_price_unit = price_unit * (1 - (discount / 100.0)) + subtotal = quantity * line_discount_price_unit # Compute 'price_total'. if taxes: - taxes_res = taxes._origin.compute_all(price_unit_wo_discount, + taxes_res = taxes._origin.compute_all(line_discount_price_unit, quantity=quantity, currency=currency, product=product, partner=partner, is_refund=move_type in ('out_refund', 'in_refund')) res['price_subtotal'] = taxes_res['total_excluded'] res['price_total'] = taxes_res['total_included'] @@ -3269,7 +3368,7 @@ class AccountMoveLine(models.Model): def _check_constrains_account_id_journal_id(self): for line in self: account = line.account_id - journal = line.journal_id + journal = line.move_id.journal_id if account.deprecated: raise UserError(_('The account %s (%s) is deprecated.') % (account.name, account.code)) @@ -3423,25 +3522,12 @@ class AccountMoveLine(models.Model): moves._check_balanced() moves._check_fiscalyear_lock_date() lines._check_tax_lock_date() + moves._synchronize_business_models({'line_ids'}) return lines def write(self, vals): # OVERRIDE - def field_will_change(line, field_name): - if field_name not in vals: - return False - field = line._fields[field_name] - if field.type == 'many2one': - return line[field_name].id != vals[field_name] - if field.type in ('one2many', 'many2many'): - current_ids = set(line[field_name].ids) - after_write_ids = set(line.new({field_name: vals[field_name]})[field_name].ids) - return current_ids != after_write_ids - if field.type == 'monetary' and line[field.currency_field]: - return not line[field.currency_field].is_zero(line[field_name] - vals[field_name]) - return line[field_name] != vals[field_name] - ACCOUNTING_FIELDS = ('debit', 'credit', 'amount_currency') BUSINESS_FIELDS = ('price_unit', 'quantity', 'discount', 'tax_ids') PROTECTED_FIELDS_TAX_LOCK_DATE = ['debit', 'credit', 'tax_line_id', 'tax_ids', 'tax_tag_ids'] @@ -3460,23 +3546,17 @@ class AccountMoveLine(models.Model): raise UserError(_("You cannot edit the following fields due to restrict mode being activated on the journal: %s.") % ', '.join(INTEGRITY_HASH_LINE_FIELDS)) if any(key in vals for key in ('tax_ids', 'tax_line_ids')): raise UserError(_('You cannot modify the taxes related to a posted journal item, you should reset the journal entry to draft to do so.')) - # When making a reconciliation on an existing liquidity journal item, mark the payment as reconciled - if 'statement_line_id' in vals and line.payment_id: - # In case of an internal transfer, there are 2 liquidity move lines to match with a bank statement - if all(line.statement_id for line in line.payment_id.move_line_ids.filtered( - lambda r: r.id != line.id and r.account_id.internal_type == 'liquidity')): - line.payment_id.state = 'reconciled' # Check the lock date. - if any(field_will_change(line, field_name) for field_name in PROTECTED_FIELDS_LOCK_DATE): + if any(self.env['account.move']._field_will_change(line, vals, field_name) for field_name in PROTECTED_FIELDS_LOCK_DATE): line.move_id._check_fiscalyear_lock_date() # Check the tax lock date. - if any(field_will_change(line, field_name) for field_name in PROTECTED_FIELDS_TAX_LOCK_DATE): + if any(self.env['account.move']._field_will_change(line, vals, field_name) for field_name in PROTECTED_FIELDS_TAX_LOCK_DATE): line._check_tax_lock_date() # Check the reconciliation. - if any(field_will_change(line, field_name) for field_name in PROTECTED_FIELDS_RECONCILIATION): + if any(self.env['account.move']._field_will_change(line, vals, field_name) for field_name in PROTECTED_FIELDS_RECONCILIATION): line._check_reconciliation() # Check switching receivable / payable accounts. @@ -3576,6 +3656,8 @@ class AccountMoveLine(models.Model): if self._context.get('check_move_validity', True): self.mapped('move_id')._check_balanced() + self.mapped('move_id')._synchronize_business_models({'line_ids'}) + return result def _valid_field_parameter(self, field, name): @@ -3910,6 +3992,8 @@ class AccountMoveLine(models.Model): all_accounts.append(line.account_id) if line.reconciled: raise UserError(_('You are trying to reconcile some entries that are already reconciled.')) + if line.move_id.state != 'posted': + raise UserError(_('You can only reconcile posted entries.')) if len(company_ids) > 1: raise UserError(_('To reconcile the entries company should be the same for all entries.')) if len(set(all_accounts)) > 1: @@ -3923,11 +4007,6 @@ class AccountMoveLine(models.Model): if not self: return - # List unpaid invoices - not_paid_invoices = self.mapped('move_id').filtered( - lambda m: m.is_invoice(include_receipts=True) and m.payment_state not in ('paid', 'in_payment') - ) - self._check_reconcile_validity() #reconcile everything that can be remaining_moves = self.auto_reconcile_lines() @@ -3948,11 +4027,6 @@ class AccountMoveLine(models.Model): # Check if reconciliation is total or needs an exchange rate entry to be created (self + writeoff_to_reconcile).check_full_reconcile() - # Trigger action for paid invoices - not_paid_invoices.filtered( - lambda m: m.payment_state in ('paid', 'in_payment') - ).action_invoice_paid() - return True def _create_writeoff(self, writeoff_vals): @@ -4048,7 +4122,7 @@ class AccountMoveLine(models.Model): def remove_move_reconcile(self): """ Undo a reconciliation """ - (self.mapped('matched_debit_ids') + self.mapped('matched_credit_ids')).unlink() + (self.matched_debit_ids + self.matched_credit_ids).unlink() def _copy_data_extend_business_fields(self, values): ''' Hook allowing copying business fields under certain conditions. @@ -4454,6 +4528,7 @@ class AccountPartialReconcile(models.Model): self.ensure_one() move_date = self.debit_move_id.date newly_created_move = self.env['account.move'] + to_reconcile = [] # We use a set here in case the reconciled lines belong to the same move (it happens with POS) for move in {self.debit_move_id.move_id, self.credit_move_id.move_id}: #move_date is the max of the 2 reconciled items @@ -4509,9 +4584,7 @@ class AccountPartialReconcile(models.Model): 'tax_tag_ids': [(6, 0, line.tax_tag_ids.ids)], }) if line.account_id.reconcile and not line.reconciled: - #setting the account to allow reconciliation will help to fix rounding errors - to_clear_aml |= line - to_clear_aml.reconcile() + to_reconcile.append(to_clear_aml + line) taxes_payment_exigible = line.tax_ids.flatten_taxes_hierarchy().filtered(lambda tax: tax.tax_exigibility == 'on_payment') if taxes_payment_exigible: @@ -4552,6 +4625,8 @@ class AccountPartialReconcile(models.Model): self._set_tax_cash_basis_entry_date(move_date, newly_created_move) # post move newly_created_move.post() + for lines in to_reconcile: + lines.reconcile() def _create_tax_basis_move(self): # Check if company_journal for cash basis is set if not, raise exception @@ -4578,9 +4653,13 @@ class AccountPartialReconcile(models.Model): move._reverse_moves([{'ref': _('Reversal of %s') % move.name}], cancel=True) else: move._reverse_moves([{'date': fields.Date.today(), 'ref': _('Reversal of %s') % move.name}], cancel=True) + + # Unlink partials then the full in this order to avoid a recursive call to the same partials. res = super(AccountPartialReconcile, self).unlink() + if full_to_unlink: full_to_unlink.unlink() + return res @@ -4600,21 +4679,19 @@ class AccountFullReconcile(models.Model): cancel the currency difference entry on the partner account (otherwise it will still appear on the aged balance for example). """ - for rec in self: - if rec.exists() and rec.exchange_move_id: - # reverse the exchange rate entry after de-referencing it to avoid looping - # (reversing will cause a nested attempt to drop the full reconciliation) - to_reverse = rec.exchange_move_id - rec.exchange_move_id = False - if to_reverse.date > (to_reverse.company_id.period_lock_date or date.min): - reverse_date = to_reverse.date - else: - reverse_date = fields.Date.today() - to_reverse._reverse_moves([{ - 'date': reverse_date, - 'ref': _('Reversal of: %s') % to_reverse.name, - }], cancel=True) - return super(AccountFullReconcile, self).unlink() + moves_to_reverse = self.exchange_move_id + + res = super(AccountFullReconcile, self).unlink() + + # Reverse all exchange moves at once. + today = fields.Date.today() + default_values_list = [{ + 'date': today, + 'ref': _('Reversal of: %s') % move.name, + } for move in moves_to_reverse] + moves_to_reverse._reverse_moves(default_values_list, cancel=True) + + return res @api.model def _prepare_exchange_diff_move(self, move_date, company): diff --git a/addons/account/models/account_payment.py b/addons/account/models/account_payment.py index 4553be3b5cf..bc4ccb4e487 100644 --- a/addons/account/models/account_payment.py +++ b/addons/account/models/account_payment.py @@ -3,19 +3,8 @@ from odoo import models, fields, api, _ from odoo.exceptions import UserError, ValidationError -from collections import defaultdict -MAP_INVOICE_TYPE_PARTNER_TYPE = { - 'out_invoice': 'customer', - 'out_refund': 'customer', - 'out_receipt': 'customer', - 'in_invoice': 'supplier', - 'in_refund': 'supplier', - 'in_receipt': 'supplier', -} - - -class account_payment_method(models.Model): +class AccountPaymentMethod(models.Model): _name = "account.payment.method" _description = "Payment Methods" @@ -24,181 +13,266 @@ class account_payment_method(models.Model): payment_type = fields.Selection([('inbound', 'Inbound'), ('outbound', 'Outbound')], required=True) -class account_payment(models.Model): +class AccountPayment(models.Model): _name = "account.payment" + _inherits = {'account.move': 'move_id'} _inherit = ['mail.thread', 'mail.activity.mixin'] _description = "Payments" - _order = "payment_date desc, name desc" + _order = "date desc, name desc" _check_company_auto = True - name = fields.Char(readonly=True, copy=False) # The name is attributed upon post() - payment_reference = fields.Char(copy=False, readonly=True, help="Reference of the document used to issue this payment. Eg. check number, file name, etc.") - move_name = fields.Char(string='Journal Entry Name', readonly=True, - default=False, copy=False, - help="Technical field holding the number given to the journal entry, automatically set when the statement line is reconciled then stored to set the same number again if the line is cancelled, set to draft and re-processed again.") + def _get_default_journal(self): + ''' Retrieve the default journal for the account.payment. + /!\ This method will not override the method in 'account.move' because the ORM + doesn't allow overriding methods using _inherits. Then, this method will be called + manually in 'create' and 'new'. + :return: An account.journal record. + ''' + return self.env['account.move']._search_default_journal(('bank', 'cash')) - # Money flows from the journal_id's default_debit_account_id or default_credit_account_id to the destination_account_id - destination_account_id = fields.Many2one('account.account', compute='_compute_destination_account_id', readonly=True) + # == Business fields == + move_id = fields.Many2one( + comodel_name='account.move', + string='Journal Entry', required=True, readonly=True, ondelete='cascade', + check_company=True) - invoice_ids = fields.Many2many('account.move', 'account_invoice_payment_rel', 'payment_id', 'invoice_id', string="Invoices", copy=False, readonly=True, - help="""Technical field containing the invoice for which the payment has been generated. - This does not especially correspond to the invoices reconciled with the payment, - as it can have been generated first, and reconciled later""") - reconciled_invoice_ids = fields.Many2many('account.move', string='Reconciled Invoices', compute='_compute_reconciled_invoice_ids', help="Invoices whose journal items have been reconciled with these payments.") - has_invoices = fields.Boolean(compute="_compute_reconciled_invoice_ids", help="Technical field used for usability purposes") - reconciled_invoices_count = fields.Integer(compute="_compute_reconciled_invoice_ids") + is_reconciled = fields.Boolean(string="Is Reconciled", store=True, + compute='_compute_reconciliation_status', + help="Technical field indicating if the payment is already reconciled.") + is_matched = fields.Boolean(string="Is Matched With a Bank Statement", store=True, + compute='_compute_reconciliation_status', + help="Technical field indicating if the payment has been matched with a statement line.") + partner_bank_id = fields.Many2one('res.partner.bank', string="Recipient Bank Account", + readonly=False, store=True, + compute='_compute_partner_bank_id', + domain="[('partner_id', '=', partner_id)]", + check_company=True) + is_internal_transfer = fields.Boolean(string="Is Internal Transfer", + readonly=False, store=True, + compute="_compute_is_internal_transfer") + qr_code = fields.Char(string="QR Code", + compute="_compute_qr_code", + help="QR-code report URL to use to generate the QR-code to scan with a banking app to perform this payment.") - move_line_ids = fields.One2many('account.move.line', 'payment_id', readonly=True, copy=False) - move_reconciled = fields.Boolean(compute="_get_move_reconciled", readonly=True) - - state = fields.Selection([('draft', 'Draft'), ('posted', 'Validated'), ('sent', 'Sent'), ('reconciled', 'Reconciled'), ('cancelled', 'Cancelled'), ('invoicing_legacy', 'Invoicing App Legacy')], readonly=True, default='draft', copy=False, string="Status", tracking=True) - payment_type = fields.Selection([('outbound', 'Send Money'), ('inbound', 'Receive Money')], string='Payment Type', required=True, readonly=True, states={'draft': [('readonly', False)]}) - _payment_methods = fields.Many2many('account.payment.method', compute='_compute_payment_methods') - payment_method_id = fields.Many2one('account.payment.method', string='Payment Method', required=True, readonly=True, states={'draft': [('readonly', False)]}, - domain="""[ - ('payment_type', '=', ('inbound' if payment_type == 'inbound' else 'outbound')), - ('id', 'in', _payment_methods), - ]""", + # == Payment methods fields == + payment_method_id = fields.Many2one('account.payment.method', string='Payment Method', + readonly=False, store=True, + compute='_compute_payment_method_id', + domain="[('id', 'in', available_payment_method_ids)]", help="Manual: Get paid by cash, check or any other method outside of Odoo.\n"\ "Electronic: Get paid automatically through a payment acquirer by requesting a transaction on a card saved by the customer when buying or subscribing online (payment token).\n"\ "Check: Pay bill by check and print it from Odoo.\n"\ "Batch Deposit: Encase several customer checks at once by generating a batch deposit to submit to your bank. When encoding the bank statement in Odoo, you are suggested to reconcile the transaction with the batch deposit.To enable batch deposit, module account_batch_payment must be installed.\n"\ "SEPA Credit Transfer: Pay bill from a SEPA Credit Transfer file you submit to your bank. To enable sepa credit transfer, module account_sepa must be installed ") - payment_method_code = fields.Char(related='payment_method_id.code', - help="Technical field used to adapt the interface to the payment type selected.", readonly=True) + available_payment_method_ids = fields.Many2many('account.payment.method', + compute='_compute_payment_method_fields') + hide_payment_method = fields.Boolean( + compute='_compute_payment_method_fields', + help="Technical field used to hide the payment method if the selected journal has only one available which is 'manual'") - partner_type = fields.Selection([('customer', 'Customer'), ('supplier', 'Vendor')], tracking=True, readonly=True, states={'draft': [('readonly', False)]}) - partner_id = fields.Many2one('res.partner', string='Partner', tracking=True, readonly=True, states={'draft': [('readonly', False)]}, domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]") - is_internal_transfer = fields.Boolean(compute="_compute_is_internal_transfer", store=True) - amount = fields.Monetary(string='Amount', required=True, readonly=True, states={'draft': [('readonly', False)]}, tracking=True) - currency_id = fields.Many2one('res.currency', string='Currency', required=True, readonly=True, states={'draft': [('readonly', False)]}, default=lambda self: self.env.company.currency_id) - payment_date = fields.Date(string='Date', default=fields.Date.context_today, required=True, readonly=True, states={'draft': [('readonly', False)]}, copy=False, tracking=True) - communication = fields.Char(string='Memo', readonly=True, states={'draft': [('readonly', False)]}) - journal_id = fields.Many2one('account.journal', string='Journal', required=True, readonly=True, states={'draft': [('readonly', False)]}, tracking=True, - check_company=True, domain="[('id', 'in', _suitable_journal_ids)]") - _suitable_journal_ids = fields.Many2many('account.journal', compute='_compute_suitable_journal_ids') - company_id = fields.Many2one('res.company', related='journal_id.company_id', string='Company', readonly=True, store=True, default=lambda self: self.env.company) + # == Synchronized fields with the account.move.lines == + amount = fields.Monetary(currency_field='currency_id') + payment_type = fields.Selection([ + ('outbound', 'Send Money'), + ('inbound', 'Receive Money'), + ], string='Payment Type', default='inbound', required=True) + partner_type = fields.Selection([ + ('customer', 'Customer'), + ('supplier', 'Vendor'), + ], default='customer', tracking=True, required=True) + payment_reference = fields.Char(string="Payment Reference", copy=False, + help="Reference of the document used to issue this payment. Eg. check number, file name, etc.") + currency_id = fields.Many2one('res.currency', string='Currency', store=True, readonly=False, + compute='_compute_currency_id', + help="The payment's currency.") + partner_id = fields.Many2one( + comodel_name='res.partner', + string="Customer/Vendor", + store=True, readonly=False, ondelete='restrict', + compute='_compute_partner_id', + domain="['|', ('parent_id','=', False), ('is_company','=', True)]", + check_company=True) + destination_account_id = fields.Many2one( + comodel_name='account.account', + string='Destination Account', + store=True, readonly=False, + compute='_compute_destination_account_id', + domain="[('user_type_id.type', 'in', ('receivable', 'payable')), ('company_id', '=', company_id)]", + check_company=True, + help="The payment's currency.") - hide_payment_method = fields.Boolean(compute='_compute_hide_payment_method', - help="Technical field used to hide the payment method if the " - "selected journal has only one available which is 'manual'") + # == Stat buttons == + reconciled_invoice_ids = fields.Many2many('account.move', string="Reconciled Invoices", + compute='_compute_stat_buttons_from_reconciliation', + help="Invoices whose journal items have been reconciled with these payments.") + reconciled_invoices_count = fields.Integer(string="# Reconciled Invoices", + compute="_compute_stat_buttons_from_reconciliation") + reconciled_statement_ids = fields.Many2many('account.move', string="Reconciled Statements", + compute='_compute_stat_buttons_from_reconciliation', + help="Statements matched to this payment") + reconciled_statements_count = fields.Integer(string="# Reconciled Statements", + compute="_compute_stat_buttons_from_reconciliation") - payment_difference = fields.Monetary(compute='_compute_payment_difference', readonly=True) - payment_difference_handling = fields.Selection([('open', 'Keep open'), ('reconcile', 'Mark invoice as fully paid')], default='open', string="Payment Difference Handling", copy=False) - writeoff_account_id = fields.Many2one('account.account', string="Difference Account", domain="[('deprecated', '=', False), ('company_id', '=', company_id)]", copy=False) - writeoff_label = fields.Char( - string='Journal Item Label', - help='Change label of the counterpart that will hold the payment difference', - default='Write-Off') - partner_bank_account_id = fields.Many2one('res.partner.bank', string="Recipient Bank Account", readonly=True, states={'draft': [('readonly', False)]}, domain="['|', ('company_id', '=', False), ('company_id', '=', company_id), ('partner_id', 'in', possible_bank_partner_ids)]", check_company=True) - possible_bank_partner_ids = fields.Many2many('res.partner', compute='_compute_possible_bank_partners') - show_partner_bank_account = fields.Boolean(compute='_compute_show_partner_bank', help='Technical field used to know whether the field `partner_bank_account_id` needs to be displayed or not in the payments form views') - require_partner_bank_account = fields.Boolean(compute='_compute_show_partner_bank', help='Technical field used to know whether the field `partner_bank_account_id` needs to be required or not in the payments form views') - qr_code = fields.Char(string="QR Code", compute="_compute_qr_code", help="QR-code report URL to use to generate the QR-code to scan with a banking app to perform this payment.") + # == Display purpose fields == + payment_method_code = fields.Char( + related='payment_method_id.code', + help="Technical field used to adapt the interface to the payment type selected.") + show_partner_bank_account = fields.Boolean( + compute='_compute_show_require_partner_bank', + help="Technical field used to know whether the field `partner_bank_id` needs to be displayed or not in the payments form views") + require_partner_bank_account = fields.Boolean( + compute='_compute_show_require_partner_bank', + help="Technical field used to know whether the field `partner_bank_id` needs to be required or not in the payments form views") - @api.model - def default_get(self, default_fields): - rec = super(account_payment, self).default_get(default_fields) - active_ids = self._context.get('active_ids') or self._context.get('active_id') - active_model = self._context.get('active_model') + _sql_constraints = [ + ( + 'check_amount_not_negative', + 'CHECK(amount >= 0.0)', + "The payment amount cannot be negative.", + ), + ] - # Check for selected invoices ids - if not active_ids or active_model != 'account.move': - return rec + # ------------------------------------------------------------------------- + # HELPERS + # ------------------------------------------------------------------------- - invoices = self.env['account.move'].browse(active_ids).filtered(lambda move: move.is_invoice(include_receipts=True)) + def _seek_for_lines(self): + ''' Helper used to dispatch the journal items between: + - The lines using the temporary liquidity account. + - The lines using the counterpart account. + - The lines being the write-off lines. + :return: (liquidity_lines, counterpart_lines, writeoff_lines) + ''' + self.ensure_one() - # Check all invoices are open - if not invoices or any(invoice.state != 'posted' for invoice in invoices): - raise UserError(_("You can only register payments for open invoices")) - # Check if, in batch payments, there are not negative invoices and positive invoices - dtype = invoices[0].move_type - for inv in invoices[1:]: - if inv.move_type != dtype: - if ((dtype == 'in_refund' and inv.move_type == 'in_invoice') or - (dtype == 'in_invoice' and inv.move_type == 'in_refund')): - raise UserError(_("You cannot register payments for vendor bills and supplier refunds at the same time.")) - if ((dtype == 'out_refund' and inv.move_type == 'out_invoice') or - (dtype == 'out_invoice' and inv.move_type == 'out_refund')): - raise UserError(_("You cannot register payments for customer invoices and credit notes at the same time.")) + liquidity_lines = self.env['account.move.line'] + counterpart_lines = self.env['account.move.line'] + writeoff_lines = self.env['account.move.line'] - amount = self._compute_payment_amount(invoices, invoices[0].currency_id, invoices[0].journal_id, rec.get('payment_date') or fields.Date.today()) - rec.update({ - 'currency_id': invoices[0].currency_id.id, - 'amount': abs(amount), - 'payment_type': 'inbound' if amount > 0 else 'outbound', - 'partner_id': invoices[0].commercial_partner_id.id, - 'partner_type': MAP_INVOICE_TYPE_PARTNER_TYPE[invoices[0].move_type], - 'communication': invoices[0].invoice_payment_ref or invoices[0].ref or invoices[0].name, - 'invoice_ids': [(6, 0, invoices.ids)], - }) - return rec - - @api.depends('partner_id', 'journal_id') - def _compute_is_internal_transfer(self): - for payment in self: - payment.is_internal_transfer = payment.partner_id == payment.journal_id.company_id.partner_id - - @api.depends('amount', 'currency_id', 'payment_type', 'company_id', 'invoice_ids.company_id', ) - def _compute_suitable_journal_ids(self): - for p in self: - domain = [('company_id', '=', p.invoice_ids[:1].company_id.id or p.company_id.id)] - if p.currency_id.is_zero(p.amount) and p.invoice_ids: - domain.append(('type', '=', 'general')) + for line in self.move_id.line_ids: + if line.account_id in (self.journal_id.payment_debit_account_id, self.journal_id.payment_credit_account_id): + liquidity_lines += line + elif line.account_id.internal_type in ('receivable', 'payable') or line.partner_id == line.company_id.partner_id: + counterpart_lines += line else: - domain.append(('type', 'in', ['bank', 'cash'])) - if p.payment_type == 'inbound': - domain.append(('at_least_one_inbound', '=', True)) - else: - domain.append(('at_least_one_outbound', '=', True)) + writeoff_lines += line - p._suitable_journal_ids = self.env['account.journal'].search(domain) + return liquidity_lines, counterpart_lines, writeoff_lines - @api.depends('partner_id.commercial_partner_id') - def _compute_possible_bank_partners(self): - for r in self: - r.possible_bank_partner_ids = \ - r.partner_id | r.partner_id.commercial_partner_id + def _prepare_move_line_default_vals(self, write_off_line_vals=None): + ''' Prepare the dictionary to create the default account.move.lines for the current payment. + :param write_off_line_vals: Optional dictionary to create a write-off account.move.line easily containing: + * amount: The amount to be added to the counterpart amount. + * name: The label to set on the line. + * account_id: The account on which create the write-off. + :return: A list of python dictionary to be passed to the account.move.line's 'create' method. + ''' + self.ensure_one() + write_off_line_vals = write_off_line_vals or {} - @api.depends('payment_type', 'journal_id.inbound_payment_method_ids', 'journal_id.outbound_payment_method_ids') - @api.depends_context('default_payment_method_id') - def _compute_payment_methods(self): - # Ensure the domain will accept the provided default value - self._payment_methods = default = self.env['account.payment.method'].browse(self.env.context.get('default_payment_method_id')) - for p in self.filtered(lambda p: p.journal_id): - if p.payment_type == 'inbound': - payment_methods = p.journal_id.inbound_payment_method_ids + if not self.journal_id.payment_debit_account_id or not self.journal_id.payment_credit_account_id: + raise UserError(_( + "You can't create a new payment without an outstanding payments/receipts accounts set on the %s journal." + ) % self.journal_id.display_name) + + # Compute amounts. + write_off_amount = write_off_line_vals.get('amount', 0.0) + + if self.payment_type == 'inbound': + # Receive money. + counterpart_amount = -self.amount + elif self.payment_type == 'outbound': + # Send money. + counterpart_amount = self.amount + else: + counterpart_amount = 0.0 + + # Manage currency. + if self.currency_id == self.company_id.currency_id: + # Single-currency. + balance = counterpart_amount + counterpart_amount_currency = 0.0 + write_off_balance = write_off_amount + write_off_amount_currency = 0.0 + currency_id = False + else: + # Multi-currencies. + balance = self.currency_id._convert(counterpart_amount, self.company_id.currency_id, self.company_id, self.date) + counterpart_amount_currency = counterpart_amount + write_off_balance = self.currency_id._convert(write_off_amount, self.company_id.currency_id, self.company_id, self.date) + write_off_amount_currency = write_off_amount + currency_id = self.currency_id.id + + if self.is_internal_transfer: + if self.payment_type == 'inbound': + liquidity_line_name = _('Transfer to %s') % self.journal_id.name + else: # payment.payment_type == 'outbound': + liquidity_line_name = _('Transfer from %s') % self.journal_id.name + else: + liquidity_line_name = self.payment_reference + + line_vals_list = [ + # Liquidity line. + { + 'name': liquidity_line_name, + 'date_maturity': self.date, + 'amount_currency': -counterpart_amount_currency, + 'currency_id': currency_id, + 'debit': balance < 0.0 and -balance or 0.0, + 'credit': balance > 0.0 and balance or 0.0, + 'partner_id': self.partner_id.id, + 'account_id': self.journal_id.payment_debit_account_id.id if balance < 0.0 else self.journal_id.payment_credit_account_id.id, + }, + # Receivable / Payable. + { + 'name': self.payment_reference, + 'date_maturity': self.date, + 'amount_currency': counterpart_amount_currency + write_off_amount_currency if currency_id else 0.0, + 'currency_id': currency_id, + 'debit': balance + write_off_balance > 0.0 and balance + write_off_balance or 0.0, + 'credit': balance + write_off_balance < 0.0 and -balance - write_off_balance or 0.0, + 'partner_id': self.partner_id.id, + 'account_id': self.destination_account_id.id, + }, + ] + if write_off_balance: + # Write-off line. + line_vals_list.append({ + 'name': write_off_line_vals.get('name'), + 'amount_currency': -write_off_amount_currency, + 'currency_id': currency_id, + 'debit': write_off_balance < 0.0 and -write_off_balance or 0.0, + 'credit': write_off_balance > 0.0 and write_off_balance or 0.0, + 'partner_id': self.partner_id.id, + 'account_id': write_off_line_vals.get('account_id'), + }) + return line_vals_list + + # ------------------------------------------------------------------------- + # COMPUTE METHODS + # ------------------------------------------------------------------------- + + @api.depends('move_id.line_ids.amount_residual', 'move_id.line_ids.amount_residual_currency') + def _compute_reconciliation_status(self): + ''' Compute the field indicating if the payments are already reconciled with something. + This field is used for display purpose (e.g. display the 'reconcile' button redirecting to the reconciliation + widget). + ''' + for pay in self: + liquidity_lines, counterpart_lines, writeoff_lines = pay._seek_for_lines() + + if not pay.currency_id or not pay.id: + pay.is_reconciled = False + pay.is_matched = False + elif pay.currency_id.is_zero(pay.amount): + pay.is_reconciled = True + pay.is_matched = True else: - payment_methods = p.journal_id.outbound_payment_method_ids - p._payment_methods = default | payment_methods - - @api.depends('partner_bank_account_id', 'amount', 'communication', 'currency_id', 'journal_id', 'state', 'payment_method_id', 'payment_type') - def _compute_qr_code(self): - for record in self: - if record.state in ('draft', 'posted') \ - and record.partner_bank_account_id \ - and record.payment_method_id.code == 'manual' \ - and record.payment_type == 'outbound': - - currency = record.currency_id or record.company_id.currency_id - qr_code = record.partner_bank_account_id and record.partner_bank_account_id.build_qr_code_url(record.amount, record.communication, None, currency, record.partner_id) or None - if qr_code: - record.qr_code = ''' -
- -
- {txt} - '''.format(txt = _('Scan me with your banking app.'), - qr_code = qr_code) - continue - - record.qr_code = None - - @api.constrains('amount') - def _check_amount(self): - for payment in self: - if payment.amount < 0: - raise ValidationError(_('The payment amount cannot be negative.')) + # The journal entry seems reconciled. + residual_field = 'amount_residual' if pay.currency_id == pay.company_id.currency_id else 'amount_residual_currency' + reconcile_lines = (counterpart_lines + writeoff_lines).filtered(lambda line: line.account_id.reconcile) + pay.is_reconciled = pay.currency_id.is_zero(sum(reconcile_lines.mapped(residual_field))) + pay.is_matched = pay.currency_id.is_zero(sum(liquidity_lines.mapped(residual_field))) @api.model def _get_method_codes_using_bank_account(self): @@ -209,591 +283,464 @@ class account_payment(models.Model): return [] @api.depends('payment_method_code') - def _compute_show_partner_bank(self): + def _compute_show_require_partner_bank(self): """ Computes if the destination bank account must be displayed in the payment form view. By default, it won't be displayed but some modules might change that, depending on the payment type.""" for payment in self: payment.show_partner_bank_account = payment.payment_method_code in self._get_method_codes_using_bank_account() payment.require_partner_bank_account = payment.state == 'draft' and payment.payment_method_code in self._get_method_codes_needing_bank_account() - @api.depends('payment_type', 'journal_id') - def _compute_hide_payment_method(self): - for payment in self: - if not payment.journal_id or payment.journal_id.type not in ['bank', 'cash']: - payment.hide_payment_method = True - continue - journal_payment_methods = payment.payment_type == 'inbound'\ - and payment.journal_id.inbound_payment_method_ids\ - or payment.journal_id.outbound_payment_method_ids - payment.hide_payment_method = len(journal_payment_methods) == 1 and journal_payment_methods[0].code == 'manual' - - @api.depends('invoice_ids', 'amount', 'payment_date', 'currency_id', 'payment_type') - def _compute_payment_difference(self): - draft_payments = self.filtered(lambda p: p.invoice_ids and p.state == 'draft') - for pay in draft_payments: - payment_amount = -pay.amount if pay.payment_type == 'outbound' else pay.amount - pay.payment_difference = pay._compute_payment_amount(pay.invoice_ids, pay.currency_id, pay.journal_id, pay.payment_date) - payment_amount - (self - draft_payments).payment_difference = 0 - - @api.onchange('journal_id') - def _onchange_journal(self): - if not self.journal_id: - return - - if self.journal_id.currency_id: - self.currency_id = self.journal_id.currency_id - - if self.payment_method_id not in self._payment_methods._origin: - self.payment_method_id = self._payment_methods[:1]._origin - - if self.env.context.get('active_model') == 'account.move': - active_ids = self._context.get('active_ids') - invoices = self.env['account.move'].browse(active_ids) - self.amount = abs(self._compute_payment_amount(invoices, self.currency_id, self.journal_id, self.payment_date)) - - @api.onchange('partner_id') - def _onchange_partner_id(self): - if self.invoice_ids and self.invoice_ids[0].invoice_partner_bank_id: - self.partner_bank_account_id = self.invoice_ids[0].invoice_partner_bank_id - elif self.partner_id != self.partner_bank_account_id.partner_id: - # This condition ensures we use the default value provided into - # context for partner_bank_account_id properly when provided with a - # default partner_id. Without it, the onchange recomputes the bank account - # uselessly and might assign a different value to it. - if self.partner_id and len(self.partner_id.bank_ids) > 0: - self.partner_bank_account_id = self.partner_id.bank_ids[0] - elif self.partner_id and len(self.partner_id.commercial_partner_id.bank_ids) > 0: - self.partner_bank_account_id = self.partner_id.commercial_partner_id.bank_ids[0] + @api.depends('partner_id') + def _compute_partner_bank_id(self): + ''' The default partner_bank_id will be the first available on the partner. ''' + for pay in self: + available_partner_bank_accounts = pay.partner_id.bank_ids + if available_partner_bank_accounts: + pay.partner_bank_id = available_partner_bank_accounts[0]._origin else: - self.partner_bank_account_id = False + pay.partner_bank_id = False - @api.onchange('payment_type') - def _onchange_payment_type(self): - if not self.invoice_ids and not self.partner_type: - # Set default partner type for the payment type - if self.payment_type == 'inbound': - self.partner_type = 'customer' - else: # -> self.payment_type == 'outbound' - self.partner_type = 'supplier' - self._onchange_journal() - if self.currency_id.is_zero(self.amount) and self.has_invoices: - self.payment_difference_handling = 'reconcile' + @api.depends('partner_id', 'destination_account_id', 'journal_id') + def _compute_is_internal_transfer(self): + for payment in self: + is_partner_ok = payment.partner_id == payment.journal_id.company_id.partner_id + is_account_ok = payment.destination_account_id and payment.destination_account_id == payment.journal_id.company_id.transfer_account_id + payment.is_internal_transfer = is_partner_ok and is_account_ok - @api.onchange('amount', 'currency_id') - def _onchange_amount(self): - journal_types = ['bank', 'cash'] - if self.currency_id.is_zero(self.amount) and self.has_invoices: - # In case of payment with 0 amount, allow to select a journal of type 'general' like - # 'Miscellaneous Operations' and set this journal by default. - journal_types = ['general'] - self.payment_difference_handling = 'reconcile' - domain_on_types = [('type', 'in', journal_types)] - if self.invoice_ids: - domain_on_types.append(('company_id', '=', self.invoice_ids[0].company_id.id)) - if self.journal_id.type not in journal_types or (self.invoice_ids and self.journal_id.company_id != self.invoice_ids[0].company_id): - self.journal_id = self.env['account.journal'].search(domain_on_types, limit=1) - - @api.onchange('currency_id') - def _onchange_currency(self): - self.amount = abs(self._compute_payment_amount(self.invoice_ids, self.currency_id, self.journal_id, self.payment_date)) - - if self.journal_id: # TODO: only return if currency differ? - return - - # Set by default the first liquidity journal having this currency if exists. - domain = [('type', 'in', ('bank', 'cash')), ('currency_id', '=', self.currency_id.id)] - if self.invoice_ids: - domain.append(('company_id', '=', self.invoice_ids[0].company_id.id)) - journal = self.env['account.journal'].search(domain, limit=1) - if journal: - return {'value': {'journal_id': journal.id}} - - @api.model - def _compute_payment_amount(self, invoices, currency, journal, date): - '''Compute the total amount for the payment wizard. - - :param invoices: Invoices on which compute the total as an account.invoice recordset. - :param currency: The payment's currency as a res.currency record. - :param journal: The payment's journal as an account.journal record. - :param date: The payment's date as a datetime.date object. - :return: The total amount to pay the invoices. + @api.depends('payment_type', 'journal_id') + def _compute_payment_method_id(self): + ''' Compute the 'payment_method_id' field. + This field is not computed in '_compute_payment_method_fields' because it's a stored editable one. ''' - company = journal.company_id - currency = currency or journal.currency_id or company.currency_id - date = date or fields.Date.today() + for pay in self: + if pay.payment_type == 'inbound': + available_payment_methods = pay.journal_id.inbound_payment_method_ids + else: + available_payment_methods = pay.journal_id.outbound_payment_method_ids - if not invoices: - return 0.0 + # Select the first available one by default. + if available_payment_methods: + pay.payment_method_id = available_payment_methods[0]._origin + else: + pay.payment_method_id = False + + @api.depends('payment_type', + 'journal_id.inbound_payment_method_ids', + 'journal_id.outbound_payment_method_ids') + def _compute_payment_method_fields(self): + for pay in self: + if pay.payment_type == 'inbound': + pay.available_payment_method_ids = pay.journal_id.inbound_payment_method_ids + else: + pay.available_payment_method_ids = pay.journal_id.outbound_payment_method_ids + + pay.hide_payment_method = len(pay.available_payment_method_ids) == 1 and pay.available_payment_method_ids.code == 'manual' + + @api.depends('journal_id') + def _compute_currency_id(self): + for pay in self: + pay.currency_id = pay.journal_id.currency_id or pay.journal_id.company_id.currency_id + + @api.depends('is_internal_transfer') + def _compute_partner_id(self): + for pay in self: + if pay.is_internal_transfer: + pay.partner_id = pay.journal_id.company_id.partner_id + elif pay.partner_id == pay.journal_id.company_id.partner_id: + pay.partner_id = False + else: + pay.partner_id = pay.partner_id + + @api.depends('journal_id', 'partner_id', 'partner_type', 'is_internal_transfer') + def _compute_destination_account_id(self): + for pay in self: + if pay.is_internal_transfer: + pay.destination_account_id = pay.journal_id.company_id.transfer_account_id + elif pay.partner_type == 'customer': + # Receive money from invoice or send money to refund it. + if pay.partner_id: + pay.destination_account_id = pay.partner_id.with_company(pay.company_id).property_account_receivable_id + else: + pay.destination_account_id = self.env['account.account'].search([ + ('company_id', '=', pay.company_id.id), + ('internal_type', '=', 'receivable'), + ], limit=1) + elif pay.partner_type == 'supplier': + # Send money to pay a bill or receive money to refund it. + if pay.partner_id: + pay.destination_account_id = pay.partner_id.with_company(pay.company_id).property_account_payable_id + else: + pay.destination_account_id = self.env['account.account'].search([ + ('company_id', '=', pay.company_id.id), + ('internal_type', '=', 'payable'), + ], limit=1) + + @api.depends('partner_bank_id', 'amount', 'ref', 'currency_id', 'journal_id', 'move_id.state', + 'payment_method_id', 'payment_type') + def _compute_qr_code(self): + for pay in self: + if pay.state in ('draft', 'posted') \ + and pay.partner_bank_id \ + and pay.payment_method_id.code == 'manual' \ + and pay.payment_type == 'outbound' \ + and pay.currency_id: + + if pay.partner_bank_id: + qr_code = pay.partner_bank_id.build_qr_code_url(pay.amount, pay.ref, None, pay.currency_id, pay.partner_id) + else: + qr_code = None + + if qr_code: + pay.qr_code = ''' +
+ +
+ {txt} + '''.format(txt = _('Scan me with your banking app.'), + qr_code = qr_code) + continue + + pay.qr_code = None + + @api.depends('move_id.line_ids.matched_debit_ids', 'move_id.line_ids.matched_credit_ids') + def _compute_stat_buttons_from_reconciliation(self): + ''' Retrieve the invoices reconciled to the payments through the reconciliation (account.partial.reconcile). ''' + stored_payments = self.filtered('id') + if not stored_payments: + return + self.env['account.move'].flush() + self.env['account.move.line'].flush() + self.env['account.partial.reconcile'].flush() - self.env['account.move'].flush(['move_type', 'currency_id']) - self.env['account.move.line'].flush(['amount_residual', 'amount_residual_currency', 'move_id', 'account_id']) - self.env['account.account'].flush(['user_type_id']) - self.env['account.account.type'].flush(['type']) self._cr.execute(''' SELECT - move.move_type AS type, - move.currency_id AS currency_id, - SUM(line.amount_residual) AS amount_residual, - SUM(line.amount_residual_currency) AS residual_currency - FROM account_move move - LEFT JOIN account_move_line line ON line.move_id = move.id - LEFT JOIN account_account account ON account.id = line.account_id - LEFT JOIN account_account_type account_type ON account_type.id = account.user_type_id - WHERE move.id IN %s - AND account_type.type IN ('receivable', 'payable') - GROUP BY move.id, move.move_type - ''', [tuple(invoices.ids)]) - query_res = self._cr.dictfetchall() + payment.id, + ARRAY_AGG(DISTINCT invoice.id) AS invoice_ids + FROM account_payment payment + JOIN account_move move ON move.id = payment.move_id + JOIN account_move_line line ON line.move_id = move.id + JOIN account_partial_reconcile part ON + part.debit_move_id = line.id + OR + part.credit_move_id = line.id + JOIN account_move_line counterpart_line ON + part.debit_move_id = counterpart_line.id + OR + part.credit_move_id = counterpart_line.id + JOIN account_move invoice ON invoice.id = counterpart_line.move_id + WHERE line.account_internal_type IN ('receivable', 'payable') + AND line.id != counterpart_line.id + AND invoice.move_type in ('out_invoice', 'out_refund', 'in_invoice', 'in_refund', 'out_receipt', 'in_receipt') + GROUP BY payment.id + ''') + query_res = dict((payment_id, invoice_ids) for payment_id, invoice_ids in self._cr.fetchall()) - total = 0.0 - for res in query_res: - move_currency = self.env['res.currency'].browse(res['currency_id']) - if move_currency == currency and move_currency != company.currency_id: - total += res['residual_currency'] - else: - total += company.currency_id._convert(res['amount_residual'], currency, company, date) - return total + for pay in self: + invoice_ids = query_res.get(pay.id, []) + pay.reconciled_invoice_ids = [(6, 0, invoice_ids)] + pay.reconciled_invoices_count = len(invoice_ids) - def name_get(self): - return [(payment.id, payment.name or _('Draft Payment')) for payment in self] + self._cr.execute(''' + SELECT + payment.id, + ARRAY_AGG(DISTINCT counterpart_line.statement_id) AS statement_ids + FROM account_payment payment + JOIN account_move move ON move.id = payment.move_id + JOIN account_journal journal ON journal.id = move.journal_id + JOIN account_move_line line ON line.move_id = move.id + JOIN account_account account ON account.id = line.account_id + JOIN account_partial_reconcile part ON + part.debit_move_id = line.id + OR + part.credit_move_id = line.id + JOIN account_move_line counterpart_line ON + part.debit_move_id = counterpart_line.id + OR + part.credit_move_id = counterpart_line.id + WHERE (account.id = journal.payment_debit_account_id OR account.id = journal.payment_credit_account_id) + AND line.id != counterpart_line.id + AND counterpart_line.statement_id IS NOT NULL + GROUP BY payment.id + ''') + query_res = dict((payment_id, statement_ids) for payment_id, statement_ids in self._cr.fetchall()) - @api.depends('move_line_ids.reconciled') - def _get_move_reconciled(self): - for payment in self: - rec = True - for aml in payment.move_line_ids.filtered(lambda x: x.account_id.reconcile): - if not aml.reconciled: - rec = False - break - payment.move_reconciled = rec + for pay in self: + statement_ids = query_res.get(pay.id, []) + pay.reconciled_statement_ids = [(6, 0, statement_ids)] + pay.reconciled_statements_count = len(statement_ids) - @api.depends('invoice_ids', 'payment_type', 'partner_type', 'partner_id', 'is_internal_transfer') - def _compute_destination_account_id(self): - for payment in self: - payment = payment.with_company(payment.company_id) - if payment.invoice_ids: - payment.destination_account_id = payment.invoice_ids[0].mapped( - 'line_ids.account_id').filtered( - lambda account: account.user_type_id.type in ('receivable', 'payable'))[0] - elif payment.is_internal_transfer: - if not payment.company_id.transfer_account_id.id: - raise UserError(_('There is no Transfer Account defined in the accounting settings. Please define one to be able to confirm this transfer.')) - payment.destination_account_id = payment.company_id.transfer_account_id.id - elif payment.partner_id: - if payment.partner_type == 'customer': - payment.destination_account_id = payment.partner_id.property_account_receivable_id.id - else: - payment.destination_account_id = payment.partner_id.property_account_payable_id.id - elif payment.partner_type == 'customer': - default_account = self.env['ir.property'].get('property_account_receivable_id', 'res.partner') - payment.destination_account_id = default_account.id - elif payment.partner_type == 'supplier': - default_account = self.env['ir.property'].get('property_account_payable_id', 'res.partner') - payment.destination_account_id = default_account.id + # ------------------------------------------------------------------------- + # CONSTRAINT METHODS + # ------------------------------------------------------------------------- - @api.depends('move_line_ids.matched_debit_ids', 'move_line_ids.matched_credit_ids') - def _compute_reconciled_invoice_ids(self): - for record in self: - reconciled_moves = record.move_line_ids.mapped('matched_debit_ids.debit_move_id.move_id')\ - + record.move_line_ids.mapped('matched_credit_ids.credit_move_id.move_id') - record.reconciled_invoice_ids = reconciled_moves.filtered(lambda move: move.is_invoice()) - record.has_invoices = bool(record.reconciled_invoice_ids) - record.reconciled_invoices_count = len(record.reconciled_invoice_ids) + @api.constrains('payment_method_id') + def _check_payment_method_id(self): + ''' Ensure the 'payment_method_id' field is not null. + Can't be done using the regular 'required=True' because the field is a computed editable stored one. + ''' + for pay in self: + if not pay.payment_method_id: + raise ValidationError(_("Please define a payment method on your payment.")) - def action_register_payment(self): - active_ids = self.env.context.get('active_ids') - if not active_ids: - return '' + # ------------------------------------------------------------------------- + # LOW-LEVEL METHODS + # ------------------------------------------------------------------------- - return { - 'name': _('Register Payment'), - 'res_model': len(active_ids) == 1 and 'account.payment' or 'account.payment.register', - 'view_mode': 'form', - 'view_id': len(active_ids) != 1 and self.env.ref('account.view_account_payment_form_multi').id or self.env.ref('account.view_account_payment_invoice_form').id, - 'context': self.env.context, - 'target': 'new', - 'type': 'ir.actions.act_window', - } + @api.model_create_multi + def create(self, vals_list): + # OVERRIDE + write_off_line_vals_list = [] - def button_journal_entries(self): - return { - 'name': _('Journal Items'), - 'view_mode': 'tree,form', - 'res_model': 'account.move.line', - 'view_id': False, - 'type': 'ir.actions.act_window', - 'domain': [('payment_id', 'in', self.ids)], - } + for vals in vals_list: - def button_invoices(self): - return { - 'name': _('Paid Invoices'), - 'view_mode': 'tree,form', - 'res_model': 'account.move', - 'view_id': False, - 'views': [(self.env.ref('account.view_move_tree').id, 'tree'), (self.env.ref('account.view_move_form').id, 'form')], - 'type': 'ir.actions.act_window', - 'domain': [('id', 'in', [x.id for x in self.reconciled_invoice_ids])], - 'context': {'create': False}, - } + # Hack to add a custom write-off line. + write_off_line_vals_list.append(vals.pop('write_off_line_vals', None)) - def mark_as_sent(self): - self.write({'state': 'sent'}) + # Force the move_type to avoid inconsistency with residual 'default_move_type' inside the context. + vals['move_type'] = 'entry' - def unmark_as_sent(self): - self.write({'state': 'posted'}) + # Force the computation of 'journal_id' since this field is set on account.move but must have the + # bank/cash type. + if 'journal_id' not in vals: + vals['journal_id'] = self._get_default_journal().id - def unreconcile(self): - """ Set back the payments in 'posted' or 'sent' state, without deleting the journal entries. - Called when cancelling a bank statement line linked to a pre-registered payment. - """ - for payment in self: - if payment.payment_reference: - payment.write({'state': 'sent'}) - else: - payment.write({'state': 'posted'}) + # Since 'currency_id' is a computed editable field, it will be computed later. + # Prevent the account.move to call the _get_default_currency method that could raise + # the 'Please define an accounting miscellaneous journal in your company' error. + if 'currency_id' not in vals: + journal = self.env['account.journal'].browse(vals['journal_id']) + vals['currency_id'] = journal.currency_id.id or journal.company_id.currency_id.id - def cancel(self): - self.write({'state': 'cancelled'}) + payments = super().create(vals_list) + + for i, pay in enumerate(payments): + write_off_line_vals = write_off_line_vals_list[i] + + to_write = {'payment_id': pay.id} + if 'line_ids' not in vals_list[i]: + to_write['line_ids'] = [(0, 0, line_vals) for line_vals in pay._prepare_move_line_default_vals(write_off_line_vals=write_off_line_vals)] + + pay.move_id.write(to_write) + + return payments + + def write(self, vals): + # OVERRIDE + res = super().write(vals) + self._synchronize_to_moves(set(vals.keys())) + return res def unlink(self): - if any(bool(rec.move_line_ids) for rec in self): - raise UserError(_("You cannot delete a payment that is already posted.")) - if any(rec.move_name for rec in self): - raise UserError(_('It is not allowed to delete a payment that already created a journal entry since it would create a gap in the numbering. You should create the journal entry again and cancel it thanks to a regular revert.')) - return super(account_payment, self).unlink() + # OVERRIDE to unlink the inherited account.move (move_id field) as well. + moves = self.with_context(force_delete=True).move_id + res = super().unlink() + moves.unlink() + return res - def _prepare_payment_moves(self): - ''' Prepare the creation of journal entries (account.move) by creating a list of python dictionary to be passed - to the 'create' method. + @api.depends('move_id.name') + def name_get(self): + return [(payment.id, payment.move_id.name or _('Draft Payment')) for payment in self] - Example : outbound with write-off: + # ------------------------------------------------------------------------- + # SYNCHRONIZATION account.payment <-> account.move + # ------------------------------------------------------------------------- - Account | Debit | Credit - --------------------------------------------------------- - BANK | 900.0 | - RECEIVABLE | | 1000.0 - WRITE-OFF ACCOUNT | 100.0 | - - :return: A list of Python dictionary to be passed to env['account.move'].create. + def _synchronize_from_moves(self, changed_fields): + ''' Update the account.payment regarding its related account.move. + Also, check both models are still consistent. + :param changed_fields: A set containing all modified fields on account.move. ''' - all_move_vals = [] - for payment in self: - company_currency = payment.company_id.currency_id + if self._context.get('skip_account_move_synchronization'): + return - # Compute amounts. - write_off_amount = payment.payment_difference_handling == 'reconcile' and -payment.payment_difference or 0.0 - if payment.payment_type == 'outbound': - counterpart_amount = payment.amount - liquidity_line_account = payment.journal_id.default_debit_account_id - else: - counterpart_amount = -payment.amount - liquidity_line_account = payment.journal_id.default_credit_account_id + for pay in self.with_context(skip_account_move_synchronization=True): + move = pay.move_id + move_vals_to_write = {} + payment_vals_to_write = {} - # Manage currency. - if payment.currency_id == company_currency: - # Single-currency. - balance = counterpart_amount - write_off_balance = write_off_amount - counterpart_amount = write_off_amount = 0.0 - currency_id = False - else: - # Multi-currencies. - balance = payment.currency_id._convert(counterpart_amount, company_currency, payment.company_id, payment.payment_date) - write_off_balance = payment.currency_id._convert(write_off_amount, company_currency, payment.company_id, payment.payment_date) - currency_id = payment.currency_id.id + if 'journal_id' in changed_fields: + if pay.journal_id.type not in ('bank', 'cash'): + raise UserError(_("A payment must always belongs to a bank or cash journal.")) - # Manage custom currency on journal for liquidity line. - if payment.journal_id.currency_id and payment.currency_id != payment.journal_id.currency_id: - # Custom currency on journal. - if payment.journal_id.currency_id == company_currency: - # Single-currency - liquidity_line_currency_id = False + if 'line_ids' in changed_fields: + all_lines = move.line_ids + liquidity_lines, counterpart_lines, writeoff_lines = pay._seek_for_lines() + + if len(liquidity_lines) != 1 or len(counterpart_lines) != 1: + raise UserError(_( + "The journal entry %s reached an invalid state relative to its payment.\n" + "To be consistent, the journal entry must always contains:\n" + "- one journal item involving the outstanding payment/receipts account.\n" + "- one journal item involving a receivable/payable account.\n" + "- optional journal items, all sharing the same account.\n\n" + ) % move.display_name) + + if writeoff_lines and len(writeoff_lines.account_id) != 1: + raise UserError(_( + "The journal entry %s reached an invalid state relative to its payment.\n" + "To be consistent, all the write-off journal items must share the same account." + ) % move.display_name) + + if any(line.currency_id != all_lines[0].currency_id for line in all_lines): + raise UserError(_( + "The journal entry %s reached an invalid state relative to its payment.\n" + "To be consistent, the journal items must share the same currency." + ) % move.display_name) + + if any(line.partner_id != all_lines[0].partner_id for line in all_lines): + raise UserError(_( + "The journal entry %s reached an invalid state relative to its payment.\n" + "To be consistent, the journal items must share the same partner." + ) % move.display_name) + + if counterpart_lines.account_id.user_type_id.type == 'receivable': + partner_type = 'customer' else: - liquidity_line_currency_id = payment.journal_id.currency_id.id - liquidity_amount = company_currency._convert( - balance, payment.journal_id.currency_id, payment.company_id, payment.payment_date) - else: - # Use the payment currency. - liquidity_line_currency_id = currency_id - liquidity_amount = counterpart_amount + partner_type = 'supplier' - # Compute 'name' to be used in receivable/payable line. - rec_pay_line_name = '' - if payment.is_internal_transfer: - rec_pay_line_name = payment.name - else: - if payment.partner_type == 'customer': - if payment.payment_type == 'inbound': - rec_pay_line_name += _("Customer Payment") - elif payment.payment_type == 'outbound': - rec_pay_line_name += _("Customer Credit Note") - elif payment.partner_type == 'supplier': - if payment.payment_type == 'inbound': - rec_pay_line_name += _("Vendor Credit Note") - elif payment.payment_type == 'outbound': - rec_pay_line_name += _("Vendor Payment") - if payment.invoice_ids: - rec_pay_line_name += ': %s' % ', '.join(payment.invoice_ids.mapped('name')) + liquidity_amount = liquidity_lines.amount_currency if liquidity_lines.currency_id else liquidity_lines.balance - # Compute 'name' to be used in liquidity line. - if payment.is_internal_transfer: - if payment.payment_type == 'inbound': - liquidity_line_name = _('Transfer to %s') % payment.journal_id.name - elif payment.payment_type == 'outbound': - liquidity_line_name = _('Transfer from %s') % payment.journal_id.name - else: - liquidity_line_name = payment.name + move_vals_to_write.update({ + 'currency_id': liquidity_lines.currency_id.id or liquidity_lines.company_currency_id.id, + 'partner_id': liquidity_lines.partner_id.id, + }) + payment_vals_to_write.update({ + 'amount': abs(liquidity_amount), + 'payment_type': 'inbound' if liquidity_amount > 0.0 else 'outbound', + 'payment_reference': liquidity_lines.name, + 'partner_type': partner_type, + 'currency_id': liquidity_lines.currency_id.id or liquidity_lines.company_currency_id.id, + 'destination_account_id': counterpart_lines.account_id.id, + 'partner_id': liquidity_lines.partner_id.id, + }) - # ==== 'inbound' / 'outbound' ==== + move.write(move._cleanup_write_orm_values(move, move_vals_to_write)) + pay.write(move._cleanup_write_orm_values(pay, payment_vals_to_write)) - move_vals = { - 'date': payment.payment_date, - 'ref': payment.communication, - 'journal_id': payment.journal_id.id, - 'currency_id': payment.journal_id.currency_id.id or payment.company_id.currency_id.id, - 'partner_id': payment.partner_id.id, - 'line_ids': [ - # Receivable / Payable / Transfer line. - (0, 0, { - 'name': rec_pay_line_name, - 'amount_currency': counterpart_amount + write_off_amount if currency_id else 0.0, - 'currency_id': currency_id, - 'debit': balance + write_off_balance > 0.0 and balance + write_off_balance or 0.0, - 'credit': balance + write_off_balance < 0.0 and -balance - write_off_balance or 0.0, - 'date_maturity': payment.payment_date, - 'partner_id': payment.partner_id.commercial_partner_id.id, - 'account_id': payment.destination_account_id.id, - 'payment_id': payment.id, - }), - # Liquidity line. - (0, 0, { - 'name': liquidity_line_name, - 'amount_currency': -liquidity_amount if liquidity_line_currency_id else 0.0, - 'currency_id': liquidity_line_currency_id, - 'debit': balance < 0.0 and -balance or 0.0, - 'credit': balance > 0.0 and balance or 0.0, - 'date_maturity': payment.payment_date, - 'partner_id': payment.partner_id.commercial_partner_id.id, - 'account_id': liquidity_line_account.id, - 'payment_id': payment.id, - }), - ], - } - if write_off_balance: - # Write-off line. - move_vals['line_ids'].append((0, 0, { - 'name': payment.writeoff_label, - 'amount_currency': -write_off_amount, - 'currency_id': currency_id, - 'debit': write_off_balance < 0.0 and -write_off_balance or 0.0, - 'credit': write_off_balance > 0.0 and write_off_balance or 0.0, - 'date_maturity': payment.payment_date, - 'partner_id': payment.partner_id.commercial_partner_id.id, - 'account_id': payment.writeoff_account_id.id, - 'payment_id': payment.id, - })) + def _synchronize_to_moves(self, changed_fields): + ''' Update the account.move regarding the modified account.payment. + :param changed_fields: A list containing all modified fields on account.payment. + ''' + if self._context.get('skip_account_move_synchronization'): + return - if payment.move_name: - move_vals['name'] = payment.move_name + if not any(field_name in changed_fields for field_name in ( + 'amount', 'payment_type', 'partner_type', 'payment_reference', 'is_internal_transfer', + 'currency_id', 'partner_id', 'destination_account_id', + )): + return - all_move_vals.append(move_vals) + for pay in self.with_context(skip_account_move_synchronization=True): + liquidity_lines, counterpart_lines, writeoff_lines = pay._seek_for_lines() - return all_move_vals + # Make sure to preserve the write-off amount. + # This allows to create a new payment with custom 'line_ids'. - def post(self): - """ Create the journal items for the payment and update the payment's state to 'posted'. - A journal entry is created containing an item in the source liquidity account (selected journal's default_debit or default_credit) - and another in the destination reconcilable account (see _compute_destination_account_id). - If invoice_ids is not empty, there will be one reconcilable move line per invoice to reconcile with. - If the payment is a transfer, a second journal entry is created in the destination journal to receive money from the transfer account. - """ - AccountMove = self.env['account.move'].with_context(default_move_type='entry') - for rec in self: - - if rec.state != 'draft': - raise UserError(_("Only a draft payment can be posted.")) - - if any(inv.state != 'posted' for inv in rec.invoice_ids): - raise ValidationError(_("The payment cannot be processed because the invoice is not open!")) - - # keep the name in case of a payment reset to draft - if not rec.name: - if rec.is_internal_transfer: - sequence_code = 'account.payment.transfer' + if writeoff_lines: + writeoff_amount_field = 'balance' if pay.currency_id == pay.company_id.currency_id else 'amount_currency' + writeoff_amount = sum(writeoff_lines.mapped(writeoff_amount_field)) + counterpart_amount = counterpart_lines[writeoff_amount_field] + if writeoff_amount > 0.0 and counterpart_amount > 0.0: + sign = 1 else: - if rec.partner_type == 'customer': - if rec.payment_type == 'inbound': - sequence_code = 'account.payment.customer.invoice' - elif rec.payment_type == 'outbound': - sequence_code = 'account.payment.customer.refund' - elif rec.partner_type == 'supplier': - if rec.payment_type == 'inbound': - sequence_code = 'account.payment.supplier.refund' - elif rec.payment_type == 'outbound': - sequence_code = 'account.payment.supplier.invoice' - rec.name = self.env['ir.sequence'].next_by_code(sequence_code, sequence_date=rec.payment_date) - if not rec.name and not rec.is_internal_transfer: - raise UserError(_("You have to define a sequence for %s in your company.") % (sequence_code,)) + sign = -1 - move = AccountMove.create(rec._prepare_payment_moves()) - if move.journal_id.post_at != 'bank_rec': - move.post() + write_off_line_vals = { + 'name': writeoff_lines[0].name, + 'amount': writeoff_amount * sign, + 'account_id': writeoff_lines[0].account_id.id, + } + else: + write_off_line_vals = {} - # Update the state / move before performing any reconciliation. - rec.write({'state': 'posted', 'move_name': move.name}) + line_vals_list = pay._prepare_move_line_default_vals(write_off_line_vals=write_off_line_vals) - if rec.invoice_ids: - (move + rec.invoice_ids).line_ids \ - .filtered(lambda line: not line.reconciled and line.account_id == rec.destination_account_id)\ - .reconcile() + line_ids_commands = [ + (1, liquidity_lines.id, line_vals_list[0]), + (1, counterpart_lines.id, line_vals_list[1]), + ] - return True + for line in writeoff_lines: + line_ids_commands.append((2, line.id)) + + if writeoff_lines: + line_ids_commands.append((0, 0, line_vals_list[2])) + + # Update the existing journal items. + # If dealing with multiple write-off lines, they are dropped and a new one is generated. + + pay.move_id.write({ + 'partner_id': pay.partner_id.id, + 'currency_id': pay.currency_id.id, + 'line_ids': line_ids_commands, + }) + + # ------------------------------------------------------------------------- + # BUSINESS METHODS + # ------------------------------------------------------------------------- + + def mark_as_sent(self): + self.write({'is_move_sent': True}) + + def unmark_as_sent(self): + self.write({'is_move_sent': False}) + + def action_post(self): + ''' draft -> posted ''' + self.move_id.post() + + def action_cancel(self): + ''' draft -> cancelled ''' + self.move_id.button_cancel() def action_draft(self): - moves = self.mapped('move_line_ids.move_id') - moves.filtered(lambda move: move.state == 'posted').button_draft() - moves.with_context(force_delete=True).unlink() - self.write({'state': 'draft'}) + ''' posted -> draft ''' + self.move_id.button_draft() - def _get_invoice_payment_amount(self, inv): - """ - Computes the amount covered by the current payment in the given invoice. - - :param inv: an invoice object - :returns: the amount covered by the payment in the invoice - """ + def button_open_invoices(self): + ''' Redirect the user to the invoice(s) paid by this payment. + :return: An action on account.move. + ''' self.ensure_one() - return sum([ - data['amount'] - for data in inv._get_reconciled_info_JSON_values() - if data['account_payment_id'] == self.id - ]) -class payment_register(models.TransientModel): - _name = 'account.payment.register' - _description = 'Register Payment' - - payment_date = fields.Date(required=True, default=fields.Date.context_today) - journal_id = fields.Many2one('account.journal', required=True, domain="[('type', 'in', ('bank', 'cash')), ('company_id', '=', invoice_company_id)]") - payment_method_id = fields.Many2one('account.payment.method', string='Payment Method Type', required=True, - domain="[('id', 'in', available_payment_methods)]", - help="Manual: Get paid by cash, check or any other method outside of Odoo.\n" - "Electronic: Get paid automatically through a payment acquirer by requesting a transaction on a card saved by the customer when buying or subscribing online (payment token).\n" - "Check: Pay bill by check and print it from Odoo.\n" - "Batch Deposit: Encase several customer checks at once by generating a batch deposit to submit to your bank. When encoding the bank statement in Odoo, you are suggested to reconcile the transaction with the batch deposit.To enable batch deposit, module account_batch_payment must be installed.\n" - "SEPA Credit Transfer: Pay bill from a SEPA Credit Transfer file you submit to your bank. To enable sepa credit transfer, module account_sepa must be installed ") - invoice_ids = fields.Many2many('account.move', 'account_invoice_payment_rel_transient', 'payment_id', 'invoice_id', string="Invoices", copy=False, readonly=True) - group_payment = fields.Boolean(help="Only one payment will be created by partner (bank)/ currency.") - - invoice_company_id = fields.Many2one(related='invoice_ids.company_id') - available_payment_methods = fields.Many2many('account.payment.method', compute='_compute_available_payment_methods') - - @api.model - def default_get(self, fields): - rec = super(payment_register, self).default_get(fields) - active_ids = self._context.get('active_ids') - if not active_ids: - return rec - invoices = self.env['account.move'].browse(active_ids) - - # Check all invoices are open - if any(invoice.state != 'posted' or invoice.payment_state not in ('not_paid', 'partial') or not invoice.is_invoice() for invoice in invoices): - raise UserError(_("You can only register payments for open invoices")) - # Check all invoices are inbound or all invoices are outbound - outbound_list = [invoice.is_outbound() for invoice in invoices] - first_outbound = invoices[0].is_outbound() - if any(x != first_outbound for x in outbound_list): - raise UserError(_("You can only register at the same time for payment that are all inbound or all outbound")) - if any(inv.company_id != invoices[0].company_id for inv in invoices): - raise UserError(_("You can only register at the same time for payment that are all from the same company")) - # Check the destination account is the same - destination_account = invoices.line_ids.filtered(lambda line: line.account_internal_type in ('receivable', 'payable')).mapped('account_id') - if len(destination_account) > 1: - raise UserError(_('There is more than one receivable/payable account in the concerned invoices. You cannot group payments in that case.')) - if 'invoice_ids' not in rec: - rec['invoice_ids'] = [(6, 0, invoices.ids)] - if 'journal_id' not in rec: - rec['journal_id'] = self.env['account.journal'].search([('company_id', '=', self.env.company.id), ('type', 'in', ('bank', 'cash'))], limit=1).id - if 'payment_method_id' not in rec: - if invoices[0].is_inbound(): - domain = [('payment_type', '=', 'inbound')] - else: - domain = [('payment_type', '=', 'outbound')] - rec['payment_method_id'] = self.env['account.payment.method'].search(domain, limit=1).id - return rec - - @api.depends('invoice_ids', 'journal_id.inbound_payment_method_ids', 'journal_id.outbound_payment_method_ids') - def _compute_available_payment_methods(self): - for p in self: - invoice = p.invoice_ids[:1] - if not invoice: - p.available_payment_methods = self.env['account.payment.method'] - elif invoice.is_inbound(): - p.available_payment_methods = self.journal_id.inbound_payment_method_ids._origin - else: - p.available_payment_methods = self.journal_id.outbound_payment_method_ids._origin - - def _prepare_payment_vals(self, invoices): - '''Create the payment values. - - :param invoices: The invoices/bills to pay. In case of multiple - documents, they need to be grouped by partner, bank, journal and - currency. - :return: The payment values as a dictionary. - ''' - amount = self.env['account.payment']._compute_payment_amount(invoices, invoices[0].currency_id, self.journal_id, self.payment_date) - values = { - 'journal_id': self.journal_id.id, - 'payment_method_id': self.payment_method_id.id, - 'payment_date': self.payment_date, - 'communication': " ".join(i.invoice_payment_ref or i.ref or i.name for i in invoices), - 'invoice_ids': [(6, 0, invoices.ids)], - 'payment_type': ('inbound' if amount > 0 else 'outbound'), - 'amount': abs(amount), - 'currency_id': invoices[0].currency_id.id, - 'partner_id': invoices[0].commercial_partner_id.id, - 'partner_type': MAP_INVOICE_TYPE_PARTNER_TYPE[invoices[0].move_type], - 'partner_bank_account_id': invoices[0].invoice_partner_bank_id.id, - } - return values - - def get_payments_vals(self): - '''Compute the values for payments. - - :return: a list of payment values (dictionary). - ''' - grouped = defaultdict(lambda: self.env["account.move"]) - for inv in self.invoice_ids: - if self.group_payment: - grouped[(inv.commercial_partner_id, inv.currency_id, inv.invoice_partner_bank_id, MAP_INVOICE_TYPE_PARTNER_TYPE[inv.move_type])] += inv - else: - grouped[inv.id] += inv - return [self._prepare_payment_vals(invoices) for invoices in grouped.values()] - - def create_payments(self): - '''Create payments according to the invoices. - Having invoices with different commercial_partner_id or different type - (Vendor bills with customer invoices) leads to multiple payments. - In case of all the invoices are related to the same - commercial_partner_id and have the same type, only one payment will be - created. - - :return: The ir.actions.act_window to show created payments. - ''' - Payment = self.env['account.payment'] - payments = Payment.create(self.get_payments_vals()) - payments.post() - - action_vals = { - 'name': _('Payments'), - 'domain': [('id', 'in', payments.ids), ('state', '=', 'posted')], - 'res_model': 'account.payment', - 'view_id': False, + action = { + 'name': _("Paid Invoices"), 'type': 'ir.actions.act_window', + 'res_model': 'account.move', + 'context': {'create': False}, } - if len(payments) == 1: - action_vals.update({'res_id': payments[0].id, 'view_mode': 'form'}) + if len(self.reconciled_invoice_ids) == 1: + action.update({ + 'view_mode': 'form', + 'res_id': self.reconciled_invoice_ids.id, + }) else: - action_vals['view_mode'] = 'tree,form' - return action_vals + action.update({ + 'view_mode': 'list,form', + 'domain': [('id', 'in', self.reconciled_invoice_ids.ids)], + }) + return action + + def button_open_statements(self): + ''' Redirect the user to the statement line(s) reconciled to this payment. + :return: An action on account.move. + ''' + self.ensure_one() + + action = { + 'name': _("Matched Statements"), + 'type': 'ir.actions.act_window', + 'res_model': 'account.bank.statement', + 'context': {'create': False}, + } + if len(self.reconciled_statement_ids) == 1: + action.update({ + 'view_mode': 'form', + 'res_id': self.reconciled_statement_ids.id, + }) + else: + action.update({ + 'view_mode': 'list,form', + 'domain': [('id', 'in', self.reconciled_statement_ids.ids)], + }) + return action diff --git a/addons/account/models/account_reconcile_model.py b/addons/account/models/account_reconcile_model.py index 96bc1553630..d5790d0d654 100644 --- a/addons/account/models/account_reconcile_model.py +++ b/addons/account/models/account_reconcile_model.py @@ -218,10 +218,9 @@ class AccountReconcileModel(models.Model): :return: A list of dict representing move.lines to be created corresponding to the tax. ''' self.ensure_one() - balance = base_line_dict['debit'] - base_line_dict['credit'] - currency = base_line_dict.get('currency_id') and self.env['res.currency'].browse(base_line_dict['currency_id']) + balance = base_line_dict['balance'] - res = tax.compute_all(balance, currency=currency) + res = tax.compute_all(balance) new_aml_dicts = [] for tax_res in res['taxes']: @@ -231,27 +230,26 @@ class AccountReconcileModel(models.Model): 'account_id': tax_res['account_id'] or base_line_dict['account_id'], 'name': tax_res['name'], 'partner_id': base_line_dict.get('partner_id'), - 'debit': tax_res['amount'] > 0 and tax_res['amount'] or 0, - 'credit': tax_res['amount'] < 0 and -tax_res['amount'] or 0, + 'balance': tax_res['amount'], 'analytic_account_id': tax.analytic and base_line_dict['analytic_account_id'], 'analytic_tag_ids': tax.analytic and base_line_dict['analytic_tag_ids'], 'tax_exigible': tax_res['tax_exigibility'], 'tax_repartition_line_id': tax_res['tax_repartition_line_id'], 'tax_ids': [(6, 0, tax_res['tax_ids'])], 'tax_tag_ids': [(6, 0, tax_res['tag_ids'])], + 'currency_id': False, 'reconcile_model_id': self.id, }) # Handle price included taxes. - base_line_dict['debit'] = tax_res['base'] > 0 and tax_res['base'] or base_line_dict['debit'] - base_line_dict['credit'] = tax_res['base'] < 0 and -tax_res['base'] or base_line_dict['credit'] + base_line_dict['balance'] = tax_res['base'] base_line_dict['tax_tag_ids'] = [(6, 0, res['base_tags'])] return new_aml_dicts - def _get_write_off_move_lines_dict(self, st_line, move_lines=None, residual_balance=None): + def _get_write_off_move_lines_dict(self, st_line, residual_balance): ''' Get move.lines dict (to be passed to the create()) corresponding to the reconciliation model's write-off lines. - :param st_line: An account.bank.statement.line record.(possibly empty, if performing manual reconciliation) - :param move_lines: An account.move.line recordset. + :param st_line: An account.bank.statement.line record.(possibly empty, if performing manual reconciliation) + :param residual_balance: The residual balance of the statement line. :return: A list of dict representing move.lines to be created corresponding to the write-off lines. ''' self.ensure_one() @@ -259,42 +257,39 @@ class AccountReconcileModel(models.Model): if self.rule_type == 'invoice_matching' and (not self.match_total_amount or (self.match_total_amount_param == 100)): return [] - line_residual = st_line.currency_id and st_line.amount_currency or st_line.amount - line_currency = st_line.currency_id or st_line.journal_id.currency_id or self.company_id.currency_id + lines_vals_list = [] - total_residual = move_lines and sum(aml.currency_id and aml.amount_residual_currency or aml.amount_residual for aml in move_lines) or 0.0 - - balance = total_residual - line_residual - - new_aml_dicts = [] - if residual_balance is None: - residual_balance = balance for line in self.line_ids: - if not line.account_id or float_is_zero(residual_balance, precision_rounding=line_currency.rounding): - continue + + if not line.account_id or st_line.company_currency_id.is_zero(residual_balance): + return [] if line.amount_type == 'percentage': - line_balance = residual_balance * (line.amount / 100.0) + balance = residual_balance * (line.amount / 100.0) elif line.amount_type == "regex": - match = re.search(line.amount_string, st_line.name) + match = re.search(line.amount_string, st_line.payment_ref) if match: - line_balance = copysign(float(re.sub(r'\D' + self.decimal_separator, '', match.group(1)).replace(self.decimal_separator, '.')) * (1 if balance > 0.0 else -1), balance) + sign = 1 if residual_balance > 0.0 else -1 + extracted_balance = float(re.sub(r'\D' + self.decimal_separator, '', match.group(1)).replace(self.decimal_separator, '.')) + balance = copysign(extracted_balance * sign, residual_balance) else: - line_balance = 0 + balance = 0 else: - line_balance = line.amount * (1 if residual_balance > 0.0 else -1) + balance = line.amount * (1 if residual_balance > 0.0 else -1) + writeoff_line = { - 'name': line.label or st_line.name, + 'name': line.label or st_line.payment_ref, + 'balance': balance, 'account_id': line.account_id.id, + 'currency_id': False, 'analytic_account_id': line.analytic_account_id.id, 'analytic_tag_ids': [(6, 0, line.analytic_tag_ids.ids)], - 'debit': line_balance > 0 and line_balance or 0, - 'credit': line_balance < 0 and -line_balance or 0, 'reconcile_model_id': self.id, } - new_aml_dicts.append(writeoff_line) + lines_vals_list.append(writeoff_line) + + residual_balance -= balance - residual_balance -= line_balance if line.tax_ids: writeoff_line['tax_ids'] = [(6, None, line.tax_ids.ids)] tax = line.tax_ids @@ -302,63 +297,59 @@ class AccountReconcileModel(models.Model): # only allow to set the force_tax_included field if we have one tax selected if line.force_tax_included: tax = tax[0].with_context(force_price_include=True) - new_aml_dicts += self._get_taxes_move_lines_dict(tax, writeoff_line) + lines_vals_list += self._get_taxes_move_lines_dict(tax, writeoff_line) - return new_aml_dicts + return lines_vals_list - def _prepare_reconciliation(self, st_line, move_lines=None, partner=None): - ''' Reconcile the statement line with some move lines using this reconciliation model. - :param st_line: An account.bank.statement.line record. - :param move_lines: An account.move.line recordset. - :param partner_id: An optional res.partner record. If not set, st_line.partner_id will be used. - :return: Counterpart account.moves. + def _prepare_reconciliation(self, st_line, aml_ids=[], partner=None): + ''' Prepare the reconciliation of the statement line with some counterpart line but + also with some auto-generated write-off lines. + + The complexity of this method comes from the fact the reconciliation will be soft meaning + it will be done only if the reconciliation will not trigger an error. + For example, the reconciliation will be skipped if we need to create an open balance but we + don't have a partner to get the receivable/payable account. + + This method works in two major steps. First, simulate the reconciliation of the account.move.line. + Then, add some write-off lines depending the rule's fields. + + :param st_line: An account.bank.statement.line record. + :param aml_ids: The ids of some account.move.line to reconcile. + :param partner: An optional res.partner record. If not specified, fallback on the statement line's partner. + :return: A list of dictionary to be passed to the account.bank.statement.line's 'reconcile' method. ''' self.ensure_one() + liquidity_lines, suspense_lines, other_lines = st_line._seek_for_lines() - # Create counterpart_aml_dicts + payment_aml_rec. - counterpart_aml_dicts = [] - payment_aml_rec = self.env['account.move.line'] - if move_lines: - for aml in move_lines: - if aml.account_id.internal_type == 'liquidity': - payment_aml_rec |= aml - else: - amount = aml.currency_id and aml.amount_residual_currency or aml.amount_residual - counterpart_aml_dicts.append({ - 'name': aml.name if aml.name != '/' else aml.move_id.name, - 'debit': amount < 0 and -amount or 0, - 'credit': amount > 0 and amount or 0, - 'move_line': aml, - }) + if st_line.to_check: + residual_balance = -liquidity_lines.balance + elif suspense_lines.account_id.reconcile: + residual_balance = sum(suspense_lines.mapped('amount_residual')) + else: + residual_balance = sum(suspense_lines.mapped('balance')) - # Create new_aml_dicts. - new_aml_dicts = self._get_write_off_move_lines_dict(st_line, move_lines=move_lines) + partner = partner or st_line.partner_id + lines_vals_list = [{'id': aml_id} for aml_id in aml_ids] + reconciliation_overview, open_balance_vals = st_line._prepare_reconciliation(lines_vals_list) - line_residual = st_line.currency_id and st_line.amount_currency or st_line.amount - line_currency = st_line.currency_id or st_line.journal_id.currency_id or st_line.company_id.currency_id - total_residual = move_lines and sum(aml.currency_id and aml.amount_residual_currency or aml.amount_residual for aml in move_lines) or 0.0 - total_residual -= sum(aml['debit'] - aml['credit'] for aml in new_aml_dicts) + for reconciliation_vals in reconciliation_overview: + residual_balance -= reconciliation_vals['line_vals']['debit'] - reconciliation_vals['line_vals']['credit'] - # Create open_balance_dict - open_balance_dict = None - if float_compare(line_residual, total_residual, precision_rounding=line_currency.rounding) != 0: - if not partner and not st_line.partner_id: - open_balance_dict = False + writeoff_vals_list = self._get_write_off_move_lines_dict(st_line, residual_balance) + + for line_vals in writeoff_vals_list: + residual_balance -= st_line.company_currency_id.round(line_vals['balance']) + + # Check we have enough information to create an open balance. + if not st_line.company_currency_id.is_zero(residual_balance): + if st_line.amount > 0: + open_balance_account = partner.property_account_receivable_id else: - balance = total_residual - line_residual - partner = (partner or st_line.partner_id).with_company(st_line.company_id) - open_balance_dict = { - 'name': '%s : %s' % (st_line.name, _('Open Balance')), - 'account_id': balance < 0 and partner.property_account_payable_id.id or partner.property_account_receivable_id.id, - 'debit': balance > 0 and balance or 0, - 'credit': balance < 0 and -balance or 0, - } - return { - 'counterpart_aml_dicts': counterpart_aml_dicts, - 'payment_aml_rec': payment_aml_rec, - 'new_aml_dicts': new_aml_dicts, - 'open_balance_dict': open_balance_dict - } + open_balance_account = partner.property_account_payable_id + if not open_balance_account: + return [] + + return lines_vals_list + writeoff_vals_list #################################################### # RECONCILIATION CRITERIA @@ -369,7 +360,7 @@ class AccountReconcileModel(models.Model): rule = self # Filter on journals. if rule.match_journal_ids: - query += ' AND st_line.journal_id IN %s' + query += ' AND st_line_move.journal_id IN %s' params += [tuple(rule.match_journal_ids.ids)] # Filter on amount nature. @@ -393,15 +384,15 @@ class AccountReconcileModel(models.Model): params += [rule.match_amount_min, rule.match_amount_max] # Filter on label, note and transaction_type - for field, column in [('label', 'name'), ('note', 'note'), ('transaction_type', 'transaction_type')]: + for table, field, column in [('st_line', 'label', 'payment_ref'), ('st_line_move', 'note', 'narration'), ('st_line', 'transaction_type', 'transaction_type')]: if rule['match_' + field] == 'contains': - query += ' AND st_line.{} ILIKE %s'.format(column) + query += ' AND {}.{} ILIKE %s'.format(table, column) params += ['%%%s%%' % rule['match_' + field + '_param']] elif rule['match_' + field] == 'not_contains': - query += ' AND st_line.{} NOT ILIKE %s'.format(column) + query += ' AND {}.{} NOT ILIKE %s'.format(table, column) params += ['%%%s%%' % rule['match_' + field + '_param']] elif rule['match_' + field] == 'match_regex': - query += ' AND st_line.{} ~* %s'.format(column) + query += ' AND {}.{} ~* %s'.format(table, column) params += [rule['match_' + field + '_param']] # Filter on partners. @@ -478,11 +469,11 @@ class AccountReconcileModel(models.Model): substring(REGEXP_REPLACE(aml.name, '[^0-9|^\s]', '', 'g'), '\S(?:.*\S)*') != '' AND regexp_split_to_array(substring(REGEXP_REPLACE(aml.name, '[^0-9|^\s]', '', 'g'), '\S(?:.*\S)*'),'\s+') - && regexp_split_to_array(substring(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g'), '\S(?:.*\S)*'), '\s+') + && regexp_split_to_array(substring(REGEXP_REPLACE(st_line.payment_ref, '[^0-9|^\s]', '', 'g'), '\S(?:.*\S)*'), '\s+') ) OR regexp_split_to_array(substring(REGEXP_REPLACE(move.name, '[^0-9|^\s]', '', 'g'), '\S(?:.*\S)*'),'\s+') - && regexp_split_to_array(substring(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g'), '\S(?:.*\S)*'), '\s+') + && regexp_split_to_array(substring(REGEXP_REPLACE(st_line.payment_ref, '[^0-9|^\s]', '', 'g'), '\S(?:.*\S)*'), '\s+') OR ( move.ref IS NOT NULL @@ -490,24 +481,25 @@ class AccountReconcileModel(models.Model): substring(REGEXP_REPLACE(move.ref, '[^0-9|^\s]', '', 'g'), '\S(?:.*\S)*') != '' AND regexp_split_to_array(substring(REGEXP_REPLACE(move.ref, '[^0-9|^\s]', '', 'g'), '\S(?:.*\S)*'),'\s+') - && regexp_split_to_array(substring(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g'), '\S(?:.*\S)*'), '\s+') + && regexp_split_to_array(substring(REGEXP_REPLACE(st_line.payment_ref, '[^0-9|^\s]', '', 'g'), '\S(?:.*\S)*'), '\s+') ) AS communication_flag, - -- Determine a matching or not with the statement line communication using the move.invoice_payment_ref. + -- Determine a matching or not with the statement line communication using the move.payment_reference. ( - move.invoice_payment_ref IS NOT NULL + move.payment_reference IS NOT NULL AND - regexp_replace(move.invoice_payment_ref, '\s+', '', 'g') = regexp_replace(st_line.name, '\s+', '', 'g') + regexp_replace(move.payment_reference, '\s+', '', 'g') = regexp_replace(st_line.payment_ref, '\s+', '', 'g') ) AS payment_reference_flag FROM account_bank_statement_line st_line - LEFT JOIN account_journal journal ON journal.id = st_line.journal_id + JOIN account_move st_line_move ON st_line_move.id = st_line.move_id + JOIN account_journal journal ON journal.id = st_line_move.journal_id LEFT JOIN jnl_precision ON jnl_precision.journal_id = journal.id - LEFT JOIN res_company company ON company.id = st_line.company_id + JOIN res_company company ON company.id = st_line_move.company_id LEFT JOIN partners_table line_partner ON line_partner.line_id = st_line.id , account_move_line aml LEFT JOIN account_move move ON move.id = aml.move_id AND move.state = 'posted' LEFT JOIN account_account account ON account.id = aml.account_id WHERE st_line.id IN %s - AND aml.company_id = st_line.company_id + AND aml.company_id = st_line_move.company_id AND move.state = 'posted' AND ( -- the field match_partner of the rule might enforce the second part of @@ -534,7 +526,7 @@ class AccountReconcileModel(models.Model): ( line_partner.partner_id = 0 AND - substring(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g'), '\S(?:.*\S)*') != '' + substring(REGEXP_REPLACE(st_line.payment_ref, '[^0-9|^\s]', '', 'g'), '\S(?:.*\S)*') != '' AND ( ( @@ -543,11 +535,11 @@ class AccountReconcileModel(models.Model): substring(REGEXP_REPLACE(aml.name, '[^0-9|^\s]', '', 'g'), '\S(?:.*\S)*') != '' AND regexp_split_to_array(substring(REGEXP_REPLACE(aml.name, '[^0-9|^\s]', '', 'g'), '\S(?:.*\S)*'),'\s+') - && regexp_split_to_array(substring(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g'), '\S(?:.*\S)*'), '\s+') + && regexp_split_to_array(substring(REGEXP_REPLACE(st_line.payment_ref, '[^0-9|^\s]', '', 'g'), '\S(?:.*\S)*'), '\s+') ) OR regexp_split_to_array(substring(REGEXP_REPLACE(move.name, '[^0-9|^\s]', '', 'g'), '\S(?:.*\S)*'),'\s+') - && regexp_split_to_array(substring(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g'), '\S(?:.*\S)*'), '\s+') + && regexp_split_to_array(substring(REGEXP_REPLACE(st_line.payment_ref, '[^0-9|^\s]', '', 'g'), '\S(?:.*\S)*'), '\s+') OR ( move.ref IS NOT NULL @@ -555,41 +547,24 @@ class AccountReconcileModel(models.Model): substring(REGEXP_REPLACE(move.ref, '[^0-9|^\s]', '', 'g'), '\S(?:.*\S)*') != '' AND regexp_split_to_array(substring(REGEXP_REPLACE(move.ref, '[^0-9|^\s]', '', 'g'), '\S(?:.*\S)*'),'\s+') - && regexp_split_to_array(substring(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g'), '\S(?:.*\S)*'), '\s+') + && regexp_split_to_array(substring(REGEXP_REPLACE(st_line.payment_ref, '[^0-9|^\s]', '', 'g'), '\S(?:.*\S)*'), '\s+') ) OR ( - move.invoice_payment_ref IS NOT NULL + move.payment_reference IS NOT NULL AND - regexp_replace(move.invoice_payment_ref, '\s+', '', 'g') = regexp_replace(st_line.name, '\s+', '', 'g') + regexp_replace(move.payment_reference, '\s+', '', 'g') = regexp_replace(st_line.payment_ref, '\s+', '', 'g') ) ) ) ) - AND - ( - ( - -- blue lines appearance conditions - aml.account_id IN (journal.default_credit_account_id, journal.default_debit_account_id) - AND aml.statement_id IS NULL - AND ( - company.account_bank_reconciliation_start IS NULL - OR - aml.date > company.account_bank_reconciliation_start - ) - ) - OR - ( - -- black lines appearance conditions - account.reconcile IS TRUE - AND aml.reconciled IS FALSE - ) - ) + AND account.reconcile IS TRUE + AND aml.reconciled IS FALSE ''' # Filter on the same currency. if rule.match_same_currency: query += ''' - AND COALESCE(st_line.currency_id, journal.currency_id, company.currency_id) = COALESCE(aml.currency_id, company.currency_id) + AND COALESCE(st_line.foreign_currency_id, st_line_move.currency_id) = COALESCE(aml.currency_id, company.currency_id) ''' params = [rule.sequence, rule.id, tuple(st_lines.ids)] @@ -625,9 +600,10 @@ class AccountReconcileModel(models.Model): %s AS model_id, st_line.id AS id FROM account_bank_statement_line st_line - LEFT JOIN account_journal journal ON journal.id = st_line.journal_id + JOIN account_move st_line_move ON st_line_move.id = st_line.move_id + LEFT JOIN account_journal journal ON journal.id = st_line_move.journal_id LEFT JOIN jnl_precision ON jnl_precision.journal_id = journal.id - LEFT JOIN res_company company ON company.id = st_line.company_id + LEFT JOIN res_company company ON company.id = st_line_move.company_id LEFT JOIN partners_table line_partner ON line_partner.line_id = st_line.id WHERE st_line.id IN %s ''' @@ -803,30 +779,20 @@ class AccountReconcileModel(models.Model): # Create write-off lines. move_lines = self.env['account.move.line'].browse(results[line.id]['aml_ids']) partner = partner_map and partner_map.get(line.id) and self.env['res.partner'].browse(partner_map[line.id]) - reconciliation_results = model._prepare_reconciliation(line, move_lines, partner=partner) + lines_vals_list = model._prepare_reconciliation(line, aml_ids=results[line.id]['aml_ids'], partner=partner) - # A write-off must be applied. - if reconciliation_results['new_aml_dicts']: + # A write-off must be applied since there is some 'new' lines to propose. + if not lines_vals_list or any(not line_vals.get('id') for line_vals in lines_vals_list): results[line.id]['status'] = 'write_off' # Process auto-reconciliation. - if (first_batch_candidates or second_batch_candidates) and model.auto_reconcile: - # An open balance is needed but no partner has been found. - if reconciliation_results['open_balance_dict'] is False: - break - - new_aml_dicts = reconciliation_results['new_aml_dicts'] - if reconciliation_results['open_balance_dict']: - new_aml_dicts.append(reconciliation_results['open_balance_dict']) + if lines_vals_list and (first_batch_candidates or second_batch_candidates) and model.auto_reconcile: if not line.partner_id and partner: line.partner_id = partner - counterpart_moves = line.process_reconciliation( - counterpart_aml_dicts=reconciliation_results['counterpart_aml_dicts'], - payment_aml_rec=reconciliation_results['payment_aml_rec'], - new_aml_dicts=new_aml_dicts, - ) + line.reconcile(lines_vals_list) + results[line.id]['status'] = 'reconciled' - results[line.id]['reconciled_lines'] = counterpart_moves.mapped('line_ids') + results[line.id]['reconciled_lines'] = line.line_ids # The reconciled move lines are no longer candidates for another rule. reconciled_amls_ids.update(move_lines.ids) @@ -840,26 +806,18 @@ class AccountReconcileModel(models.Model): # Create write-off lines. partner = partner_map and partner_map.get(line.id) and self.env['res.partner'].browse(partner_map[line.id]) - reconciliation_results = model._prepare_reconciliation(line, partner=partner) + lines_vals_list = model._prepare_reconciliation(line, partner=partner) - # An open balance is needed but no partner has been found. - if reconciliation_results['open_balance_dict'] is False: + if not lines_vals_list: break # Process auto-reconciliation. if model.auto_reconcile: - new_aml_dicts = reconciliation_results['new_aml_dicts'] - if reconciliation_results['open_balance_dict']: - new_aml_dicts.append(reconciliation_results['open_balance_dict']) if not line.partner_id and partner: line.partner_id = partner - counterpart_moves = line.process_reconciliation( - counterpart_aml_dicts=reconciliation_results['counterpart_aml_dicts'], - payment_aml_rec=reconciliation_results['payment_aml_rec'], - new_aml_dicts=new_aml_dicts, - ) + line.reconcile(lines_vals_list) results[line.id]['status'] = 'reconciled' - results[line.id]['reconciled_lines'] = counterpart_moves.mapped('line_ids') + results[line.id]['reconciled_lines'] = line.line_ids # Break models loop. break diff --git a/addons/account/models/chart_template.py b/addons/account/models/chart_template.py index d004a02235e..1131f0b117b 100644 --- a/addons/account/models/chart_template.py +++ b/addons/account/models/chart_template.py @@ -105,6 +105,7 @@ class AccountChartTemplate(models.Model): string="Gain Exchange Rate Account", domain=[('internal_type', '=', 'other'), ('deprecated', '=', False)]) expense_currency_exchange_account_id = fields.Many2one('account.account.template', string="Loss Exchange Rate Account", domain=[('internal_type', '=', 'other'), ('deprecated', '=', False)]) + account_journal_suspense_account_id = fields.Many2one('account.account.template', string='Journal Suspense Account') default_cash_difference_income_account_id = fields.Many2one('account.account.template', string="Cash Difference Income Account") default_cash_difference_expense_account_id = fields.Many2one('account.account.template', string="Cash Difference Expense Account") default_pos_receivable_account_id = fields.Many2one('account.account.template', string="PoS receivable account") @@ -122,14 +123,14 @@ class AccountChartTemplate(models.Model): property_advance_tax_payment_account_id = fields.Many2one('account.account.template', string="Advance tax payment account") @api.model - def _prepare_transfer_account_template(self): + def _prepare_transfer_account_template(self, prefix=None): ''' Prepare values to create the transfer account that is an intermediary account used when moving money from a liquidity account to another. :return: A dictionary of values to create a new account.account. ''' digits = self.code_digits - prefix = self.transfer_account_code_prefix or '' + prefix = prefix or self.transfer_account_code_prefix or '' # Flatten the hierarchy of chart templates. chart_template = self chart_templates = self @@ -154,6 +155,15 @@ class AccountChartTemplate(models.Model): 'chart_template_id': self.id, } + @api.model + def _create_liquidity_journal_suspense_account(self, company, code_digits): + return self.env['account.account'].create({ + 'name': _("Bank Suspense Account"), + 'code': self.env['account.account']._search_new_account_code(company, code_digits, company.transfer_account_code_prefix or ''), + 'user_type_id': self.env.ref('account.data_account_type_current_assets').id, + 'company_id': company.id, + }) + def try_loading(self, company=False): """ Installs this chart of accounts for the current company if not chart of accounts had been created for it yet. @@ -239,8 +249,12 @@ class AccountChartTemplate(models.Model): company.write({ 'default_cash_difference_income_account_id': acc_template_ref.get(self.default_cash_difference_income_account_id.id, False), 'default_cash_difference_expense_account_id': acc_template_ref.get(self.default_cash_difference_expense_account_id.id, False), + 'account_journal_suspense_account_id': acc_template_ref.get(self.account_journal_suspense_account_id.id), }) + if not company.account_journal_suspense_account_id: + company.account_journal_suspense_account_id = self._create_liquidity_journal_suspense_account(company, self.code_digits) + # Set default PoS receivable account in company default_pos_receivable = self.default_pos_receivable_account_id.id if not default_pos_receivable and self.parent_id: @@ -333,19 +347,11 @@ class AccountChartTemplate(models.Model): 'type': acc['account_type'], 'company_id': company.id, 'currency_id': acc.get('currency_id', self.env['res.currency']).id, - 'sequence': 10 + 'sequence': 10, }) return bank_journals - def get_countries_posting_at_bank_rec(self): - """ Returns the list of the country codes of the countries for which, by default, - payments made on bank journals should be creating draft account.move objects, - which get in turn posted when their payment gets reconciled with a bank statement line. - This function is an extension hook for localization modules. - """ - return [] - @api.model def _get_default_bank_journals_data(self): """ Returns the data needed to create the default bank journals when diff --git a/addons/account/models/company.py b/addons/account/models/company.py index 330257355ea..4535f0da68d 100644 --- a/addons/account/models/company.py +++ b/addons/account/models/company.py @@ -45,6 +45,7 @@ class ResCompany(models.Model): cash_account_code_prefix = fields.Char(string='Prefix of the cash accounts') default_cash_difference_income_account_id = fields.Many2one('account.account', string="Cash Difference Income Account") default_cash_difference_expense_account_id = fields.Many2one('account.account', string="Cash Difference Expense Account") + account_journal_suspense_account_id = fields.Many2one('account.account', string='Journal Suspense Account') transfer_account_code_prefix = fields.Char(string='Prefix of the transfer accounts') account_sale_tax_id = fields.Many2one('account.tax', string="Default Sale Tax") account_purchase_tax_id = fields.Many2one('account.tax', string="Default Purchase Tax") @@ -64,9 +65,6 @@ class ResCompany(models.Model): property_stock_valuation_account_id = fields.Many2one('account.account', string="Account Template for Stock Valuation") bank_journal_ids = fields.One2many('account.journal', 'company_id', domain=[('type', '=', 'bank')], string='Bank Journals') tax_exigibility = fields.Boolean(string='Use Cash Basis') - account_bank_reconciliation_start = fields.Date(string="Bank Reconciliation Threshold", help="""The bank reconciliation widget won't ask to reconcile payments older than this date. - This is useful if you install accounting after having used invoicing for some time and - don't want to reconcile all the past payments with bank statements.""") incoterm_id = fields.Many2one('account.incoterms', string='Default incoterm', help='International Commercial Terms are a series of predefined commercial terms used in international transactions.') @@ -456,7 +454,7 @@ class ResCompany(models.Model): raise RedirectWarning(msg, action.id, _("Go to the journal configuration")) sample_invoice = self.env['account.move'].with_context(default_move_type='out_invoice', default_journal_id=journal.id).create({ - 'invoice_payment_ref': _('Sample invoice'), + 'payment_reference': _('Sample invoice'), 'partner_id': partner.id, 'invoice_line_ids': [ (0, 0, { diff --git a/addons/account/models/res_config_settings.py b/addons/account/models/res_config_settings.py index d6bb95c2a32..d29ad24ea1a 100644 --- a/addons/account/models/res_config_settings.py +++ b/addons/account/models/res_config_settings.py @@ -74,11 +74,6 @@ class ResConfigSettings(models.TransientModel): module_snailmail_account = fields.Boolean(string="Snailmail") tax_exigibility = fields.Boolean(string='Cash Basis', related='company_id.tax_exigibility', readonly=False) tax_cash_basis_journal_id = fields.Many2one('account.journal', related='company_id.tax_cash_basis_journal_id', string="Tax Cash Basis Journal", readonly=False) - account_bank_reconciliation_start = fields.Date(string="Bank Reconciliation Threshold", - related='company_id.account_bank_reconciliation_start', readonly=False, - help="""The bank reconciliation widget won't ask to reconcile payments older than this date. - This is useful if you install accounting after having used invoicing for some time and - don't want to reconcile all the past payments with bank statements.""") qr_code = fields.Boolean(string='Display SEPA QR-code', related='company_id.qr_code', readonly=False) invoice_is_print = fields.Boolean(string='Print', related='company_id.invoice_is_print', readonly=False) diff --git a/addons/account/report/account_invoice_report.py b/addons/account/report/account_invoice_report.py index 03d41a20e72..80271a887d7 100644 --- a/addons/account/report/account_invoice_report.py +++ b/addons/account/report/account_invoice_report.py @@ -55,7 +55,7 @@ class AccountInvoiceReport(models.Model): _depends = { 'account.move': [ 'name', 'state', 'move_type', 'partner_id', 'invoice_user_id', 'fiscal_position_id', - 'invoice_date', 'invoice_date_due', 'invoice_payment_term_id', 'invoice_partner_bank_id', + 'invoice_date', 'invoice_date_due', 'invoice_payment_term_id', 'partner_bank_id', ], 'account.move.line': [ 'quantity', 'price_subtotal', 'amount_residual', 'balance', 'amount_currency', diff --git a/addons/account/static/src/xml/account_payment.xml b/addons/account/static/src/xml/account_payment.xml index b8aa5024a37..8d6f484cb6c 100644 --- a/addons/account/static/src/xml/account_payment.xml +++ b/addons/account/static/src/xml/account_payment.xml @@ -17,7 +17,7 @@ Add -
+
diff --git a/addons/account/tests/__init__.py b/addons/account/tests/__init__.py index 49ed8d23f7e..0be7b9511b9 100644 --- a/addons/account/tests/__init__.py +++ b/addons/account/tests/__init__.py @@ -10,15 +10,13 @@ from . import test_account_account from . import test_account_tax from . import test_account_analytic from . import test_account_payment +from . import test_account_bank_statement from . import test_account_move_partner_count from . import test_account_move_rounding from . import test_account_invoice_report from . import test_account_journal_dashboard -from . import test_account_bank_statement -from . import test_bank_statement_reconciliation from . import test_control_journal_account from . import test_fiscal_position -from . import test_payment from . import test_reconciliation from . import test_search from . import test_settings @@ -31,6 +29,6 @@ from . import test_reconciliation_matching_rules from . import test_account_onboarding from . import test_portal_attachment from . import test_tax_report -from . import test_reconciliation_heavy_load from . import test_transfer_wizard from . import test_account_incoming_supplier_invoice +from . import test_account_payment_register diff --git a/addons/account/tests/common.py b/addons/account/tests/common.py index e047303c0fb..a0c3d87fee2 100644 --- a/addons/account/tests/common.py +++ b/addons/account/tests/common.py @@ -133,6 +133,12 @@ class AccountTestCommon(SavepointCase): 'name': 'Opening Expense - (test)', 'user_type_id': cls.env.ref('account.data_account_type_expenses').id, }) + cls.sus = cls.env['account.account'].create({ + 'name': 'Suspense Account - (test)', + 'code': 'X1115', + 'user_type_id': cls.env.ref('account.data_account_type_current_assets').id, + }) + cls.company.account_journal_suspense_account_id = cls.sus # Profit and Loss cls.income_fx_income = cls.env['account.account'].create({ @@ -238,16 +244,12 @@ class AccountTestCommon(SavepointCase): 'name': 'Vendor Bills - Test', 'code': 'TEXJ', 'type': 'purchase', - 'default_credit_account_id': cls.a_expense.id, - 'default_debit_account_id': cls.a_expense.id, 'refund_sequence': True, }) cls.bank_journal = cls.env['account.journal'].create({ 'name': 'Bank - Test', 'code': 'TBNK', 'type': 'bank', - 'default_credit_account_id': cls.bnk.id, - 'default_debit_account_id': cls.bnk.id, }) cls.cash_journal = cls.env['account.journal'].create({ 'name': 'Cash - Test', @@ -367,6 +369,12 @@ class AccountTestNoChartCommon(SavepointCaseWithUserDemo): 'code': 'SJT0', }) + cls.general_journal0 = cls.env['account.journal'].create({ + 'name': 'General Journal', + 'type': 'general', + 'code': 'GJT0', + }) + @classmethod def setUpAdditionalAccounts(cls): """ Set up some addionnal accounts: expenses, revenue, ... """ @@ -415,6 +423,12 @@ class AccountTestNoChartCommon(SavepointCaseWithUserDemo): 'type': 'general', 'company_id': cls.env.user.company_id.id, }) + cls.journal_bank = cls.env['account.journal'].create({ + 'name': 'Bank Journal - Test', + 'code': 'AJ-BANK', + 'type': 'bank', + 'company_id': cls.env.user.company_id.id, + }) @classmethod def setUpUsers(cls): @@ -586,10 +600,24 @@ class AccountTestNoChartCommonMultiCompany(AccountTestNoChartCommon): class AccountTestInvoicingCommon(SavepointCase): @classmethod - def setUpClass(cls): + def copy_account(cls, account): + suffix_nb = 1 + while True: + new_code = '%s (%s)' % (account.code, suffix_nb) + if account.search_count([('company_id', '=', account.company_id.id), ('code', '=', new_code)]): + suffix_nb += 1 + else: + return account.copy(default={'code': new_code}) + + @classmethod + def setUpClass(cls, chart_template_ref=None): super(AccountTestInvoicingCommon, cls).setUpClass() - chart_template = cls.env.user.company_id.chart_template_id + chart_template = None + if chart_template_ref: + chart_template = cls.env.ref(chart_template_ref) + if not chart_template: + chart_template = cls.env.user.company_id.chart_template_id if not chart_template: chart_template = cls.env.ref('l10n_generic_coa.configurable_chart_template', raise_if_not_found=False) if not chart_template: @@ -634,8 +662,8 @@ class AccountTestInvoicingCommon(SavepointCase): 'uom_id': cls.env.ref('uom.product_uom_dozen').id, 'lst_price': 200.0, 'standard_price': 160.0, - 'property_account_income_id': cls.company_data['default_account_revenue'].copy().id, - 'property_account_expense_id': cls.company_data['default_account_expense'].copy().id, + 'property_account_income_id': cls.copy_account(cls.company_data['default_account_revenue']).id, + 'property_account_expense_id': cls.copy_account(cls.company_data['default_account_expense']).id, 'taxes_id': [(6, 0, (cls.tax_sale_a + cls.tax_sale_b).ids)], 'supplier_taxes_id': [(6, 0, (cls.tax_purchase_a + cls.tax_purchase_b).ids)], }) @@ -793,29 +821,30 @@ class AccountTestInvoicingCommon(SavepointCase): } @classmethod - def setup_multi_currency_data(cls): - gold_currency = cls.env['res.currency'].create({ + def setup_multi_currency_data(cls, default_values={}, rate2016=3.0, rate2017=2.0): + foreign_currency = cls.env['res.currency'].create({ 'name': 'Gold Coin', 'symbol': '☺', 'rounding': 0.001, 'position': 'after', 'currency_unit_label': 'Gold', 'currency_subunit_label': 'Silver', + **default_values, }) rate1 = cls.env['res.currency.rate'].create({ 'name': '2016-01-01', - 'rate': 3.0, - 'currency_id': gold_currency.id, + 'rate': rate2016, + 'currency_id': foreign_currency.id, 'company_id': cls.env.company.id, }) rate2 = cls.env['res.currency.rate'].create({ 'name': '2017-01-01', - 'rate': 2.0, - 'currency_id': gold_currency.id, + 'rate': rate2017, + 'currency_id': foreign_currency.id, 'company_id': cls.env.company.id, }) return { - 'currency': gold_currency, + 'currency': foreign_currency, 'rates': rate1 + rate2, } @@ -931,9 +960,11 @@ class TestAccountReconciliationCommon(AccountTestCommon): super(TestAccountReconciliationCommon, cls).setUpClass() cls.company = cls.env['res.company'].create({ 'name': 'A test company', - 'currency_id': cls.env.ref('base.EUR').id + 'currency_id': cls.env.ref('base.EUR').id, }) + cls.env.user.company_id = cls.company + cls.env.user.groups_id |= cls.env.ref('account.group_account_user') # Generate minimal data for my new company cls.create_accounting_minimal_data() @@ -1116,41 +1147,36 @@ class TestAccountReconciliationCommon(AccountTestCommon): auto_validate=True ) - def make_payment(self, invoice_record, bank_journal, amount=0.0, amount_currency=0.0, currency_id=None): + def make_payment(self, invoice_record, bank_journal, amount=0.0, amount_currency=0.0, currency_id=None, reconcile_param=[]): bank_stmt = self.acc_bank_stmt_model.create({ 'journal_id': bank_journal.id, 'date': time.strftime('%Y') + '-07-15', - 'name': 'payment' + invoice_record.name + 'name': 'payment' + invoice_record.name, + 'line_ids': [(0, 0, { + 'payment_ref': 'payment', + 'partner_id': self.partner_agrolait_id, + 'amount': amount, + 'amount_currency': amount_currency, + 'foreign_currency_id': currency_id, + })], }) + bank_stmt.button_post() - bank_stmt_line = self.acc_bank_stmt_line_model.create({'name': 'payment', - 'statement_id': bank_stmt.id, - 'partner_id': self.partner_agrolait_id, - 'amount': amount, - 'amount_currency': amount_currency, - 'currency_id': currency_id, - 'date': time.strftime('%Y') + '-07-15', - }) - line_id = invoice_record.line_ids.filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable')) - amount_in_widget = currency_id and amount_currency or amount - bank_stmt_line.process_reconciliation(counterpart_aml_dicts=[{ - 'move_line': line_id, - 'debit': amount_in_widget < 0 and -amount_in_widget or 0.0, - 'credit': amount_in_widget > 0 and amount_in_widget or 0.0, - 'name': line_id.name, - }]) + bank_stmt.line_ids[0].reconcile(reconcile_param) return bank_stmt def make_customer_and_supplier_flows(self, invoice_currency_id, invoice_amount, bank_journal, amount, amount_currency, transaction_currency_id): #we create an invoice in given invoice_currency invoice_record = self.create_invoice(type='out_invoice', invoice_amount=invoice_amount, currency_id=invoice_currency_id) #we encode a payment on it, on the given bank_journal with amount, amount_currency and transaction_currency given - bank_stmt = self.make_payment(invoice_record, bank_journal, amount=amount, amount_currency=amount_currency, currency_id=transaction_currency_id) - customer_move_lines = bank_stmt.move_line_ids + line = invoice_record.line_ids.filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable')) + bank_stmt = self.make_payment(invoice_record, bank_journal, amount=amount, amount_currency=amount_currency, currency_id=transaction_currency_id, reconcile_param=[{'id': line.id}]) + customer_move_lines = bank_stmt.line_ids.line_ids #we create a supplier bill in given invoice_currency invoice_record = self.create_invoice(type='in_invoice', invoice_amount=invoice_amount, currency_id=invoice_currency_id) #we encode a payment on it, on the given bank_journal with amount, amount_currency and transaction_currency given - bank_stmt = self.make_payment(invoice_record, bank_journal, amount=-amount, amount_currency=-amount_currency, currency_id=transaction_currency_id) - supplier_move_lines = bank_stmt.move_line_ids + line = invoice_record.line_ids.filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable')) + bank_stmt = self.make_payment(invoice_record, bank_journal, amount=-amount, amount_currency=-amount_currency, currency_id=transaction_currency_id, reconcile_param=[{'id': line.id}]) + supplier_move_lines = bank_stmt.line_ids.line_ids return customer_move_lines, supplier_move_lines diff --git a/addons/account/tests/test_account_bank_statement.py b/addons/account/tests/test_account_bank_statement.py index 6e017e3340e..6cca0200e57 100644 --- a/addons/account/tests/test_account_bank_statement.py +++ b/addons/account/tests/test_account_bank_statement.py @@ -1,5 +1,12 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.common import AccountTestInvoicingCommon from odoo.addons.account.tests.common import AccountTestCommon from odoo.tests import tagged +from odoo.tests.common import Form +from odoo.exceptions import ValidationError, UserError +from odoo import fields + +from unittest.mock import patch @tagged('post_install', '-at_install') @@ -29,7 +36,7 @@ class TestBankStatement(AccountTestCommon): 'name': 'BNK' + str(self.number), 'date': date, 'line_ids': [(0, 0, { - 'name': '_', + 'payment_ref': '_', 'amount': line_amount, })], 'journal_id': journal or self.journal.id @@ -58,7 +65,7 @@ class TestBankStatement(AccountTestCommon): self.assertEqual(bnk1.balance_end, 0) # Add a line self.bsl_model.create({ - 'name': '_', + 'payment_ref': '_', 'amount': 10, 'statement_id': bnk1.id }) @@ -76,7 +83,7 @@ class TestBankStatement(AccountTestCommon): self.assertEqual(bnk1.balance_end_real, 100) self.assertEqual(bnk1.balance_end, 100) self.assertEqual(bnk1.previous_statement_id.id, False) - + # Create a new statement after that one bnk2 = self.create_bank_statement('2019-01-10', 50) self.assertEqual(bnk2.balance_start, 100) @@ -242,3 +249,1093 @@ class TestBankStatement(AccountTestCommon): self.assertEqual(bnk5.previous_statement_id.id, False) self.assertEqual(bnk5.balance_start, 0) self.assertEqual(bnk5.balance_end_real, 375) # Ending balance of last statement should not have changed + + +class TestAccountBankStatementCommon(AccountTestInvoicingCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # We need a third currency as you could have a company's currency != journal's currency != + cls.currency_data_2 = cls.setup_multi_currency_data(default_values={ + 'name': 'Dark Chocolate Coin', + 'symbol': '🍫', + 'currency_unit_label': 'Dark Choco', + 'currency_subunit_label': 'Dark Cacao Powder', + }, rate2016=6.0, rate2017=4.0) + cls.currency_data_3 = cls.setup_multi_currency_data(default_values={ + 'name': 'Black Chocolate Coin', + 'symbol': '🍫', + 'currency_unit_label': 'Black Choco', + 'currency_subunit_label': 'Black Cacao Powder', + }, rate2016=12.0, rate2017=8.0) + + cls.bank_journal_1 = cls.company_data['default_journal_bank'] + cls.bank_journal_2 = cls.bank_journal_1.copy() + cls.bank_journal_3 = cls.bank_journal_2.copy() + cls.currency_1 = cls.company_data['currency'] + cls.currency_2 = cls.currency_data['currency'] + cls.currency_3 = cls.currency_data_2['currency'] + cls.currency_4 = cls.currency_data_3['currency'] + + cls.statement = cls.env['account.bank.statement'].create({ + 'name': 'test_statement', + 'date': '2019-01-01', + 'journal_id': cls.bank_journal_1.id, + 'line_ids': [ + (0, 0, { + 'date': '2019-01-01', + 'payment_ref': 'line_1', + 'partner_id': cls.partner_a.id, + 'foreign_currency_id': cls.currency_2.id, + 'amount': 1250.0, + 'amount_currency': 2500.0, + }), + ], + }) + cls.statement_line = cls.statement.line_ids + + cls.expected_st_line = { + 'date': fields.Date.from_string('2019-01-01'), + 'journal_id': cls.statement.journal_id.id, + 'payment_ref': 'line_1', + 'partner_id': cls.partner_a.id, + 'currency_id': cls.currency_1.id, + 'foreign_currency_id': cls.currency_2.id, + 'amount': 1250.0, + 'amount_currency': 2500.0, + 'is_reconciled': False, + } + + cls.expected_bank_line = { + 'name': cls.statement_line.payment_ref, + 'partner_id': cls.statement_line.partner_id.id, + 'currency_id': cls.currency_2.id, + 'account_id': cls.statement.journal_id.default_debit_account_id.id, + 'debit': 1250.0, + 'credit': 0.0, + 'amount_currency': 2500.0, + } + + cls.expected_counterpart_line = { + 'name': cls.statement_line.payment_ref, + 'partner_id': cls.statement_line.partner_id.id, + 'currency_id': cls.currency_2.id, + 'account_id': cls.statement.journal_id.suspense_account_id.id, + 'debit': 0.0, + 'credit': 1250.0, + 'amount_currency': -2500.0, + } + + def assertBankStatementLine(self, statement_line, expected_statement_line_vals, expected_move_line_vals): + self.assertRecordValues(statement_line, [expected_statement_line_vals]) + self.assertRecordValues(statement_line.line_ids.sorted('balance'), expected_move_line_vals) + + +@tagged('post_install', '-at_install') +class TestAccountBankStatement(TestAccountBankStatementCommon): + + # ------------------------------------------------------------------------- + # TESTS about the statement line model. + # ------------------------------------------------------------------------- + + def _test_statement_line_edition( + self, + journal, + amount, amount_currency, + journal_currency, foreign_currency, + expected_liquidity_values, expected_counterpart_values): + ''' Test the edition of a statement line from itself or from its linked journal entry. + :param journal: The account.journal record that will be set on the statement line. + :param amount: The amount in journal's currency. + :param amount_currency: The amount in the foreign currency. + :param journal_currency: The journal's currency as a res.currency record. + :param foreign_currency: The foreign currency as a res.currency record. + :param expected_liquidity_values: The expected account.move.line values for the liquidity line. + :param expected_counterpart_values: The expected account.move.line values for the counterpart line. + ''' + if journal_currency: + journal.currency_id = journal_currency.id + + statement = self.env['account.bank.statement'].create({ + 'name': 'test_statement', + 'date': '2019-01-01', + 'journal_id': journal.id, + 'line_ids': [ + (0, 0, { + 'date': '2019-01-01', + 'payment_ref': 'line_1', + 'partner_id': self.partner_a.id, + 'foreign_currency_id': foreign_currency and foreign_currency.id, + 'amount': amount, + 'amount_currency': amount_currency, + }), + ], + }) + statement_line = statement.line_ids + + # ==== Test the statement line amounts are correct ==== + # If there is a bug in the compute/inverse methods, the amount/amount_currency could be + # incorrect directly after the creation of the statement line. + + self.assertRecordValues(statement_line, [{ + 'amount': amount, + 'amount_currency': amount_currency, + }]) + self.assertRecordValues(statement_line.move_id, [{ + 'partner_id': self.partner_a.id, + 'currency_id': (statement_line.foreign_currency_id or statement_line.currency_id).id, + }]) + + # ==== Test the edition of statement line amounts ==== + # The statement line must remain consistent with its account.move. + # To test the compute/inverse methods are correctly managing all currency setup, + # we check the edition of amounts in both directions statement line <-> journal entry. + + # Check initial state of the statement line. + liquidity_lines, suspense_lines, other_lines = statement_line._seek_for_lines() + self.assertRecordValues(liquidity_lines, [expected_liquidity_values]) + self.assertRecordValues(suspense_lines, [expected_counterpart_values]) + + # Check the account.move is still correct after editing the account.bank.statement.line. + statement_line.write({ + 'amount': statement_line.amount * 2, + 'amount_currency': statement_line.amount_currency * 2, + }) + self.assertRecordValues(statement_line, [{ + 'amount': amount * 2, + 'amount_currency': amount_currency * 2, + }]) + self.assertRecordValues(liquidity_lines, [{ + **expected_liquidity_values, + 'debit': expected_liquidity_values.get('debit', 0.0) * 2, + 'credit': expected_liquidity_values.get('credit', 0.0) * 2, + 'amount_currency': expected_liquidity_values.get('amount_currency', 0.0) * 2, + }]) + self.assertRecordValues(suspense_lines, [{ + 'debit': expected_counterpart_values.get('debit', 0.0) * 2, + 'credit': expected_counterpart_values.get('credit', 0.0) * 2, + 'amount_currency': expected_counterpart_values.get('amount_currency', 0.0) * 2, + }]) + + # Check the account.bank.statement.line is still correct after editing the account.move. + statement_line.move_id.write({'line_ids': [ + (1, liquidity_lines.id, { + 'debit': expected_liquidity_values.get('debit', 0.0), + 'credit': expected_liquidity_values.get('credit', 0.0), + 'amount_currency': expected_liquidity_values.get('amount_currency', 0.0), + }), + (1, suspense_lines.id, { + 'debit': expected_counterpart_values.get('debit', 0.0), + 'credit': expected_counterpart_values.get('credit', 0.0), + 'amount_currency': expected_counterpart_values.get('amount_currency', 0.0), + }), + ]}) + self.assertRecordValues(statement_line, [{ + 'amount': amount, + 'amount_currency': amount_currency, + }]) + + def _test_edition_customer_and_supplier_flows( + self, + amount, amount_currency, + journal_currency, foreign_currency, + expected_liquidity_values, expected_counterpart_values): + ''' Test '_test_statement_line_edition' using the customer (positive amounts) + & the supplier flow (negative amounts). + :param amount: The amount in journal's currency. + :param amount_currency: The amount in the foreign currency. + :param journal_currency: The journal's currency as a res.currency record. + :param foreign_currency: The foreign currency as a res.currency record. + :param expected_liquidity_values: The expected account.move.line values for the liquidity line. + :param expected_counterpart_values: The expected account.move.line values for the counterpart line. + ''' + + # Check the full process with positive amount (customer process). + self._test_statement_line_edition( + self.bank_journal_2, + amount, amount_currency, + journal_currency, foreign_currency, + expected_liquidity_values, + expected_counterpart_values, + ) + + # Check the full process with negative amount (supplier process). + self._test_statement_line_edition( + self.bank_journal_3, + -amount, -amount_currency, + journal_currency, foreign_currency, + { + **expected_liquidity_values, + 'debit': expected_liquidity_values.get('credit', 0.0), + 'credit': expected_liquidity_values.get('debit', 0.0), + 'amount_currency': -expected_liquidity_values.get('amount_currency', 0.0), + }, + { + **expected_counterpart_values, + 'debit': expected_counterpart_values.get('credit', 0.0), + 'credit': expected_counterpart_values.get('debit', 0.0), + 'amount_currency': -expected_counterpart_values.get('amount_currency', 0.0), + }, + ) + + def test_edition_journal_curr_2_statement_curr_3(self): + self._test_edition_customer_and_supplier_flows( + 80.0, 120.0, + self.currency_2, self.currency_3, + {'debit': 40.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 40.0, 'amount_currency': -120.0, 'currency_id': self.currency_3.id}, + ) + + def test_edition_journal_curr_2_statement_curr_1(self): + self._test_edition_customer_and_supplier_flows( + 120.0, 80.0, + self.currency_2, self.currency_1, + {'debit': 80.0, 'credit': 0.0, 'amount_currency': 120.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 80.0, 'amount_currency': 0.0, 'currency_id': False}, + ) + + def test_edition_journal_curr_1_statement_curr_2(self): + self._test_edition_customer_and_supplier_flows( + 80.0, 120.0, + self.currency_1, self.currency_2, + {'debit': 80.0, 'credit': 0.0, 'amount_currency': 120.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 80.0, 'amount_currency': -120.0, 'currency_id': self.currency_2.id}, + ) + + def test_edition_journal_curr_2_statement_false(self): + self._test_edition_customer_and_supplier_flows( + 80.0, 0.0, + self.currency_2, False, + {'debit': 40.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 40.0, 'amount_currency': -80.0, 'currency_id': self.currency_2.id}, + ) + + def test_edition_journal_curr_1_statement_false(self): + self._test_edition_customer_and_supplier_flows( + 80.0, 0.0, + self.currency_1, False, + {'debit': 80.0, 'credit': 0.0, 'amount_currency': 0.0, 'currency_id': False}, + {'debit': 0.0, 'credit': 80.0, 'amount_currency': 0.0, 'currency_id': False}, + ) + + def test_constraints(self): + def assertStatementLineConstraint(statement_vals, statement_line_vals): + with self.assertRaises(Exception), self.cr.savepoint(): + self.env['account.bank.statement'].create({ + **statement_vals, + 'line_ids': [(0, 0, statement_line_vals)], + }) + + statement_vals = { + 'name': 'test_statement', + 'date': '2019-01-01', + 'journal_id': self.bank_journal_2.id, + } + statement_line_vals = { + 'date': '2019-01-01', + 'payment_ref': 'line_1', + 'partner_id': self.partner_a.id, + 'foreign_currency_id': False, + 'amount': 10.0, + 'amount_currency': 0.0, + } + + # ==== Test constraints at creation ==== + + # Amount can't be 0.0 on a statement line. + assertStatementLineConstraint(statement_vals, { + **statement_line_vals, + 'amount': 0.0, + }) + + # Foreign currency must not be the same as the journal one. + assertStatementLineConstraint(statement_vals, { + **statement_line_vals, + 'foreign_currency_id': self.currency_1.id, + }) + + # Can't have amount_currency = 0.0 with a specified foreign currency. + assertStatementLineConstraint(statement_vals, { + **statement_line_vals, + 'foreign_currency_id': self.currency_2.id, + 'amount_currency': 0.0, + }) + + # Can't have a stand alone amount in foreign currency without foreign currency set. + assertStatementLineConstraint(statement_vals, { + **statement_line_vals, + 'amount_currency': 10.0, + }) + + # ==== Test constraints at edition ==== + + statement = self.env['account.bank.statement'].create({ + **statement_vals, + 'line_ids': [(0, 0, statement_line_vals)], + }) + st_line = statement.line_ids + + # You can't messed up the journal entry by adding another liquidity line. + addition_lines_to_create = [ + { + 'debit': 1.0, + 'credit': 0, + 'account_id': self.bank_journal_2.default_debit_account_id.id, + 'move_id': st_line.move_id.id, + }, + { + 'debit': 0, + 'credit': 1.0, + 'account_id': self.company_data['default_account_revenue'].id, + 'move_id': st_line.move_id.id, + }, + ] + with self.assertRaises(UserError), self.cr.savepoint(): + st_line.move_id.write({ + 'line_ids': [(0, 0, vals) for vals in addition_lines_to_create] + }) + + with self.assertRaises(UserError), self.cr.savepoint(): + st_line.line_ids.create(addition_lines_to_create) + + # You can't set the journal entry in an unconsistent state. + with self.assertRaises(UserError), self.cr.savepoint(): + st_line.move_id.post() + + def test_statement_line_move_onchange_1(self): + ''' Test the consistency between the account.bank.statement.line and the generated account.move.lines + using the form view emulator. + ''' + + # Check the initial state of the statement line. + self.assertBankStatementLine(self.statement_line, self.expected_st_line, [self.expected_counterpart_line, self.expected_bank_line]) + + # Inverse the amount + change them. + with Form(self.statement) as statement_form: + with statement_form.line_ids.edit(0) as st_line_form: + st_line_form.amount = -2000.0 + st_line_form.amount_currency = -4000.0 + st_line_form.foreign_currency_id = self.currency_3 + + self.assertBankStatementLine(self.statement_line, { + **self.expected_st_line, + 'amount': -2000.0, + 'amount_currency': -4000.0, + 'foreign_currency_id': self.currency_3.id, + }, [ + { + **self.expected_bank_line, + 'debit': 0.0, + 'credit': 2000.0, + 'amount_currency': -4000.0, + 'currency_id': self.currency_3.id, + }, + { + **self.expected_counterpart_line, + 'debit': 2000.0, + 'credit': 0.0, + 'amount_currency': 4000.0, + 'currency_id': self.currency_3.id, + }, + ]) + + # Check changing the label and the partner. + with Form(self.statement) as statement_form: + with statement_form.line_ids.edit(0) as st_line_form: + st_line_form.payment_ref = 'line_1 (bis)' + st_line_form.partner_id = self.partner_b + + self.assertBankStatementLine(self.statement_line, { + **self.expected_st_line, + 'payment_ref': self.statement_line.payment_ref, + 'partner_id': self.statement_line.partner_id.id, + 'amount': -2000.0, + 'amount_currency': -4000.0, + 'foreign_currency_id': self.currency_3.id, + }, [ + { + **self.expected_bank_line, + 'name': self.statement_line.payment_ref, + 'partner_id': self.statement_line.partner_id.id, + 'debit': 0.0, + 'credit': 2000.0, + 'amount_currency': -4000.0, + 'currency_id': self.currency_3.id, + }, + { + **self.expected_counterpart_line, + 'name': self.statement_line.payment_ref, + 'partner_id': self.statement_line.partner_id.id, + 'debit': 2000.0, + 'credit': 0.0, + 'amount_currency': 4000.0, + 'currency_id': self.currency_3.id, + }, + ]) + + # ------------------------------------------------------------------------- + # TESTS about reconciliation: + # - Test '_prepare_counterpart_move_line_vals': one test for each case. + # - Test 'reconcile': 3 cases: + # - Open-balance in debit. + # - Open-balance in credit. + # - No open-balance. + # - Test 'button_undo_reconciliation'. + # ------------------------------------------------------------------------- + + def _test_statement_line_reconciliation( + self, + journal, + amount, amount_currency, counterpart_amount, + journal_currency, foreign_currency, counterpart_currency, + expected_liquidity_values, expected_counterpart_values): + ''' Test the reconciliation of a statement line. + :param journal: The account.journal record that will be set on the statement line. + :param amount: The amount in journal's currency. + :param amount_currency: The amount in the foreign currency. + :param counterpart_amount: The amount of the invoice to reconcile. + :param journal_currency: The journal's currency as a res.currency record. + :param foreign_currency: The foreign currency as a res.currency record. + :param counterpart_currency: The invoice currency as a res.currency record. + :param expected_liquidity_values: The expected account.move.line values for the liquidity line. + :param expected_counterpart_values: The expected account.move.line values for the counterpart line. + ''' + if journal_currency: + journal.currency_id = journal_currency.id + + statement = self.env['account.bank.statement'].create({ + 'name': 'test_statement', + 'date': '2019-01-01', + 'journal_id': journal.id, + 'line_ids': [ + (0, 0, { + 'date': '2019-01-01', + 'payment_ref': 'line_1', + 'partner_id': self.partner_a.id, + 'foreign_currency_id': foreign_currency and foreign_currency.id, + 'amount': amount, + 'amount_currency': amount_currency, + }), + ], + }) + statement_line = statement.line_ids + + # - There is 3 flows to check: + # * The invoice will fully reconcile the statement line. + # * The invoice will partially reconcile the statement line and leads to an open balance in debit. + # * The invoice will partially reconcile the statement line and leads to an open balance in credit. + # - The dates are different to be sure the reconciliation will preserve the conversion rate bank side. + move_type = 'out_invoice' if counterpart_amount < 0.0 else 'in_invoice' + + test_invoices = self.env['account.move'].create([ + { + 'move_type': move_type, + 'invoice_date': fields.Date.from_string('2016-01-01'), + 'date': fields.Date.from_string('2016-01-01'), + 'partner_id': self.partner_a.id, + 'currency_id': counterpart_currency.id, + 'invoice_line_ids': [ + (0, None, { + 'name': 'counterpart line, same amount', + 'account_id': self.company_data['default_account_revenue'].id, + 'quantity': 1, + 'price_unit': abs(counterpart_amount), + }), + ], + }, + { + 'move_type': move_type, + 'invoice_date': fields.Date.from_string('2016-01-01'), + 'date': fields.Date.from_string('2016-01-01'), + 'partner_id': self.partner_a.id, + 'currency_id': counterpart_currency.id, + 'invoice_line_ids': [ + (0, None, { + 'name': 'counterpart line, lower amount', + 'account_id': self.company_data['default_account_revenue'].id, + 'quantity': 1, + 'price_unit': abs(counterpart_amount / 2), + }), + ], + }, + { + 'move_type': move_type, + 'invoice_date': fields.Date.from_string('2016-01-01'), + 'date': fields.Date.from_string('2016-01-01'), + 'partner_id': self.partner_a.id, + 'currency_id': counterpart_currency.id, + 'invoice_line_ids': [ + (0, None, { + 'name': 'counterpart line, bigger amount', + 'account_id': self.company_data['default_account_revenue'].id, + 'quantity': 1, + 'price_unit': abs(counterpart_amount * 2), + }), + ], + }, + ]) + test_invoices.post() + statement.button_post() + counterpart_lines = test_invoices.mapped('line_ids').filtered(lambda line: line.account_internal_type in ('receivable', 'payable')) + + # Check the full reconciliation. + statement_line.reconcile([{'id': counterpart_lines[0].id}]) + liquidity_lines, suspense_lines, other_lines = statement_line._seek_for_lines() + self.assertRecordValues(liquidity_lines, [expected_liquidity_values]) + self.assertRecordValues(other_lines, [expected_counterpart_values]) + + # Check the reconciliation with partial lower amount. + statement_line.button_undo_reconciliation() + statement_line.reconcile([{'id': counterpart_lines[1].id}]) + liquidity_lines, suspense_lines, other_lines = statement_line._seek_for_lines() + self.assertRecordValues(liquidity_lines, [expected_liquidity_values]) + self.assertRecordValues(other_lines.sorted('balance', reverse=amount < 0.0), [ + { + **expected_counterpart_values, + 'debit': expected_counterpart_values.get('debit', 0.0) / 2, + 'credit': expected_counterpart_values.get('credit', 0.0) / 2, + 'amount_currency': expected_counterpart_values.get('amount_currency', 0.0) / 2, + }, + { + 'debit': expected_counterpart_values.get('debit', 0.0) / 2, + 'credit': expected_counterpart_values.get('credit', 0.0) / 2, + 'amount_currency': expected_counterpart_values.get('amount_currency', 0.0) / 2, + 'currency_id': expected_counterpart_values.get('currency_id'), + }, + ]) + + # Check the reconciliation with partial higher amount. + statement_line.button_undo_reconciliation() + statement_line.reconcile([{'id': counterpart_lines[2].id}]) + liquidity_lines, suspense_lines, other_lines = statement_line._seek_for_lines() + self.assertRecordValues(liquidity_lines, [expected_liquidity_values]) + self.assertRecordValues(other_lines.sorted('balance', reverse=amount < 0.0), [ + { + **expected_counterpart_values, + 'debit': expected_counterpart_values.get('debit', 0.0) * 2, + 'credit': expected_counterpart_values.get('credit', 0.0) * 2, + 'amount_currency': expected_counterpart_values.get('amount_currency', 0.0) * 2, + }, + { + 'debit': expected_counterpart_values.get('credit', 0.0), + 'credit': expected_counterpart_values.get('debit', 0.0), + 'amount_currency': -expected_counterpart_values.get('amount_currency', 0.0), + 'currency_id': expected_counterpart_values.get('currency_id'), + }, + ]) + + # Make sure the statement line is still correct. + self.assertRecordValues(statement_line, [{ + 'amount': amount, + 'amount_currency': amount_currency, + }]) + + def _test_reconciliation_customer_and_supplier_flows( + self, + amount, amount_currency, counterpart_amount, + journal_currency, foreign_currency, counterpart_currency, + expected_liquidity_values, expected_counterpart_values): + ''' Test '_test_statement_line_reconciliation' using the customer (positive amounts) + & the supplier flow (negative amounts). + :param amount: The amount in journal's currency. + :param amount_currency: The amount in the foreign currency. + :param counterpart_amount: The amount of the invoice to reconcile. + :param journal_currency: The journal's currency as a res.currency record. + :param foreign_currency: The foreign currency as a res.currency record. + :param counterpart_currency: The invoice currency as a res.currency record. + :param expected_liquidity_values: The expected account.move.line values for the liquidity line. + :param expected_counterpart_values: The expected account.move.line values for the counterpart line. + ''' + + # Check the full process with positive amount (customer process). + self._test_statement_line_reconciliation( + self.bank_journal_2, + amount, amount_currency, counterpart_amount, + journal_currency, foreign_currency, counterpart_currency, + expected_liquidity_values, + expected_counterpart_values, + ) + + # Check the full process with negative amount (supplier process). + self._test_statement_line_reconciliation( + self.bank_journal_3, + -amount, -amount_currency, -counterpart_amount, + journal_currency, foreign_currency, counterpart_currency, + { + **expected_liquidity_values, + 'debit': expected_liquidity_values.get('credit', 0.0), + 'credit': expected_liquidity_values.get('debit', 0.0), + 'amount_currency': -expected_liquidity_values.get('amount_currency', 0.0), + }, + { + **expected_counterpart_values, + 'debit': expected_counterpart_values.get('credit', 0.0), + 'credit': expected_counterpart_values.get('debit', 0.0), + 'amount_currency': -expected_counterpart_values.get('amount_currency', 0.0), + }, + ) + + def test_reconciliation_journal_curr_2_statement_curr_3_counterpart_curr_3(self): + self._test_reconciliation_customer_and_supplier_flows( + 80.0, 120.0, -120.0, + self.currency_2, self.currency_3, self.currency_3, + {'debit': 40.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 40.0, 'amount_currency': -120.0, 'currency_id': self.currency_3.id}, + ) + + def test_reconciliation_journal_curr_2_statement_curr_1_counterpart_curr_2(self): + self._test_reconciliation_customer_and_supplier_flows( + 120.0, 80.0, -120.0, + self.currency_2, self.currency_1, self.currency_2, + {'debit': 80.0, 'credit': 0.0, 'amount_currency': 120.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 80.0, 'amount_currency': 0.0, 'currency_id': False}, + ) + + def test_reconciliation_journal_curr_2_statement_curr_3_counterpart_curr_2(self): + self._test_reconciliation_customer_and_supplier_flows( + 80.0, 120.0, -80.0, + self.currency_2, self.currency_3, self.currency_2, + {'debit': 40.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 40.0, 'amount_currency': -120.0, 'currency_id': self.currency_3.id}, + ) + + def test_reconciliation_journal_curr_2_statement_curr_3_counterpart_curr_4(self): + self._test_reconciliation_customer_and_supplier_flows( + 80.0, 120.0, -480.0, + self.currency_2, self.currency_3, self.currency_4, + {'debit': 40.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 40.0, 'amount_currency': -120.0, 'currency_id': self.currency_3.id}, + ) + + def test_reconciliation_journal_curr_1_statement_curr_2_counterpart_curr_2(self): + self._test_reconciliation_customer_and_supplier_flows( + 80.0, 120.0, -120.0, + self.currency_1, self.currency_2, self.currency_2, + {'debit': 80.0, 'credit': 0.0, 'amount_currency': 120.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 80.0, 'amount_currency': -120.0, 'currency_id': self.currency_2.id}, + ) + + def test_reconciliation_journal_curr_1_statement_curr_2_counterpart_curr_3(self): + self._test_reconciliation_customer_and_supplier_flows( + 80.0, 120.0, -480.0, + self.currency_1, self.currency_2, self.currency_3, + {'debit': 80.0, 'credit': 0.0, 'amount_currency': 120.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 80.0, 'amount_currency': -120.0, 'currency_id': self.currency_2.id}, + ) + + def test_reconciliation_journal_curr_2_statement_false_counterpart_curr_2(self): + self._test_reconciliation_customer_and_supplier_flows( + 80.0, 0.0, -80.0, + self.currency_2, False, self.currency_2, + {'debit': 40.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 40.0, 'amount_currency': -80.0, 'currency_id': self.currency_2.id}, + ) + + def test_reconciliation_journal_curr_2_statement_false_counterpart_curr_3(self): + self._test_reconciliation_customer_and_supplier_flows( + 80.0, 0.0, -240.0, + self.currency_2, False, self.currency_3, + {'debit': 40.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 40.0, 'amount_currency': -80.0, 'currency_id': self.currency_2.id}, + ) + + def test_reconciliation_journal_curr_1_statement_false_counterpart_curr_3(self): + self._test_reconciliation_customer_and_supplier_flows( + 80.0, 0.0, -480.0, + self.currency_1, False, self.currency_3, + {'debit': 80.0, 'credit': 0.0, 'amount_currency': 0.0, 'currency_id': False}, + {'debit': 0.0, 'credit': 80.0, 'amount_currency': 0.0, 'currency_id': False}, + ) + + def test_reconciliation_journal_curr_2_statement_curr_1_counterpart_curr_1(self): + self._test_reconciliation_customer_and_supplier_flows( + 120.0, 80.0, -80.0, + self.currency_2, self.currency_1, self.currency_1, + {'debit': 80.0, 'credit': 0.0, 'amount_currency': 120.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 80.0, 'amount_currency': 0.0, 'currency_id': False}, + ) + + def test_reconciliation_journal_curr_2_statement_curr_3_counterpart_curr_1(self): + self._test_reconciliation_customer_and_supplier_flows( + 80.0, 120.0, -40.0, + self.currency_2, self.currency_3, self.currency_1, + {'debit': 40.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 40.0, 'amount_currency': -120.0, 'currency_id': self.currency_3.id}, + ) + + def test_reconciliation_journal_curr_1_statement_curr_2_counterpart_curr_1(self): + self._test_reconciliation_customer_and_supplier_flows( + 80.0, 120.0, -80.0, + self.currency_1, self.currency_2, self.currency_1, + {'debit': 80.0, 'credit': 0.0, 'amount_currency': 120.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 80.0, 'amount_currency': -120.0, 'currency_id': self.currency_2.id}, + ) + + def test_reconciliation_journal_curr_2_statement_false_counterpart_curr_1(self): + self._test_reconciliation_customer_and_supplier_flows( + 80.0, 0.0, -40.0, + self.currency_2, False, self.currency_1, + {'debit': 40.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 40.0, 'amount_currency': -80.0, 'currency_id': self.currency_2.id}, + ) + + def test_reconciliation_journal_curr_1_statement_false_counterpart_curr_1(self): + self._test_reconciliation_customer_and_supplier_flows( + 80.0, 0.0, -80.0, + self.currency_1, False, self.currency_1, + {'debit': 80.0, 'credit': 0.0, 'amount_currency': 0.0, 'currency_id': False}, + {'debit': 0.0, 'credit': 80.0, 'amount_currency': 0.0, 'currency_id': False}, + ) + + def test_reconciliation_statement_line_state(self): + ''' Test the reconciliation on the bank statement line with a foreign currency on the journal: + - Ensure the statement line is_reconciled field is well computed. + - Ensure the reconciliation is working well when dealing with a foreign currency at different dates. + - Ensure the reconciliation can be undo. + - Ensure the reconciliation is still possible with to_check. + ''' + self.statement.button_post() + + receivable_acc_1 = self.company_data['default_account_receivable'] + receivable_acc_2 = self.copy_account(self.company_data['default_account_receivable']) + payment_account = self.bank_journal_1.payment_debit_account_id + random_acc_1 = self.company_data['default_account_revenue'] + random_acc_2 = self.copy_account(self.company_data['default_account_revenue']) + test_move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': fields.Date.from_string('2016-01-01'), + 'line_ids': [ + (0, None, { + 'name': 'counterpart of the whole move', + 'account_id': random_acc_1.id, + 'debit': 0.0, + 'credit': 1030.0, + }), + (0, None, { + 'name': 'test line 1 - receivable account', + 'account_id': receivable_acc_1.id, + 'currency_id': self.currency_2.id, + 'debit': 500.0, + 'credit': 0.0, + 'amount_currency': 1500.0, + }), + (0, None, { + 'name': 'test line 2 - another receivable account', + 'account_id': receivable_acc_2.id, + 'currency_id': self.currency_2.id, + 'debit': 500.0, + 'credit': 0.0, + 'amount_currency': 1500.0, + }), + (0, None, { + 'name': 'test line 3 - payment transfer account', + 'account_id': payment_account.id, + 'currency_id': self.currency_2.id, + 'debit': 30.0, + 'credit': 0.0, + 'amount_currency': 90.0, + }), + ] + }) + test_move.post() + + test_line_1 = test_move.line_ids.filtered(lambda line: line.account_id == receivable_acc_1) + test_line_2 = test_move.line_ids.filtered(lambda line: line.account_id == receivable_acc_2) + test_line_3 = test_move.line_ids.filtered(lambda line: line.account_id == payment_account) + self.statement_line.reconcile([ + # test line 1 + # Will reconcile 300.0 in balance, 600.0 in amount_currency. + {'id': test_line_1.id, 'balance': -600.0}, + # test line 2 + # Will reconcile 250.0 in balance, 500.0 in amount_currency. + {'id': test_line_2.id, 'balance': -500.0}, + # test line 3 + # Will reconcile 30.0 in balance, 90.0 in amount_currency. + {'id': test_line_3.id}, + # test line 4 + # Will reconcile 50.0 in balance, 100.0 in amount_currency. + {'name': 'whatever', 'account_id': random_acc_1.id, 'balance': -100.0}, + ]) + + self.assertBankStatementLine(self.statement_line, { + **self.expected_st_line, + 'is_reconciled': True, + }, [ + { + 'name': '%s: Open Balance' % self.statement_line.payment_ref, + 'partner_id': self.statement_line.partner_id.id, + 'currency_id': self.currency_2.id, + 'account_id': receivable_acc_1.id, # This account is retrieved on the partner. + 'debit': 0.0, + 'credit': 605.0, + 'amount_currency': -1210.0, + 'amount_residual': -605.0, + 'amount_residual_currency': -1210.0, + }, + { + 'name': test_line_1.name, + 'partner_id': self.statement_line.partner_id.id, + 'currency_id': self.currency_2.id, + 'account_id': receivable_acc_1.id, + 'debit': 0.0, + 'credit': 300.0, + 'amount_currency': -600.0, + 'amount_residual': 0.0, + 'amount_residual_currency': 0.0, + }, + { + 'name': test_line_2.name, + 'partner_id': self.statement_line.partner_id.id, + 'currency_id': self.currency_2.id, + 'account_id': receivable_acc_2.id, + 'debit': 0.0, + 'credit': 250.0, + 'amount_currency': -500.0, + 'amount_residual': 0.0, + 'amount_residual_currency': 0.0, + }, + { + 'name': 'whatever', + 'partner_id': self.statement_line.partner_id.id, + 'currency_id': self.currency_2.id, + 'account_id': random_acc_1.id, + 'debit': 0.0, + 'credit': 50.0, + 'amount_currency': -100.0, + 'amount_residual': 0.0, + 'amount_residual_currency': 0.0, + }, + { + 'name': test_line_3.name, + 'partner_id': self.statement_line.partner_id.id, + 'currency_id': self.currency_2.id, + 'account_id': test_line_3.account_id.id, + 'debit': 0.0, + 'credit': 45.0, + 'amount_currency': -90.0, + 'amount_residual': 0.0, + 'amount_residual_currency': 0.0, + }, + { + **self.expected_bank_line, + 'amount_residual': 1250.0, + 'amount_residual_currency': 2500.0, + }, + ]) + + # Undo the reconciliation to return to the initial state. + self.statement_line.button_undo_reconciliation() + self.assertBankStatementLine(self.statement_line, self.expected_st_line, [self.expected_counterpart_line, self.expected_bank_line]) + + # Modify the counterpart line with to_check enabled. + self.statement_line.reconcile([ + {'name': 'whatever', 'account_id': random_acc_1.id, 'balance': -100.0}, + ], to_check=True) + + self.assertBankStatementLine(self.statement_line, { + **self.expected_st_line, + 'is_reconciled': True, + }, [ + { + 'name': '%s: Open Balance' % self.statement_line.payment_ref, + 'partner_id': self.statement_line.partner_id.id, + 'currency_id': self.currency_2.id, + 'account_id': receivable_acc_1.id, # This account is retrieved on the partner. + 'debit': 0.0, + 'credit': 1200.0, + 'amount_currency': -2400.0, + 'amount_residual': -1200.0, + 'amount_residual_currency': -2400.0, + }, + { + 'name': 'whatever', + 'partner_id': self.statement_line.partner_id.id, + 'currency_id': self.currency_2.id, + 'account_id': random_acc_1.id, + 'debit': 0.0, + 'credit': 50.0, + 'amount_currency': -100.0, + 'amount_residual': 0.0, + 'amount_residual_currency': 0.0, + }, + { + **self.expected_bank_line, + 'amount_residual': 1250.0, + 'amount_residual_currency': 2500.0, + }, + ]) + + # Modify the counterpart line. Should be allowed by the to_check enabled. + self.statement_line.reconcile([ + {'name': 'whatever again', 'account_id': random_acc_2.id, 'balance': -500.0}, + ]) + + self.assertBankStatementLine(self.statement_line, { + **self.expected_st_line, + 'is_reconciled': True, + }, [ + { + 'name': '%s: Open Balance' % self.statement_line.payment_ref, + 'partner_id': self.statement_line.partner_id.id, + 'currency_id': self.currency_2.id, + 'account_id': receivable_acc_1.id, # This account is retrieved on the partner. + 'debit': 0.0, + 'credit': 1000.0, + 'amount_currency': -2000.0, + 'amount_residual': -1000.0, + 'amount_residual_currency': -2000.0, + }, + { + 'name': 'whatever again', + 'partner_id': self.statement_line.partner_id.id, + 'currency_id': self.currency_2.id, + 'account_id': random_acc_2.id, + 'debit': 0.0, + 'credit': 250.0, + 'amount_currency': -500.0, + 'amount_residual': 0.0, + 'amount_residual_currency': 0.0, + }, + { + **self.expected_bank_line, + 'amount_residual': 1250.0, + 'amount_residual_currency': 2500.0, + }, + ]) + + # The statement line is no longer in the 'to_check' mode. + # Reconciling again should raise an error. + with self.assertRaises(UserError), self.cr.savepoint(): + self.statement_line.reconcile([ + {'name': 'whatever', 'account_id': random_acc_1.id, 'balance': -100.0}, + ]) + + def test_reconciliation_statement_line_with_generated_payments(self): + self.statement.button_post() + + receivable_account = self.company_data['default_account_receivable'] + payment_account = self.bank_journal_1.payment_debit_account_id + random_account = self.company_data['default_account_revenue'] + test_move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': fields.Date.from_string('2016-01-01'), + 'line_ids': [ + (0, None, { + 'name': 'counterpart of the whole move', + 'account_id': random_account.id, + 'debit': 0.0, + 'credit': 1000.0, + }), + (0, None, { + 'name': 'test line 1', + 'account_id': receivable_account.id, + 'debit': 100.0, + 'credit': 0.0, + }), + (0, None, { + 'name': 'test line 2', + 'account_id': receivable_account.id, + 'currency_id': self.currency_2.id, + 'debit': 900.0, + 'credit': 0.0, + 'amount_currency': 1500.0, + }), + ] + }) + test_move.post() + + test_line_1 = test_move.line_ids.filtered(lambda line: line.name == 'test line 1') + test_line_2 = test_move.line_ids.filtered(lambda line: line.name == 'test line 2') + + def _prepare_reconciliation_with_payments(lines_vals_list): + statement_line_wo_patch = self.env['account.bank.statement.line'].browse(self.statement_line.id) + return statement_line_wo_patch._prepare_reconciliation(lines_vals_list, create_payment_for_invoice=True) + + with patch.object( + self.statement_line, + '_prepare_reconciliation', + lambda *args, **kwargs: _prepare_reconciliation_with_payments(*args) + ): + self.statement_line.reconcile([ + {'id': test_line_1.id, 'balance': -50.0}, + {'id': test_line_2.id}, + ]) + + self.assertBankStatementLine(self.statement_line, { + **self.expected_st_line, + 'is_reconciled': True, + }, [ + { + 'name': 'test line 2', + 'account_id': payment_account.id, + 'currency_id': self.currency_2.id, + 'debit': 0.0, + 'credit': 750.0, + 'amount_currency': -1500.0, + }, + { + 'name': 'line_1: Open Balance', + 'account_id': receivable_account.id, + 'currency_id': self.currency_2.id, + 'debit': 0.0, + 'credit': 450.0, + 'amount_currency': -900.0, + }, + { + 'name': 'test line 1', + 'account_id': payment_account.id, + 'currency_id': self.currency_2.id, + 'debit': 0.0, + 'credit': 50.0, + 'amount_currency': -100.0, + }, + self.expected_bank_line, + ]) + + # Check generated payments. + self.assertRecordValues(test_line_1.matched_credit_ids.credit_move_id.payment_id.line_ids.sorted('balance'), [ + { + 'partner_id': self.statement_line.partner_id.id, + 'currency_id': self.currency_2.id, + 'account_id': test_line_1.account_id.id, + 'debit': 0.0, + 'credit': 50.0, + 'amount_currency': -100.0, + 'amount_residual': 0.0, + 'amount_residual_currency': 50.0, + }, + { + 'partner_id': self.statement_line.partner_id.id, + 'currency_id': self.currency_2.id, + 'account_id': payment_account.id, + 'debit': 50.0, + 'credit': 0.0, + 'amount_currency': 100.0, + 'amount_residual': 0.0, + 'amount_residual_currency': 0.0, + }, + ]) + self.assertRecordValues(test_line_2.matched_credit_ids.credit_move_id.payment_id.line_ids.sorted('balance'), [ + { + 'partner_id': self.statement_line.partner_id.id, + 'currency_id': self.currency_2.id, + 'account_id': test_line_2.account_id.id, + 'debit': 0.0, + 'credit': 750.0, + 'amount_currency': -1500.0, + 'amount_residual': 0.0, + 'amount_residual_currency': 0.0, + }, + { + 'partner_id': self.statement_line.partner_id.id, + 'currency_id': self.currency_2.id, + 'account_id': payment_account.id, + 'debit': 750.0, + 'credit': 0.0, + 'amount_currency': 1500.0, + 'amount_residual': 0.0, + 'amount_residual_currency': 0.0, + }, + ]) diff --git a/addons/account/tests/test_account_journal.py b/addons/account/tests/test_account_journal.py index d2b287ca5b7..7dc8895c904 100644 --- a/addons/account/tests/test_account_journal.py +++ b/addons/account/tests/test_account_journal.py @@ -1,30 +1,36 @@ # -*- coding: utf-8 -*- from odoo.addons.account.tests.common import AccountTestInvoicingCommon from odoo.tests import tagged -from odoo.exceptions import UserError +from odoo.exceptions import UserError, ValidationError @tagged('post_install', '-at_install') class TestAccountJournal(AccountTestInvoicingCommon): - @classmethod - def setUpClass(cls): - super().setUpClass() + def test_constraint_currency_consistency_with_accounts(self): + ''' The accounts linked to a bank/cash journal must share the same foreign currency + if specified. + ''' + journal_bank = self.company_data['default_journal_bank'] + journal_bank.currency_id = self.currency_data['currency'] - # Create another company. - cls.company_data_2 = cls.setup_company_data('company_2_data') + # Try to set a different currency on the 'debit' account. + with self.assertRaises(ValidationError), self.cr.savepoint(): + journal_bank.default_debit_account_id.currency_id = self.company_data['currency'] - # By default, tests are run with the current user set on the first company. - cls.env.user.company_id = cls.company_data['company'] + def test_constraint_shared_accounts(self): + ''' Ensure the bank/outstanding accounts are not shared between multiple journals. ''' + journal_bank = self.company_data['default_journal_bank'] - def test_changing_journal_company(self): - ''' Ensure you can't change the company of an account.journal if there are some journal entries ''' - - self.env['account.move'].create({ - 'move_type': 'entry', - 'date': '2019-01-01', - 'journal_id': self.company_data['default_journal_sale'].id, - }) - - with self.assertRaises(UserError), self.cr.savepoint(): - self.company_data['default_journal_sale'].company_id = self.company_data_2['company'] + account_fields = ( + 'default_debit_account_id', + 'default_credit_account_id', + 'payment_debit_account_id', + 'payment_credit_account_id', + ) + for account_field in account_fields: + with self.assertRaises(ValidationError), self.cr.savepoint(): + journal_bank.copy(default={ + 'name': 'test_constraint_shared_accounts %s' % account_field, + account_field: journal_bank[account_field].id, + }) diff --git a/addons/account/tests/test_account_move_entry.py b/addons/account/tests/test_account_move_entry.py index 92159a3e506..c4198a1eecb 100644 --- a/addons/account/tests/test_account_move_entry.py +++ b/addons/account/tests/test_account_move_entry.py @@ -280,6 +280,7 @@ class TestAccountMove(AccountTestInvoicingCommon): # lines[1] = 'move 1 counterpart line' # lines[2] = 'move 1 receivable line' # lines[3] = 'move 2 counterpart line' + draft_moves.post() lines = draft_moves.mapped('line_ids').sorted('balance') (lines[0] + lines[2]).reconcile() diff --git a/addons/account/tests/test_account_move_in_invoice.py b/addons/account/tests/test_account_move_in_invoice.py index c25ae9c52d5..9a844fb23c2 100644 --- a/addons/account/tests/test_account_move_in_invoice.py +++ b/addons/account/tests/test_account_move_in_invoice.py @@ -121,7 +121,7 @@ class TestAccountMoveInInvoiceOnchanges(AccountTestInvoicingCommon): 'journal_id': cls.company_data['default_journal_purchase'].id, 'date': fields.Date.from_string('2019-01-01'), 'fiscal_position_id': False, - 'invoice_payment_ref': '', + 'payment_reference': '', 'invoice_payment_term_id': cls.pay_terms_a.id, 'amount_untaxed': 960.0, 'amount_tax': 168.0, @@ -601,7 +601,7 @@ class TestAccountMoveInInvoiceOnchanges(AccountTestInvoicingCommon): def test_in_invoice_line_onchange_partner_1(self): move_form = Form(self.invoice) move_form.partner_id = self.partner_b - move_form.invoice_payment_ref = 'turlututu' + move_form.payment_reference = 'turlututu' move_form.save() self.assertInvoiceValues(self.invoice, [ @@ -645,7 +645,7 @@ class TestAccountMoveInInvoiceOnchanges(AccountTestInvoicingCommon): ], { **self.move_vals, 'partner_id': self.partner_b.id, - 'invoice_payment_ref': 'turlututu', + 'payment_reference': 'turlututu', 'fiscal_position_id': self.fiscal_pos_a.id, 'invoice_payment_term_id': self.pay_terms_b.id, 'amount_untaxed': 960.0, @@ -706,7 +706,7 @@ class TestAccountMoveInInvoiceOnchanges(AccountTestInvoicingCommon): ], { **self.move_vals, 'partner_id': self.partner_b.id, - 'invoice_payment_ref': 'turlututu', + 'payment_reference': 'turlututu', 'fiscal_position_id': self.fiscal_pos_a.id, 'invoice_payment_term_id': self.pay_terms_b.id, 'amount_untaxed': 960.0, diff --git a/addons/account/tests/test_account_move_in_refund.py b/addons/account/tests/test_account_move_in_refund.py index 2e0aaf520f9..6667983fe45 100644 --- a/addons/account/tests/test_account_move_in_refund.py +++ b/addons/account/tests/test_account_move_in_refund.py @@ -121,7 +121,7 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon): 'journal_id': cls.company_data['default_journal_purchase'].id, 'date': fields.Date.from_string('2019-01-01'), 'fiscal_position_id': False, - 'invoice_payment_ref': '', + 'payment_reference': '', 'invoice_payment_term_id': cls.pay_terms_a.id, 'amount_untaxed': 960.0, 'amount_tax': 168.0, @@ -313,7 +313,7 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon): def test_in_refund_line_onchange_partner_1(self): move_form = Form(self.invoice) move_form.partner_id = self.partner_b - move_form.invoice_payment_ref = 'turlututu' + move_form.payment_reference = 'turlututu' move_form.save() self.assertInvoiceValues(self.invoice, [ @@ -357,7 +357,7 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon): ], { **self.move_vals, 'partner_id': self.partner_b.id, - 'invoice_payment_ref': 'turlututu', + 'payment_reference': 'turlututu', 'fiscal_position_id': self.fiscal_pos_a.id, 'invoice_payment_term_id': self.pay_terms_b.id, 'amount_untaxed': 960.0, @@ -418,7 +418,7 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon): ], { **self.move_vals, 'partner_id': self.partner_b.id, - 'invoice_payment_ref': 'turlututu', + 'payment_reference': 'turlututu', 'fiscal_position_id': self.fiscal_pos_a.id, 'invoice_payment_term_id': self.pay_terms_b.id, 'amount_untaxed': 960.0, diff --git a/addons/account/tests/test_account_move_out_invoice.py b/addons/account/tests/test_account_move_out_invoice.py index 7edca0f9d8e..d09adc1e7ff 100644 --- a/addons/account/tests/test_account_move_out_invoice.py +++ b/addons/account/tests/test_account_move_out_invoice.py @@ -123,7 +123,7 @@ class TestAccountMoveOutInvoiceOnchanges(AccountTestInvoicingCommon): 'journal_id': cls.company_data['default_journal_sale'].id, 'date': fields.Date.from_string('2019-01-01'), 'fiscal_position_id': False, - 'invoice_payment_ref': '', + 'payment_reference': '', 'invoice_payment_term_id': cls.pay_terms_a.id, 'amount_untaxed': 1200.0, 'amount_tax': 210.0, @@ -603,7 +603,7 @@ class TestAccountMoveOutInvoiceOnchanges(AccountTestInvoicingCommon): def test_out_invoice_line_onchange_partner_1(self): move_form = Form(self.invoice) move_form.partner_id = self.partner_b - move_form.invoice_payment_ref = 'turlututu' + move_form.payment_reference = 'turlututu' move_form.save() self.assertInvoiceValues(self.invoice, [ @@ -647,7 +647,7 @@ class TestAccountMoveOutInvoiceOnchanges(AccountTestInvoicingCommon): ], { **self.move_vals, 'partner_id': self.partner_b.id, - 'invoice_payment_ref': 'turlututu', + 'payment_reference': 'turlututu', 'fiscal_position_id': self.fiscal_pos_a.id, 'invoice_payment_term_id': self.pay_terms_b.id, 'amount_untaxed': 1200.0, @@ -708,7 +708,7 @@ class TestAccountMoveOutInvoiceOnchanges(AccountTestInvoicingCommon): ], { **self.move_vals, 'partner_id': self.partner_b.id, - 'invoice_payment_ref': 'turlututu', + 'payment_reference': 'turlututu', 'fiscal_position_id': self.fiscal_pos_a.id, 'invoice_payment_term_id': self.pay_terms_b.id, 'amount_untaxed': 1200.0, @@ -2006,7 +2006,7 @@ class TestAccountMoveOutInvoiceOnchanges(AccountTestInvoicingCommon): }, ], { **self.move_vals, - 'invoice_payment_ref': move.name, + 'payment_reference': move.name, 'currency_id': self.currency_data['currency'].id, 'date': frozen_today, 'invoice_date': frozen_today, @@ -2112,7 +2112,7 @@ class TestAccountMoveOutInvoiceOnchanges(AccountTestInvoicingCommon): }, ], { **self.move_vals, - 'invoice_payment_ref': move.name, + 'payment_reference': move.name, 'currency_id': self.currency_data['currency'].id, 'date': fields.Date.from_string('2017-01-01'), 'amount_untaxed': 1200.0, @@ -2392,7 +2392,7 @@ class TestAccountMoveOutInvoiceOnchanges(AccountTestInvoicingCommon): **self.move_vals, 'currency_id': self.currency_data['currency'].id, 'date': fields.Date.from_string('2017-01-01'), - 'invoice_payment_ref': 'INV/2017/01/0001', + 'payment_reference': 'INV/2017/01/0001', }) accrual_lines = self.env['account.move'].browse(wizard_res['domain'][0][2]).line_ids.sorted('date') diff --git a/addons/account/tests/test_account_move_out_refund.py b/addons/account/tests/test_account_move_out_refund.py index 369e85eb2dc..bf6931f0ebf 100644 --- a/addons/account/tests/test_account_move_out_refund.py +++ b/addons/account/tests/test_account_move_out_refund.py @@ -121,7 +121,7 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon): 'journal_id': cls.company_data['default_journal_sale'].id, 'date': fields.Date.from_string('2019-01-01'), 'fiscal_position_id': False, - 'invoice_payment_ref': '', + 'payment_reference': '', 'invoice_payment_term_id': cls.pay_terms_a.id, 'amount_untaxed': 1200.0, 'amount_tax': 210.0, @@ -313,7 +313,7 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon): def test_out_refund_line_onchange_partner_1(self): move_form = Form(self.invoice) move_form.partner_id = self.partner_b - move_form.invoice_payment_ref = 'turlututu' + move_form.payment_reference = 'turlututu' move_form.save() self.assertInvoiceValues(self.invoice, [ @@ -357,7 +357,7 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon): ], { **self.move_vals, 'partner_id': self.partner_b.id, - 'invoice_payment_ref': 'turlututu', + 'payment_reference': 'turlututu', 'fiscal_position_id': self.fiscal_pos_a.id, 'invoice_payment_term_id': self.pay_terms_b.id, 'amount_untaxed': 1200.0, @@ -418,7 +418,7 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon): ], { **self.move_vals, 'partner_id': self.partner_b.id, - 'invoice_payment_ref': 'turlututu', + 'payment_reference': 'turlututu', 'fiscal_position_id': self.fiscal_pos_a.id, 'invoice_payment_term_id': self.pay_terms_b.id, 'amount_untaxed': 1200.0, diff --git a/addons/account/tests/test_account_payment.py b/addons/account/tests/test_account_payment.py index a9db870745d..7c108e97533 100644 --- a/addons/account/tests/test_account_payment.py +++ b/addons/account/tests/test_account_payment.py @@ -2,221 +2,492 @@ from odoo.addons.account.tests.common import AccountTestInvoicingCommon from odoo.tests import tagged, new_test_user from odoo.tests.common import Form -from odoo import fields @tagged('post_install', '-at_install') -class TestAccountMovePayment(AccountTestInvoicingCommon): +class TestAccountPayment(AccountTestInvoicingCommon): @classmethod def setUpClass(cls): - super(TestAccountMovePayment, cls).setUpClass() + super().setUpClass() - cls.bank_journal = cls.company_data['default_journal_bank'] - cls.bank_journal.default_credit_account_id = cls.bank_journal.default_debit_account_id.copy() + cls.payment_debit_account_id = cls.copy_account(cls.company_data['default_journal_bank'].payment_debit_account_id) + cls.payment_credit_account_id = cls.copy_account(cls.company_data['default_journal_bank'].payment_credit_account_id) - cls.inbound_payment = cls._init_payment(cls, 'inbound', cls.partner_a, partner_type='customer') - cls.outbound_payment = cls._init_payment(cls, 'outbound', cls.partner_a, partner_type='supplier') - cls.inbound_internal_transfer = cls._init_payment(cls, 'inbound', cls.company_data['company'].partner_id) - cls.outbound_internal_transfer = cls._init_payment(cls, 'outbound', cls.company_data['company'].partner_id) + cls.company_data['default_journal_bank'].write({ + 'payment_debit_account_id': cls.payment_debit_account_id.id, + 'payment_credit_account_id': cls.payment_credit_account_id.id, + 'inbound_payment_method_ids': [(6, 0, cls.env.ref('account.account_payment_method_manual_in').ids)], + 'outbound_payment_method_ids': [(6, 0, cls.env.ref('account.account_payment_method_manual_out').ids)], + }) - @staticmethod - def _init_payment(cls, payment_type, partner_id, partner_type=None): - payment_form = Form(cls.env['account.payment']) - payment_form.journal_id = cls.bank_journal - payment_form.payment_date = fields.Date.from_string('2019-01-01') - payment_form.amount = 100 - payment_form.partner_type = partner_type - payment_form.partner_id = partner_id - payment_form.payment_type = payment_type - return payment_form.save() + def test_payment_move_sync_create_write(self): + copy_receivable = self.copy_account(self.company_data['default_account_receivable']) - def assertPaymentValues(self, payment, expected_lines_values): - self.assertRecordValues(payment.move_line_ids.sorted(lambda line: (line.name, line.balance)), expected_lines_values) + payment = self.env['account.payment'].create({ + 'amount': 50.0, + 'payment_type': 'inbound', + 'partner_type': 'customer', + 'destination_account_id': copy_receivable.id, + }) - def test_inbound_payment_move_1(self): - ''' Check created account.move for an account.payment having the 'inbound' type. ''' - liquidity_account = self.bank_journal.default_credit_account_id - - expected_bank_line = { - 'account_id': liquidity_account.id, - 'partner_id': self.partner_a.id, - 'currency_id': False, - 'amount_currency': 0.0, - 'debit': 100.0, + expected_payment_values = { + 'amount': 50.0, + 'payment_type': 'inbound', + 'partner_type': 'customer', + 'payment_reference': False, + 'is_reconciled': False, + 'currency_id': self.company_data['currency'].id, + 'partner_id': False, + 'destination_account_id': copy_receivable.id, + 'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id, + } + expected_move_values = { + 'currency_id': self.company_data['currency'].id, + 'partner_id': False, + } + expected_liquidity_line = { + 'debit': 50.0, 'credit': 0.0, - } - expected_receivable_line = { - 'account_id': self.inbound_payment.destination_account_id .id, - 'partner_id': self.partner_a.id, - 'currency_id': False, 'amount_currency': 0.0, + 'currency_id': False, + 'account_id': self.payment_debit_account_id.id, + } + expected_counterpart_line = { 'debit': 0.0, - 'credit': 100.0, + 'credit': 50.0, + 'amount_currency': 0.0, + 'currency_id': False, + 'account_id': copy_receivable.id, } - # Single currency. - - self.inbound_payment.post() - - self.assertPaymentValues(self.inbound_payment, [ - expected_bank_line, - expected_receivable_line, + self.assertRecordValues(payment, [expected_payment_values]) + self.assertRecordValues(payment.move_id, [expected_move_values]) + self.assertRecordValues(payment.line_ids.sorted('balance'), [ + expected_counterpart_line, + expected_liquidity_line, ]) - # Multi currencies: Foreign currency on payment. + # ==== Check editing the account.payment ==== - self.inbound_payment.action_draft() - self.inbound_payment.currency_id = self.currency_data['currency'] - self.inbound_payment.post() + payment.write({ + 'partner_type': 'supplier', + 'currency_id': self.currency_data['currency'].id, + 'partner_id': self.partner_a.id, + }) - self.assertPaymentValues(self.inbound_payment, [ + self.assertRecordValues(payment, [{ + **expected_payment_values, + 'partner_type': 'supplier', + 'destination_account_id': self.partner_a.property_account_payable_id.id, + 'currency_id': self.currency_data['currency'].id, + 'partner_id': self.partner_a.id, + }]) + self.assertRecordValues(payment.move_id, [{ + **expected_move_values, + 'currency_id': self.currency_data['currency'].id, + 'partner_id': self.partner_a.id, + }]) + self.assertRecordValues(payment.line_ids.sorted('balance'), [ { - **expected_bank_line, + **expected_counterpart_line, + 'debit': 0.0, + 'credit': 25.0, + 'amount_currency': -50.0, 'currency_id': self.currency_data['currency'].id, - 'amount_currency': 100.0, - 'debit': 50.0, + 'account_id': self.partner_a.property_account_payable_id.id, }, { - **expected_receivable_line, + **expected_liquidity_line, + 'debit': 25.0, + 'credit': 0.0, + 'amount_currency': 50.0, 'currency_id': self.currency_data['currency'].id, - 'amount_currency': -100.0, - 'credit': 50.0, }, ]) - self.inbound_payment.currency_id = self.company_data['currency'] + # ==== Check editing the account.move.line ==== - # Multi currencies: Foreign currency on journal. + liquidity_lines, counterpart_lines, writeoff_lines = payment._seek_for_lines() + payment.move_id.write({ + 'line_ids': [ + (1, counterpart_lines.id, { + 'debit': 0.0, + 'credit': 75.0, + 'amount_currency': 0.0, + 'currency_id': False, + 'account_id': copy_receivable.id, + 'partner_id': self.partner_b.id, + }), + (1, liquidity_lines.id, { + 'debit': 100.0, + 'credit': 0.0, + 'amount_currency': 0.0, + 'currency_id': False, + 'partner_id': self.partner_b.id, + }), - self.inbound_payment.action_draft() - self.inbound_payment.journal_id.currency_id = self.currency_data['currency'] - self.inbound_payment.post() + # Additional write-off: + (0, 0, { + 'debit': 0.0, + 'credit': 25.0, + 'amount_currency': 0.0, + 'currency_id': False, + 'account_id': self.company_data['default_account_revenue'].id, + 'partner_id': self.partner_b.id, + }), + ] + }) - self.assertPaymentValues(self.inbound_payment, [ + self.assertRecordValues(payment, [{ + **expected_payment_values, + 'amount': 100.0, + 'partner_id': self.partner_b.id, + }]) + self.assertRecordValues(payment.move_id, [{ + **expected_move_values, + 'partner_id': self.partner_b.id, + }]) + self.assertRecordValues(payment.line_ids.sorted('balance'), [ { - **expected_bank_line, - 'currency_id': self.currency_data['currency'].id, - 'amount_currency': 200.0, + **expected_counterpart_line, + 'debit': 0.0, + 'credit': 75.0, + 'partner_id': self.partner_b.id, + }, + { + 'debit': 0.0, + 'credit': 25.0, + 'amount_currency': 0.0, + 'currency_id': False, + 'account_id': self.company_data['default_account_revenue'].id, + 'partner_id': self.partner_b.id, + }, + { + **expected_liquidity_line, 'debit': 100.0, + 'credit': 0.0, + 'account_id': self.payment_debit_account_id.id, + 'partner_id': self.partner_b.id, }, - expected_receivable_line, ]) - def test_outbound_payment_move_1(self): - ''' Check created account.move for an account.payment having the 'outbound' type. ''' - liquidity_account = self.bank_journal.default_debit_account_id + def test_payment_move_sync_onchange(self): + copy_receivable = self.copy_account(self.company_data['default_account_receivable']) - expected_bank_line = { - 'account_id': liquidity_account.id, + pay_form = Form(self.env['account.payment'].with_context(default_journal_id=self.company_data['default_journal_bank'].id)) + pay_form.amount = 50.0 + pay_form.payment_type = 'inbound' + pay_form.partner_type = 'customer' + pay_form.destination_account_id = copy_receivable + payment = pay_form.save() + + expected_payment_values = { + 'amount': 50.0, + 'payment_type': 'inbound', + 'partner_type': 'customer', + 'payment_reference': False, + 'is_reconciled': False, + 'currency_id': self.company_data['currency'].id, + 'partner_id': False, + 'destination_account_id': copy_receivable.id, + 'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id, + } + expected_move_values = { + 'currency_id': self.company_data['currency'].id, + 'partner_id': False, + } + expected_liquidity_line = { + 'debit': 50.0, + 'credit': 0.0, + 'amount_currency': 0.0, + 'currency_id': False, + 'account_id': self.payment_debit_account_id.id, + } + expected_counterpart_line = { + 'debit': 0.0, + 'credit': 50.0, + 'amount_currency': 0.0, + 'currency_id': False, + 'account_id': copy_receivable.id, + } + + self.assertRecordValues(payment, [expected_payment_values]) + self.assertRecordValues(payment.move_id, [expected_move_values]) + self.assertRecordValues(payment.line_ids.sorted('balance'), [ + expected_counterpart_line, + expected_liquidity_line, + ]) + + # ==== Check editing the account.payment ==== + + pay_form = Form(payment) + pay_form.partner_type = 'supplier' + pay_form.currency_id = self.currency_data['currency'] + pay_form.partner_id = self.partner_a + payment = pay_form.save() + + self.assertRecordValues(payment, [{ + **expected_payment_values, + 'partner_type': 'supplier', + 'destination_account_id': self.partner_a.property_account_payable_id.id, + 'currency_id': self.currency_data['currency'].id, 'partner_id': self.partner_a.id, - 'currency_id': False, - 'amount_currency': 0.0, - 'debit': 0.0, - 'credit': 100.0, - } - expected_payable_line = { - 'account_id': self.outbound_payment.destination_account_id.id, + }]) + self.assertRecordValues(payment.move_id, [{ + **expected_move_values, + 'currency_id': self.currency_data['currency'].id, 'partner_id': self.partner_a.id, - 'currency_id': False, - 'amount_currency': 0.0, - 'debit': 100.0, - 'credit': 0.0, - } - - # Single currency. - - self.outbound_payment.post() - - self.assertPaymentValues(self.outbound_payment, [ - expected_bank_line, - expected_payable_line, - ]) - - # Multi currencies: Foreign currency on payment. - - self.outbound_payment.action_draft() - self.outbound_payment.currency_id = self.currency_data['currency'] - self.outbound_payment.post() - - self.assertPaymentValues(self.outbound_payment, [ + }]) + self.assertRecordValues(payment.line_ids.sorted('balance'), [ { - **expected_bank_line, + **expected_counterpart_line, + 'debit': 0.0, + 'credit': 25.0, + 'amount_currency': -50.0, 'currency_id': self.currency_data['currency'].id, - 'amount_currency': -100.0, - 'credit': 50.0, + 'account_id': self.partner_a.property_account_payable_id.id, }, { - **expected_payable_line, + **expected_liquidity_line, + 'debit': 25.0, + 'credit': 0.0, + 'amount_currency': 50.0, 'currency_id': self.currency_data['currency'].id, - 'amount_currency': 100.0, - 'debit': 50.0, }, ]) - self.outbound_payment.currency_id = self.company_data['currency'] + # ==== Check editing the account.move.line ==== - # Multi currencies: Foreign currency on journal. + move_form = Form(payment.move_id) + with move_form.line_ids.edit(0) as line_form: + line_form.currency_id = self.env['res.currency'] + line_form.amount_currency = 0.0 + line_form.debit = 100.0 + line_form.partner_id = self.partner_b + with move_form.line_ids.edit(1) as line_form: + line_form.currency_id = self.env['res.currency'] + line_form.amount_currency = 0.0 + line_form.credit = 75.0 + line_form.account_id = copy_receivable + line_form.partner_id = self.partner_b + with move_form.line_ids.new() as line_form: + line_form.currency_id = self.env['res.currency'] + line_form.amount_currency = 0.0 + line_form.credit = 25.0 + line_form.account_id = self.company_data['default_account_revenue'] + line_form.partner_id = self.partner_b + move_form.save() - self.outbound_payment.action_draft() - self.outbound_payment.journal_id.currency_id = self.currency_data['currency'] - self.outbound_payment.post() - - self.assertPaymentValues(self.outbound_payment, [ + self.assertRecordValues(payment, [{ + **expected_payment_values, + 'amount': 100.0, + 'partner_id': self.partner_b.id, + }]) + self.assertRecordValues(payment.line_ids.sorted('balance'), [ { - **expected_bank_line, - 'currency_id': self.currency_data['currency'].id, - 'amount_currency': -200.0, - 'credit': 100.0, + **expected_counterpart_line, + 'debit': 0.0, + 'credit': 75.0, + 'partner_id': self.partner_b.id, + }, + { + 'debit': 0.0, + 'credit': 25.0, + 'amount_currency': 0.0, + 'currency_id': False, + 'account_id': self.company_data['default_account_revenue'].id, + 'partner_id': self.partner_b.id, + }, + { + **expected_liquidity_line, + 'debit': 100.0, + 'credit': 0.0, + 'account_id': self.payment_debit_account_id.id, + 'partner_id': self.partner_b.id, }, - expected_payable_line, ]) - def test_inbound_transfer_payment_1(self): - liquidity_bank_account = self.bank_journal.default_debit_account_id - self.inbound_internal_transfer.post() - transfer_line = { - 'account_id': self.company_data['company'].transfer_account_id.id, + def test_internal_transfer(self): + copy_receivable = self.copy_account(self.company_data['default_account_receivable']) + + payment = self.env['account.payment'].create({ + 'amount': 50.0, + 'is_internal_transfer': True, + }) + + expected_payment_values = { + 'amount': 50.0, + 'payment_type': 'inbound', + 'currency_id': self.company_data['currency'].id, 'partner_id': self.company_data['company'].partner_id.id, - 'currency_id': False, + 'destination_account_id': self.company_data['company'].transfer_account_id.id, + } + expected_move_values = { + 'currency_id': self.company_data['currency'].id, + 'partner_id': self.company_data['company'].partner_id.id, + } + expected_liquidity_line = { + 'debit': 50.0, + 'credit': 0.0, 'amount_currency': 0.0, + 'currency_id': False, + 'account_id': self.payment_debit_account_id.id, + } + expected_counterpart_line = { 'debit': 0.0, - 'credit': 100.0, - } - liquidity_bank_line = { - 'account_id': self.bank_journal.default_credit_account_id.id, - 'partner_id': self.company_data['company'].partner_id.id, - 'currency_id': False, + 'credit': 50.0, 'amount_currency': 0.0, - 'debit': 100.0, - 'credit': 0.0, - } - self.assertPaymentValues(self.inbound_internal_transfer, [ - transfer_line, - liquidity_bank_line, - ]) - self.assertTrue(self.inbound_internal_transfer.is_internal_transfer) - - def test_outbound_transfer_payment_1(self): - self.outbound_internal_transfer.post() - transfer_line = { + 'currency_id': False, 'account_id': self.company_data['company'].transfer_account_id.id, - 'partner_id': self.company_data['company'].partner_id.id, - 'currency_id': False, - 'amount_currency': 0.0, - 'debit': 100.0, - 'credit': 0.0, } - liquidity_bank_line = { - 'account_id': self.bank_journal.default_debit_account_id.id, - 'partner_id': self.company_data['company'].partner_id.id, - 'currency_id': False, - 'amount_currency': 0.0, - 'debit': 0.0, - 'credit': 100.0, - } - self.assertPaymentValues(self.outbound_internal_transfer, [ - transfer_line, - liquidity_bank_line, + + self.assertRecordValues(payment, [expected_payment_values]) + self.assertRecordValues(payment.move_id, [expected_move_values]) + self.assertRecordValues(payment.line_ids.sorted('balance'), [ + expected_counterpart_line, + expected_liquidity_line, ]) - self.assertTrue(self.outbound_internal_transfer.is_internal_transfer) + + # ==== Check editing the account.payment ==== + + payment.write({ + 'partner_type': 'customer', + 'partner_id': self.partner_a.id, + 'destination_account_id': copy_receivable.id, + }) + + self.assertRecordValues(payment, [{ + **expected_payment_values, + 'partner_type': 'customer', + 'destination_account_id': copy_receivable.id, + 'partner_id': self.partner_a.id, + 'is_internal_transfer': False, + }]) + self.assertRecordValues(payment.move_id, [{ + **expected_move_values, + 'partner_id': self.partner_a.id, + }]) + self.assertRecordValues(payment.line_ids.sorted('balance'), [ + { + **expected_counterpart_line, + 'account_id': copy_receivable.id, + }, + expected_liquidity_line, + ]) + + # ==== Check editing the account.move.line ==== + + liquidity_lines, counterpart_lines, writeoff_lines = payment._seek_for_lines() + payment.move_id.write({ + 'line_ids': [ + (1, counterpart_lines.id, { + 'account_id': self.company_data['company'].transfer_account_id.id, + 'partner_id': self.company_data['company'].partner_id.id, + }), + (1, liquidity_lines.id, { + 'partner_id': self.company_data['company'].partner_id.id, + }), + ] + }) + + self.assertRecordValues(payment, [expected_payment_values]) + self.assertRecordValues(payment.move_id, [expected_move_values]) + self.assertRecordValues(payment.line_ids.sorted('balance'), [ + expected_counterpart_line, + expected_liquidity_line, + ]) + + def test_compute_currency_id(self): + ''' When creating a new account.payment without specifying a currency, the default currency should be the one + set on the journal. + ''' + self.company_data['default_journal_bank'].write({ + 'currency_id': self.currency_data['currency'].id, + }) + + payment = self.env['account.payment'].create({ + 'amount': 50.0, + 'payment_type': 'inbound', + 'partner_type': 'customer', + 'partner_id': self.partner_a.id, + 'journal_id': self.company_data['default_journal_bank'].id, + }) + + self.assertRecordValues(payment, [{ + 'currency_id': self.currency_data['currency'].id, + }]) + self.assertRecordValues(payment.move_id, [{ + 'currency_id': self.currency_data['currency'].id, + }]) + self.assertRecordValues(payment.line_ids.sorted('balance'), [ + { + 'debit': 0.0, + 'credit': 25.0, + 'amount_currency': -50.0, + 'currency_id': self.currency_data['currency'].id, + }, + { + 'debit': 25.0, + 'credit': 0.0, + 'amount_currency': 50.0, + 'currency_id': self.currency_data['currency'].id, + }, + ]) + + def test_reconciliation_payment_states(self): + payment = self.env['account.payment'].create({ + 'amount': 50.0, + 'payment_type': 'inbound', + 'partner_type': 'customer', + 'destination_account_id': self.company_data['default_account_receivable'].id, + }) + liquidity_lines, counterpart_lines, writeoff_lines = payment._seek_for_lines() + + self.assertRecordValues(payment, [{ + 'is_reconciled': False, + 'is_matched': False, + }]) + + invoice = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'partner_id': self.partner_a.id, + 'invoice_line_ids': [(0, 0, { + 'name': '50 to pay', + 'price_unit': 50.0, + 'quantity': 1, + 'account_id': self.company_data['default_account_revenue'].id, + })], + }) + + payment.action_post() + invoice.post() + + (counterpart_lines + invoice.line_ids.filtered(lambda line: line.account_internal_type == 'receivable'))\ + .reconcile() + + self.assertRecordValues(payment, [{ + 'is_reconciled': True, + 'is_matched': False, + }]) + + statement = self.env['account.bank.statement'].create({ + 'name': 'test_statement', + 'journal_id': self.company_data['default_journal_bank'].id, + 'line_ids': [ + (0, 0, { + 'payment_ref': '50 to pay', + 'partner_id': self.partner_a.id, + 'amount': 50.0, + }), + ], + }) + statement.button_post() + statement_line = statement.line_ids + + statement_line.reconcile([{'id': liquidity_lines.id}]) + + self.assertRecordValues(payment, [{ + 'is_reconciled': True, + 'is_matched': True, + }]) diff --git a/addons/account/tests/test_account_payment_register.py b/addons/account/tests/test_account_payment_register.py new file mode 100644 index 00000000000..a5083e5225a --- /dev/null +++ b/addons/account/tests/test_account_payment_register.py @@ -0,0 +1,424 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.exceptions import UserError +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestAccountPaymentRegister(AccountTestInvoicingCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.payment_debit_account_id = cls.company_data['default_journal_bank'].payment_debit_account_id.copy() + cls.payment_credit_account_id = cls.company_data['default_journal_bank'].payment_credit_account_id.copy() + + cls.custom_payment_method_in = cls.env['account.payment.method'].create({ + 'name': 'custom_payment_method_in', + 'code': 'CUSTOMIN', + 'payment_type': 'inbound', + }) + cls.manual_payment_method_in = cls.env.ref('account.account_payment_method_manual_in') + + cls.custom_payment_method_out = cls.env['account.payment.method'].create({ + 'name': 'custom_payment_method_out', + 'code': 'CUSTOMOUT', + 'payment_type': 'outbound', + }) + cls.manual_payment_method_out = cls.env.ref('account.account_payment_method_manual_out') + + cls.company_data['default_journal_bank'].write({ + 'payment_debit_account_id': cls.payment_debit_account_id.id, + 'payment_credit_account_id': cls.payment_credit_account_id.id, + 'inbound_payment_method_ids': [(6, 0, ( + cls.manual_payment_method_in.id, + cls.custom_payment_method_in.id, + ))], + 'outbound_payment_method_ids': [(6, 0, ( + cls.env.ref('account.account_payment_method_manual_out').id, + cls.custom_payment_method_out.id, + cls.manual_payment_method_out.id, + ))], + }) + + # Customer invoices sharing the same batch. + cls.out_invoice_1 = cls.env['account.move'].create({ + 'move_type': 'out_invoice', + 'date': '2017-01-01', + 'invoice_date': '2017-01-01', + 'partner_id': cls.partner_a.id, + 'currency_id': cls.currency_data['currency'].id, + 'invoice_line_ids': [(0, 0, {'product_id': cls.product_a.id, 'price_unit': 1000.0})], + }) + cls.out_invoice_1.post() + cls.out_invoice_2 = cls.env['account.move'].create({ + 'move_type': 'out_invoice', + 'date': '2017-01-01', + 'invoice_date': '2017-01-01', + 'partner_id': cls.partner_a.id, + 'currency_id': cls.currency_data['currency'].id, + 'invoice_line_ids': [(0, 0, {'product_id': cls.product_a.id, 'price_unit': 2000.0})], + }) + (cls.out_invoice_1 + cls.out_invoice_2).post() + + # Vendor bills, in_invoice_1 + in_invoice_2 are sharing the same batch but not in_invoice_3. + cls.in_invoice_1 = cls.env['account.move'].create({ + 'move_type': 'in_invoice', + 'date': '2017-01-01', + 'invoice_date': '2017-01-01', + 'partner_id': cls.partner_a.id, + 'invoice_line_ids': [(0, 0, {'product_id': cls.product_a.id, 'price_unit': 1000.0})], + }) + cls.in_invoice_2 = cls.env['account.move'].create({ + 'move_type': 'in_invoice', + 'date': '2017-01-01', + 'invoice_date': '2017-01-01', + 'partner_id': cls.partner_a.id, + 'invoice_line_ids': [(0, 0, {'product_id': cls.product_a.id, 'price_unit': 2000.0})], + }) + cls.in_invoice_3 = cls.env['account.move'].create({ + 'move_type': 'in_invoice', + 'date': '2017-01-01', + 'invoice_date': '2017-01-01', + 'partner_id': cls.partner_b.id, + 'currency_id': cls.currency_data['currency'].id, + 'invoice_line_ids': [(0, 0, {'product_id': cls.product_a.id, 'price_unit': 3000.0})], + }) + (cls.in_invoice_1 + cls.in_invoice_2 + cls.in_invoice_3).post() + + def test_register_payment_single_batch_grouped_keep_open_lower_amount(self): + ''' Pay 800.0 with 'open' as payment difference handling on two customer invoices (1000 + 2000). ''' + active_ids = (self.out_invoice_1 + self.out_invoice_2).ids + payments = self.env['account.payment.register'].with_context(active_model='account.move', active_ids=active_ids).create({ + 'amount': 800.0, + 'group_payment': True, + 'payment_difference_handling': 'open', + 'currency_id': self.currency_data['currency'].id, + 'payment_method_id': self.custom_payment_method_in.id, + })._create_payments() + + self.assertRecordValues(payments, [{ + 'payment_method_id': self.custom_payment_method_in.id, + }]) + self.assertRecordValues(payments.line_ids.sorted('balance'), [ + # Receivable line: + { + 'debit': 0.0, + 'credit': 400.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -800.0, + 'reconciled': True, + }, + # Liquidity line: + { + 'debit': 400.0, + 'credit': 0.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 800.0, + 'reconciled': False, + }, + ]) + + def test_register_payment_single_batch_grouped_keep_open_higher_amount(self): + ''' Pay 3100.0 with 'open' as payment difference handling on two customer invoices (1000 + 2000). ''' + active_ids = (self.out_invoice_1 + self.out_invoice_2).ids + payments = self.env['account.payment.register'].with_context(active_model='account.move', active_ids=active_ids).create({ + 'amount': 3100.0, + 'group_payment': True, + 'payment_difference_handling': 'open', + 'currency_id': self.currency_data['currency'].id, + 'payment_method_id': self.custom_payment_method_in.id, + })._create_payments() + + self.assertRecordValues(payments, [{ + 'payment_method_id': self.custom_payment_method_in.id, + }]) + self.assertRecordValues(payments.line_ids.sorted('balance'), [ + # Receivable line: + { + 'debit': 0.0, + 'credit': 1550.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -3100.0, + 'reconciled': False, + }, + # Liquidity line: + { + 'debit': 1550.0, + 'credit': 0.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 3100.0, + 'reconciled': False, + }, + ]) + + def test_register_payment_single_batch_grouped_writeoff_lower_amount(self): + ''' Pay 800.0 with 'reconcile' as payment difference handling on two customer invoices (1000 + 2000). ''' + active_ids = (self.out_invoice_1 + self.out_invoice_2).ids + payments = self.env['account.payment.register'].with_context(active_model='account.move', active_ids=active_ids).create({ + 'amount': 800.0, + 'group_payment': True, + 'payment_difference_handling': 'reconcile', + 'writeoff_account_id': self.company_data['default_account_revenue'].id, + 'writeoff_label': 'writeoff', + 'currency_id': self.currency_data['currency'].id, + 'payment_method_id': self.custom_payment_method_in.id, + })._create_payments() + + self.assertRecordValues(payments, [{ + 'payment_method_id': self.custom_payment_method_in.id, + }]) + self.assertRecordValues(payments.line_ids.sorted('balance'), [ + # Receivable line: + { + 'debit': 0.0, + 'credit': 1500.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -3000.0, + 'reconciled': True, + }, + # Liquidity line: + { + 'debit': 400.0, + 'credit': 0.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 800.0, + 'reconciled': False, + }, + # Writeoff line: + { + 'debit': 1100.0, + 'credit': 0.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 2200.0, + 'reconciled': False, + }, + ]) + + def test_register_payment_single_batch_grouped_writeoff_higher_amount(self): + ''' Pay 3100.0 with 'reconcile' as payment difference handling on two customer invoices (1000 + 2000). ''' + active_ids = (self.out_invoice_1 + self.out_invoice_2).ids + payments = self.env['account.payment.register'].with_context(active_model='account.move', active_ids=active_ids).create({ + 'amount': 3100.0, + 'group_payment': True, + 'payment_difference_handling': 'reconcile', + 'writeoff_account_id': self.company_data['default_account_revenue'].id, + 'writeoff_label': 'writeoff', + 'currency_id': self.currency_data['currency'].id, + 'payment_method_id': self.custom_payment_method_in.id, + })._create_payments() + + self.assertRecordValues(payments, [{ + 'payment_method_id': self.custom_payment_method_in.id, + }]) + self.assertRecordValues(payments.line_ids.sorted('balance'), [ + # Receivable line: + { + 'debit': 0.0, + 'credit': 1500.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -3000.0, + 'reconciled': True, + }, + # Writeoff line: + { + 'debit': 0.0, + 'credit': 50.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -100.0, + 'reconciled': False, + }, + # Liquidity line: + { + 'debit': 1550.0, + 'credit': 0.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 3100.0, + 'reconciled': False, + }, + ]) + + def test_register_payment_single_batch_not_grouped(self): + ''' Choose to pay two customer invoices with separated payments (1000 + 2000). ''' + active_ids = (self.out_invoice_1 + self.out_invoice_2).ids + payments = self.env['account.payment.register'].with_context(active_model='account.move', active_ids=active_ids).create({ + 'group_payment': False, + })._create_payments() + + self.assertRecordValues(payments, [ + {'payment_method_id': self.manual_payment_method_in.id}, + {'payment_method_id': self.manual_payment_method_in.id}, + ]) + self.assertRecordValues(payments[0].line_ids.sorted('balance') + payments[1].line_ids.sorted('balance'), [ + # == Payment 1: to pay out_invoice_1 == + # Receivable line: + { + 'debit': 0.0, + 'credit': 500.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -1000.0, + 'reconciled': True, + }, + # Liquidity line: + { + 'debit': 500.0, + 'credit': 0.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 1000.0, + 'reconciled': False, + }, + # == Payment 2: to pay out_invoice_2 == + # Receivable line: + { + 'debit': 0.0, + 'credit': 1000.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -2000.0, + 'reconciled': True, + }, + # Liquidity line: + { + 'debit': 1000.0, + 'credit': 0.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 2000.0, + 'reconciled': False, + }, + ]) + + def test_register_payment_multi_batches_grouped(self): + ''' Choose to pay multiple batches, one with two customer invoices (1000 + 2000) + and one with a vendor bill of 600, by grouping payments. + ''' + active_ids = (self.in_invoice_1 + self.in_invoice_2 + self.in_invoice_3).ids + payments = self.env['account.payment.register'].with_context(active_model='account.move', active_ids=active_ids).create({ + 'group_payment': True, + })._create_payments() + + self.assertRecordValues(payments, [ + {'payment_method_id': self.manual_payment_method_out.id}, + {'payment_method_id': self.manual_payment_method_out.id}, + ]) + self.assertRecordValues(payments[0].line_ids.sorted('balance') + payments[1].line_ids.sorted('balance'), [ + # == Payment 1: to pay in_invoice_1 & in_invoice_2 == + # Liquidity line: + { + 'debit': 0.0, + 'credit': 3000.0, + 'currency_id': False, + 'amount_currency': 0.0, + 'reconciled': False, + }, + # Payable line: + { + 'debit': 3000.0, + 'credit': 0.0, + 'currency_id': False, + 'amount_currency': 0.0, + 'reconciled': True, + }, + # == Payment 2: to pay in_invoice_3 == + # Liquidity line: + { + 'debit': 0.0, + 'credit': 1500.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -3000.0, + 'reconciled': False, + }, + # Payable line: + { + 'debit': 1500.0, + 'credit': 0.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 3000.0, + 'reconciled': True, + }, + ]) + + def test_register_payment_multi_batches_not_grouped(self): + ''' Choose to pay multiple batches, one with two customer invoices (1000 + 2000) + and one with a vendor bill of 600, by splitting payments. + ''' + active_ids = (self.in_invoice_1 + self.in_invoice_2 + self.in_invoice_3).ids + payments = self.env['account.payment.register'].with_context(active_model='account.move', active_ids=active_ids).create({ + 'group_payment': False, + })._create_payments() + + self.assertRecordValues(payments, [ + {'payment_method_id': self.manual_payment_method_out.id}, + {'payment_method_id': self.manual_payment_method_out.id}, + {'payment_method_id': self.manual_payment_method_out.id}, + ]) + self.assertRecordValues(payments[0].line_ids.sorted('balance') + payments[1].line_ids.sorted('balance') + payments[2].line_ids.sorted('balance'), [ + # == Payment 1: to pay in_invoice_1 == + # Liquidity line: + { + 'debit': 0.0, + 'credit': 1000.0, + 'currency_id': False, + 'amount_currency': 0.0, + 'reconciled': False, + }, + # Payable line: + { + 'debit': 1000.0, + 'credit': 0.0, + 'currency_id': False, + 'amount_currency': 0.0, + 'reconciled': True, + }, + # == Payment 2: to pay in_invoice_2 == + # Liquidity line: + { + 'debit': 0.0, + 'credit': 2000.0, + 'currency_id': False, + 'amount_currency': 0.0, + 'reconciled': False, + }, + # Payable line: + { + 'debit': 2000.0, + 'credit': 0.0, + 'currency_id': False, + 'amount_currency': 0.0, + 'reconciled': True, + }, + # == Payment 3: to pay in_invoice_3 == + # Liquidity line: + { + 'debit': 0.0, + 'credit': 1500.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -3000.0, + 'reconciled': False, + }, + # Payable line: + { + 'debit': 1500.0, + 'credit': 0.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 3000.0, + 'reconciled': True, + }, + ]) + + def test_register_payment_constraints(self): + # Test to register a payment for a draft journal entry. + self.out_invoice_1.button_draft() + with self.assertRaises(UserError), self.cr.savepoint(): + self.env['account.payment.register']\ + .with_context(active_model='account.move', active_ids=self.out_invoice_1.ids)\ + .create({}) + + # Test to register a payment for an already fully reconciled journal entry. + self.env['account.payment.register']\ + .with_context(active_model='account.move', active_ids=self.out_invoice_2.ids)\ + .create({})\ + ._create_payments() + with self.assertRaises(UserError), self.cr.savepoint(): + self.env['account.payment.register']\ + .with_context(active_model='account.move', active_ids=self.out_invoice_2.ids)\ + .create({}) diff --git a/addons/account/tests/test_bank_statement_reconciliation.py b/addons/account/tests/test_bank_statement_reconciliation.py deleted file mode 100644 index 9910ead5728..00000000000 --- a/addons/account/tests/test_bank_statement_reconciliation.py +++ /dev/null @@ -1,111 +0,0 @@ -from odoo.addons.account.tests.common import AccountTestCommon -from odoo.tests import tagged - - -@tagged('post_install', '-at_install') -class TestBankStatementReconciliation(AccountTestCommon): - - @classmethod - def setUpClass(cls): - super(TestBankStatementReconciliation, cls).setUpClass() - cls.bs_model = cls.env['account.bank.statement'] - cls.bsl_model = cls.env['account.bank.statement.line'] - cls.partner = cls.env['res.partner'].create({'name': 'test'}) - cls.currency_usd_id = cls.env.ref("base.USD").id - cls.currency_euro_id = cls.env.ref("base.EUR").id - - def test_full_reconcile(self): - self._reconcile_invoice_with_statement('pay_val') - - def test_post_at_bank_rec_full_reconcile(self): - """ Test the full reconciliation of a bank statement directly with an invoice. - """ - self._reconcile_invoice_with_statement('bank_rec') - - def _reconcile_invoice_with_statement(self, post_at): - """ Tests the reconciliation of an invoice with a bank statement, using - the provided 'post at bank reconciliation' value for the bank journal - where to generate the statement. - """ - self.bs_model.with_context(journal_type='bank')._default_journal().post_at_bank_reconciliation = post_at == 'bank_rec' - rcv_mv_line = self.create_invoice(100) - st_line = self.create_statement_line(100) - # reconcile - st_line.process_reconciliation(counterpart_aml_dicts=[{ - 'move_line': rcv_mv_line, - 'credit': 100, - 'debit': 0, - 'name': rcv_mv_line.name, - }]) - - # check everything went as expected - self.assertTrue(st_line.journal_entry_ids) - counterpart_mv_line = None - for l in st_line.journal_entry_ids: - if l.account_id.user_type_id.type == 'receivable': - counterpart_mv_line = l - break - self.assertIsNotNone(counterpart_mv_line) - self.assertTrue(rcv_mv_line.reconciled) - self.assertTrue(counterpart_mv_line.reconciled) - self.assertEqual(counterpart_mv_line.matched_credit_ids, rcv_mv_line.matched_debit_ids) - self.assertEqual(rcv_mv_line.move_id.payment_state, 'paid', "The related invoice's state should now be 'paid'") - - def test_reconcile_with_write_off(self): - pass - - def create_invoice(self, amount): - """ Return the move line that gets to be reconciled (the one in the receivable account) """ - move = self.env['account.move'].create({ - 'move_type': 'out_invoice', - 'partner_id': self.partner.id, - 'invoice_line_ids': [(0, 0, { - 'quantity': 1, - 'price_unit': amount, - 'name': 'test invoice', - })], - }) - move.post() - return move.line_ids.filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable')) - - def create_statement_line(self, st_line_amount): - journal = self.bs_model.with_context(journal_type='bank')._default_journal() - #journal = self.env.ref('l10n_be.bank_journal') - bank_stmt = self.bs_model.create({'journal_id': journal.id}) - - bank_stmt_line = self.bsl_model.create({ - 'name': '_', - 'statement_id': bank_stmt.id, - 'partner_id': self.partner.id, - 'amount': st_line_amount, - }) - - return bank_stmt_line - - def test_confirm_statement_usd(self): - company = self.env.ref('base.main_company') - self.cr.execute("UPDATE res_company SET currency_id = %s WHERE id = %s", [self.currency_euro_id, company.id]) - self.env['res.currency.rate'].search([]).unlink() - self.env['res.currency.rate'].create({ - 'currency_id': self.currency_usd_id, - 'rate': 2.0, - 'name': '2001-01-01', - }) - bank_journal_usd = self.env['account.journal'].create({ - 'name': 'Bank US', - 'type': 'bank', - 'code': 'BNK68', - 'currency_id': self.currency_usd_id, - }) - statement = self.bs_model.create({ - 'journal_id': bank_journal_usd.id, - 'balance_end_real': 100, - 'line_ids': [(0, 0, { - 'name': '_', - 'partner_id': self.partner.id, - 'amount': 100, - 'account_id': bank_journal_usd.default_debit_account_id.id, - })], - }) - statement.button_open() - statement.button_confirm_bank() diff --git a/addons/account/tests/test_payment.py b/addons/account/tests/test_payment.py deleted file mode 100644 index 18e874cb9ea..00000000000 --- a/addons/account/tests/test_payment.py +++ /dev/null @@ -1,546 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo.addons.account.tests.common import AccountTestCommon -from odoo.tests import tagged -from odoo.tests.common import Form -import time - - -@tagged('post_install', '-at_install') -class TestPayment(AccountTestCommon): - - def setUp(self): - self.env.ref('base.main_company').currency_id = self.env.ref('base.USD') - super(TestPayment, self).setUp() - self.register_payments_model = self.env['account.payment.register'].with_context(active_model='account.move') - self.payment_model = self.env['account.payment'] - self.acc_bank_stmt_model = self.env['account.bank.statement'] - self.acc_bank_stmt_line_model = self.env['account.bank.statement.line'] - - self.partner_agrolait = self.env['res.partner'].create({'name': 'Agrolait', 'is_company': True}) - self.partner_china_exp = self.env['res.partner'].create({'name': 'China Export', 'is_company': True}) - self.currency_chf_id = self.env.ref("base.CHF").id - self.currency_usd_id = self.env.ref("base.USD").id - self.currency_eur_id = self.env.ref("base.EUR").id - - company = self.env.ref('base.main_company') - self.cr.execute("UPDATE res_company SET currency_id = %s WHERE id = %s", [self.currency_eur_id, company.id]) - - self.product = self.env['product.product'].create({ - 'name': 'Product Product 4', - 'standard_price': 500.0, - 'list_price': 750.0, - 'type': 'consu', - 'categ_id': self.env.ref('product.product_category_all').id, - }) - self.payment_method_manual_in = self.env.ref("account.account_payment_method_manual_in") - self.payment_method_manual_out = self.env.ref("account.account_payment_method_manual_out") - - self.account_receivable = self.env['account.account'].search([('user_type_id', '=', self.env.ref('account.data_account_type_receivable').id)], limit=1) - self.account_payable = self.env['account.account'].search([('user_type_id', '=', self.env.ref('account.data_account_type_payable').id)], limit=1) - self.account_revenue = self.env['account.account'].search([('user_type_id', '=', self.env.ref('account.data_account_type_revenue').id)], limit=1) - - self.bank_journal_euro = self.env['account.journal'].create({'name': 'Bank', 'type': 'bank', 'code': 'BNK67'}) - self.account_eur = self.bank_journal_euro.default_debit_account_id - - self.cash_journal_euro = self.env['account.journal'].create({'name': 'Cash', 'type': 'cash', 'code': 'CASH'}) - - self.bank_journal_usd = self.env['account.journal'].create({'name': 'Bank US', 'type': 'bank', 'code': 'BNK68', 'currency_id': self.currency_usd_id}) - self.account_usd = self.bank_journal_usd.default_debit_account_id - - if not self.env.user.company_id.transfer_account_id: - self.env.user.company_id.transfer_account_id = self.usd_bnk - self.transfer_account = self.env.user.company_id.transfer_account_id - self.diff_income_account = self.env.user.company_id.income_currency_exchange_account_id - self.diff_expense_account = self.env.user.company_id.expense_currency_exchange_account_id - - self.form_payment = Form(self.env['account.payment']) - - self.env['res.currency.rate'].create([ - { - 'currency_id': self.env.ref('base.EUR').id, - 'name': '2010-01-02', - 'rate': 1.0, - }, { - 'currency_id': self.env.ref('base.USD').id, - 'name': '2010-01-02', - 'rate': 1.2834, - }, { - 'currency_id': self.env.ref('base.USD').id, - 'name': time.strftime('%Y-06-05'), - 'rate': 1.5289, - } - ]) - - def create_invoice(self, amount=100, type='out_invoice', currency_id=None, partner=None, account_id=None): - """ Returns an open invoice """ - invoice = self.env['account.move'].create({ - 'move_type': type, - 'partner_id': partner or self.partner_agrolait.id, - 'currency_id': currency_id or self.currency_eur_id, - 'invoice_date': time.strftime('%Y') + '-06-26', - 'date': time.strftime('%Y') + '-06-26', - 'invoice_line_ids': [ - (0, 0, {'product_id': self.product.id, 'quantity': 1, 'price_unit': amount}) - ], - }) - invoice.post() - return invoice - - def reconcile(self, liquidity_aml, amount=0.0, amount_currency=0.0, currency_id=None): - """ Reconcile a journal entry corresponding to a payment with its bank statement line """ - bank_stmt = self.acc_bank_stmt_model.create({ - 'journal_id': liquidity_aml.journal_id.id, - 'date': time.strftime('%Y') + '-07-15', - }) - bank_stmt_line = self.acc_bank_stmt_line_model.create({ - 'name': 'payment', - 'statement_id': bank_stmt.id, - 'partner_id': self.partner_agrolait.id, - 'amount': amount, - 'amount_currency': amount_currency, - 'currency_id': currency_id, - 'date': time.strftime('%Y') + '-07-15' - }) - - bank_stmt_line.process_reconciliation(payment_aml_rec=liquidity_aml) - return bank_stmt - - def test_full_payment_process(self): - """ Create a payment for one invoice, post it and reconcile it with a bank statement """ - inv_1 = self.create_invoice(amount=300, currency_id=self.currency_eur_id, partner=self.partner_agrolait.id) - - ids = [inv_1.id] - register_payments = self.register_payments_model.with_context(active_ids=ids).create({ - 'payment_date': time.strftime('%Y') + '-07-15', - 'journal_id': self.bank_journal_euro.id, - 'payment_method_id': self.payment_method_manual_in.id, - }) - payment = self.payment_model.browse(register_payments.create_payments()['res_id']) - - self.assertAlmostEqual(payment.amount, 300) - self.assertEqual(payment.state, 'posted') - self.assertEqual(payment.state, 'posted') - self.assertEqual(inv_1.payment_state, 'paid') - - rec_line = payment.move_line_ids.filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable')) - self.assertRecordValues(payment.move_line_ids.sorted('credit'), [ - {'account_id': self.account_eur.id, 'debit': 300.0, 'credit': 0.0, 'amount_currency': 0, 'currency_id': False}, - {'account_id': rec_line.account_id.id, 'debit': 0.0, 'credit': 300.0, 'amount_currency': 0, 'currency_id': False}, - ]) - self.assertTrue(rec_line.full_reconcile_id.exists()) - - liquidity_aml = payment.move_line_ids - rec_line - bank_statement = self.reconcile(liquidity_aml, 300, 0, False) - - self.assertEqual(liquidity_aml.statement_id, bank_statement) - self.assertEqual(liquidity_aml.statement_line_id, bank_statement.line_ids[0]) - - self.assertEqual(payment.state, 'reconciled') - - def test_internal_transfer_journal_usd_journal_eur(self): - """ Create a transfer from a EUR journal to a USD journal """ - payment = self.payment_model.create({ - 'partner_id': self.company.partner_id.id, - 'payment_date': time.strftime('%Y') + '-07-15', - 'payment_type': 'outbound', - 'partner_type': 'customer', - 'amount': 50, - 'currency_id': self.currency_usd_id, - 'journal_id': self.bank_journal_usd.id, - 'payment_method_id': self.payment_method_manual_out.id, - }) - payment.post() - self.assertRecordValues(payment.move_line_ids, [ - {'account_id': self.transfer_account.id, 'debit': 32.70, 'credit': 0.0, 'amount_currency': 50, 'currency_id': self.currency_usd_id}, - {'account_id': self.account_usd.id, 'debit': 0.0, 'credit': 32.70, 'amount_currency': -50, 'currency_id': self.currency_usd_id}, - ]) - - def test_payment_chf_journal_usd(self): - self.env['res.currency.rate'].create({ - 'rate': 1.3086, - 'currency_id': self.currency_chf_id, - 'name': '2010-01-02', - }) - - payment = self.payment_model.create({ - 'payment_date': time.strftime('%Y') + '-07-15', - 'payment_type': 'outbound', - 'amount': 50, - 'currency_id': self.currency_chf_id, - 'journal_id': self.bank_journal_usd.id, - 'partner_type': 'supplier', - 'partner_id': self.partner_china_exp.id, - 'payment_method_id': self.payment_method_manual_out.id, - }) - payment.post() - - self.assertRecordValues(payment.move_line_ids, [ - {'account_id': self.partner_china_exp.property_account_payable_id.id, 'debit': 38.21, 'credit': 0.0, 'amount_currency': 50, 'currency_id': self.currency_chf_id}, - {'account_id': self.account_usd.id, 'debit': 0.0, 'credit': 38.21, 'amount_currency': -58.42, 'currency_id': self.currency_usd_id}, - ]) - - def test_partial_payment(self): - """ Create test to pay invoices (cust. inv + vendor bill) with partial payment """ - # Test Customer Invoice - inv_1 = self.create_invoice(amount=600) - payment_register = Form(self.env['account.payment'].with_context(active_model='account.move', active_ids=inv_1.ids)) - payment_register.payment_date = time.strftime('%Y') + '-07-15' - payment_register.journal_id = self.bank_journal_euro - payment_register.payment_method_id = self.payment_method_manual_in - - # Perform the partial payment by setting the amount at 550 instead of 600 - payment_register.amount = 550 - - payment = payment_register.save() - - self.assertEqual(len(payment), 1) - self.assertEqual(payment.invoice_ids[0].id, inv_1.id) - self.assertAlmostEqual(payment.amount, 550) - self.assertEqual(payment.payment_type, 'inbound') - self.assertEqual(payment.partner_id, self.partner_agrolait) - self.assertEqual(payment.partner_type, 'customer') - - # Test Vendor Bill - inv_2 = self.create_invoice(amount=500, type='in_invoice', partner=self.partner_china_exp.id) - payment_register = Form(self.env['account.payment'].with_context(active_model='account.move', active_ids=inv_2.ids)) - payment_register.payment_date = time.strftime('%Y') + '-07-15' - payment_register.journal_id = self.bank_journal_euro - payment_register.payment_method_id = self.payment_method_manual_in - - # Perform the partial payment by setting the amount at 300 instead of 500 - payment_register.amount = 300 - - payment = payment_register.save() - - self.assertEqual(len(payment), 1) - self.assertEqual(payment.invoice_ids[0].id, inv_2.id) - self.assertAlmostEqual(payment.amount, 300) - self.assertEqual(payment.payment_type, 'outbound') - self.assertEqual(payment.partner_id, self.partner_china_exp) - self.assertEqual(payment.partner_type, 'supplier') - - def test_payment_and_writeoff_in_other_currency_1(self): - # Use case: - # Company is in EUR, create a customer invoice for 25 EUR and register payment of 25 USD. - # Mark invoice as fully paid with a write_off - # Check that all the aml are correctly created. - invoice = self.create_invoice(amount=25, type='out_invoice', currency_id=self.currency_eur_id, partner=self.partner_agrolait.id) - receivable_line = invoice.line_ids.filtered(lambda l: l.account_id.user_type_id.type == 'receivable') - # register payment on invoice - payment = self.payment_model.create({'payment_type': 'inbound', - 'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id, - 'partner_type': 'customer', - 'partner_id': self.partner_agrolait.id, - 'amount': 25, - 'currency_id': self.currency_usd_id, - 'payment_date': time.strftime('%Y') + '-07-15', - 'payment_difference_handling': 'reconcile', - 'writeoff_account_id': self.account_payable.id, - 'journal_id': self.bank_journal_euro.id, - 'invoice_ids': [(4, invoice.id, None)] - }) - payment.post() - self.assertRecordValues(payment.move_line_ids, [ - {'account_id': receivable_line.account_id.id, 'debit': 0.0, 'credit': 25.0, 'amount_currency': -38.22, 'currency_id': self.currency_usd_id}, - {'account_id': self.account_eur.id, 'debit': 16.35, 'credit': 0.0, 'amount_currency': 25.0, 'currency_id': self.currency_usd_id}, - {'account_id': self.account_payable.id, 'debit': 8.65, 'credit': 0.0, 'amount_currency': 13.22, 'currency_id': self.currency_usd_id}, - ]) - self.assertTrue(receivable_line.full_reconcile_id) - self.assertEqual(invoice.payment_state, 'paid') - - # Use case: - # Company is in EUR, create a vendor bill for 25 EUR and register payment of 25 USD. - # Mark invoice as fully paid with a write_off - # Check that all the aml are correctly created. - invoice = self.create_invoice(amount=25, type='in_invoice', currency_id=self.currency_eur_id, partner=self.partner_agrolait.id) - payable_line = invoice.line_ids.filtered(lambda l: l.account_id.user_type_id.type == 'payable') - # register payment on invoice - payment = self.payment_model.create({'payment_type': 'outbound', - 'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id, - 'partner_type': 'supplier', - 'partner_id': self.partner_agrolait.id, - 'amount': 25, - 'currency_id': self.currency_usd_id, - 'payment_date': time.strftime('%Y') + '-07-15', - 'payment_difference_handling': 'reconcile', - 'writeoff_account_id': self.account_receivable.id, - 'journal_id': self.bank_journal_euro.id, - 'invoice_ids': [(4, invoice.id, None)] - }) - payment.post() - self.assertRecordValues(payment.move_line_ids, [ - {'account_id': payable_line.account_id.id, 'debit': 25.0, 'credit': 0.0, 'amount_currency': 38.22, 'currency_id': self.currency_usd_id}, - {'account_id': self.account_eur.id, 'debit': 0.0, 'credit': 16.35, 'amount_currency': -25.0, 'currency_id': self.currency_usd_id}, - {'account_id': self.account_receivable.id, 'debit': 0.0, 'credit': 8.65, 'amount_currency': -13.22, 'currency_id': self.currency_usd_id}, - ]) - self.assertTrue(payable_line.full_reconcile_id) - self.assertEqual(invoice.payment_state, 'paid') - - def test_payment_and_writeoff_out_refund(self): - # Use case: - # Company is in EUR, create a credit note for 100 EUR and register payment of 90. - # Mark invoice as fully paid with a write_off - # Check that all the aml are correctly created. - invoice = self.create_invoice(amount=100, type='out_refund', currency_id=self.currency_eur_id, partner=self.partner_agrolait.id) - receivable_line = invoice.line_ids.filtered(lambda l: l.account_id.user_type_id.type == 'receivable') - # register payment on invoice - payment = self.payment_model.create({'payment_type': 'outbound', - 'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id, - 'partner_type': 'customer', - 'partner_id': self.partner_agrolait.id, - 'amount': 90, - 'payment_date': time.strftime('%Y') + '-07-15', - 'payment_difference_handling': 'reconcile', - 'writeoff_account_id': self.account_payable.id, - 'journal_id': self.bank_journal_euro.id, - 'invoice_ids': [(4, invoice.id, None)] - }) - payment.post() - self.assertRecordValues(payment.move_line_ids, [ - {'account_id': receivable_line.account_id.id, 'debit': 100.0, 'credit': 0.0, 'amount_currency': 0.0, 'currency_id': False}, - {'account_id': self.account_eur.id, 'debit': 0.0, 'credit': 90.0, 'amount_currency': 0.0, 'currency_id': False}, - {'account_id': self.account_payable.id, 'debit': 0.0, 'credit': 10.0, 'amount_currency': 0.0, 'currency_id': False}, - ]) - self.assertEqual(invoice.payment_state, 'paid') - - def test_payment_and_writeoff_in_other_currency_2(self): - # Use case: - # Company is in EUR, create a supplier bill of 5325.6 USD and register payment of 5325 USD, at a different rate - # Mark invoice as fully paid with a write_off - # Check that all the aml are correctly created. - - # Set exchange rates 0.895@2017-11-01 and 0.88@2017-12-01 - self.env['res.currency.rate'].create({ - 'currency_id': self.currency_usd_id, - 'rate': 0.895, - 'name': time.strftime('%Y') + '-06-26'}) - self.env['res.currency.rate'].create({ - 'currency_id': self.currency_usd_id, - 'rate': 0.88, - 'name': time.strftime('%Y') + '-07-15'}) - - invoice = self.create_invoice(amount=5325.6, type='in_invoice', currency_id=self.currency_usd_id, partner=self.partner_agrolait.id) - payable_line = invoice.line_ids.filtered(lambda l: l.account_id.user_type_id.type == 'payable') - - # register payment on invoice - payment = self.payment_model.create({'payment_type': 'outbound', - 'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id, - 'partner_type': 'supplier', - 'partner_id': self.partner_agrolait.id, - 'amount': 5325, - 'currency_id': self.currency_usd_id, - 'payment_date': time.strftime('%Y') + '-07-15', - 'payment_difference_handling': 'reconcile', - 'writeoff_account_id': self.account_revenue.id, - 'journal_id': self.bank_journal_euro.id, - 'invoice_ids': [(4, invoice.id, None)] - }) - payment.post() - self.assertRecordValues(payment.move_line_ids, [ - {'debit': 6051.82, 'credit': 0.0, 'amount_currency': 5325.6, 'currency_id': self.currency_usd_id}, - {'debit': 0.0, 'credit': 6051.14, 'amount_currency': -5325.0, 'currency_id': self.currency_usd_id}, - {'debit': 0.0, 'credit': 0.68, 'amount_currency': -0.6, 'currency_id': self.currency_usd_id}, - ]) - exchange_lines = payable_line.full_reconcile_id.exchange_move_id.line_ids - self.assertRecordValues(exchange_lines, [ - {'debit': 0.0, 'credit': 101.43, 'account_id': payable_line.account_id.id}, - {'debit': 101.43, 'credit': 0.0, 'account_id': self.diff_expense_account.id}, - ]) - - #check the invoice status - self.assertEqual(invoice.payment_state, 'paid') - - def test_payment_and_writeoff_in_other_currency_3(self): - # Use case related in revision 20935462a0cabeb45480ce70114ff2f4e91eaf79 - # Invoice made in secondary currency for which the rate to the company currency - # is higher than the foreign currency decimal precision. - # E.g: Company currency is EUR, create a customer invoice of 247590.40 EUR and - # register payment of 267 USD (1 USD = 948 EUR) - # Mark invoice as fully paid with a write_off - # Check that all the aml are correctly created and that the invoice is paid - - self.env['res.currency.rate'].create({ - 'currency_id': self.currency_usd_id, - 'rate': 1, - 'name': time.strftime('%Y') + '-06-26'}) - self.env['res.currency.rate'].create({ - 'currency_id': self.currency_eur_id, - 'rate': 948, - 'name': time.strftime('%Y') + '-06-26'}) - - invoice = self.create_invoice(amount=247590.4, type='out_invoice', currency_id=self.currency_eur_id, partner=self.partner_agrolait.id) - receivable_line = invoice.line_ids.filtered(lambda l: l.account_id.user_type_id.type == 'receivable') - - # register payment on invoice - payment = self.payment_model.create({'payment_type': 'inbound', - 'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id, - 'partner_type': 'customer', - 'partner_id': self.partner_agrolait.id, - 'amount': 267, - 'currency_id': self.currency_usd_id, - 'payment_date': time.strftime('%Y') + '-06-26', - 'payment_difference_handling': 'reconcile', - 'writeoff_account_id': self.account_revenue.id, - 'journal_id': self.bank_journal_euro.id, - 'invoice_ids': [(4, invoice.id, None)], - 'name': 'test_payment_and_writeoff_in_other_currency_3', - }) - payment.post() - self.assertRecordValues(payment.move_line_ids, [ - {'account_id': receivable_line.account_id.id, 'debit': 0.0, 'credit': 247589.16, 'amount_currency': -261.17, 'currency_id': self.currency_usd_id}, - {'account_id': self.account_eur.id, 'debit': 253116.0, 'credit': 0.0, 'amount_currency': 267.0, 'currency_id': self.currency_usd_id}, - {'account_id': self.account_revenue.id, 'debit': 0.0, 'credit': 5526.84, 'amount_currency': -5.83, 'currency_id': self.currency_usd_id}, - ]) - - # Check the invoice status and the full reconciliation: the difference on the receivable account - # should have been completed by an exchange rate difference entry - self.assertEqual(invoice.payment_state, 'paid') - self.assertTrue(receivable_line.full_reconcile_id) - - def test_post_at_bank_reconciliation_payment(self): - # Create two new payments in a journal requiring the journal entries to be posted at bank reconciliation - post_at_bank_rec_journal = self.env['account.journal'].create({ - 'name': 'Bank', - 'type': 'bank', - 'code': 'COUCOU', - 'post_at': 'bank_rec', - }) - payment_one = self.payment_model.create({'payment_type': 'inbound', - 'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id, - 'partner_type': 'customer', - 'partner_id': self.partner_agrolait.id, - 'amount': 42, - 'payment_date': time.strftime('%Y') + '-07-15', - 'payment_difference_handling': 'reconcile', - 'writeoff_account_id': self.account_receivable.id, - 'journal_id': post_at_bank_rec_journal.id, - }) - payment_one.post() - payment_two = self.payment_model.create({'payment_type': 'inbound', - 'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id, - 'partner_type': 'customer', - 'partner_id': self.partner_agrolait.id, - 'amount': 11, - 'payment_date': time.strftime('%Y') + '-12-15', - 'payment_difference_handling': 'reconcile', - 'writeoff_account_id': self.account_receivable.id, - 'journal_id': post_at_bank_rec_journal.id, - }) - payment_two.post() - - # Check the payments and their move state - self.assertEqual(payment_one.state, 'posted', "Payment one shoud be in posted state.") - self.assertEqual(payment_one.mapped('move_line_ids.move_id.state'), ['draft'], "A posted payment (payment_one) in a bank journal with the 'post at bank reconciliation' option activated should correspond to a draft account.move") - self.assertEqual(payment_two.state, 'posted', "Payment two shoud be in posted state.") - self.assertEqual(payment_two.mapped('move_line_ids.move_id.state'), ['draft'], "A posted payment (payment_two) in a bank journal with the 'post at bank reconciliation' option activated should correspond to a draft account.move") - - # Reconcile the two payments with an invoice, whose full amount is equal to their sum - invoice = self.create_invoice(amount=53, partner=self.partner_agrolait.id) - (payment_one.move_line_ids + payment_two.move_line_ids + invoice.line_ids).filtered(lambda x: x.account_id.user_type_id.type == 'receivable').reconcile() - - self.assertEqual(invoice.payment_state, 'in_payment', "Invoice should be in 'in payment' state") - - # Match the first payment with a bank statement line - bank_statement_one = self.reconcile(payment_one.move_line_ids.filtered(lambda x: x.account_id.user_type_id.type == 'liquidity'), 42) - stmt_line_date_one = bank_statement_one.mapped('line_ids.date') - - self.assertEqual(payment_one.mapped('move_line_ids.move_id.state'), ['posted'], "After bank reconciliation, payment one's account.move should be posted.") - self.assertEqual(payment_one.mapped('move_line_ids.move_id.date'), stmt_line_date_one, "After bank reconciliation, payment one's account.move should share the same date as the bank statement.") - self.assertEqual([payment_one.payment_date], stmt_line_date_one, "After bank reconciliation, payment one should share the same date as the bank statement.") - self.assertEqual(invoice.payment_state, 'in_payment', "The invoice should still be 'in payment', not all its payments are reconciled with a statement") - - # Match the second payment with a bank statement line - bank_statement_two = self.reconcile(payment_two.move_line_ids.filtered(lambda x: x.account_id.user_type_id.type == 'liquidity'), 42) - stmt_line_date_two = bank_statement_two.mapped('line_ids.date') - - self.assertEqual(payment_two.mapped('move_line_ids.move_id.state'), ['posted'], "After bank reconciliation, payment two's account.move should be posted.") - self.assertEqual(payment_two.mapped('move_line_ids.move_id.date'), stmt_line_date_two, "After bank reconciliation, payment two's account.move should share the same date as the bank statement.") - self.assertEqual([payment_two.payment_date], stmt_line_date_two, "After bank reconciliation, payment two should share the same date as the bank statement.") - - # The invoice should now be paid - self.assertEqual(invoice.payment_state, 'paid', "Invoice should be in 'paid' state after having reconciled the two payments with a bank statement") - - def test_payment_draft_keep_name(self): - payment = self.payment_model.create({ - 'payment_type': 'inbound', - 'payment_method_id': self.payment_method_manual_in.id, - 'partner_type': 'customer', - 'partner_id': self.partner_agrolait.id, - 'amount': 90, - 'payment_date': time.strftime('%Y') + '-07-15', - 'payment_difference_handling': 'reconcile', - 'journal_id': self.bank_journal_euro.id, - }) - - payment.post() - self.assertEqual(len(payment.move_line_ids.mapped('move_id')), 1) - name = payment.move_line_ids.mapped('move_id').name - self.assertTrue(name) - - payment.action_draft() - self.assertFalse(payment.move_line_ids.mapped('move_id')) - - payment.post() - self.assertEqual(len(payment.move_line_ids.mapped('move_id')), 1) - self.assertEqual(name, payment.move_line_ids.mapped('move_id').name) - - def test_partial_payment_inv_foreign_payment_domestic(self): - """ - Invoice of 1000$ (foreign $) at 01/01 with foreign exchange rate of 0.50000 - Payment of 500 (domestic €) at 15/01 with foreign exchange rate of 1.00000 - The residuals should be 500 in foreign and 1500 in domestic. - """ - company = self.env.ref('base.main_company') - self.env['res.currency.rate'].search([]).unlink() - self.env['res.currency.rate'].create({ - 'name': time.strftime('%Y') + '-01-01', - 'rate': 1.0, - 'currency_id': self.currency_eur_id, - 'company_id': company.id - }) - self.env['res.currency.rate'].create({ - 'name': time.strftime('%Y') + '-01-01', - 'rate': 0.5, # Don't change this ! - 'currency_id': self.currency_usd_id, - 'company_id': self.env.ref('base.main_company').id - }) - self.env['res.currency.rate'].create({ - 'name': time.strftime('%Y') + '-01-15', - 'rate': 1.0, # Don't change this ! - 'currency_id': self.currency_usd_id, - 'company_id': self.env.ref('base.main_company').id - }) - inv1 = self.env['account.move'].create({ - 'move_type': 'out_invoice', - 'partner_id': self.partner_agrolait.id, - 'currency_id': self.currency_usd_id, - 'invoice_date': time.strftime('%Y') + '-01-01', - 'date': time.strftime('%Y') + '-01-01', - 'invoice_line_ids': [ - (0, 0, {'product_id': self.product.id, 'quantity': 1, 'price_unit': 1000}) - ], - }) - inv1.post() - payment = self.payment_model.create({ - 'payment_date': time.strftime('%Y') + '-01-15', - 'payment_method_id': self.payment_method_manual_in.id, - 'payment_type': 'inbound', - 'partner_type': 'customer', - 'partner_id': inv1.partner_id.id, - 'amount': 500, - 'journal_id': self.bank_journal_euro.id, - 'company_id': company.id, - 'currency_id': self.currency_eur_id, - }) - payment.post() - inv1_receivable = inv1.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') - pay_receivable = payment.move_line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') - - self.assertEqual(inv1_receivable.balance, 2000) - self.assertEqual(pay_receivable.balance, -500) - - (inv1_receivable + pay_receivable).reconcile() - self.assertEqual(inv1.amount_residual, 500) - self.assertEqual(inv1.amount_residual_signed, 1500) diff --git a/addons/account/tests/test_reconciliation.py b/addons/account/tests/test_reconciliation.py index b85b1162ac5..6658704f23a 100644 --- a/addons/account/tests/test_reconciliation.py +++ b/addons/account/tests/test_reconciliation.py @@ -7,128 +7,12 @@ from datetime import timedelta from odoo import api, fields from odoo.addons.account.tests.common import TestAccountReconciliationCommon -from odoo.exceptions import UserError from odoo.tests import Form, tagged @tagged('post_install', '-at_install') class TestReconciliationExec(TestAccountReconciliationCommon): - def test_statement_usd_invoice_eur_transaction_eur(self): - customer_move_lines, supplier_move_lines = self.make_customer_and_supplier_flows(self.currency_euro_id, 30, self.bank_journal_usd, 42, 30, self.currency_euro_id) - self.assertRecordValues(customer_move_lines, [ - {'debit': 0.0, 'credit': 30.0, 'amount_currency': -42, 'currency_id': self.currency_usd_id}, - {'debit': 30.0, 'credit': 0.0, 'amount_currency': 42, 'currency_id': self.currency_usd_id}, - ]) - self.assertRecordValues(supplier_move_lines, [ - {'debit': 30.0, 'credit': 0.0, 'amount_currency': 42, 'currency_id': self.currency_usd_id}, - {'debit': 0.0, 'credit': 30.0, 'amount_currency': -42, 'currency_id': self.currency_usd_id}, - ]) - - def test_statement_usd_invoice_usd_transaction_usd(self): - customer_move_lines, supplier_move_lines = self.make_customer_and_supplier_flows(self.currency_usd_id, 50, self.bank_journal_usd, 50, 0, False) - self.assertRecordValues(customer_move_lines, [ - {'debit': 0.0, 'credit': 32.70, 'amount_currency': -50, 'currency_id': self.currency_usd_id}, - {'debit': 32.70, 'credit': 0.0, 'amount_currency': 50, 'currency_id': self.currency_usd_id}, - ]) - self.assertRecordValues(supplier_move_lines, [ - {'debit': 32.70, 'credit': 0.0, 'amount_currency': 50, 'currency_id': self.currency_usd_id}, - {'debit': 0.0, 'credit': 32.70, 'amount_currency': -50, 'currency_id': self.currency_usd_id}, - ]) - - def test_statement_usd_invoice_usd_transaction_eur(self): - customer_move_lines, supplier_move_lines = self.make_customer_and_supplier_flows(self.currency_usd_id, 50, self.bank_journal_usd, 50, 40, self.currency_euro_id) - self.assertRecordValues(customer_move_lines, [ - {'debit': 0.0, 'credit': 40.0, 'amount_currency': -50, 'currency_id': self.currency_usd_id}, - {'debit': 40.0, 'credit': 0.0, 'amount_currency': 50, 'currency_id': self.currency_usd_id}, - ]) - exchange_lines = customer_move_lines.mapped('full_reconcile_id.exchange_move_id.line_ids') - self.assertRecordValues(exchange_lines, [ - {'debit': 7.30, 'credit': 0.0, 'account_id': self.account_rcv.id}, - {'debit': 0.0, 'credit': 7.30, 'account_id': self.diff_income_account.id}, - ]) - - self.assertRecordValues(supplier_move_lines, [ - {'debit': 40.0, 'credit': 0.0, 'amount_currency': 50, 'currency_id': self.currency_usd_id}, - {'debit': 0.0, 'credit': 40.0, 'amount_currency': -50, 'currency_id': self.currency_usd_id}, - ]) - exchange_lines = supplier_move_lines.mapped('full_reconcile_id.exchange_move_id.line_ids') - self.assertRecordValues(exchange_lines, [ - {'debit': 0.0, 'credit': 7.30, 'account_id': self.account_rsa.id}, - {'debit': 7.30, 'credit': 0.0, 'account_id': self.diff_expense_account.id}, - ]) - - def test_statement_usd_invoice_chf_transaction_chf(self): - self.env['res.currency.rate'].create({ - 'rate': 1.3086, - 'currency_id': self.env.ref('base.CHF').id, - 'name': '2010-01-02', - }) - customer_move_lines, supplier_move_lines = self.make_customer_and_supplier_flows(self.currency_swiss_id, 50, self.bank_journal_usd, 42, 50, self.currency_swiss_id) - self.assertRecordValues(customer_move_lines, [ - {'debit': 0.0, 'credit': 27.47, 'amount_currency': -50, 'currency_id': self.currency_swiss_id}, - {'debit': 27.47, 'credit': 0.0, 'amount_currency': 42, 'currency_id': self.currency_usd_id}, - ]) - exchange_lines = customer_move_lines.mapped('full_reconcile_id.exchange_move_id.line_ids') - self.assertRecordValues(exchange_lines, [ - {'debit': 0.0, 'credit': 10.74, 'account_id': self.account_rcv.id}, - {'debit': 10.74, 'credit': 0.0, 'account_id': self.diff_expense_account.id}, - ]) - - self.assertRecordValues(supplier_move_lines, [ - {'debit': 27.47, 'credit': 0.0, 'amount_currency': 50, 'currency_id': self.currency_swiss_id}, - {'debit': 0.0, 'credit': 27.47, 'amount_currency': -42, 'currency_id': self.currency_usd_id}, - ]) - exchange_lines = supplier_move_lines.mapped('full_reconcile_id.exchange_move_id.line_ids') - self.assertRecordValues(exchange_lines, [ - {'debit': 10.74, 'credit': 0.0, 'account_id': self.account_rsa.id}, - {'debit': 0.0, 'credit': 10.74, 'account_id': self.diff_income_account.id}, - ]) - - def test_statement_eur_invoice_usd_transaction_usd(self): - customer_move_lines, supplier_move_lines = self.make_customer_and_supplier_flows(self.currency_usd_id, 50, self.bank_journal_euro, 40, 50, self.currency_usd_id) - self.assertRecordValues(customer_move_lines, [ - {'debit': 0.0, 'credit': 40.0, 'amount_currency': -50, 'currency_id': self.currency_usd_id}, - {'debit': 40.0, 'credit': 0.0, 'amount_currency': 50, 'currency_id': self.currency_usd_id}, - ]) - exchange_lines = customer_move_lines.mapped('full_reconcile_id.exchange_move_id.line_ids') - self.assertRecordValues(exchange_lines, [ - {'debit': 7.30, 'credit': 0.0, 'account_id': self.account_rcv.id}, - {'debit': 0.0, 'credit': 7.30, 'account_id': self.diff_income_account.id}, - ]) - - self.assertRecordValues(supplier_move_lines, [ - {'debit': 40.0, 'credit': 0.0, 'amount_currency': 50, 'currency_id': self.currency_usd_id}, - {'debit': 0.0, 'credit': 40.0, 'amount_currency': -50, 'currency_id': self.currency_usd_id}, - ]) - exchange_lines = supplier_move_lines.mapped('full_reconcile_id.exchange_move_id.line_ids') - self.assertRecordValues(exchange_lines, [ - {'debit': 0.0, 'credit': 7.30, 'account_id': self.account_rsa.id}, - {'debit': 7.30, 'credit': 0.0, 'account_id': self.diff_expense_account.id}, - ]) - - def test_statement_eur_invoice_usd_transaction_eur(self): - customer_move_lines, supplier_move_lines = self.make_customer_and_supplier_flows(self.currency_usd_id, 50, self.bank_journal_euro, 40, 0.0, False) - self.assertRecordValues(customer_move_lines, [ - {'debit': 0.0, 'credit': 40.0, 'amount_currency': 0.0, 'currency_id': False}, - {'debit': 40.0, 'credit': 0.0, 'amount_currency': 0.0, 'currency_id': False}, - ]) - self.assertRecordValues(supplier_move_lines, [ - {'debit': 40.0, 'credit': 0.0, 'amount_currency': 0.0, 'currency_id': False}, - {'debit': 0.0, 'credit': 40.0, 'amount_currency': 0.0, 'currency_id': False}, - ]) - - def test_statement_euro_invoice_usd_transaction_chf(self): - customer_move_lines, supplier_move_lines = self.make_customer_and_supplier_flows(self.currency_usd_id, 50, self.bank_journal_euro, 42, 50, self.currency_swiss_id) - self.assertRecordValues(customer_move_lines, [ - {'debit': 0.0, 'credit': 42.0, 'amount_currency': -50, 'currency_id': self.currency_swiss_id}, - {'debit': 42.0, 'credit': 0.0, 'amount_currency': 50, 'currency_id': self.currency_swiss_id}, - ]) - self.assertRecordValues(supplier_move_lines, [ - {'debit': 42.0, 'credit': 0.0, 'amount_currency': 50, 'currency_id': self.currency_swiss_id}, - {'debit': 0.0, 'credit': 42.0, 'amount_currency': -50, 'currency_id': self.currency_swiss_id}, - ]) - def test_statement_euro_invoice_usd_transaction_euro_full(self): # Create a customer invoice of 50 USD. partner = self.env['res.partner'].create({'name': 'test'}) @@ -150,7 +34,7 @@ class TestReconciliationExec(TestAccountReconciliationCommon): 'date': '%s-01-01' % time.strftime('%Y'), 'line_ids': [ (0, 0, { - 'name': 'test', + 'payment_ref': 'test', 'partner_id': partner.id, 'amount': 40.0, 'date': '%s-01-01' % time.strftime('%Y') @@ -160,22 +44,16 @@ class TestReconciliationExec(TestAccountReconciliationCommon): # Reconcile the bank statement with the invoice. receivable_line = move.line_ids.filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable')) - bank_stmt.line_ids[0].process_reconciliation(counterpart_aml_dicts=[{ - 'move_line': receivable_line, - 'debit': 0.0, - 'credit': 32.7, - 'name': 'test_statement_euro_invoice_usd_transaction_euro_full', - }], new_aml_dicts=[{ - 'debit': 0.0, - 'credit': 7.3, - 'name': 'exchange difference', - 'account_id': self.diff_income_account.id - }]) + bank_stmt.button_post() + bank_stmt.line_ids[0].reconcile([ + {'id': receivable_line.id}, + {'name': 'exchange difference', 'balance': -7.3, 'account_id': self.diff_income_account.id}, + ]) - self.assertRecordValues(bank_stmt.move_line_ids, [ + self.assertRecordValues(bank_stmt.line_ids.line_ids, [ + {'debit': 40.0, 'credit': 0.0, 'amount_currency': 0.0, 'currency_id': False}, {'debit': 0.0, 'credit': 7.3, 'amount_currency': 0.0, 'currency_id': False}, {'debit': 0.0, 'credit': 32.7, 'amount_currency': 0.0, 'currency_id': False}, - {'debit': 40.0, 'credit': 0.0, 'amount_currency': 0.0, 'currency_id': False}, ]) # The invoice should be paid, as the payments totally cover its total @@ -244,9 +122,7 @@ class TestReconciliationExec(TestAccountReconciliationCommon): line_id = l break for statement_line in statement.line_ids: - statement_line.process_reconciliation([ - {'counterpart_move_line_id': line_id.id, 'credit': 1.0, 'debit': 0.0, 'name': line_id.name} - ]) + statement_line.reconcile([{'id': line_id.id}]) # The invoice should be paid, as the payments totally cover its total self.assertEqual(invoice.state, 'paid', 'The invoice should be paid by now') @@ -298,7 +174,9 @@ class TestReconciliationExec(TestAccountReconciliationCommon): 'journal_id': self.bank_journal_euro.id, 'line_ids': [(0,0, debit_line_vals), (0, 0, credit_line_vals)] } - return self.env['account.move'].create(vals).id + move = self.env['account.move'].create(vals) + move.post() + return move.id move_list_vals = [ ('1', -1.83, 0, self.currency_swiss_id), ('2', 728.35, 795.05, self.currency_swiss_id), @@ -380,6 +258,7 @@ class TestReconciliationExec(TestAccountReconciliationCommon): 'line_ids': [(0,0, debit_line_vals), (0, 0, credit_line_vals)] } move_ids += self.env['account.move'].create(vals) + move_ids.post() account_move_line = move_ids.mapped('line_ids').filtered(lambda l: l.account_id == self.account_rcv) writeoff_vals = [{ @@ -395,80 +274,6 @@ class TestReconciliationExec(TestAccountReconciliationCommon): self.assertTrue(all(l.reconciled for l in writeoff_line), 'The balance lines should be totally reconciled') self.assertTrue(all(l.reconciled for l in account_move_line), 'The move lines should be totally reconciled') - def test_reconcile_bank_statement_with_payment_and_writeoff(self): - # Use case: - # Company is in EUR, create a bill for 80 USD and register payment of 80 USD. - # create a bank statement in USD bank journal with a bank statement line of 85 USD - # Reconcile bank statement with payment and put the remaining 5 USD in bank fees or another account. - - invoice = self.create_invoice(type='out_invoice', invoice_amount=80, currency_id=self.currency_usd_id) - # register payment on invoice - 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_agrolait_id, - 'amount': 80, - 'currency_id': self.currency_usd_id, - 'payment_date': time.strftime('%Y') + '-07-15', - 'journal_id': self.bank_journal_usd.id, - 'invoice_ids': [(6, 0, invoice.ids)], - }) - payment.post() - bank_move_line = payment.move_line_ids.filtered(lambda line: line.account_id.internal_type == 'liquidity') - - # create bank statement - bank_stmt = self.acc_bank_stmt_model.create({ - 'journal_id': self.bank_journal_usd.id, - 'date': time.strftime('%Y') + '-07-15', - }) - - bank_stmt_line = self.acc_bank_stmt_line_model.create({'name': 'payment', - 'statement_id': bank_stmt.id, - 'partner_id': self.partner_agrolait_id, - 'amount': 85, - 'date': time.strftime('%Y') + '-07-15',}) - - #reconcile the statement with invoice and put remaining in another account - bank_stmt_line.process_reconciliation(payment_aml_rec= bank_move_line, new_aml_dicts=[{ - 'account_id': self.diff_income_account.id, - 'debit': 0, - 'credit': 5, - 'name': 'bank fees', - }]) - - # Check that move lines associated to bank_statement are correct - bank_stmt_aml = self.env['account.move.line'].search([('statement_id', '=', bank_stmt.id)]) - bank_stmt_aml |= bank_stmt_aml.mapped('move_id').mapped('line_ids') - self.assertEqual(len(bank_stmt_aml), 4, "The bank statement should have 4 moves lines") - lines = { - self.account_usd.id: [ - {'debit': 3.27, 'credit': 0.0, 'amount_currency': 5, 'currency_id': self.currency_usd_id}, - {'debit': 52.33, 'credit': 0, 'amount_currency': 80, 'currency_id': self.currency_usd_id} - ], - self.diff_income_account.id: {'debit': 0.0, 'credit': 3.27, 'amount_currency': -5, 'currency_id': self.currency_usd_id}, - self.account_rcv.id: {'debit': 0.0, 'credit': 52.33, 'amount_currency': -80, 'currency_id': self.currency_usd_id}, - } - - payments = bank_stmt_aml.mapped('payment_id') - # creation and reconciliation of the over-amount statement - # has created an another payment - self.assertEqual(len(payments), 2) - # Check amount of second, automatically created payment - self.assertEqual((payments - payment).amount, 5) - - for aml in bank_stmt_aml: - line = lines[aml.account_id.id] - if type(line) == list: - # find correct line inside the list - if line[0]['debit'] == round(aml.debit, 2): - line = line[0] - else: - line = line[1] - self.assertEqual(round(aml.debit, 2), line['debit']) - self.assertEqual(round(aml.credit, 2), line['credit']) - self.assertEqual(round(aml.amount_currency, 2), line['amount_currency']) - self.assertEqual(aml.currency_id.id, line['currency_id']) - def test_partial_reconcile_currencies_01(self): # client Account (payable, rsa) # Debit Credit @@ -482,9 +287,11 @@ class TestReconciliationExec(TestAccountReconciliationCommon): # Balance Debit = 18.75 # Counterpart Credit goes in Exchange diff - dest_journal_id = self.env['account.journal'].search([('type', '=', 'purchase'), ('company_id', '=', self.company.id)], limit=1) - dest_journal_id.write({'default_debit_account_id': self.bank_journal_euro.default_credit_account_id, - 'default_credit_account_id': self.bank_journal_euro.default_credit_account_id}) + dest_journal_id = self.env['account.journal'].create({ + 'name': 'dest_journal_id', + 'type': 'bank', + }) + # Setting up rates for USD (main_company is in EUR) self.env['res.currency.rate'].create({'name': time.strftime('%Y') + '-' + '07' + '-01', 'rate': 0.5, @@ -531,7 +338,7 @@ class TestReconciliationExec(TestAccountReconciliationCommon): 'currency_id': self.currency_usd_id, 'journal_id': self.bank_journal_euro.id, 'company_id': self.company.id, - 'payment_date': time.strftime('%Y') + '-' + '07' + '-01', + 'date': time.strftime('%Y') + '-' + '07' + '-01', 'partner_id': self.partner_agrolait_id, 'payment_method_id': self.env.ref('account.account_payment_method_manual_out').id, 'partner_type': 'supplier'}) @@ -542,7 +349,7 @@ class TestReconciliationExec(TestAccountReconciliationCommon): 'currency_id': self.currency_usd_id, 'journal_id': self.bank_journal_euro.id, 'company_id': self.company.id, - 'payment_date': time.strftime('%Y') + '-' + '08' + '-01', + 'date': time.strftime('%Y') + '-' + '08' + '-01', 'partner_id': self.partner_agrolait_id, 'payment_method_id': self.env.ref('account.account_payment_method_manual_out').id, 'partner_type': 'supplier'}) @@ -553,19 +360,19 @@ class TestReconciliationExec(TestAccountReconciliationCommon): 'currency_id': self.currency_usd_id, 'journal_id': self.bank_journal_euro.id, 'company_id': self.company.id, - 'payment_date': time.strftime('%Y') + '-' + '09' + '-01', + 'date': time.strftime('%Y') + '-' + '09' + '-01', 'partner_id': self.partner_agrolait_id, 'payment_method_id': self.env.ref('account.account_payment_method_manual_out').id, 'partner_type': 'supplier'}) - payment_a.post() - payment_b.post() - payment_c.post() + payment_a.action_post() + payment_b.action_post() + payment_c.action_post() # Assigning payments to invoices - debit_line_a = payment_a.move_line_ids.filtered(lambda l: l.debit and l.account_id == self.account_rsa) - debit_line_b = payment_b.move_line_ids.filtered(lambda l: l.debit and l.account_id == self.account_rsa) - debit_line_c = payment_c.move_line_ids.filtered(lambda l: l.debit and l.account_id == self.account_rsa) + debit_line_a = payment_a.line_ids.filtered(lambda l: l.debit and l.account_id == self.account_rsa) + debit_line_b = payment_b.line_ids.filtered(lambda l: l.debit and l.account_id == self.account_rsa) + debit_line_c = payment_c.line_ids.filtered(lambda l: l.debit and l.account_id == self.account_rsa) invoice_a.js_assign_outstanding_line(debit_line_a.id) invoice_a.js_assign_outstanding_line(debit_line_b.id) @@ -608,8 +415,8 @@ class TestReconciliationExec(TestAccountReconciliationCommon): 'currency_id': self.currency_usd_id, 'journal_id': self.bank_journal_usd.id, }) - payment.post() - credit_aml = payment.move_line_ids.filtered('credit') + payment.action_post() + credit_aml = payment.line_ids.filtered('credit') # Check residual before assignation self.assertAlmostEqual(inv1.amount_residual, 10) @@ -657,17 +464,17 @@ class TestReconciliationExec(TestAccountReconciliationCommon): 'amount': 111, 'currency_id': self.currency_usd_id, 'journal_id': self.bank_journal_usd.id, - 'payment_date': time.strftime('%Y') + '-08-01', + 'date': time.strftime('%Y') + '-08-01', }) - payment.post() - credit_aml = payment.move_line_ids.filtered('credit') + payment.action_post() + credit_aml = payment.line_ids.filtered('credit') # Check residual before assignation self.assertAlmostEqual(inv.amount_residual, 111) # Assign credit, check exchange move and residual inv.js_assign_outstanding_line(credit_aml.id) - self.assertEqual(len(payment.move_line_ids.mapped('full_reconcile_id').exchange_move_id), 1) + self.assertEqual(len(payment.line_ids.mapped('full_reconcile_id').exchange_move_id), 1) self.assertAlmostEqual(inv.amount_residual, 0) # Unreconcile invoice and check residual @@ -681,15 +488,15 @@ class TestReconciliationExec(TestAccountReconciliationCommon): 'partner_type': 'customer', 'partner_id': self.partner_agrolait_id, 'journal_id': self.bank_journal_usd.id, - 'payment_date': '2018-06-04', + 'date': '2018-06-04', 'amount': 666, }) - payment.post() + payment.action_post() - self.assertEqual(len(payment.move_line_ids), 2) + self.assertEqual(len(payment.line_ids), 2) - bank_line = payment.move_line_ids.filtered(lambda l: l.account_id.id == self.bank_journal_usd.default_debit_account_id.id) - customer_line = payment.move_line_ids - bank_line + bank_line = payment.line_ids.filtered(lambda l: l.account_id.id == self.bank_journal_usd.payment_debit_account_id.id) + customer_line = payment.line_ids - bank_line self.assertEqual(len(bank_line), 1) self.assertEqual(len(customer_line), 1) @@ -705,7 +512,7 @@ class TestReconciliationExec(TestAccountReconciliationCommon): self.assertEqual(len(reversed_move.line_ids), 2) # Testing the reconciliation matching between the move lines and their reversed counterparts - reversed_bank_line = reversed_move.line_ids.filtered(lambda l: l.account_id.id == self.bank_journal_usd.default_debit_account_id.id) + reversed_bank_line = reversed_move.line_ids.filtered(lambda l: l.account_id.id == self.bank_journal_usd.payment_debit_account_id.id) reversed_customer_line = reversed_move.line_ids - reversed_bank_line self.assertEqual(len(reversed_bank_line), 1) @@ -763,17 +570,17 @@ class TestReconciliationExec(TestAccountReconciliationCommon): 'amount': 111, 'currency_id': self.currency_usd_id, 'journal_id': self.bank_journal_usd.id, - 'payment_date': time.strftime('%Y') + '-08-01', + 'date': time.strftime('%Y') + '-08-01', }) - payment.post() + payment.action_post() - credit_aml = payment.move_line_ids.filtered('credit') + credit_aml = payment.line_ids.filtered('credit') inv.js_assign_outstanding_line(credit_aml.id) - self.assertTrue(inv.payment_state == 'paid', 'The invoice should be paid') + self.assertTrue(inv.payment_state in ('in_payment', 'paid'), "Invoice should be paid") - exchange_reconcile = payment.move_line_ids.mapped('full_reconcile_id') + exchange_reconcile = payment.line_ids.mapped('full_reconcile_id') exchange_move = exchange_reconcile.exchange_move_id - payment_move = payment.move_line_ids[0].move_id + payment_move = payment.line_ids[0].move_id reverted_payment_move = payment_move._reverse_moves([{'date': time.strftime('%Y') + '-08-01'}], cancel=True) @@ -792,10 +599,11 @@ class TestReconciliationExec(TestAccountReconciliationCommon): # * Dr. 100 USD / 50 EUR - Accounts receivable # * Cr. 100 USD / 50 EUR - Revenue #### - dest_journal_id = self.env['account.journal'].search( - [('type', '=', 'purchase'), - ('company_id', '=', self.company.id)], - limit=1) + dest_journal_id = self.env['account.journal'].create({ + 'name': 'turlututu', + 'type': 'bank', + 'company_id': self.env.company.id, + }) # Delete any old rate - to make sure that we use the ones we need. old_rates = self.env['res.currency.rate'].search( @@ -836,20 +644,17 @@ class TestReconciliationExec(TestAccountReconciliationCommon): 'name': time.strftime('%Y') + '-01-02', 'rate': 1, }) - # register payment on invoice - 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_agrolait_id, - 'amount': 50, - 'currency_id': self.currency_usd_id, - 'payment_date': time.strftime('%Y') + '-01-02', - 'journal_id': dest_journal_id.id, - 'invoice_ids': [(6, 0, invoice_cust_1.ids)], - }) - payment.post() + + payment = self.env['account.payment.register']\ + .with_context(active_model='account.move', active_ids=invoice_cust_1.ids)\ + .create({ + 'payment_date': time.strftime('%Y') + '-01-02', + 'amount': 50, + 'journal_id': dest_journal_id.id, + 'currency_id': self.currency_usd_id, + })\ + ._create_payments() + # We expect at this point that the invoice should still be open, in 'partial' state, # because they owe us still 50 CC. self.assertEqual(invoice_cust_1.payment_state, 'partial', 'Invoice is in status %s' % invoice_cust_1.state) @@ -884,7 +689,7 @@ class TestReconciliationExec(TestAccountReconciliationCommon): ) payment = self.env['account.payment'].create({ - 'payment_date': time.strftime('%Y') + '-07-15', + 'date': time.strftime('%Y') + '-07-15', 'payment_type': 'inbound', 'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id, 'partner_type': 'customer', @@ -893,9 +698,9 @@ class TestReconciliationExec(TestAccountReconciliationCommon): 'currency_id': self.currency_usd_id, 'journal_id': self.bank_journal_usd.id, }) - payment.post() + payment.action_post() - receivable_line = payment.move_line_ids.filtered('credit') + receivable_line = payment.line_ids.filtered('credit') invoice.js_assign_outstanding_line(receivable_line.id) self.assertTrue(receivable_line.matched_debit_ids) @@ -946,17 +751,17 @@ class TestReconciliationExec(TestAccountReconciliationCommon): self.assertTrue(cash_basis_moves.exists()) # check reconciliation in Payable account - purchase_move_line_ids = purchase_move.line_ids.sorted() - payment_move_line_ids = payment_move.line_ids.sorted() - self.assertTrue(purchase_move_line_ids[0].full_reconcile_id.exists()) - self.assertEqual(purchase_move_line_ids[0].full_reconcile_id.reconciled_line_ids, - purchase_move_line_ids[0] + purchase_move_line_ids[1] + payment_move_line_ids[0]) + purchase_line_ids = purchase_move.line_ids.sorted() + payment_line_ids = payment_move.line_ids.sorted() + self.assertTrue(purchase_line_ids[0].full_reconcile_id.exists()) + self.assertEqual(purchase_line_ids[0].full_reconcile_id.reconciled_line_ids, + purchase_line_ids[0] + purchase_line_ids[1] + payment_line_ids[0]) cash_basis_aml_ids = cash_basis_moves.mapped('line_ids') # check reconciliation in the tax waiting account - self.assertTrue(purchase_move_line_ids[4].full_reconcile_id.exists()) - self.assertEqual(purchase_move_line_ids[4].full_reconcile_id.reconciled_line_ids, - cash_basis_aml_ids.filtered(lambda l: l.account_id == self.tax_waiting_account) + purchase_move_line_ids[4]) + self.assertTrue(purchase_line_ids[4].full_reconcile_id.exists()) + self.assertEqual(purchase_line_ids[4].full_reconcile_id.reconciled_line_ids, + cash_basis_aml_ids.filtered(lambda l: l.account_id == self.tax_waiting_account) + purchase_line_ids[4]) self.assertEqual(len(cash_basis_aml_ids), 8) @@ -1052,6 +857,7 @@ class TestReconciliationExec(TestAccountReconciliationCommon): }) payment_move1.post() + (purchase_move + payment_move0 + payment_move1).post() (purchase_move + payment_move0).mapped('line_ids').sorted().filtered(lambda l: l.account_id.internal_type == 'payable').reconcile() (purchase_move + payment_move1).mapped('line_ids').sorted().filtered(lambda l: l.account_id.internal_type == 'payable').reconcile() @@ -1061,21 +867,21 @@ class TestReconciliationExec(TestAccountReconciliationCommon): self.assertTrue(cash_basis_moves.exists()) # check reconciliation in Payable account - purchase_move_line_ids = purchase_move.line_ids.sorted() - self.assertTrue(purchase_move_line_ids[0].full_reconcile_id.exists()) - self.assertEqual(purchase_move_line_ids[0].full_reconcile_id.reconciled_line_ids, + purchase_line_ids = purchase_move.line_ids.sorted() + self.assertTrue(purchase_line_ids[0].full_reconcile_id.exists()) + self.assertEqual(purchase_line_ids[0].full_reconcile_id.reconciled_line_ids, (purchase_move + payment_move0 + payment_move1).mapped('line_ids').filtered(lambda l: l.account_id.internal_type == 'payable')) cash_basis_aml_ids = cash_basis_moves.mapped('line_ids') # check reconciliation in the tax waiting account - self.assertTrue(purchase_move_line_ids[3].full_reconcile_id.exists()) - self.assertEqual(purchase_move_line_ids[3].full_reconcile_id.reconciled_line_ids, - cash_basis_aml_ids.filtered(lambda l: l.account_id == tax_waiting_account10) + purchase_move_line_ids[3]) + self.assertTrue(purchase_line_ids[3].full_reconcile_id.exists()) + self.assertEqual(purchase_line_ids[3].full_reconcile_id.reconciled_line_ids, + cash_basis_aml_ids.filtered(lambda l: l.account_id == tax_waiting_account10) + purchase_line_ids[3]) - self.assertTrue(purchase_move_line_ids[5].full_reconcile_id.exists()) - self.assertEqual(purchase_move_line_ids[5].full_reconcile_id.reconciled_line_ids, - cash_basis_aml_ids.filtered(lambda l: l.account_id == self.tax_waiting_account) + purchase_move_line_ids[5]) + self.assertTrue(purchase_line_ids[5].full_reconcile_id.exists()) + self.assertEqual(purchase_line_ids[5].full_reconcile_id.reconciled_line_ids, + cash_basis_aml_ids.filtered(lambda l: l.account_id == self.tax_waiting_account) + purchase_line_ids[5]) self.assertEqual(len(cash_basis_aml_ids), 24) @@ -1124,40 +930,6 @@ class TestReconciliationExec(TestAccountReconciliationCommon): ) index += 1 - def test_reconciliation_to_check(self): - partner = self.env['res.partner'].create({'name': 'UncertainPartner'}) - currency = self.env.company.currency_id - invoice = self.create_invoice_partner(currency_id=currency.id, partner_id=partner.id) - journal = self.env['account.journal'].create({'name': 'Bank', 'type': 'bank', 'code': 'THE', 'restrict_mode_hash_table':False}) - - statement = self.make_payment(invoice, journal, 50) - st_line = statement.line_ids - previous_move_lines = st_line.journal_entry_ids.ids - previous_name = st_line.move_name - - with self.assertRaises(UserError): #you need edition mode to be able to change it - st_line.with_context(suspense_moves_mode=False).process_reconciliation( - counterpart_aml_dicts=[], - new_aml_dicts=[{ - 'debit': 0, - 'credit': 50, - 'name': 'exchange difference', - 'account_id': self.diff_income_account.id - }], - ) - - st_line.with_context(suspense_moves_mode=True).process_reconciliation( - counterpart_aml_dicts=[], - new_aml_dicts=[{ - 'debit': 0, - 'credit': 50, - 'name': 'exchange difference', - 'account_id': self.diff_income_account.id - }], - ) - self.assertEqual(previous_name, st_line.move_name) # the name of the move hasnt changed - self.assertNotEqual(previous_move_lines, st_line.journal_entry_ids.ids) # the lines are new - def test_reconciliation_cash_basis_fx_01(self): """ Company's Currency EUR @@ -1275,11 +1047,11 @@ class TestReconciliationExec(TestAccountReconciliationCommon): # check reconciliation in Payable account purchase_line_ids = purchase_move.line_ids.sorted() fx_move_01_line_ids = fx_move_01.line_ids.sorted() - payment_move_line_ids = payment_move.line_ids.sorted() + payment_line_ids = payment_move.line_ids.sorted() self.assertTrue(purchase_line_ids[2].full_reconcile_id.exists()) self.assertEqual( purchase_line_ids[2].full_reconcile_id.reconciled_line_ids, - purchase_line_ids[2] + fx_move_01_line_ids[0] + payment_move_line_ids[0]) + purchase_line_ids[2] + fx_move_01_line_ids[0] + payment_line_ids[0]) # check cash basis cash_basis_moves = self.env['account.move'].search( @@ -1455,15 +1227,15 @@ class TestReconciliationExec(TestAccountReconciliationCommon): to_reconcile.reconcile() # check reconciliation in Payable account - purchase_move_line_ids = purchase_move.line_ids.sorted() + purchase_line_ids = purchase_move.line_ids.sorted() fx_move_01_line_ids = fx_move_01.line_ids.sorted() fx_move_02_line_ids = fx_move_02.line_ids.sorted() - payment_move_line_ids = payment_move.line_ids.sorted() - self.assertTrue(purchase_move_line_ids[2].full_reconcile_id.exists()) + payment_line_ids = payment_move.line_ids.sorted() + self.assertTrue(purchase_line_ids[2].full_reconcile_id.exists()) self.assertEqual( - purchase_move_line_ids[2].full_reconcile_id.reconciled_line_ids, - purchase_move_line_ids[2] + fx_move_01_line_ids[0] + fx_move_02_line_ids[0] + - payment_move_line_ids[0]) + purchase_line_ids[2].full_reconcile_id.reconciled_line_ids, + purchase_line_ids[2] + fx_move_01_line_ids[0] + fx_move_02_line_ids[0] + + payment_line_ids[0]) # check cash basis cash_basis_moves = self.env['account.move'].search( @@ -1665,7 +1437,8 @@ class TestReconciliationExec(TestAccountReconciliationCommon): self.assertTrue(invoice.currency_id != self.env.user.company_id.currency_id) # First Payment - payment0 = self.make_payment(invoice, journal, amount=59.99) + line = invoice.line_ids.filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable')) + payment0 = self.make_payment(invoice, journal, amount=59.99, reconcile_param=[{'id': line.id, 'balance': -59.99}]) self.assertEqual(invoice.amount_residual, 0.01) tax_waiting_line = invoice.line_ids.filtered(lambda l: l.account_id == self.tax_waiting_account) @@ -1681,7 +1454,7 @@ class TestReconciliationExec(TestAccountReconciliationCommon): self.assertEqual(pay_receivable_line0.matched_debit_ids, move_caba0.tax_cash_basis_rec_id) # Second Payment - payment1 = self.make_payment(invoice, journal, 0.01) + payment1 = self.make_payment(invoice, journal, 0.01, reconcile_param=[{'id': line.id, 'balance': -0.01}]) self.assertEqual(invoice.amount_residual, 0) self.assertEqual(invoice.payment_state, 'paid') @@ -1778,7 +1551,7 @@ class TestReconciliationExec(TestAccountReconciliationCommon): inv2 = self.create_invoice(type="out_refund", invoice_amount=400, currency_id=self.currency_usd_id) payment = self.env['account.payment'].create({ - 'payment_date': time.strftime('%Y') + '-07-15', + 'date': time.strftime('%Y') + '-07-15', 'payment_method_id': self.inbound_payment_method.id, 'payment_type': 'inbound', 'partner_type': 'customer', @@ -1787,11 +1560,11 @@ class TestReconciliationExec(TestAccountReconciliationCommon): 'journal_id': self.bank_journal_euro.id, 'company_id': company.id, }) - payment.post() + payment.action_post() inv1_receivable = inv1.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') inv2_receivable = inv2.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') - pay_receivable = payment.move_line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + pay_receivable = payment.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') move_balance = self.env['account.move'].create({ 'partner_id': inv1.partner_id.id, @@ -1813,7 +1586,7 @@ class TestReconciliationExec(TestAccountReconciliationCommon): self.assertEqual(inv1_receivable.full_reconcile_id, pay_receivable.full_reconcile_id) self.assertEqual(inv1_receivable.full_reconcile_id, move_balance_receiv.full_reconcile_id) - self.assertEqual(inv1.payment_state, 'paid') + self.assertTrue(inv1.payment_state in ('in_payment', 'paid'), "Invoice should be paid") self.assertEqual(inv2.payment_state, 'paid') def test_inv_refund_foreign_payment_writeoff_domestic3(self): @@ -1846,21 +1619,20 @@ class TestReconciliationExec(TestAccountReconciliationCommon): inv2 = self.create_invoice(type="out_refund", invoice_amount=225, currency_id=self.currency_usd_id) payment = self.env['account.payment'].create({ - 'payment_date': time.strftime('%Y') + '-07-15', 'payment_method_id': self.inbound_payment_method.id, 'payment_type': 'inbound', 'partner_type': 'customer', 'partner_id': inv1.partner_id.id, 'amount': 372.10, - 'payment_date': time.strftime('%Y') + '-07-01', + 'date': time.strftime('%Y') + '-07-01', 'journal_id': self.bank_journal_euro.id, 'company_id': company.id, }) - payment.post() + payment.action_post() inv1_receivable = inv1.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') inv2_receivable = inv2.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') - pay_receivable = payment.move_line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + pay_receivable = payment.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') move_balance = self.env['account.move'].create({ 'partner_id': inv1.partner_id.id, @@ -1884,7 +1656,7 @@ class TestReconciliationExec(TestAccountReconciliationCommon): self.assertFalse(inv1_receivable.full_reconcile_id.exchange_move_id) - self.assertEqual(inv1.payment_state, 'paid') + self.assertTrue(inv1.payment_state in ('in_payment', 'paid'), "Invoice should be paid") self.assertEqual(inv2.payment_state, 'paid') def test_inv_refund_foreign_payment_writeoff_domestic4(self): @@ -1923,7 +1695,7 @@ class TestReconciliationExec(TestAccountReconciliationCommon): inv2 = self._create_invoice(type="out_refund", invoice_amount=225, currency_id=self.currency_usd_id, date_invoice=time.strftime('%Y') + '-07-15', auto_validate=True) payment = self.env['account.payment'].create({ - 'payment_date': time.strftime('%Y') + '-07-15', + 'date': time.strftime('%Y') + '-07-15', 'payment_method_id': self.inbound_payment_method.id, 'payment_type': 'inbound', 'partner_type': 'customer', @@ -1933,11 +1705,11 @@ class TestReconciliationExec(TestAccountReconciliationCommon): 'company_id': company.id, 'currency_id': self.currency_euro_id, }) - payment.post() + payment.action_post() inv1_receivable = inv1.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') inv2_receivable = inv2.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') - pay_receivable = payment.move_line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + pay_receivable = payment.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') self.assertEqual(inv1_receivable.balance, 658) self.assertEqual(inv2_receivable.balance, -202.59) @@ -1963,7 +1735,7 @@ class TestReconciliationExec(TestAccountReconciliationCommon): self.assertEqual(inv1_receivable.full_reconcile_id, pay_receivable.full_reconcile_id) self.assertEqual(inv1_receivable.full_reconcile_id, move_balance_receiv.full_reconcile_id) - self.assertEqual(inv1.payment_state, 'paid') + self.assertTrue(inv1.payment_state in ('in_payment', 'paid'), "Invoice should be paid") self.assertEqual(inv2.payment_state, 'paid') def test_inv_refund_foreign_payment_writeoff_domestic5(self): @@ -2006,23 +1778,19 @@ class TestReconciliationExec(TestAccountReconciliationCommon): inv1.js_assign_outstanding_line(inv2_receivable.id) self.assertEqual(inv1.amount_residual, 350) - Payment = self.env['account.payment'].with_context(default_invoice_ids=[(4, inv1.id, False)]) - payment = Payment.create({ - 'payment_date': time.strftime('%Y') + '-07-15', - 'payment_method_id': self.inbound_payment_method.id, - 'payment_type': 'inbound', - 'partner_type': 'customer', - 'partner_id': inv1.partner_id.id, - 'amount': 314.07, - 'journal_id': self.bank_journal_euro.id, - 'company_id': company.id, - 'currency_id': self.currency_euro_id, - 'payment_difference_handling': 'reconcile', - 'writeoff_account_id': self.diff_income_account.id, - }) - payment.post() + payment = self.env['account.payment.register']\ + .with_context(active_model='account.move', active_ids=inv1.ids)\ + .create({ + 'payment_date': time.strftime('%Y') + '-07-15', + 'amount': 314.07, + 'journal_id': self.bank_journal_euro.id, + 'currency_id': self.currency_euro_id, + 'payment_difference_handling': 'reconcile', + 'writeoff_account_id': self.diff_income_account.id, + })\ + ._create_payments() - payment_receivable = payment.move_line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + payment_receivable = payment.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') self.assertEqual(payment_receivable.balance, -350) self.assertTrue(inv1_receivable.full_reconcile_id.exists()) @@ -2031,7 +1799,7 @@ class TestReconciliationExec(TestAccountReconciliationCommon): self.assertFalse(inv1_receivable.full_reconcile_id.exchange_move_id) - self.assertEqual(inv1.payment_state, 'paid') + self.assertTrue(inv1.payment_state in ('in_payment', 'paid'), "Invoice should be paid") self.assertEqual(inv2.payment_state, 'paid') def test_inv_refund_foreign_payment_writeoff_domestic6(self): @@ -2070,24 +1838,19 @@ class TestReconciliationExec(TestAccountReconciliationCommon): self.assertAlmostEqual(inv1.amount_residual, 350) self.assertAlmostEqual(inv1_receivable.amount_residual, 315.15) - Payment = self.env['account.payment'] - payment = Payment.create({ - 'payment_date': time.strftime('%Y') + '-07-15', - 'payment_method_id': self.inbound_payment_method.id, - 'payment_type': 'inbound', - 'partner_type': 'customer', - 'partner_id': inv1.partner_id.id, - 'amount': 314.07, - 'journal_id': self.bank_journal_euro.id, - 'company_id': company.id, - 'currency_id': self.currency_euro_id, - 'payment_difference_handling': 'reconcile', - 'writeoff_account_id': self.diff_income_account.id, - 'invoice_ids': [(4, inv1.id, False)], - }) - payment.post() + payment = self.env['account.payment.register']\ + .with_context(active_model='account.move', active_ids=inv1.ids)\ + .create({ + 'payment_date': time.strftime('%Y') + '-07-15', + 'amount': 314.07, + 'journal_id': self.bank_journal_euro.id, + 'currency_id': self.currency_euro_id, + 'payment_difference_handling': 'reconcile', + 'writeoff_account_id': self.diff_income_account.id, + })\ + ._create_payments() - payment_receivable = payment.move_line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + payment_receivable = payment.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') self.assertTrue(inv1_receivable.full_reconcile_id.exists()) self.assertEqual(inv1_receivable.full_reconcile_id, inv2_receivable.full_reconcile_id) @@ -2096,7 +1859,7 @@ class TestReconciliationExec(TestAccountReconciliationCommon): exchange_rcv = inv1_receivable.full_reconcile_id.exchange_move_id.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') self.assertEqual(exchange_rcv.amount_currency, 0.01) - self.assertEqual(inv1.payment_state, 'paid') + self.assertTrue(inv1.payment_state in ('in_payment', 'paid'), "Invoice should be paid") self.assertEqual(inv2.payment_state, 'paid') def test_inv_refund_foreign_payment_writeoff_domestic6bis(self): @@ -2146,24 +1909,19 @@ class TestReconciliationExec(TestAccountReconciliationCommon): self.assertAlmostEqual(inv1.amount_residual, 350) self.assertAlmostEqual(inv1_receivable.amount_residual, 315.15) - Payment = self.env['account.payment'].with_context(default_invoice_ids=[(4, inv1.id, False)]) - payment = Payment.create({ - 'payment_date': time.strftime('%Y') + '-07-15', - 'payment_method_id': self.inbound_payment_method.id, - 'payment_type': 'inbound', - 'partner_type': 'customer', - 'partner_id': inv1.partner_id.id, - 'amount': 314.07, - 'journal_id': self.bank_journal_euro.id, - 'company_id': company.id, - 'currency_id': foreign_0.id, - 'payment_difference_handling': 'reconcile', - 'writeoff_account_id': self.diff_income_account.id, - 'invoice_ids': [(4, inv1.id, False)], - }) - payment.post() + payment = self.env['account.payment.register']\ + .with_context(active_model='account.move', active_ids=inv1.ids)\ + .create({ + 'payment_date': time.strftime('%Y') + '-07-15', + 'amount': 314.07, + 'journal_id': self.bank_journal_euro.id, + 'currency_id': foreign_0.id, + 'payment_difference_handling': 'reconcile', + 'writeoff_account_id': self.diff_income_account.id, + })\ + ._create_payments() - payment_receivable = payment.move_line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + payment_receivable = payment.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') self.assertTrue(inv1_receivable.full_reconcile_id.exists()) self.assertEqual(inv1_receivable.full_reconcile_id, inv2_receivable.full_reconcile_id) @@ -2171,7 +1929,7 @@ class TestReconciliationExec(TestAccountReconciliationCommon): self.assertFalse(inv1_receivable.full_reconcile_id.exchange_move_id) - self.assertEqual(inv1.payment_state, 'paid') + self.assertTrue(inv1.payment_state in ('in_payment', 'paid'), "Invoice should be paid") self.assertEqual(inv2.payment_state, 'paid') def test_inv_refund_foreign_payment_writeoff_domestic7(self): @@ -2206,31 +1964,26 @@ class TestReconciliationExec(TestAccountReconciliationCommon): self.assertAlmostEqual(inv1_receivable.balance, 5384.48) - Payment = self.env['account.payment'] - payment = Payment.create({ - 'payment_date': time.strftime('%Y') + '-07-15', - 'payment_method_id': self.inbound_payment_method.id, - 'payment_type': 'inbound', - 'partner_type': 'customer', - 'partner_id': inv1.partner_id.id, - 'amount': 5384.43, - 'journal_id': self.bank_journal_euro.id, - 'company_id': company.id, - 'currency_id': self.currency_euro_id, - 'payment_difference_handling': 'reconcile', - 'writeoff_account_id': self.diff_income_account.id, - 'invoice_ids': [(4, inv1.id, False)], - }) - payment.post() + payment = self.env['account.payment.register']\ + .with_context(active_model='account.move', active_ids=inv1.ids)\ + .create({ + 'payment_date': time.strftime('%Y') + '-07-15', + 'amount': 5384.43, + 'journal_id': self.bank_journal_euro.id, + 'currency_id': self.currency_euro_id, + 'payment_difference_handling': 'reconcile', + 'writeoff_account_id': self.diff_income_account.id, + })\ + ._create_payments() - payment_receivable = payment.move_line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + payment_receivable = payment.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') self.assertTrue(inv1_receivable.full_reconcile_id.exists()) self.assertEqual(inv1_receivable.full_reconcile_id, payment_receivable.full_reconcile_id) self.assertFalse(inv1_receivable.full_reconcile_id.exchange_move_id) - self.assertEqual(inv1.payment_state, 'paid') + self.assertTrue(inv1.payment_state in ('in_payment', 'paid'), "Invoice should be paid") def test_inv_refund_foreign_payment_writeoff_domestic8(self): """ @@ -2260,7 +2013,7 @@ class TestReconciliationExec(TestAccountReconciliationCommon): Payment = self.env['account.payment'] payment = Payment.create({ - 'payment_date': time.strftime('%Y') + '-07-15', + 'date': time.strftime('%Y') + '-07-15', 'payment_method_id': self.inbound_payment_method.id, 'payment_type': 'inbound', 'partner_type': 'customer', @@ -2270,8 +2023,8 @@ class TestReconciliationExec(TestAccountReconciliationCommon): 'company_id': company.id, 'currency_id': self.currency_euro_id, }) - payment.post() - payment_receivable = payment.move_line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + payment.action_post() + payment_receivable = payment.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') move_balance = self.env['account.move'].create({ 'partner_id': inv1.partner_id.id, @@ -2294,4 +2047,4 @@ class TestReconciliationExec(TestAccountReconciliationCommon): exchange_rcv = inv1_receivable.full_reconcile_id.exchange_move_id.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') self.assertEqual(exchange_rcv.amount_currency, 0.01) - self.assertEqual(inv1.payment_state, 'paid') + self.assertTrue(inv1.payment_state in ('in_payment', 'paid'), "Invoice should be paid") diff --git a/addons/account/tests/test_reconciliation_heavy_load.py b/addons/account/tests/test_reconciliation_heavy_load.py deleted file mode 100644 index 726e0d102af..00000000000 --- a/addons/account/tests/test_reconciliation_heavy_load.py +++ /dev/null @@ -1,79 +0,0 @@ -from odoo import api, fields -from odoo.tests import tagged -from odoo.addons.account.tests.common import TestAccountReconciliationCommon - - -@tagged('post_install', '-at_install') -class TestReconciliationHeavyLoad(TestAccountReconciliationCommon): - """Check that reconciliation can be done for a move with many lines - """ - - def _create_move(self, journal): - values = { - 'ref': "Test reconcile - Auto-generated by script", - 'journal_id': journal.id, - 'state': 'draft', - 'company_id': journal.env.user.company_id.id, - } - return journal.env['account.move'].create(values) - - def _get_values_account_move_line( - self, account, journal, name, move, - credit=0, debit=0, date=fields.Date.today()): - return { - 'journal_id': journal.id, - 'name': name, - 'account_id': account.id, - 'move_id': move.id, - 'quantity': 1, - 'credit': credit, - 'debit': debit, - 'date': date, - } - - def setUp(self): - super(TestReconciliationHeavyLoad, self).setUp() - - self.account_type = self.env.ref('account.data_account_type_receivable') - - self.journal = self.env['account.journal'].search([ - ('type', '=', 'bank'), - ('company_id', '=', self.env.user.company_id.id), - ], limit=1) - - self.account = self.env['account.account'].search([ - ('user_type_id', '=', self.account_type.id), - ('company_id', '=', self.env.user.company_id.id), - ], limit=1) - - def test_heavy_load_reconciliation(self): - """Does reconciliation on a move with nb_lines lines. - To avoid burdening uselessly the runbot, we only set nb_lines to 10, - but it should be of order 10^3 to be meaningful. - The day we manage to use system build settings to execute tests - this could be done automatically for "heavy load builds", - but for now this should be changed manually. - """ - - total = 0 - line_ids = [] - amount_per_line = 1 - nb_lines = 10 # change this to 1000 or more - move = self._create_move(self.journal) - - for i in range(nb_lines): - name = "Move line credit #%s" % i - total += amount_per_line - values = self._get_values_account_move_line( - self.account, self.journal, name, move, credit=amount_per_line) - line_ids.append((0, False, values)) - - values = self._get_values_account_move_line( - self.account, self.journal, "Move line Debit", move, debit=total) - line_ids.append((0, False, values)) - - move.write({'line_ids': line_ids}) - - move.line_ids.reconcile() - - self.assertTrue(all(move.line_ids.mapped('reconciled'))) diff --git a/addons/account/tests/test_reconciliation_matching_rules.py b/addons/account/tests/test_reconciliation_matching_rules.py index 82091b5c1fa..4133d4c6d98 100644 --- a/addons/account/tests/test_reconciliation_matching_rules.py +++ b/addons/account/tests/test_reconciliation_matching_rules.py @@ -13,7 +13,6 @@ class TestReconciliationMatchingRules(AccountTestCommon): super(TestReconciliationMatchingRules, cls).setUpClass() cls.company = cls.env.user.company_id cls.account_pay = cls.a_pay - cls.account_liq = cls.bnk cls.account_rcv = cls.a_recv cls.partner_1 = cls.env['res.partner'].create({'name': 'partner_1', 'company_id': cls.company.id}) @@ -65,16 +64,16 @@ class TestReconciliationMatchingRules(AccountTestCommon): }) cls.bank_line_1 = cls.env['account.bank.statement.line'].create({ 'statement_id': cls.bank_st.id, - 'name': 'invoice %s-%s-%s' % (invoice_number.split('/')[1], invoice_number.split('/')[2], invoice_number.split('/')[3]), + 'payment_ref': 'invoice %s-%s-%s' % (invoice_number.split('/')[1], invoice_number.split('/')[2], invoice_number.split('/')[3]), 'partner_id': cls.partner_1.id, - 'amount': '100', + 'amount': 100, 'sequence': 1, }) cls.bank_line_2 = cls.env['account.bank.statement.line'].create({ 'statement_id': cls.bank_st.id, - 'name': 'xxxxx', + 'payment_ref': 'xxxxx', 'partner_id': cls.partner_1.id, - 'amount': '600', + 'amount': 600, 'sequence': 2, }) @@ -84,16 +83,16 @@ class TestReconciliationMatchingRules(AccountTestCommon): }) cls.cash_line_1 = cls.env['account.bank.statement.line'].create({ 'statement_id': cls.cash_st.id, - 'name': 'yyyyy', + 'payment_ref': 'yyyyy', 'partner_id': cls.partner_2.id, - 'amount': '-1000', + 'amount': -1000, 'sequence': 1, }) cls.tax21 = cls.env['account.tax'].create({ 'name': '21%', 'type_tax_use': 'purchase', - 'amount': '21', + 'amount': 21, }) @classmethod @@ -111,6 +110,11 @@ class TestReconciliationMatchingRules(AccountTestCommon): lines = invoice.line_ids return lines.filtered(lambda l: l.account_id.user_type_id.type in ('receivable', 'payable')) + def _post_statements(self): + self.bank_st.balance_end_real = self.bank_st.balance_end + self.cash_st.balance_end_real = self.cash_st.balance_end + (self.bank_st + self.cash_st).button_post() + def _check_statement_matching(self, rules, expected_values, statements=None): if statements is None: statements = self.bank_st + self.cash_st @@ -120,9 +124,9 @@ class TestReconciliationMatchingRules(AccountTestCommon): values.pop('reconciled_lines', None) self.assertDictEqual(values, expected_values[st_line_id]) - def test_matching_fields(self): ''' Test all fields used to restrict the rules's applicability.''' + self._post_statements() # Check without restriction. self._check_statement_matching(self.rule_1, { @@ -267,6 +271,7 @@ class TestReconciliationMatchingRules(AccountTestCommon): def test_mixin_rules(self): ''' Test usage of rules together.''' + self._post_statements() # rule_1 is used before rule_2. self.rule_1.sequence = 1 @@ -303,6 +308,8 @@ class TestReconciliationMatchingRules(AccountTestCommon): def test_auto_reconcile(self): ''' Test auto reconciliation.''' + self.bank_line_1.amount += 5 + self._post_statements() self.rule_1.sequence = 2 self.rule_1.auto_reconcile = True @@ -311,8 +318,6 @@ class TestReconciliationMatchingRules(AccountTestCommon): self.rule_2.match_partner_ids |= self.partner_2 self.rule_2.auto_reconcile = True - self.bank_line_1.amount += 5 - self._check_statement_matching(self.rule_1 + self.rule_2, { self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1, 'status': 'reconciled'}, self.bank_line_2.id: {'aml_ids': []}, @@ -320,21 +325,20 @@ class TestReconciliationMatchingRules(AccountTestCommon): }) # Check first line has been well reconciled. - self.assertRecordValues(self.bank_line_1.journal_entry_ids, [ + self.assertRecordValues(self.bank_line_1.line_ids, [ + {'partner_id': self.partner_1.id, 'debit': 105.0, 'credit': 0.0}, {'partner_id': self.partner_1.id, 'debit': 0.0, 'credit': 5.0}, {'partner_id': self.partner_1.id, 'debit': 0.0, 'credit': 100.0}, - {'partner_id': self.partner_1.id, 'debit': 105.0, 'credit': 0.0}, ]) # Check second line has been well reconciled. - self.assertRecordValues(self.cash_line_1.journal_entry_ids, [ - {'partner_id': self.partner_2.id, 'debit': 1000.0, 'credit': 0.0}, + self.assertRecordValues(self.cash_line_1.line_ids, [ {'partner_id': self.partner_2.id, 'debit': 0.0, 'credit': 1000.0}, + {'partner_id': self.partner_2.id, 'debit': 1000.0, 'credit': 0.0}, ]) def test_auto_reconcile_with_tax(self): ''' Test auto reconciliation with a tax amount included in the bank statement line''' - self.rule_1.write({ 'auto_reconcile': True, 'rule_type': 'writeoff_suggestion', @@ -346,6 +350,7 @@ class TestReconciliationMatchingRules(AccountTestCommon): self.bank_line_2.unlink() self.bank_line_1.amount = -121 + self._post_statements() self._check_statement_matching( self.rule_1, @@ -356,20 +361,20 @@ class TestReconciliationMatchingRules(AccountTestCommon): ) # Check first line has been well reconciled. - self.assertRecordValues(self.bank_line_1.journal_entry_ids, [ + self.assertRecordValues(self.bank_line_1.line_ids, [ + {'partner_id': self.partner_1.id, 'debit': 0.0, 'credit': 121.0, 'tax_ids': [], 'tax_line_id': False}, {'partner_id': self.partner_1.id, 'debit': 100.0, 'credit': 0.0, 'tax_ids': [self.tax21.id], 'tax_line_id': False}, {'partner_id': self.partner_1.id, 'debit': 21.0, 'credit': 0.0, 'tax_ids': [], 'tax_line_id': self.tax21.id}, - {'partner_id': self.partner_1.id, 'debit': 0.0, 'credit': 121.0, 'tax_ids': [], 'tax_line_id': False}, ]) def test_reverted_move_matching(self): + partner = self.env['res.partner'].create({'name': 'Eugene'}) AccountMove = self.env['account.move'] move = AccountMove.create({ 'name': 'To Revert', 'journal_id': self.bank_journal.id, }) - partner = self.env['res.partner'].create({'name': 'Eugene'}) AccountMoveLine = self.env['account.move.line'].with_context(check_move_validity=False) payment_payable_line = AccountMoveLine.create({ 'account_id': self.account_pay.id, @@ -379,7 +384,7 @@ class TestReconciliationMatchingRules(AccountTestCommon): 'debit': 10, }) payment_bnk_line = AccountMoveLine.create({ - 'account_id': self.account_liq.id, + 'account_id': self.bank_journal.payment_credit_account_id.id, 'move_id': move.id, 'partner_id': partner.id, 'name': 'I\'m gonna cut you into little pieces', @@ -398,7 +403,7 @@ class TestReconciliationMatchingRules(AccountTestCommon): bank_st.journal_id.default_debit_account_id = payment_bnk_line.account_id bank_line_1 = self.env['account.bank.statement.line'].create({ 'statement_id': bank_st.id, - 'name': '8', + 'payment_ref': '8', 'partner_id': partner.id, 'amount': -10, 'sequence': 1, diff --git a/addons/account/views/account_journal_dashboard_view.xml b/addons/account/views/account_journal_dashboard_view.xml index c15fe61be4b..b70357f607e 100644 --- a/addons/account/views/account_journal_dashboard_view.xml +++ b/addons/account/views/account_journal_dashboard_view.xml @@ -233,8 +233,8 @@ -
- +
+
@@ -250,16 +250,24 @@
-
- -
-
- Balance in GL -
-
- -
+
+
+
+ Balance in GL
+
+ +
+
+
+
+ Outstanding Payments/Receipts +
+
+ +
+
+
Latest Statement @@ -269,16 +277,6 @@
- -
-
- Balance in GL -
-
- -
-
-
Sample data diff --git a/addons/account/views/account_move_views.xml b/addons/account/views/account_move_views.xml index 600bdc69f31..80e769da0cd 100644 --- a/addons/account/views/account_move_views.xml +++ b/addons/account/views/account_move_views.xml @@ -409,7 +409,7 @@ - + @@ -484,18 +484,19 @@
- +
@@ -82,31 +83,36 @@ + filter_domain="['|', '|', ('name', 'ilike', self), ('partner_id', 'ilike', self), ('ref', 'ilike', self)]"/> - - + + - - - + + + + + - + - - +
-
+ + + + + + + + + + +
- + + + - -
-
-

+

- - - - - - - + + + + + + + - - - - - - - - - - - - + + + + + + + - + - - @@ -223,31 +255,6 @@ - - account.payment.form.multi - account.payment.register - - - - - - - - - - - - - - -
-
- -
-
- account.payment.graph account.payment @@ -260,6 +267,8 @@
+ + Payments account.payment @@ -268,7 +277,7 @@ 'default_payment_type': 'inbound', 'default_partner_type': 'customer', 'search_default_inbound_filter': 1, - 'res_partner_search_mode': 'customer', + 'default_move_journal_types': ('bank', 'cash'), }
@@ -288,7 +297,7 @@ 'default_payment_type': 'outbound', 'default_partner_type': 'supplier', 'search_default_outbound_filter': 1, - 'res_partner_search_mode': 'supplier', + 'default_move_journal_types': ('bank', 'cash'), } @@ -316,33 +325,16 @@ - - account.payment.invoice.form - account.payment - - primary - - - - -
- -
-
-
-
- Register Payment - + list code - action = model.action_register_payment() + if records: + action = records.action_register_payment() @@ -356,7 +348,7 @@ list - records.post() + records.action_post() diff --git a/addons/account/views/account_view.xml b/addons/account/views/account_view.xml index c75900f444d..adc629e7037 100644 --- a/addons/account/views/account_view.xml +++ b/addons/account/views/account_view.xml @@ -289,8 +289,22 @@
- - + + + + + +
@@ -313,16 +327,17 @@ Configure Email Servers
- - - - + + +
Keep empty for no control
- +
@@ -572,23 +587,31 @@ action = model.setting_init_bank_account_action()
- -
+ + + +
+
-

- Please use the following communication for your payment : +

+ Please use the following communication for your payment :

diff --git a/addons/account/views/report_payment_receipt_templates.xml b/addons/account/views/report_payment_receipt_templates.xml index 04a7e649e08..c472cd3ff68 100644 --- a/addons/account/views/report_payment_receipt_templates.xml +++ b/addons/account/views/report_payment_receipt_templates.xml @@ -6,8 +6,8 @@

Payment Receipt:

-
- Payment Date: +
+ Payment Date:
@@ -27,8 +27,8 @@
Payment Amount:
-
- Memo: +
+ Memo:
@@ -43,12 +43,14 @@ - + + + - + diff --git a/addons/account/views/report_statement.xml b/addons/account/views/report_statement.xml index f57e5b96d30..39503d5ddb8 100644 --- a/addons/account/views/report_statement.xml +++ b/addons/account/views/report_statement.xml @@ -25,7 +25,7 @@
- - + - - @@ -101,9 +101,9 @@
- - - + + + diff --git a/addons/account/views/res_config_settings_views.xml b/addons/account/views/res_config_settings_views.xml index 9e2b8869f3c..fefa89d4e4a 100644 --- a/addons/account/views/res_config_settings_views.xml +++ b/addons/account/views/res_config_settings_views.xml @@ -432,16 +432,6 @@

Bank & Cash

-
-
-
-
-
diff --git a/addons/account/wizard/__init__.py b/addons/account/wizard/__init__.py index 9be1a3ef8d1..a130d327938 100644 --- a/addons/account/wizard/__init__.py +++ b/addons/account/wizard/__init__.py @@ -16,3 +16,4 @@ from . import wizard_tax_adjustments from . import account_invoice_send from . import base_document_layout from . import account_transfer_wizard +from . import account_payment_register diff --git a/addons/account/wizard/account_invoice_send.py b/addons/account/wizard/account_invoice_send.py index 7ef17afc7c1..90861b0c91e 100644 --- a/addons/account/wizard/account_invoice_send.py +++ b/addons/account/wizard/account_invoice_send.py @@ -88,7 +88,7 @@ class AccountInvoiceSend(models.TransientModel): if self.env.context.get('mark_invoice_as_sent'): #Salesman send posted invoice, without the right to write #but they should have the right to change this flag - self.mapped('invoice_ids').sudo().write({'invoice_sent': True}) + self.mapped('invoice_ids').sudo().write({'is_move_sent': True}) def _print_document(self): """ to override for each type of models that will use this composer.""" diff --git a/addons/account/wizard/account_payment_register.py b/addons/account/wizard/account_payment_register.py new file mode 100644 index 00000000000..c36f5bbfb9c --- /dev/null +++ b/addons/account/wizard/account_payment_register.py @@ -0,0 +1,504 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api, _ +from odoo.exceptions import UserError + + +class AccountPaymentRegister(models.TransientModel): + _name = 'account.payment.register' + _description = 'Register Payment' + + # == Business fields == + payment_date = fields.Date(String="Payment Date", required=True, + default=fields.Date.context_today) + amount = fields.Monetary(currency_field='currency_id', store=True, readonly=False, + compute='_compute_amount') + communication = fields.Char(string="Memo", store=True, readonly=False, + compute='_compute_communication') + group_payment = fields.Boolean(string="Group Payments", store=True, readonly=False, + compute='_compute_group_payment', + help="Only one payment will be created by partner (bank)/ currency.") + currency_id = fields.Many2one('res.currency', string='Currency', store=True, readonly=False, + compute='_compute_currency_id', + help="The payment's currency.") + journal_id = fields.Many2one('account.journal', store=True, readonly=False, + compute='_compute_journal_id', + domain="[('company_id', '=', company_id), ('type', 'in', ('bank', 'cash'))]") + partner_bank_id = fields.Many2one('res.partner.bank', string="Recipient Bank Account", + readonly=False, store=True, + compute='_compute_partner_bank_id', + domain="['|', ('company_id', '=', False), ('company_id', '=', company_id), ('partner_id', '=', partner_id)]") + company_currency_id = fields.Many2one('res.currency', string="Company Currency", + related='company_id.currency_id') + + # == Fields given through the context == + line_ids = fields.Many2many('account.move.line', 'account_payment_register_move_line_rel', 'wizard_id', 'line_id', + string="Journal items", readonly=True, copy=False,) + payment_type = fields.Selection([ + ('outbound', 'Send Money'), + ('inbound', 'Receive Money'), + ], string='Payment Type', store=True, copy=False, + compute='_compute_from_lines') + partner_type = fields.Selection([ + ('customer', 'Customer'), + ('supplier', 'Vendor'), + ], store=True, copy=False, + compute='_compute_from_lines') + source_amount = fields.Monetary( + string="Amount to Pay (company currency)", store=True, copy=False, + currency_field='company_currency_id', + compute='_compute_from_lines') + source_amount_currency = fields.Monetary( + string="Amount to Pay (foreign currency)", store=True, copy=False, + currency_field='source_currency_id', + compute='_compute_from_lines') + source_currency_id = fields.Many2one('res.currency', + string='Source Currency', store=True, copy=False, + compute='_compute_from_lines', + help="The payment's currency.") + can_edit_wizard = fields.Boolean(store=True, copy=False, + compute='_compute_from_lines', + help="Technical field used to indicate the user can edit the wizard content such as the amount.") + can_group_payments = fields.Boolean(store=True, copy=False, + compute='_compute_from_lines', + help="Technical field used to indicate the user can see the 'group_payments' box.") + company_id = fields.Many2one('res.company', store=True, copy=False, + compute='_compute_from_lines') + partner_id = fields.Many2one('res.partner', + string="Customer/Vendor", store=True, copy=False, ondelete='restrict', + compute='_compute_from_lines') + + # == Payment methods fields == + payment_method_id = fields.Many2one('account.payment.method', string='Payment Method', + readonly=False, store=True, + compute='_compute_payment_method_id', + domain="[('id', 'in', available_payment_method_ids)]", + help="Manual: Get paid by cash, check or any other method outside of Odoo.\n"\ + "Electronic: Get paid automatically through a payment acquirer by requesting a transaction on a card saved by the customer when buying or subscribing online (payment token).\n"\ + "Check: Pay bill by check and print it from Odoo.\n"\ + "Batch Deposit: Encase several customer checks at once by generating a batch deposit to submit to your bank. When encoding the bank statement in Odoo, you are suggested to reconcile the transaction with the batch deposit.To enable batch deposit, module account_batch_payment must be installed.\n"\ + "SEPA Credit Transfer: Pay bill from a SEPA Credit Transfer file you submit to your bank. To enable sepa credit transfer, module account_sepa must be installed ") + available_payment_method_ids = fields.Many2many('account.payment.method', + compute='_compute_payment_method_fields') + hide_payment_method = fields.Boolean( + compute='_compute_payment_method_fields', + help="Technical field used to hide the payment method if the selected journal has only one available which is 'manual'") + + # == Payment difference fields == + payment_difference = fields.Monetary( + compute='_compute_payment_difference') + payment_difference_handling = fields.Selection([ + ('open', 'Keep open'), + ('reconcile', 'Mark invoice as fully paid'), + ], default='open', string="Payment Difference Handling") + writeoff_account_id = fields.Many2one('account.account', string="Difference Account", copy=False, + domain="[('deprecated', '=', False), ('company_id', '=', company_id)]") + writeoff_label = fields.Char(string='Journal Item Label', default='Write-Off', + help='Change label of the counterpart that will hold the payment difference') + + # == Display purpose fields == + show_partner_bank_account = fields.Boolean( + compute='_compute_show_require_partner_bank', + help="Technical field used to know whether the field `partner_bank_id` needs to be displayed or not in the payments form views") + require_partner_bank_account = fields.Boolean( + compute='_compute_show_require_partner_bank', + help="Technical field used to know whether the field `partner_bank_id` needs to be required or not in the payments form views") + + # ------------------------------------------------------------------------- + # HELPERS + # ------------------------------------------------------------------------- + + @api.model + def _get_batch_communication(self, batch_result): + ''' Helper to compute the communication based on the batch. + :param batch_result: A batch returned by '_get_batches'. + :return: A string representing a communication to be set on payment. + ''' + return ' '.join(label for label in batch_result['lines'].mapped('name') if label) + + @api.model + def _get_line_batch_key(self, line): + ''' Turn the line passed as parameter to a dictionary defining on which way the lines + will be grouped together. + :return: A python dictionary. + ''' + return { + 'partner_id': line.partner_id.id, + 'account_id': line.account_id.id, + 'currency_id': (line.currency_id or line.company_currency_id).id, + 'partner_bank_id': line.move_id.partner_bank_id.id, + 'partner_type': 'customer' if line.account_internal_type == 'receivable' else 'supplier', + 'payment_type': 'inbound' if line.balance > 0.0 else 'outbound', + } + + def _get_batches(self): + ''' Group the account.move.line linked to the wizard together. + :return: A list of batches, each one containing: + * key_values: The key as a dictionary used to group the journal items together. + * moves: An account.move recordset. + ''' + self.ensure_one() + + lines = self.line_ids + + if len(lines.company_id) > 1: + raise UserError(_("You can't create payments for entries belonging to different companies.")) + if not lines: + raise UserError(_("You can't open the register payment wizard without at least one receivable/payable line.")) + + batches = {} + for line in lines: + batch_key = self._get_line_batch_key(line) + + serialized_key = '-'.join(str(v) for v in batch_key.values()) + batches.setdefault(serialized_key, { + 'key_values': batch_key, + 'lines': self.env['account.move.line'], + }) + batches[serialized_key]['lines'] += line + return list(batches.values()) + + @api.model + def _get_wizard_values_from_batch(self, batch_result): + ''' Extract values from the batch passed as parameter (see '_get_batches') + to be mounted in the wizard view. + :param batch_result: A batch returned by '_get_batches'. + :return: A dictionary containing valid fields + ''' + key_values = batch_result['key_values'] + lines = batch_result['lines'] + company = lines[0].company_id + + source_amount = abs(sum(lines.mapped('amount_residual'))) + if key_values['currency_id'] == company.currency_id.id: + source_amount_currency = source_amount + else: + source_amount_currency = abs(sum(lines.mapped('amount_residual_currency'))) + + return { + 'company_id': company.id, + 'partner_id': key_values['partner_id'], + 'partner_type': key_values['partner_type'], + 'payment_type': key_values['payment_type'], + 'source_currency_id': key_values['currency_id'], + 'source_amount': source_amount, + 'source_amount_currency': source_amount_currency, + } + + # ------------------------------------------------------------------------- + # COMPUTE METHODS + # ------------------------------------------------------------------------- + + @api.depends('line_ids') + def _compute_from_lines(self): + ''' Load initial values from the account.moves passed through the context. ''' + for wizard in self: + batches = wizard._get_batches() + + if len(batches) == 1: + # == Single batch to be mounted on the view == + batch_result = batches[0] + wizard.update(wizard._get_wizard_values_from_batch(batch_result)) + + wizard.can_edit_wizard = True + wizard.can_group_payments = len(batch_result['lines']) != 1 + else: + # == Multiple batches: The wizard is not editable == + wizard.update({ + 'company_id': batches[0]['lines'][0].company_id.id, + 'partner_id': False, + 'partner_type': False, + 'payment_type': False, + 'source_currency_id': False, + 'source_amount': False, + 'source_amount_currency': False, + }) + + wizard.can_edit_wizard = False + wizard.can_group_payments = any(len(batch_result['lines']) != 1 for batch_result in batches) + + @api.depends('can_edit_wizard') + def _compute_communication(self): + # The communication can't be computed in '_compute_from_lines' because + # it's a compute editable field and then, should be computed in a separated method. + for wizard in self: + if wizard.can_edit_wizard: + batches = self._get_batches() + wizard.communication = wizard._get_batch_communication(batches[0]) + else: + wizard.communication = False + + @api.depends('can_edit_wizard') + def _compute_group_payment(self): + for wizard in self: + if wizard.can_edit_wizard: + batches = wizard._get_batches() + wizard.group_payment = len(batches[0]['lines'].move_id) == 1 + else: + wizard.group_payment = False + + @api.depends('company_id', 'source_currency_id') + def _compute_journal_id(self): + for wizard in self: + domain = [ + ('type', 'in', ('bank', 'cash')), + ('company_id', '=', wizard.company_id.id), + ] + journal = None + if wizard.source_currency_id: + journal = self.env['account.journal'].search(domain + [('currency_id', '=', wizard.source_currency_id.id)], limit=1) + if not journal: + journal = self.env['account.journal'].search(domain, limit=1) + wizard.journal_id = journal + + @api.depends('journal_id') + def _compute_currency_id(self): + for wizard in self: + wizard.currency_id = wizard.journal_id.currency_id or wizard.source_currency_id or wizard.company_id.currency_id + + @api.depends('partner_id') + def _compute_partner_bank_id(self): + ''' The default partner_bank_id will be the first available on the partner. ''' + for wizard in self: + available_partner_bank_accounts = wizard.partner_id.bank_ids + if available_partner_bank_accounts: + wizard.partner_bank_id = available_partner_bank_accounts[0]._origin + else: + wizard.partner_bank_id = False + + @api.depends('journal_id') + def _compute_payment_method_id(self): + for wizard in self: + batches = wizard._get_batches() + payment_type = batches[0]['key_values']['payment_type'] + + if payment_type == 'inbound': + available_payment_methods = wizard.journal_id.inbound_payment_method_ids + else: + available_payment_methods = wizard.journal_id.outbound_payment_method_ids + + # Select the first available one by default. + if available_payment_methods: + wizard.payment_method_id = available_payment_methods[0]._origin + else: + wizard.payment_method_id = False + + @api.depends('payment_type', + 'journal_id.inbound_payment_method_ids', + 'journal_id.outbound_payment_method_ids') + def _compute_payment_method_fields(self): + for wizard in self: + if wizard.payment_type == 'inbound': + wizard.available_payment_method_ids = wizard.journal_id.inbound_payment_method_ids + else: + wizard.available_payment_method_ids = wizard.journal_id.outbound_payment_method_ids + + wizard.hide_payment_method = len(wizard.available_payment_method_ids) == 1 and wizard.available_payment_method_ids.code == 'manual' + + @api.depends('payment_type', + 'journal_id.inbound_payment_method_ids', + 'journal_id.outbound_payment_method_ids') + def _compute_payment_method_id(self): + for wizard in self: + if wizard.payment_type == 'inbound': + available_payment_methods = wizard.journal_id.inbound_payment_method_ids + else: + available_payment_methods = wizard.journal_id.outbound_payment_method_ids + + # Select the first available one by default. + if available_payment_methods: + wizard.payment_method_id = available_payment_methods[0]._origin + else: + wizard.payment_method_id = False + + @api.depends('payment_method_id') + def _compute_show_require_partner_bank(self): + """ Computes if the destination bank account must be displayed in the payment form view. By default, it + won't be displayed but some modules might change that, depending on the payment type.""" + for wizard in self: + wizard.show_partner_bank_account = wizard.payment_method_id.code in self.env['account.payment']._get_method_codes_using_bank_account() + wizard.require_partner_bank_account = wizard.payment_method_id.code in self.env['account.payment']._get_method_codes_needing_bank_account() + + @api.depends('source_amount', 'source_amount_currency', 'source_currency_id', 'company_id', 'currency_id', 'payment_date') + def _compute_amount(self): + for wizard in self: + if wizard.source_currency_id == wizard.currency_id: + # Same currency. + wizard.amount = wizard.source_amount_currency + elif wizard.currency_id == wizard.company_id.currency_id: + # Payment expressed on the company's currency. + wizard.amount = wizard.source_amount + else: + # Foreign currency on payment different than the one set on the journal entries. + amount_payment_currency = wizard.company_id.currency_id._convert(wizard.source_amount, wizard.currency_id, wizard.company_id, wizard.payment_date) + wizard.amount = amount_payment_currency + + @api.depends('amount') + def _compute_payment_difference(self): + for wizard in self: + if wizard.source_currency_id == wizard.currency_id: + # Same currency. + wizard.payment_difference = wizard.source_amount_currency - wizard.amount + elif wizard.currency_id == wizard.company_id.currency_id: + # Payment expressed on the company's currency. + wizard.payment_difference = wizard.source_amount - wizard.amount + else: + # Foreign currency on payment different than the one set on the journal entries. + amount_payment_currency = wizard.company_id.currency_id._convert(wizard.source_amount, wizard.currency_id, wizard.company_id, wizard.payment_date) + wizard.payment_difference = amount_payment_currency - wizard.amount + + # ------------------------------------------------------------------------- + # LOW-LEVEL METHODS + # ------------------------------------------------------------------------- + + @api.model + def default_get(self, fields_list): + # OVERRIDE + res = super().default_get(fields_list) + + if 'line_ids' in fields_list and 'line_ids' not in res: + + # Retrieve moves to pay from the context. + + if self._context.get('active_model') == 'account.move': + lines = self.env['account.move'].browse(self._context.get('active_ids', [])).line_ids + elif self._context.get('active_model') == 'account.move.line': + lines = self.env['account.move.line'].browse(self._context.get('active_ids', [])) + else: + raise UserError(_( + "The register payment wizard should only be called on account.move or account.move.line records." + )) + + # Keep lines having a residual amount to pay. + available_lines = self.env['account.move.line'] + for line in lines: + if line.move_id.state != 'posted': + raise UserError(_("You can only register payment for posted journal entries.")) + + if line.account_internal_type not in ('receivable', 'payable'): + continue + if line.currency_id: + if line.currency_id.is_zero(line.amount_residual_currency): + continue + else: + if line.company_currency_id.is_zero(line.amount_residual): + continue + available_lines |= line + + # Check. + if not available_lines: + raise UserError(_("You can't register a payment because there is nothing left to pay on the selected journal items.")) + if len(lines.company_id) > 1: + raise UserError(_("You can't create payments for entries belonging to different companies.")) + if len(set(available_lines.mapped('account_internal_type'))) > 1: + raise UserError(_("You can't register payments for journal items being either all inbound, either all outbound.")) + + res['line_ids'] = [(6, 0, available_lines.ids)] + + return res + + # ------------------------------------------------------------------------- + # BUSINESS METHODS + # ------------------------------------------------------------------------- + + def _create_payment_vals_from_wizard(self): + payment_vals = { + 'date': self.payment_date, + 'amount': self.amount, + 'payment_type': self.payment_type, + 'partner_type': self.partner_type, + 'ref': self.communication, + 'journal_id': self.journal_id.id, + 'currency_id': self.currency_id.id, + 'partner_id': self.partner_id.id, + 'partner_bank_id': self.partner_bank_id.id, + 'payment_method_id': self.payment_method_id.id, + 'destination_account_id': self.line_ids[0].account_id.id + } + + if self.payment_difference and self.payment_difference_handling == 'reconcile': + payment_vals['write_off_line_vals'] = { + 'name': self.writeoff_label, + 'amount': -self.payment_difference, + 'account_id': self.writeoff_account_id.id, + } + return payment_vals + + def _create_payment_vals_from_batch(self, batch_result): + batch_values = self._get_wizard_values_from_batch(batch_result) + return { + 'date': self.payment_date, + 'amount': batch_values['source_amount_currency'], + 'payment_type': batch_values['payment_type'], + 'partner_type': batch_values['partner_type'], + 'ref': self._get_batch_communication(batch_result), + 'journal_id': self.journal_id.id, + 'currency_id': batch_values['source_currency_id'], + 'partner_id': batch_values['partner_id'], + 'partner_bank_id': batch_result['key_values']['partner_bank_id'], + 'payment_method_id': self.payment_method_id.id, + 'destination_account_id': batch_result['lines'][0].account_id.id + } + + def _create_payments(self): + self.ensure_one() + batches = self._get_batches() + + to_reconcile = [] + if self.can_edit_wizard and (len(batches[0]['lines']) == 1 or self.group_payment): + payment_vals = self._create_payment_vals_from_wizard() + payment_vals_list = [payment_vals] + to_reconcile.append(batches[0]['lines']) + else: + # Don't group payments: Create one batch per move. + if not self.group_payment: + new_batches = [] + for batch_result in batches: + for line in batch_result['lines']: + new_batches.append({ + **batch_result, + 'lines': line, + }) + batches = new_batches + + payment_vals_list = [] + for batch_result in batches: + payment_vals_list.append(self._create_payment_vals_from_batch(batch_result)) + to_reconcile.append(batch_result['lines']) + + payments = self.env['account.payment'].create(payment_vals_list) + payments.action_post() + + domain = [('account_internal_type', 'in', ('receivable', 'payable')), ('reconciled', '=', False)] + for payment, lines in zip(payments, to_reconcile): + payment_lines = payment.line_ids.filtered_domain(domain) + for account in payment_lines.account_id: + (payment_lines + lines)\ + .filtered_domain([('account_id', '=', account.id), ('reconciled', '=', False)])\ + .reconcile() + + return payments + + def action_create_payments(self): + payments = self._create_payments() + + if self._context.get('dont_redirect_to_payments'): + return True + + action = { + 'name': _('Payments'), + 'type': 'ir.actions.act_window', + 'res_model': 'account.payment', + 'context': {'create': False}, + } + if len(payments) == 1: + action.update({ + 'view_mode': 'form', + 'res_id': payments.id, + }) + else: + action.update({ + 'view_mode': 'tree,form', + 'domain': [('id', 'in', payments.ids)], + }) + return action diff --git a/addons/account/wizard/account_payment_register_views.xml b/addons/account/wizard/account_payment_register_views.xml new file mode 100644 index 00000000000..15aee188635 --- /dev/null +++ b/addons/account/wizard/account_payment_register_views.xml @@ -0,0 +1,82 @@ + + + + + + account.payment.register.form + account.payment.register + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+ +
+
diff --git a/addons/account/wizard/setup_wizards.py b/addons/account/wizard/setup_wizards.py index d0f50da005a..4606c48c9b5 100644 --- a/addons/account/wizard/setup_wizards.py +++ b/addons/account/wizard/setup_wizards.py @@ -93,7 +93,7 @@ class SetupBarBankConfigWizard(models.TransientModel): def _onchange_new_journal_code(self): for record in self: if not record.linked_journal_id: - record.new_journal_code = self.env['account.journal'].get_next_bank_cash_default_code('bank', self.env.company.id) + record.new_journal_code = self.env['account.journal'].get_next_bank_cash_default_code('bank', self.env.company) else: record.new_journal_code = self.linked_journal_id.code diff --git a/addons/account_check_printing/models/account_payment.py b/addons/account_check_printing/models/account_payment.py index eeb098c2f45..1034cf84ada 100644 --- a/addons/account_check_printing/models/account_payment.py +++ b/addons/account_check_printing/models/account_payment.py @@ -7,31 +7,38 @@ from odoo.tools.misc import formatLang, format_date INV_LINES_PER_STUB = 9 -class AccountPaymentRegister(models.TransientModel): - _inherit = "account.payment.register" - - def _prepare_payment_vals(self, invoices): - res = super(AccountPaymentRegister, self)._prepare_payment_vals(invoices) - if self.payment_method_id == self.env.ref('account_check_printing.account_payment_method_check'): - currency_id = self.env['res.currency'].browse(res['currency_id']) - res.update({ - 'check_amount_in_words': currency_id.amount_to_text(res['amount']), - }) - return res - class AccountPayment(models.Model): _inherit = "account.payment" - check_amount_in_words = fields.Char(string="Amount in Words") - check_manual_sequencing = fields.Boolean(related='journal_id.check_manual_sequencing', readonly=1) - check_number = fields.Char(string="Check Number", readonly=True, copy=False, + check_amount_in_words = fields.Char(string="Amount in Words", store=True, readonly=False, + compute='_compute_check_amount_in_words') + check_manual_sequencing = fields.Boolean(related='journal_id.check_manual_sequencing') + check_number = fields.Char(string="Check Number", store=True, readonly=True, + compute='_compute_check_number', help="The selected journal is configured to print check numbers. If your pre-printed check paper already has numbers " "or if the current numbering is wrong, you can change it in the journal configuration page.") - check_number_int = fields.Integer(string="Check Number Integer", store=True, compute='_compute_check_number') + check_number_int = fields.Integer(string="Check Number Integer", store=True, + compute='_compute_check_number_int') + + @api.depends('payment_method_id', 'currency_id', 'amount') + def _compute_check_amount_in_words(self): + for pay in self: + if pay.currency_id and pay.payment_method_id == self.env.ref('account_check_printing.account_payment_method_check'): + pay.check_amount_in_words = pay.currency_id.amount_to_text(pay.amount) + else: + pay.check_amount_in_words = False + + @api.depends('journal_id') + def _compute_check_number(self): + for pay in self: + if pay.journal_id.check_manual_sequencing: + pay.check_number = pay.journal_id.check_sequence_id.number_next_actual + else: + pay.check_number = False @api.depends('check_number') - def _compute_check_number(self): + def _compute_check_number_int(self): # store check number as int to avoid doing a lot of checks and transformations # when calculating next_check_number for record in self: @@ -42,29 +49,8 @@ class AccountPayment(models.Model): number = 0 record.check_number_int = number - @api.onchange('amount', 'currency_id') - def _onchange_amount(self): - res = super(AccountPayment, self)._onchange_amount() - self.check_amount_in_words = self.currency_id.amount_to_text(self.amount) if self.currency_id else '' - return res - - @api.onchange('journal_id') - def _onchange_journal_id(self): - if hasattr(super(AccountPayment, self), '_onchange_journal_id'): - super(AccountPayment, self)._onchange_journal_id() - if self.journal_id.check_manual_sequencing: - self.check_number = self.journal_id.check_sequence_id.number_next_actual - - def _check_communication(self, payment_method_id, communication): - super(AccountPayment, self)._check_communication(payment_method_id, communication) - if payment_method_id == self.env.ref('account_check_printing.account_payment_method_check').id: - if not communication: - return - if len(communication) > 60: - raise ValidationError(_("A check memo cannot exceed 60 characters.")) - - def post(self): - res = super(AccountPayment, self).post() + def action_post(self): + res = super(AccountPayment, self).action_post() payment_method_check = self.env.ref('account_check_printing.account_payment_method_check') for payment in self.filtered(lambda p: p.payment_method_id == payment_method_check and p.check_manual_sequencing): sequence = payment.journal_id.check_sequence_id @@ -105,8 +91,8 @@ class AccountPayment(models.Model): self.filtered(lambda r: r.state == 'draft').post() return self.do_print_checks() - def unmark_sent(self): - self.write({'state': 'posted'}) + def action_unmark_sent(self): + self.write({'is_move_sent': False}) def do_print_checks(self): check_layout = self[0].company_id.account_check_printing_layout @@ -115,13 +101,9 @@ class AccountPayment(models.Model): report_action = self.env.ref(check_layout, False) if not report_action: raise UserError(_("Something went wrong with Check Layout, please select another layout in Invoicing/Accounting Settings and try again.")) - self.write({'state': 'sent'}) + self.write({'is_move_sent': True}) return report_action.report_action(self) - def set_check_amount_in_words(self): - for payment in self: - payment.check_amount_in_words = payment.currency_id.amount_to_text(payment.amount) if payment.currency_id else '' - ####################### #CHECK PRINTING METHODS ####################### @@ -132,15 +114,15 @@ class AccountPayment(models.Model): multi_stub = self.company_id.account_check_printing_multi_stub return { 'sequence_number': self.check_number if (self.journal_id.check_manual_sequencing and self.check_number != 0) else False, - 'payment_date': format_date(self.env, self.payment_date), + 'date': format_date(self.env, self.date), 'partner_id': self.partner_id, 'partner_name': self.partner_id.name, 'currency': self.currency_id, 'state': self.state, 'amount': formatLang(self.env, self.amount, currency_obj=self.currency_id) if i == 0 else 'VOID', 'amount_in_word': self._check_fill_line(self.check_amount_in_words) if i == 0 else 'VOID', - 'memo': self.communication, - 'stub_cropped': not multi_stub and len(self.invoice_ids) > INV_LINES_PER_STUB, + 'memo': self.ref, + 'stub_cropped': not multi_stub and len(self.move_id._get_reconciled_invoices()) > INV_LINES_PER_STUB, # If the payment does not reference an invoice, there is no stub line to display 'stub_lines': p, } @@ -158,12 +140,12 @@ class AccountPayment(models.Model): """ The stub is the summary of paid invoices. It may spill on several pages, in which case only the check on first page is valid. This function returns a list of stub lines per page. """ - if len(self.reconciled_invoice_ids) == 0: + if len(self.move_id._get_reconciled_invoices()) == 0: return None multi_stub = self.company_id.account_check_printing_multi_stub - invoices = self.reconciled_invoice_ids.sorted(key=lambda r: r.invoice_date_due) + invoices = self.move_id._get_reconciled_invoices().sorted(key=lambda r: r.invoice_date_due) debits = invoices.filtered(lambda r: r.move_type == 'in_invoice') credits = invoices.filtered(lambda r: r.move_type == 'in_refund') @@ -201,10 +183,10 @@ class AccountPayment(models.Model): # Find the account.partial.reconcile which are common to the invoice and the payment if invoice.move_type in ['in_invoice', 'out_refund']: invoice_sign = 1 - invoice_payment_reconcile = invoice.line_ids.mapped('matched_debit_ids').filtered(lambda r: r.debit_move_id in self.move_line_ids) + invoice_payment_reconcile = invoice.line_ids.mapped('matched_debit_ids').filtered(lambda r: r.debit_move_id in self.line_ids) else: invoice_sign = -1 - invoice_payment_reconcile = invoice.line_ids.mapped('matched_credit_ids').filtered(lambda r: r.credit_move_id in self.move_line_ids) + invoice_payment_reconcile = invoice.line_ids.mapped('matched_credit_ids').filtered(lambda r: r.credit_move_id in self.line_ids) if self.currency_id != self.journal_id.company_id.currency_id: amount_paid = abs(sum(invoice_payment_reconcile.mapped('amount_currency'))) diff --git a/addons/account_check_printing/tests/test_print_check.py b/addons/account_check_printing/tests/test_print_check.py index 1a79d7c419b..d693bdc3bb4 100644 --- a/addons/account_check_printing/tests/test_print_check.py +++ b/addons/account_check_printing/tests/test_print_check.py @@ -1,81 +1,105 @@ -# Make sure / performs a floating point division even if environment is python 2 -from __future__ import division - -from odoo.addons.account.tests.common import AccountTestCommon +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.common import AccountTestInvoicingCommon from odoo.addons.account_check_printing.models.account_payment import INV_LINES_PER_STUB from odoo.tests import tagged -from odoo.tests.common import Form -import time + import math - @tagged('post_install', '-at_install') -class TestPrintCheck(AccountTestCommon): +class TestPrintCheck(AccountTestInvoicingCommon): @classmethod def setUpClass(cls): - super(TestPrintCheck, cls).setUpClass() + super().setUpClass() - cls.invoice_model = cls.env['account.move'] - cls.invoice_line_model = cls.env['account.move.line'] - cls.payment_model = cls.env['account.payment'] - - cls.partner_axelor = cls.env['res.partner'].create({'name': 'A Partner'}) - cls.product = cls.env['product.product'].create({'name': 'A test Product'}) cls.payment_method_check = cls.env.ref("account_check_printing.account_payment_method_check") - cls.account_payable = cls.env['account.account'].search([('user_type_id', '=', cls.env.ref('account.data_account_type_payable').id)], limit=1) - cls.account_expenses = cls.env['account.account'].search([('user_type_id', '=', cls.env.ref('account.data_account_type_expenses').id)], limit=1) - - cls.bank_journal = cls.env['account.journal'].create({'name': 'Bank', 'type': 'bank', 'code': 'BNK67'}) - cls.bank_journal.check_manual_sequencing = True - - def create_invoice(self, amount=100, is_refund=False): - invoice = self.env['account.move'].with_context(default_move_type=is_refund and 'out_refund' or 'in_invoice').create({ - 'partner_id': self.partner_axelor.id, - 'invoice_date': time.strftime('%Y') + '-06-26', - 'date': time.strftime('%Y') + '-06-26', - 'invoice_line_ids': [ - (0, 0, { - 'product_id': self.product.id, - 'quantity': 1, - 'price_unit': is_refund and amount / 4 or amount, - }) - ] + cls.company_data['default_journal_bank'].write({ + 'outbound_payment_method_ids': [(6, 0, ( + cls.env.ref('account.account_payment_method_manual_out').id, + cls.payment_method_check.id, + ))], }) - invoice.post() - return invoice - def create_payment(self, invoices): - payment_register = Form(self.env['account.payment'].with_context(active_model='account.move', active_ids=invoices.ids)) - payment_register.payment_date = time.strftime('%Y') + '-07-15' - payment_register.journal_id = self.bank_journal - payment_register.payment_method_id = self.payment_method_check - payment = payment_register.save() - payment.post() - return payment + def test_inbound_check_manual_sequencing(self): + ''' Test the check generation for customer invoices. ''' + nb_invoices_to_test = INV_LINES_PER_STUB + 1 - def test_print_check(self): - # Make a payment for 10 invoices and 5 credit notes - invoices = self.env['account.move'] - for i in range(0, 15): - invoices |= self.create_invoice(is_refund=(i % 3 == 0)) - payment = self.create_payment(invoices) - self.assertEqual(all(payment.mapped('check_amount_in_words')), True, 'The amount in words is not set on all the payments') - self.assertEqual(all(payment.mapped('check_number')), True, 'The check number is not set on all the payments') + self.company_data['default_journal_bank'].write({ + 'check_manual_sequencing': True, + 'check_next_number': 42, + }) - # Check the data generated for the report - self.env.ref('base.main_company').write({'account_check_printing_multi_stub': True}) + # Create 10 customer invoices. + out_invoices = self.env['account.move'].create([{ + 'move_type': 'out_invoice', + 'partner_id': self.partner_a.id, + 'date': '2017-01-01', + 'invoice_date': '2017-01-01', + 'invoice_line_ids': [(0, 0, {'product_id': self.product_a.id, 'price_unit': 100.0})] + } for i in range(nb_invoices_to_test)]) + out_invoices.post() + + # Create a single payment. + payment = self.env['account.payment.register'].with_context(active_model='account.move', active_ids=out_invoices.ids).create({ + 'group_payment': True, + 'payment_method_id': self.payment_method_check.id, + })._create_payments() + + # Check created payment. + self.assertRecordValues(payment, [{ + 'payment_method_id': self.payment_method_check.id, + 'check_amount_in_words': payment.currency_id.amount_to_text(100.0 * nb_invoices_to_test), + 'check_number': '00042', + }]) + + # Check pages. + self.company_data['company'].account_check_printing_multi_stub = True report_pages = payment._check_get_pages() - self.assertEqual(len(report_pages), int(math.ceil(len(payment.reconciled_invoice_ids) / INV_LINES_PER_STUB))) - self.env.ref('base.main_company').write({'account_check_printing_multi_stub': False}) + self.assertEqual(len(report_pages), int(math.ceil(len(out_invoices) / INV_LINES_PER_STUB))) + + self.company_data['company'].account_check_printing_multi_stub = False report_pages = payment._check_get_pages() self.assertEqual(len(report_pages), 1) - def test_from_register(self): - invoices = self.env['account.move'] - for i in range(0, 3): - invoices |= self.create_invoice(is_refund=(i % 3 == 0)) - payment = self.create_payment(invoices) - self.assertEqual(all(payment.mapped('check_amount_in_words')), True, 'The amount in words is not set on all the payments') + def test_outbound_check_manual_sequencing(self): + ''' Test the check generation for refunds. ''' + nb_invoices_to_test = INV_LINES_PER_STUB + 1 + + self.company_data['default_journal_bank'].write({ + 'check_manual_sequencing': True, + 'check_next_number': 42, + }) + + # Create 10 refunds. + out_refunds = self.env['account.move'].create([{ + 'move_type': 'out_refund', + 'partner_id': self.partner_a.id, + 'date': '2017-01-01', + 'invoice_date': '2017-01-01', + 'invoice_line_ids': [(0, 0, {'product_id': self.product_a.id, 'price_unit': 100.0})] + } for i in range(nb_invoices_to_test)]) + out_refunds.post() + + # Create a single payment. + payment = self.env['account.payment.register'].with_context(active_model='account.move', active_ids=out_refunds.ids).create({ + 'group_payment': True, + 'payment_method_id': self.payment_method_check.id, + })._create_payments() + + # Check created payment. + self.assertRecordValues(payment, [{ + 'payment_method_id': self.payment_method_check.id, + 'check_amount_in_words': payment.currency_id.amount_to_text(100.0 * nb_invoices_to_test), + 'check_number': '00042', + }]) + + # Check pages. + self.company_data['company'].account_check_printing_multi_stub = True + report_pages = payment._check_get_pages() + self.assertEqual(len(report_pages), int(math.ceil(len(out_refunds) / INV_LINES_PER_STUB))) + + self.company_data['company'].account_check_printing_multi_stub = False + report_pages = payment._check_get_pages() + self.assertEqual(len(report_pages), 1) diff --git a/addons/account_check_printing/views/account_payment_views.xml b/addons/account_check_printing/views/account_payment_views.xml index 91f27101fd2..32093a46845 100644 --- a/addons/account_check_printing/views/account_payment_views.xml +++ b/addons/account_check_printing/views/account_payment_views.xml @@ -5,17 +5,16 @@ account.payment - +