From 7482aff6d719722f93f71d169d27d1c3e156ebd7 Mon Sep 17 00:00:00 2001 From: Camille Spiritus Date: Wed, 10 Aug 2022 11:22:07 +0000 Subject: [PATCH] [IMP] account: cash discount improvements Purpose : Improve multiple use cases while handling the Accounting part of Cash Discounts in Odoo. When creating a Payment Term, the user can now specifically design an early payment discount through an additional part in the wizard. This will, amongst other things, allow the user to choose in which way the tax should be computed on the discounted invoice (tax included/excluded). The discounted amount and the conditions to fulfill are printed below the invoice PDF, as well as the details. Registering a payment while filling the condition for an early payment discount will prefill the fields accordingly. Reconciliation --> In the reconciliation widget, the logic of Early Payments Discounts are also applied. Related : odoo/enterprise#28607 Task:2729038 Part-of: odoo/odoo#94050 --- addons/account/models/account_move.py | 51 +++ addons/account/models/account_payment_term.py | 38 +++ .../account/models/account_reconcile_model.py | 126 +++++-- addons/account/models/account_tax.py | 43 ++- addons/account/models/chart_template.py | 30 ++ addons/account/models/company.py | 8 +- addons/account/models/res_config_settings.py | 15 + addons/account/tests/__init__.py | 1 + .../tests/test_early_payment_discount.py | 316 ++++++++++++++++++ .../test_reconciliation_matching_rules.py | 13 +- .../views/account_payment_term_views.xml | 42 +++ addons/account/views/report_invoice.xml | 24 +- .../views/res_config_settings_views.xml | 8 + .../wizard/account_payment_register.py | 141 +++++++- .../wizard/account_payment_register_views.xml | 5 + 15 files changed, 791 insertions(+), 70 deletions(-) create mode 100644 addons/account/tests/test_early_payment_discount.py diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index e2baf6ee0b4..5d6f70d3277 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -444,6 +444,12 @@ class AccountMove(models.Model): tracking=True, ) + # ==== Early payment cash discount field ==== + invoice_early_pay_amount_after_discount = fields.Monetary( + compute='_compute_early_pay_amount_after_discount', + help="Total amount left to pay after the discount was applied", + ) + # === Reverse feature fields === # reversed_entry_id = fields.Many2one( comodel_name='account.move', @@ -1155,6 +1161,21 @@ class AccountMove(models.Model): # Non-invoice moves don't support that field (because of multicurrency: all lines of the invoice share the same currency) move.tax_totals = None + def _get_report_early_payment_totals_values(self): + self.ensure_one() + + if self.move_type not in ['out_invoice', 'out_receipt', 'in_invoice', 'in_receipt'] or \ + not self.payment_state == 'not_paid' or \ + not self.invoice_payment_term_id.has_early_payment: + return + + base_lines = self.line_ids.filtered(lambda x: x.display_type == 'product') + return self.env['account.tax']._prepare_tax_totals( + [x._convert_to_tax_base_line_dict() for x in base_lines], + self.currency_id, + early_payment_term=self.invoice_payment_term_id, + ) + @api.depends('partner_id', 'invoice_source_email', 'partner_id.name') def _compute_invoice_partner_display_info(self): for move in self: @@ -1273,6 +1294,36 @@ class AccountMove(models.Model): updated_credit = move.partner_id.credit + move.amount_total_signed move.partner_credit_warning = self._build_credit_warning_message(move, updated_credit) + @api.depends('invoice_payment_term_id', 'amount_residual_signed', 'currency_id') + def _compute_early_pay_amount_after_discount(self): + for record in self: + if record.invoice_payment_term_id.has_early_payment: + percentage_to_discount = record.invoice_payment_term_id.percentage_to_discount + discount_computation = self.invoice_payment_term_id.discount_computation + + discounted_amount_untaxed = (100 - percentage_to_discount) * record.amount_untaxed / 100 + if discount_computation == 'included': + discounted_amount_tax = (100 - percentage_to_discount) * record.amount_tax / 100 + else: + discounted_amount_tax = record.amount_tax + record.invoice_early_pay_amount_after_discount = discounted_amount_untaxed + discounted_amount_tax + if record.currency_id.compare_amounts(record.invoice_early_pay_amount_after_discount, 0.0) <= 0.0: + record.invoice_early_pay_amount_after_discount = 0 + else: + record.invoice_early_pay_amount_after_discount = 0 + + def _is_eligible_for_early_discount(self, payment_date): + ''' + An early payment discount is possible if the option has been activated, + no partial payment was registered, + and the payment date is before the last early_payment_date possible. + ''' + self.ensure_one() + return self.move_type in ['out_invoice', 'out_receipt', 'in_invoice', 'in_receipt'] and \ + self.invoice_payment_term_id.has_early_payment and \ + self.payment_state == 'not_paid' and \ + payment_date <= self.invoice_payment_term_id._get_last_date_for_discount(self.invoice_date) + def _build_credit_warning_message(self, record, updated_credit): ''' Build the warning message that will be displayed in a yellow banner on top of the current record if the partner exceeds a credit limit (set on the company or the partner itself). diff --git a/addons/account/models/account_payment_term.py b/addons/account/models/account_payment_term.py index c7d526c5b06..48adc657fc0 100644 --- a/addons/account/models/account_payment_term.py +++ b/addons/account/models/account_payment_term.py @@ -4,6 +4,7 @@ from odoo import api, exceptions, fields, models, _ from odoo.exceptions import UserError, ValidationError from dateutil.relativedelta import relativedelta +from datetime import timedelta class AccountPaymentTerm(models.Model): @@ -21,6 +22,17 @@ class AccountPaymentTerm(models.Model): company_id = fields.Many2one('res.company', string='Company') sequence = fields.Integer(required=True, default=10) + # -------Early payment discount fields------- + early_payment_applicable = fields.Boolean(string="True if the conditions required to offer an EPD are fulfilled.", compute="_compute_early_payment_applicable") + has_early_payment = fields.Boolean(string="Apply Early Payment Discount", compute="_compute_has_early_payment", readonly=False, store=True) + percentage_to_discount = fields.Float("Discount", digits='Discount', default=2) + discount_computation = fields.Selection([ + ('included', 'Tax included'), + ('excluded', 'Tax excluded'), + ], string='Computation', default='included') + discount_days = fields.Integer(string='Availability', required=True, default=7) + discount_account_id = fields.Many2one(comodel_name='account.account', string='Counterpart Account') + @api.constrains('line_ids') def _check_lines(self): for terms in self: @@ -95,6 +107,32 @@ class AccountPaymentTerm(models.Model): ).unlink() return super(AccountPaymentTerm, self).unlink() + @api.depends('line_ids') + def _compute_early_payment_applicable(self): + for term in self: + term.early_payment_applicable = not any(line.value != 'balance' for line in term.line_ids) + + @api.depends('early_payment_applicable') + def _compute_has_early_payment(self): + for term in self: + if not term.early_payment_applicable: + term.has_early_payment = False + + @api.onchange('discount_days') + def _onchange_discount_days(self): + if self.discount_days <= 0: + raise ValidationError(_("The discount availability must be strictly positive.")) + + @api.onchange('percentage_to_discount') + def _onchange_percentage_to_discount(self): + if self.percentage_to_discount <= 0: + raise ValidationError(_("The discount percentage must be strictly positive.")) + if self.percentage_to_discount > 100: + raise ValidationError(_("The discount percentage cannot exceed 100.")) + + def _get_last_date_for_discount(self, move_date): + return move_date + timedelta(days=self.discount_days) + class AccountPaymentTermLine(models.Model): _name = "account.payment.term.line" diff --git a/addons/account/models/account_reconcile_model.py b/addons/account/models/account_reconcile_model.py index 0d2d67f8e39..9c7dff3f4ae 100644 --- a/addons/account/models/account_reconcile_model.py +++ b/addons/account/models/account_reconcile_model.py @@ -809,74 +809,126 @@ class AccountReconcileModel(models.Model): return self.env['res.partner'] def _get_invoice_matching_amls_result(self, st_line, partner, candidate_vals): + def _create_result_dict(amls_values_list, status): + if 'rejected' in status: + return + + result = { + 'amls': self.env['account.move.line'], + 'amls_values_list': amls_values_list, + } + for aml_values in amls_values_list: + result['amls'] |= aml_values['aml'] + + if 'allow_write_off' in status and self.line_ids: + result['status'] = 'write_off' + + if 'allow_auto_reconcile' in status and candidate_vals['allow_auto_reconcile'] and self.auto_reconcile: + result['auto_reconcile'] = True + + return result + st_line_currency = st_line.foreign_currency_id or st_line.currency_id + st_line_amount = st_line._prepare_move_line_default_vals()[1]['amount_currency'] + sign = 1 if st_line_amount > 0.0 else -1 amls = candidate_vals['amls'] - if amls.currency_id == st_line_currency: - st_line_amount = st_line._prepare_move_line_default_vals()[1]['amount_currency'] - sign = 1 if st_line_amount > 0.0 else -1 + amls_values_list = [] + same_currency_mode = amls.currency_id == st_line_currency + for aml in amls: + extra_aml_values = {} - kepts_amls = self.env['account.move.line'] - sum_aml_residual = 0.0 - for aml in amls: + amount_residual = aml.amount_residual + amount_residual_currency = aml.amount_residual_currency - if st_line_currency.compare_amounts(st_line_amount, -aml.amount_residual_currency) == 0: + # Manage the early payment discount. + if same_currency_mode and aml.move_id._is_eligible_for_early_discount(st_line.date): + amount_residual = aml.move_id.amount_total_signed + amount_residual_currency = -aml.move_id.invoice_early_pay_amount_after_discount * sign + discounted_balance = aml.amount_residual_currency - amount_residual_currency + + if aml.move_id.move_type == 'out_invoice': + account = aml.company_id.account_journal_cash_discount_expense_id + else: + account = aml.company_id.account_journal_cash_discount_income_id + + extra_aml_values['early_payment_vals'] = { + 'name': _("%s - Early Payment Discount", aml.display_name), + 'account': account, + 'balance': discounted_balance, + } + + amls_values_list.append({ + 'aml': aml, + 'amount_residual': amount_residual, + 'amount_residual_currency': amount_residual_currency, + **extra_aml_values, + }) + + kepts_amls_values_list = [] + sum_amount_residual_currency = 0.0 + if same_currency_mode: + for aml_values in amls_values_list: + + if st_line_currency.compare_amounts(st_line_amount, -aml_values['amount_residual_currency']) == 0: # Special case: the amounts are the same, submit the line directly. - kepts_amls = aml + kepts_amls_values_list = [aml_values] break - elif st_line_currency.compare_amounts(sign * (st_line_amount + sum_aml_residual), 0.0) > 0: + if st_line_currency.compare_amounts(sign * (st_line_amount + sum_amount_residual_currency), 0.0) > 0: # Here, we still have room for other candidates ; so we add the current one to the list we keep. # Then, we continue iterating, even if there is no room anymore, just in case one of the following candidates # is an exact match, which would then be preferred on the current candidates. - kepts_amls |= aml - sum_aml_residual += aml.amount_residual_currency - else: - kepts_amls = amls + kepts_amls_values_list.append(aml_values) + sum_amount_residual_currency += aml_values['amount_residual_currency'] - # We check the amount criteria of the reconciliation model, and select the - # kept_candidates if they pass the verification. - status = self._check_rule_propositions(st_line, kepts_amls) - if 'rejected' in status: - return None + # Try to match the amls having the same currency as the statement line. + if kepts_amls_values_list: + status = self._check_rule_propositions(st_line, kepts_amls_values_list) + result = _create_result_dict(kepts_amls_values_list, status) + if result: + return result - rslt = {'amls': kepts_amls} + # Try to match the whole candidates. + if amls: + status = self._check_rule_propositions(st_line, amls_values_list) + result = _create_result_dict(amls_values_list, status) + if result: + return result - if 'allow_write_off' in status and self.line_ids: - rslt['status'] = 'write_off' - - if 'allow_auto_reconcile' in status and candidate_vals['allow_auto_reconcile'] and self.auto_reconcile: - rslt['auto_reconcile'] = True - - return rslt - - def _check_rule_propositions(self, st_line, amls): + def _check_rule_propositions(self, st_line, amls_values_list): """ Check restrictions that can't be handled for each move.line separately. Note: Only used by models having a type equals to 'invoice_matching'. - - :param st_line: The statement line. - :param amls: The candidates account.move.line. + :param st_line: The statement line. + :param amls_values_list: The candidates account.move.line as a list of dict: + * aml: The record. + * amount_residual: The amount residual to consider. + * amount_residual_currency: The amount residual in foreign currency to consider. :return: A string representing what to do with the candidates: * rejected: Reject candidates. * allow_write_off: Allow to generate the write-off from the reconcile model lines if specified. * allow_auto_reconcile: Allow to automatically reconcile entries if 'auto_validate' is enabled. """ + self.ensure_one() + if not self.allow_payment_tolerance: return {'allow_write_off', 'allow_auto_reconcile'} - if not amls: - return {'rejected'} - currency = st_line.foreign_currency_id or st_line.currency_id + st_line_currency = st_line.foreign_currency_id or st_line.currency_id st_line_amount_curr = st_line._prepare_move_line_default_vals()[1]['amount_currency'] amls_amount_curr = sum( - st_line._prepare_counterpart_amounts_using_st_line_rate(aml.currency_id, aml.amount_residual, aml.amount_residual_currency)['amount_currency'] - for aml in amls + st_line._prepare_counterpart_amounts_using_st_line_rate( + aml_values['aml'].currency_id, + aml_values['amount_residual'], + aml_values['amount_residual_currency'], + )['amount_currency'] + for aml_values in amls_values_list ) sign = 1 if st_line_amount_curr > 0.0 else -1 amount_curr_after_rec = sign * (amls_amount_curr + st_line_amount_curr) # The statement line will be fully reconciled. - if currency.is_zero(amount_curr_after_rec): + if st_line_currency.is_zero(amount_curr_after_rec): return {'allow_auto_reconcile'} # The payment amount is higher than the sum of invoices. diff --git a/addons/account/models/account_tax.py b/addons/account/models/account_tax.py index 879cfc6d72b..29fc18354a0 100644 --- a/addons/account/models/account_tax.py +++ b/addons/account/models/account_tax.py @@ -807,8 +807,7 @@ class AccountTax(models.Model): 'analytic_account_id': line_vals['analytic_account'].id if tax.analytic else False, } - def _compute_taxes(self, base_lines, tax_lines=None, - handle_price_include=True, include_caba_tags=False): + def _compute_taxes(self, base_lines, tax_lines=None, handle_price_include=True, include_caba_tags=False, early_payment_term=None): """ Generic method to compute the taxes for different business models. :param base_lines: A list of python dictionaries created using the '_convert_to_tax_base_line_dict' method. @@ -816,6 +815,7 @@ class AccountTax(models.Model): :param handle_price_include: Manage the price-included taxes. If None, use the 'handle_price_include' key set on base lines. :param include_caba_tags: Manage tags for taxes being exigible on_payment. + :param early_payment_term: The payment term to consider for the early payment discount. :return: A python dictionary containing: The complete diff on tax lines if 'tax_lines' is passed as parameter: @@ -852,10 +852,15 @@ class AccountTax(models.Model): base_line_map = {} for line_vals in base_lines: - price_unit_after_discount = line_vals['price_unit'] * (1 - (line_vals['discount'] / 100.0)) + orig_price_unit_after_discount = line_vals['price_unit'] * (1 - (line_vals['discount'] / 100.0)) + price_unit_after_discount = orig_price_unit_after_discount taxes = line_vals['taxes']._origin currency = line_vals['currency'] or self.env.company.currency_id + if early_payment_term and early_payment_term.has_early_payment: + remaining_part_to_consider = (100 - early_payment_term.percentage_to_discount) / 100.0 + price_unit_after_discount = remaining_part_to_consider * price_unit_after_discount + if taxes: if handle_price_include is None: @@ -880,6 +885,24 @@ class AccountTax(models.Model): 'price_total': taxes_res['total_included'], } + if early_payment_term \ + and early_payment_term.has_early_payment \ + and early_payment_term.discount_computation == 'excluded': + new_taxes_res = taxes.with_context(**line_vals['extra_context']).compute_all( + orig_price_unit_after_discount, + currency=currency, + quantity=line_vals['quantity'], + product=line_vals['product'], + partner=line_vals['partner'], + is_refund=line_vals['is_refund'], + handle_price_include=manage_price_include, + include_caba_tags=include_caba_tags, + ) + for tax_res, new_taxes_res in zip(taxes_res['taxes'], new_taxes_res['taxes']): + delta_tax = new_taxes_res['amount'] - tax_res['amount'] + tax_res['amount'] += delta_tax + to_update_vals['price_total'] += delta_tax + for tax_res in taxes_res['taxes']: grouping_dict = self._get_generation_dict_from_base_line(line_vals, tax_res) tax_res['grouping_dict'] = grouping_dict @@ -1006,13 +1029,14 @@ class AccountTax(models.Model): }) return tax_group_vals_list - def _prepare_tax_totals(self, base_lines, currency, tax_lines=None): + def _prepare_tax_totals(self, base_lines, currency, tax_lines=None, early_payment_term=None): """ Compute the tax totals details for the business documents. - :param base_lines: A list of python dictionaries created using the '_convert_to_tax_base_line_dict' method. - :param currency: The currency set on the business document. - :param tax_lines: Optional list of python dictionaries created using the '_convert_to_tax_line_dict' method. - If specified, the taxes will be recomputed using them instead of recomputing the taxes on - the provided base lines. + :param base_lines: A list of python dictionaries created using the '_convert_to_tax_base_line_dict' method. + :param currency: The currency set on the business document. + :param tax_lines: Optional list of python dictionaries created using the '_convert_to_tax_line_dict' method. + If specified, the taxes will be recomputed using them instead of recomputing the taxes on + the provided base lines. + :param early_payment_term: The payment term to consider for the early payment discount. :return: A dictionary in the following form: { 'amount_total': The total amount to be displayed on the document, including every total @@ -1062,6 +1086,7 @@ class AccountTax(models.Model): tax_results = self._compute_taxes( base_lines, tax_lines=tax_lines, + early_payment_term=early_payment_term, ) for base_line_vals, to_update in tax_results['base_lines_to_update']: diff --git a/addons/account/models/chart_template.py b/addons/account/models/chart_template.py index a95f2b395c0..bed7c82425a 100644 --- a/addons/account/models/chart_template.py +++ b/addons/account/models/chart_template.py @@ -136,6 +136,9 @@ class AccountChartTemplate(models.Model): 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") + account_journal_cash_discount_expense_id = fields.Many2one(comodel_name='account.account.template', string='Cash Discount Write-Off Expense Account',) + account_journal_cash_discount_income_id = fields.Many2one(comodel_name='account.account.template', string='Cash Discount Write-Off Income Account',) + property_account_receivable_id = fields.Many2one('account.account.template', string='Receivable Account') property_account_payable_id = fields.Many2one('account.account.template', string='Payable Account') property_account_expense_categ_id = fields.Many2one('account.account.template', string='Category of Expense Account') @@ -197,6 +200,24 @@ class AccountChartTemplate(models.Model): 'company_id': company.id, }) + @api.model + def _create_cash_discount_expense_account(self, company, code_digits): + return self.env['account.account'].create({ + 'name': _("Cash Discount Expense Account"), + 'code': 999997, + 'account_type': 'expense', + 'company_id': company.id, + }) + + @api.model + def _create_cash_discount_income_account(self, company, code_digits): + return self.env['account.account'].create({ + 'name': _("Cash Discount Income Account"), + 'code': 999998, + 'account_type': 'income_other', + 'company_id': company.id, + }) + def try_loading(self, company=False, install_demo=True): """ Installs this chart of accounts for the current company if not chart of accounts had been created for it yet. @@ -311,6 +332,13 @@ class AccountChartTemplate(models.Model): 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 cash discount write-off accounts + if not company.account_journal_cash_discount_expense_id: + company.account_journal_cash_discount_expense_id = self._create_cash_discount_expense_account(company, self.code_digits) + + if not company.account_journal_cash_discount_income_id: + company.account_journal_cash_discount_income_id = self._create_cash_discount_income_account(company, self.code_digits) + if not company.account_journal_payment_debit_account_id: company.account_journal_payment_debit_account_id = self.env['account.account'].create({ 'name': _("Outstanding Receipts"), @@ -617,6 +645,8 @@ class AccountChartTemplate(models.Model): 'account_default_pos_receivable_account_id': self.default_pos_receivable_account_id, 'income_currency_exchange_account_id': self.income_currency_exchange_account_id, 'expense_currency_exchange_account_id': self.expense_currency_exchange_account_id, + 'account_journal_cash_discount_expense_id': self.account_journal_cash_discount_expense_id, + 'account_journal_cash_discount_income_id': self.account_journal_cash_discount_income_id, } values = {} diff --git a/addons/account/models/company.py b/addons/account/models/company.py index c1579671df3..7bf66d19c62 100644 --- a/addons/account/models/company.py +++ b/addons/account/models/company.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- -from datetime import timedelta, datetime, date import calendar -from dateutil.relativedelta import relativedelta +from datetime import timedelta, datetime, date from odoo import fields, models, api, _ from odoo.exceptions import ValidationError, UserError, RedirectWarning +from odoo.tools.float_utils import float_round, float_is_zero from odoo.tools.mail import is_html_empty from odoo.tools.misc import format_date from odoo.tools.float_utils import float_round, float_is_zero @@ -66,6 +66,10 @@ class ResCompany(models.Model): account_journal_suspense_account_id = fields.Many2one('account.account', string='Journal Suspense Account') account_journal_payment_debit_account_id = fields.Many2one('account.account', string='Journal Outstanding Receipts Account') account_journal_payment_credit_account_id = fields.Many2one('account.account', string='Journal Outstanding Payments Account') + + account_journal_cash_discount_income_id = fields.Many2one(comodel_name='account.account', string='Cash Discount Write-Off Income Account') + account_journal_cash_discount_expense_id = fields.Many2one(comodel_name='account.account', string='Cash Discount Write-Off Expense 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") diff --git a/addons/account/models/res_config_settings.py b/addons/account/models/res_config_settings.py index 54cd92d4978..686a143783b 100644 --- a/addons/account/models/res_config_settings.py +++ b/addons/account/models/res_config_settings.py @@ -161,6 +161,21 @@ class ResConfigSettings(models.TransientModel): # Quick encoding (fiduciary mode) quick_edit_mode = fields.Selection(string="Quick encoding", readonly=False, related='company_id.quick_edit_mode') + account_journal_cash_discount_income_id = fields.Many2one( + comodel_name='account.account', + string='Cash Discount Write-Off Income Account', + readonly=False, + related='company_id.account_journal_cash_discount_income_id', + domain="[('deprecated', '=', False), ('company_id', '=', company_id), \ + ('account_type', 'in', ('income', 'income_other'))]") + account_journal_cash_discount_expense_id = fields.Many2one( + comodel_name='account.account', + string='Cash Discount Write-Off Expense Account', + readonly=False, + related='company_id.account_journal_cash_discount_expense_id', + domain="[('deprecated', '=', False), ('company_id', '=', company_id), \ + ('account_type', '=', 'expense')]") + def set_values(self): super().set_values() # install a chart of accounts for the given company (if required) diff --git a/addons/account/tests/__init__.py b/addons/account/tests/__init__.py index 0f599db0c0b..65a5440755a 100644 --- a/addons/account/tests/__init__.py +++ b/addons/account/tests/__init__.py @@ -35,3 +35,4 @@ from . import test_account_incoming_supplier_invoice from . import test_payment_term from . import test_account_payment_register from . import test_tour +from . import test_early_payment_discount diff --git a/addons/account/tests/test_early_payment_discount.py b/addons/account/tests/test_early_payment_discount.py new file mode 100644 index 00000000000..1d8878e1381 --- /dev/null +++ b/addons/account/tests/test_early_payment_discount.py @@ -0,0 +1,316 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged +from odoo import fields, Command + + +@tagged('post_install', '-at_install') +class TestAccountEarlyPaymentDiscount(AccountTestInvoicingCommon): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + # Payment Terms + cls.default_pay_term_cash_discount = cls.env['account.payment.term'].create({ + 'name': 'Default Early Payment Cash Discount Payment Term', + 'has_early_payment': True, + 'line_ids': [ + (0, 0, { + 'value': 'balance', + 'days': 0, + 'option': 'day_after_invoice_date', + }), + ], + }) + + cls.pay_term_cash_discount_10_percent_10_days_tax_inc = cls.env['account.payment.term'].create({ + 'name': '10% reduction if payment within 10 days, tax included', + 'has_early_payment': True, + 'discount_days': 10, + 'percentage_to_discount': 10, + 'line_ids': [ + (0, 0, { + 'value': 'balance', + 'days': 0, + 'option': 'day_after_invoice_date', + }), + ], + }) + cls.pay_term_cash_discount_10_percent_10_days_tax_excl = cls.env['account.payment.term'].create({ + 'name': '10% reduction if payment within 10 days, tax excluded', + 'has_early_payment': True, + 'discount_days': 10, + 'percentage_to_discount': 10, + 'discount_computation': 'excluded', + 'line_ids': [ + (0, 0, { + 'value': 'balance', + 'days': 0, + 'option': 'day_after_invoice_date', + }), + ], + }) + # Invoices (account move tests) + cls.inv_1200_10_percents_discount_no_tax = cls.env['account.move'].create({ + 'move_type': 'in_invoice', + 'partner_id': cls.partner_a.id, + 'invoice_date': '2019-01-01', + 'date': '2019-01-01', + 'invoice_line_ids': [(0, 0, { + 'name': 'line', + 'price_unit': 1200.0, + 'tax_ids': [] + })], + 'invoice_payment_term_id': cls.pay_term_cash_discount_10_percent_10_days_tax_inc.id, + }) + cls.inv_1500_10_percents_discount_tax_incl_15_percents_tax = cls.env['account.move'].create({ + 'move_type': 'in_invoice', + 'partner_id': cls.partner_a.id, + 'invoice_date': '2019-01-01', + 'date': '2019-01-01', + 'invoice_line_ids': [(0, 0, { + 'name': 'line', + 'price_unit': 1500.0, + 'tax_ids': [Command.set(cls.product_a.taxes_id.ids)],# 15% + })], + 'invoice_payment_term_id': cls.pay_term_cash_discount_10_percent_10_days_tax_inc.id, + }) + cls.inv_1500_10_percents_discount_tax_excl_15_percents_tax = cls.env['account.move'].create({ + 'move_type': 'in_invoice', + 'partner_id': cls.partner_a.id, + 'invoice_date': '2019-01-01', + 'date': '2019-01-01', + 'invoice_line_ids': [(0, 0, { + 'name': 'line', + 'price_unit': 1500.0, + 'tax_ids': [Command.set(cls.product_a.taxes_id.ids)],# 15% + })], + 'invoice_payment_term_id': cls.pay_term_cash_discount_10_percent_10_days_tax_excl.id, + }) + # Invoices (Payment Register & reconciliation tests) + # -- 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, 'tax_ids': []})], + 'invoice_payment_term_id': cls.default_pay_term_cash_discount.id, + }) + 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, 'tax_ids': []})], + 'invoice_payment_term_id': cls.default_pay_term_cash_discount.id, + }) + #--Customer invoices from a different batch + cls.out_invoice_3 = cls.env['account.move'].create({ + 'move_type': 'out_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, 'tax_ids': []})], + 'invoice_payment_term_id': cls.default_pay_term_cash_discount.id, + }) + cls.out_invoice_4_no_discount = cls.env['account.move'].create({ + 'move_type': 'out_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': 5000.0, 'tax_ids': []})], + 'invoice_payment_term_id': None, + }) + + (cls.out_invoice_1 + cls.out_invoice_2 + cls.out_invoice_3 + cls.out_invoice_4_no_discount).action_post() + + # Taxes + cls.tax_10 = cls.env['account.tax'].create({ + 'name': "tax_10", + 'amount_type': 'percent', + 'amount': 10.0, + }) + + cls.tax_20 = cls.env['account.tax'].create({ + 'name': "tax_20", + 'amount_type': 'percent', + 'amount': 20.0, + }) + lines_data = [ + (1100, cls.tax_10 + cls.tax_20), + (1100, cls.tax_10), + (1000, cls.tax_20), + ] + invoice_lines_vals = [ + (0, 0, { + 'name': 'line', + 'account_id': cls.company_data['default_account_revenue'].id, + 'price_unit': amount, + 'tax_ids': [(6, 0, taxes.ids)], + }) + for amount, taxes in lines_data] + cls.taxed_invoice = cls.env['account.move'].create({ + 'move_type': 'out_invoice', + 'partner_id': cls.partner_a.id, + 'invoice_line_ids': invoice_lines_vals, + 'invoice_payment_term_id': cls.pay_term_cash_discount_10_percent_10_days_tax_inc.id, + }) + + # ========================== Tests Payment Terms ========================== + def test_early_payment_assert_default_values(self): + # Check that the early discount payment term is correctly set by default + self.assertRecordValues(self.default_pay_term_cash_discount, [{ + 'discount_computation': 'included', + 'discount_days': 7, + 'percentage_to_discount': 2.0 + }]) + + def test_early_payment_end_date(self): + # Check that the last date of the cash discount availability is correctly computed. + # The default time delay is 7 days after the invoice. + def assertEarlyDiscountPaymentTermDate(pay_term, move_date, expected_date): + self.assertEqual( + pay_term._get_last_date_for_discount(fields.Date.from_string(move_date)), + fields.Date.from_string(expected_date) + ) + assertEarlyDiscountPaymentTermDate(self.default_pay_term_cash_discount, '2022-01-01', '2022-01-08') + assertEarlyDiscountPaymentTermDate(self.default_pay_term_cash_discount, '2022-01-31', '2022-02-07') + assertEarlyDiscountPaymentTermDate(self.default_pay_term_cash_discount, '2022-02-25', '2022-03-04') + + # ========================== Tests Account Move ========================== + def test_early_pay_reduced_amounts(self): + # Check that the monetary amounts after the discount are correct. + # 1200 invoice, no tax, 10% discount + self.assertRecordValues(self.inv_1200_10_percents_discount_no_tax, [{ + 'invoice_early_pay_amount_after_discount': 1080.00, + }]) + # 1500 invoice, 15% tax, 10% discount including taxes + self.assertRecordValues(self.inv_1500_10_percents_discount_tax_incl_15_percents_tax, [{ + 'invoice_early_pay_amount_after_discount': 1552.50, + }]) + # 1500 invoice, 15% tax, 10% discount excluding taxes + self.assertRecordValues(self.inv_1500_10_percents_discount_tax_excl_15_percents_tax, [{ + 'invoice_early_pay_amount_after_discount': 1575.0, + }]) + + # ========================== Tests Payment Register ========================== + def test_register_discounted_payment_on_single_invoice(self): + active_ids = self.out_invoice_1.ids + payments = self.env['account.payment.register'].with_context(active_model='account.move', active_ids=active_ids).create({ + 'payment_date': '2017-01-01', + 'early_pay_discount_toggle_button': True, + })._create_payments() + + self.assertTrue(payments.is_reconciled) + self.assertRecordValues(payments.line_ids.sorted('balance'), [ + {'amount_currency': -1000.0}, + { + 'account_id': self.env.company['account_journal_cash_discount_expense_id'].id, + 'amount_currency': 20.0, + }, + {'amount_currency': 980.0}, + ]) + + def test_register_discounted_payment_on_batched_invoice(self): + active_ids = (self.out_invoice_1 + self.out_invoice_2).ids + payment_register = self.env['account.payment.register']\ + .with_context(active_model='account.move', active_ids=active_ids)\ + .create({ + 'payment_date': '2017-01-01', + 'early_pay_discount_toggle_button': True, + }) + payments = payment_register._create_payments() + self.assertRecordValues(payments.line_ids.sorted('balance'), [ + {'amount_currency': -2000.0}, + {'amount_currency': -1000.0}, + { + 'account_id': self.env.company['account_journal_cash_discount_expense_id'].id, + 'amount_currency': 20.0, + }, + { + 'account_id': self.env.company['account_journal_cash_discount_expense_id'].id, + 'amount_currency': 40.0, + }, + {'amount_currency': 980.0}, + {'amount_currency': 1960.0}, + ]) + + self.assertTrue(all(p.is_reconciled for p in payments)) + + def test_register_discounted_payment_on_non_batched_invoice(self): + active_ids = (self.out_invoice_1 + self.out_invoice_2 + self.out_invoice_3).ids + payment_register = self.env['account.payment.register'] \ + .with_context(active_model='account.move', active_ids=active_ids) \ + .create({ + 'payment_date': '2017-01-01', + 'early_pay_discount_toggle_button': True, + }) + payments = payment_register._create_payments() + self.assertRecordValues(payments.line_ids.sorted('balance'), [ + {'amount_currency': -3000.0}, + {'amount_currency': -2000.0}, + {'amount_currency': -1000.0}, + { + 'account_id': self.env.company['account_journal_cash_discount_expense_id'].id, + 'amount_currency': 20.0, + }, + { + 'account_id': self.env.company['account_journal_cash_discount_expense_id'].id, + 'amount_currency': 40.0, + }, + { + 'account_id': self.env.company['account_journal_cash_discount_expense_id'].id, + 'amount_currency': 60.0, + }, + {'amount_currency': 980.0}, + {'amount_currency': 1960.0}, + {'amount_currency': 2940.0}, + ]) + self.assertTrue(all(p.is_reconciled for p in payments)) + + def test_register_discounted_payment_on_non_batched_invoice_one_no_discount(self): + active_ids = (self.out_invoice_3 + self.out_invoice_4_no_discount).ids + payment_register = self.env['account.payment.register'] \ + .with_context(active_model='account.move', active_ids=active_ids) \ + .create({ + 'payment_date': '2017-01-01', + 'early_pay_discount_toggle_button': True, + }) + payments = payment_register._create_payments() + self.assertRecordValues(payments.line_ids.sorted('balance'), [ + {'amount_currency': -5000.0}, + {'amount_currency': -3000.0}, + { + 'account_id': self.env.company['account_journal_cash_discount_expense_id'].id, + 'amount_currency': 60.0, + }, + {'amount_currency': 2940.0}, + {'amount_currency': 5000.0}, + ]) + self.assertTrue(all(p.is_reconciled for p in payments)) + + def test_register_payment_deactivate_discount(self): + active_ids = self.out_invoice_3.ids + payment_register = self.env['account.payment.register'] \ + .with_context(active_model='account.move', active_ids=active_ids) \ + .create({ + 'payment_date': '2017-01-01', + 'early_pay_discount_toggle_button': False, + }) + payments = payment_register._create_payments() + self.assertRecordValues(payments.line_ids.sorted('balance'), [ + {'amount_currency': -3000.0}, + {'amount_currency': 3000.0}, + ]) + self.assertTrue(payments.is_reconciled) + + def test_early_payment_taxes_computation(self): + result = self.taxed_invoice._get_report_early_payment_totals_values() + self.assertEqual(result['amount_total'], 3456) + self.assertEqual(result['amount_untaxed'], 2880) + self.assertEqual(result['groups_by_subtotal']['Untaxed Amount'][0]['tax_group_amount'], 576) diff --git a/addons/account/tests/test_reconciliation_matching_rules.py b/addons/account/tests/test_reconciliation_matching_rules.py index e29fc59043e..125420fdede 100644 --- a/addons/account/tests/test_reconciliation_matching_rules.py +++ b/addons/account/tests/test_reconciliation_matching_rules.py @@ -234,6 +234,7 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): def _check_statement_matching(self, rules, expected_values_list): for statement_line, expected_values in expected_values_list.items(): res = rules._apply_rules(statement_line, statement_line._retrieve_partner()) + res.pop('amls_values_list', None) self.assertDictEqual(res, expected_values) def test_matching_fields(self): @@ -258,20 +259,26 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): match_text_location_reference=False, match_text_location_note=False, ) + rslt = rule._apply_rules(st_line, st_line._retrieve_partner()) + rslt.pop('amls_values_list') self.assertDictEqual( - rule._apply_rules(st_line, st_line._retrieve_partner()), + rslt, {'amls': inv1, 'model': rule}, ) rule.match_text_location_reference = True + rslt = rule._apply_rules(st_line, st_line._retrieve_partner()) + rslt.pop('amls_values_list') self.assertDictEqual( - rule._apply_rules(st_line, st_line._retrieve_partner()), + rslt, {'amls': inv2, 'model': rule}, ) rule.match_text_location_note = True + rslt = rule._apply_rules(st_line, st_line._retrieve_partner()) + rslt.pop('amls_values_list') self.assertDictEqual( - rule._apply_rules(st_line, st_line._retrieve_partner()), + rslt, {'amls': inv3, 'model': rule}, ) diff --git a/addons/account/views/account_payment_term_views.xml b/addons/account/views/account_payment_term_views.xml index e88df582e41..e865a5d400d 100644 --- a/addons/account/views/account_payment_term_views.xml +++ b/addons/account/views/account_payment_term_views.xml @@ -102,6 +102,48 @@ The last line's computation type should be "Balance" to ensure that the whole amount will be allocated.

