[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
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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']:
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
|
||||
@@ -102,6 +102,48 @@
|
||||
The last line's computation type should be "Balance" to ensure that the whole amount will be allocated.
|
||||
</p>
|
||||
<field name="line_ids"/>
|
||||
|
||||
<!-- ============================== Early payment section ============================== -->
|
||||
<field name="early_payment_applicable" invisible="1"/>
|
||||
<div attrs="{'invisible':[('early_payment_applicable','=',False)]}">
|
||||
<field name="has_early_payment"/>
|
||||
<label for="has_early_payment"/>
|
||||
<div attrs="{'invisible':[('has_early_payment','=',False)]}">
|
||||
<div class="row mt-2">
|
||||
<div class="col-2">
|
||||
<strong><label for="percentage_to_discount"/></strong>
|
||||
</div>
|
||||
<div class="col-1">
|
||||
<field name="percentage_to_discount"/>
|
||||
</div>
|
||||
<div class="col-1 oe_inline">
|
||||
<span>%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-2">
|
||||
<strong><label for="discount_computation"/></strong>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<field name="discount_computation"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-2">
|
||||
<strong><label for="discount_days"/></strong>
|
||||
</div>
|
||||
<div class="col-1 pe-0">
|
||||
Within
|
||||
</div>
|
||||
<div class="col-1 px-0">
|
||||
<field name="discount_days"/>
|
||||
</div>
|
||||
<div class="col-3 px-0">
|
||||
days of the invoice date
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -197,7 +197,29 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p t-if="o.move_type in ('out_invoice', 'in_refund') and o.payment_reference" name="payment_communication">
|
||||
<t t-set="early_pay_totals" t-value="o._get_report_early_payment_totals_values()"/>
|
||||
<t t-if="early_pay_totals">
|
||||
<div>
|
||||
<p>
|
||||
<strong>
|
||||
Early Payment (before <span t-esc="o.invoice_payment_term_id._get_last_date_for_discount(o.invoice_date)"/>):
|
||||
<span t-esc="early_pay_totals['formatted_amount_total']"/>
|
||||
(<span t-esc="o.invoice_payment_term_id.percentage_to_discount"/> % discount)
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div id="total-early" class="row">
|
||||
<div t-attf-class="#{'col-6' if report_type != 'html' else 'col-sm-7 col-md-6'}">
|
||||
<table class="table table-sm" style="page-break-inside: avoid;">
|
||||
<t t-call="account.document_tax_totals">
|
||||
<t t-set="tax_totals" t-value="early_pay_totals"/>
|
||||
</t>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<p t-if="o.move_type in ('out_invoice', 'in_refund') and o.payment_reference" name="payment_communication" class="mt-4">
|
||||
Please use the following communication for your payment : <b><span t-field="o.payment_reference"/></b>
|
||||
</p>
|
||||
<div t-if="o.invoice_payment_term_id" name="payment_term">
|
||||
|
||||
@@ -527,6 +527,14 @@
|
||||
<label for="transfer_account_id" class="col-lg-5 o_light_label"/>
|
||||
<field name="transfer_account_id"/>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="account_journal_cash_discount_income_id" class="col-lg-5 o_light_label"/>
|
||||
<field name="account_journal_cash_discount_income_id"/>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="account_journal_cash_discount_expense_id" class="col-lg-5 o_light_label"/>
|
||||
<field name="account_journal_cash_discount_expense_id"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
<field name="available_payment_method_line_ids" invisible="1"/>
|
||||
<field name="available_partner_bank_ids" invisible="1"/>
|
||||
<field name="company_currency_id" invisible="1"/>
|
||||
<field name="show_early_pay_discount_button" invisible="1"/>
|
||||
<field name="discounted_amount" invisible="1"/>
|
||||
|
||||
<group>
|
||||
<group name="group1">
|
||||
@@ -48,7 +50,10 @@
|
||||
required="1"
|
||||
options="{'no_create': True, 'no_open': True}"
|
||||
groups="base.group_multi_currency"/>
|
||||
|
||||
</div>
|
||||
<field name="early_pay_discount_toggle_button"
|
||||
attrs="{'invisible':[('show_early_pay_discount_button', '=', False)]}"/>
|
||||
<field name="payment_date"/>
|
||||
<field name="communication"
|
||||
attrs="{'invisible': ['|', ('can_edit_wizard', '=', False), '&', ('can_group_payments', '=', True), ('group_payment', '=', False)]}"/>
|
||||
|
||||
Reference in New Issue
Block a user