+ + + +
+ +
diff --git a/addons/account/views/report_invoice.xml b/addons/account/views/report_invoice.xml index 343c88d6b65..ab28c665970 100644 --- a/addons/account/views/report_invoice.xml +++ b/addons/account/views/report_invoice.xml @@ -197,7 +197,29 @@ -

+ + +

+

+ + Early Payment (before ): + + ( % discount) + +

+
+
+
+ + + + +
+
+
+ + +

Please use the following communication for your payment :

diff --git a/addons/account/views/res_config_settings_views.xml b/addons/account/views/res_config_settings_views.xml index 6e37c5f2042..356fbb5749f 100644 --- a/addons/account/views/res_config_settings_views.xml +++ b/addons/account/views/res_config_settings_views.xml @@ -527,6 +527,14 @@
+
+
+
+
diff --git a/addons/account/wizard/account_payment_register.py b/addons/account/wizard/account_payment_register.py index d515c8a533c..ed6c74e3da4 100644 --- a/addons/account/wizard/account_payment_register.py +++ b/addons/account/wizard/account_payment_register.py @@ -3,7 +3,7 @@ from collections import defaultdict from odoo import models, fields, api, _ from odoo.exceptions import UserError -from odoo.tools import float_compare, frozendict +from odoo.tools import frozendict class AccountPaymentRegister(models.TransientModel): @@ -81,6 +81,12 @@ class AccountPaymentRegister(models.TransientModel): partner_id = fields.Many2one('res.partner', string="Customer/Vendor", store=True, copy=False, ondelete='restrict', compute='_compute_from_lines') + discounted_amount = fields.Monetary( + store=True, + copy=False, + currency_field='source_currency_id', + compute='_compute_from_lines', + ) # == Payment methods fields == payment_method_line_id = fields.Many2one('account.payment.method.line', string='Payment Method', @@ -98,15 +104,29 @@ class AccountPaymentRegister(models.TransientModel): # == Payment difference fields == payment_difference = fields.Monetary( compute='_compute_payment_difference') - payment_difference_handling = fields.Selection([ - ('open', 'Keep open'), - ('reconcile', 'Mark 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') - + payment_difference_handling = fields.Selection( + string="Payment Difference Handling", + selection=[('open', 'Keep open'), ('reconcile', 'Mark as fully paid')], + compute='_compute_payment_difference_handling', + store=True, + readonly=False, + ) + writeoff_account_id = fields.Many2one( + comodel_name='account.account', + string="Difference Account", + copy=False, + domain="[('deprecated', '=', False), ('company_id', '=', company_id)]", + compute='_compute_writeoff_account_id', + store=True, + readonly=False, + ) + writeoff_label = fields.Char( + string='Journal Item Label', + compute='_compute_writeoff_label', + store=True, + readonly=False, + 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') # Used to know whether the field `partner_bank_id` should be displayed @@ -114,10 +134,18 @@ class AccountPaymentRegister(models.TransientModel): compute='_compute_show_require_partner_bank') # used to know whether the field `partner_bank_id` should be required country_code = fields.Char(related='company_id.account_fiscal_country_id.code', readonly=True) + # == Early Payment Discount == + show_early_pay_discount_button = fields.Boolean(compute="_compute_show_early_pay_discount_button") + early_pay_discount_toggle_button = fields.Boolean( + string="Early Payment Discounts", + compute='_compute_early_pay_discount_toggle_button', + readonly=False, + store=True, + ) + # ------------------------------------------------------------------------- # HELPERS # ------------------------------------------------------------------------- - @api.model def _get_batch_communication(self, batch_result): ''' Helper to compute the communication based on the batch. @@ -205,6 +233,7 @@ class AccountPaymentRegister(models.TransientModel): partner_bank_account = self.env['res.partner.bank'] if move.is_invoice(include_receipts=True): partner_bank_account = move.partner_bank_id._origin + early_discount_pay_term = move.invoice_payment_term_id if move._is_eligible_for_early_discount(self.payment_date) else False return { 'partner_id': line.partner_id.id, @@ -212,6 +241,7 @@ class AccountPaymentRegister(models.TransientModel): 'currency_id': line.currency_id.id, 'partner_bank_id': partner_bank_account.id, 'partner_type': 'customer' if line.account_type == 'asset_receivable' else 'supplier', + 'early_discount_pay_term': early_discount_pay_term, } def _get_batches(self): @@ -290,13 +320,15 @@ class AccountPaymentRegister(models.TransientModel): payment_values = batch_result['payment_values'] lines = batch_result['lines'] company = lines[0].company_id - source_amount = abs(sum(lines.mapped('amount_residual'))) if payment_values['currency_id'] == company.currency_id.id: source_amount_currency = source_amount else: source_amount_currency = abs(sum(lines.mapped('amount_residual_currency'))) - + if payment_values['early_discount_pay_term']: + early_pay_discounted_amount = abs(sum(lines.move_id.mapped('invoice_early_pay_amount_after_discount'))) + else: + early_pay_discounted_amount = False return { 'company_id': company.id, 'partner_id': payment_values['partner_id'], @@ -305,13 +337,14 @@ class AccountPaymentRegister(models.TransientModel): 'source_currency_id': payment_values['currency_id'], 'source_amount': source_amount, 'source_amount_currency': source_amount_currency, + 'discounted_amount': early_pay_discounted_amount } # ------------------------------------------------------------------------- # COMPUTE METHODS # ------------------------------------------------------------------------- - @api.depends('line_ids') + @api.depends('line_ids', 'payment_date') def _compute_from_lines(self): ''' Load initial values from the account.moves passed through the context. ''' for wizard in self: @@ -335,6 +368,7 @@ class AccountPaymentRegister(models.TransientModel): 'source_currency_id': False, 'source_amount': False, 'source_amount_currency': False, + 'discounted_amount': False }) wizard.can_edit_wizard = False @@ -360,6 +394,51 @@ class AccountPaymentRegister(models.TransientModel): else: wizard.group_payment = False + @api.depends('can_edit_wizard', 'payment_date', 'currency_id', 'amount') + def _compute_payment_difference_handling(self): + for wizard in self: + if not wizard.payment_difference_handling: + wizard.payment_difference_handling = 'open' + if wizard.can_edit_wizard: + batch_result = wizard._get_batches()[0] + if batch_result['payment_values']['early_discount_pay_term']: + wizard.payment_difference_handling = 'reconcile' + else: + wizard.payment_difference_handling = 'open' + + @api.depends('payment_difference_handling', 'can_edit_wizard', 'amount', 'discounted_amount') + def _compute_writeoff_account_id(self): + for wizard in self: + wizard.writeoff_account_id = None + if wizard.can_edit_wizard: + batch_result = wizard._get_batches()[0] + cash_discount_accounts = wizard.company_id.account_journal_cash_discount_income_id +\ + wizard.company_id.account_journal_cash_discount_expense_id + if wizard.writeoff_account_id and wizard.writeoff_account_id not in cash_discount_accounts: + continue + if batch_result['payment_values']['early_discount_pay_term'] and wizard.amount == wizard.discounted_amount: + wizard.writeoff_account_id = self._get_early_pay_cash_discount_account(batch_result['payment_values']) + + def _get_early_pay_cash_discount_account(self, payment_values): + self.ensure_one() + if payment_values['payment_type'] == 'inbound': + writeoff_account_id = self.company_id.account_journal_cash_discount_income_id + else: + writeoff_account_id = self.company_id.account_journal_cash_discount_expense_id + return writeoff_account_id + + @api.depends('payment_difference_handling', 'can_edit_wizard', 'amount', 'discounted_amount') + def _compute_writeoff_label(self): + for wizard in self: + if wizard.writeoff_label and wizard.writeoff_label not in [_('Write-Off'), _('Early Payment Discount')]: + continue + if wizard.can_edit_wizard: + batch_result = wizard._get_batches()[0] + if batch_result['payment_values']['early_discount_pay_term'] and wizard.amount == wizard.discounted_amount: + wizard.writeoff_label = _("Early Payment Discount") + else: + wizard.writeoff_label = _('Write-Off') + @api.depends('journal_id') def _compute_currency_id(self): for wizard in self: @@ -485,16 +564,25 @@ class AccountPaymentRegister(models.TransientModel): self.payment_date, ) - @api.depends('can_edit_wizard', 'source_amount', 'source_amount_currency', 'source_currency_id', 'company_id', 'currency_id', 'payment_date') + @api.depends('early_pay_discount_toggle_button', 'discounted_amount', 'can_edit_wizard', '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 and wizard.can_edit_wizard: batch_result = wizard._get_batches()[0] wizard.amount = wizard._get_total_amount_in_wizard_currency_to_full_reconcile(batch_result) + if not wizard.currency_id.is_zero(wizard.discounted_amount) and wizard.early_pay_discount_toggle_button: + wizard.amount = wizard.discounted_amount else: # The wizard is not editable so no partial payment allowed and then, 'amount' is not used. wizard.amount = None + @api.depends('can_edit_wizard') + def _compute_early_pay_discount_toggle_button(self): + for wizard in self: + batches = wizard._get_batches() + for batch in batches: + wizard.early_pay_discount_toggle_button = batch['payment_values']['payment_type'] == 'outbound' + @api.depends('can_edit_wizard', 'amount') def _compute_payment_difference(self): for wizard in self: @@ -505,6 +593,14 @@ class AccountPaymentRegister(models.TransientModel): else: wizard.payment_difference = 0.0 + @api.depends('can_edit_wizard') + def _compute_show_early_pay_discount_button(self): + for wizard in self: + wizard.show_early_pay_discount_button = False + for wizard in self: + batches = wizard._get_batches() + wizard.show_early_pay_discount_button = any(batch['payment_values']['early_discount_pay_term'] for batch in batches) + # ------------------------------------------------------------------------- # LOW-LEVEL METHODS # ------------------------------------------------------------------------- @@ -589,7 +685,6 @@ class AccountPaymentRegister(models.TransientModel): def _create_payment_vals_from_batch(self, batch_result): batch_values = self._get_wizard_values_from_batch(batch_result) - if batch_values['payment_type'] == 'inbound': partner_bank_id = self.journal_id.bank_account_id.id else: @@ -600,9 +695,12 @@ class AccountPaymentRegister(models.TransientModel): if batch_values['payment_type'] != payment_method_line.payment_type: payment_method_line = self.journal_id._get_available_payment_method_lines(batch_values['payment_type'])[:1] - return { + early_pay_applicable = not self.currency_id.is_zero( + batch_values['discounted_amount']) and self.early_pay_discount_toggle_button + payment_vals = { 'date': self.payment_date, - 'amount': batch_values['source_amount_currency'], + 'amount': batch_values['source_amount_currency'] if not early_pay_applicable else batch_values[ + 'discounted_amount'], 'payment_type': batch_values['payment_type'], 'partner_type': batch_values['partner_type'], 'ref': self._get_batch_communication(batch_result), @@ -613,6 +711,13 @@ class AccountPaymentRegister(models.TransientModel): 'payment_method_line_id': payment_method_line.id, 'destination_account_id': batch_result['lines'][0].account_id.id } + if early_pay_applicable: + payment_vals['write_off_line_vals'] = { + 'name': _("Early payment discount"), + 'amount': batch_values['source_amount_currency'] - batch_values['discounted_amount'], + 'account_id': self._get_early_pay_cash_discount_account(payment_vals).id, + } + return payment_vals def _init_payments(self, to_process, edit_mode=False): """ Create the payments. diff --git a/addons/account/wizard/account_payment_register_views.xml b/addons/account/wizard/account_payment_register_views.xml index c59bc8bed74..5584305c5b4 100644 --- a/addons/account/wizard/account_payment_register_views.xml +++ b/addons/account/wizard/account_payment_register_views.xml @@ -26,6 +26,8 @@ + + @@ -48,7 +50,10 @@ required="1" options="{'no_create': True, 'no_open': True}" groups="base.group_multi_currency"/> + +