Odoo18-Base/addons/account/models/account_payment_term.py
2025-01-06 10:57:38 +07:00

353 lines
18 KiB
Python

# -*- coding: utf-8 -*-
from odoo import api, fields, models, _, Command
from odoo.exceptions import UserError, ValidationError
from odoo.tools import format_date, formatLang, frozendict, date_utils
from odoo.tools.float_utils import float_round
from dateutil.relativedelta import relativedelta
class AccountPaymentTerm(models.Model):
_name = "account.payment.term"
_description = "Payment Terms"
_order = "sequence, id"
_check_company_domain = models.check_company_domain_parent_of
def _default_line_ids(self):
return [Command.create({'value': 'percent', 'value_amount': 100.0, 'nb_days': 0})]
def _default_example_date(self):
return self._context.get('example_date') or fields.Date.today()
name = fields.Char(string='Payment Terms', translate=True, required=True)
active = fields.Boolean(default=True, help="If the active field is set to False, it will allow you to hide the payment terms without removing it.")
note = fields.Html(string='Description on the Invoice', translate=True)
line_ids = fields.One2many('account.payment.term.line', 'payment_id', string='Terms', copy=True, default=_default_line_ids)
company_id = fields.Many2one('res.company', string='Company')
fiscal_country_codes = fields.Char(compute='_compute_fiscal_country_codes')
sequence = fields.Integer(required=True, default=10)
currency_id = fields.Many2one('res.currency', compute="_compute_currency_id")
display_on_invoice = fields.Boolean(string='Show installment dates', default=True)
example_amount = fields.Monetary(currency_field='currency_id', default=1000, store=False, readonly=True)
example_date = fields.Date(string='Date example', default=_default_example_date, store=False)
example_invalid = fields.Boolean(compute='_compute_example_invalid')
example_preview = fields.Html(compute='_compute_example_preview')
example_preview_discount = fields.Html(compute='_compute_example_preview')
discount_percentage = fields.Float(string='Discount %', help='Early Payment Discount granted for this payment term', default=2.0)
discount_days = fields.Integer(string='Discount Days', help='Number of days before the early payment proposition expires', default=10)
early_pay_discount_computation = fields.Selection([
('included', 'On early payment'),
('excluded', 'Never'),
('mixed', 'Always (upon invoice)'),
], string='Cash Discount Tax Reduction', readonly=False, store=True, compute='_compute_discount_computation')
early_discount = fields.Boolean(string='Early Discount')
@api.depends('company_id')
@api.depends_context('allowed_company_ids')
def _compute_fiscal_country_codes(self):
for record in self:
allowed_companies = record.company_id or self.env.companies
record.fiscal_country_codes = ",".join(allowed_companies.mapped('account_fiscal_country_id.code'))
@api.depends_context('company')
@api.depends('company_id')
def _compute_currency_id(self):
for payment_term in self:
payment_term.currency_id = payment_term.company_id.currency_id or self.env.company.currency_id
def _get_amount_due_after_discount(self, total_amount, untaxed_amount):
self.ensure_one()
if self.early_discount:
percentage = self.discount_percentage / 100.0
if self.early_pay_discount_computation in ('excluded', 'mixed'):
discount_amount_currency = (total_amount - untaxed_amount) * percentage
else:
discount_amount_currency = total_amount * percentage
return self.currency_id.round(total_amount - discount_amount_currency)
return total_amount
@api.depends('company_id')
def _compute_discount_computation(self):
for pay_term in self:
country_code = pay_term.company_id.country_code or self.env.company.country_code
if country_code == 'BE':
pay_term.early_pay_discount_computation = 'mixed'
elif country_code == 'NL':
pay_term.early_pay_discount_computation = 'excluded'
else:
pay_term.early_pay_discount_computation = 'included'
@api.depends('line_ids')
def _compute_example_invalid(self):
for payment_term in self:
payment_term.example_invalid = not payment_term.line_ids
@api.depends('currency_id', 'example_amount', 'example_date', 'line_ids.value', 'line_ids.value_amount', 'line_ids.nb_days', 'early_discount', 'discount_percentage', 'discount_days')
def _compute_example_preview(self):
for record in self:
example_preview = ""
record.example_preview_discount = ""
currency = record.currency_id
if record.early_discount:
date = record._get_last_discount_date_formatted(record.example_date or fields.Date.context_today(record))
discount_amount = record._get_amount_due_after_discount(record.example_amount, 0.0)
record.example_preview_discount = _(
"Early Payment Discount: <b>%(amount)s</b> if paid before <b>%(date)s</b>",
amount=formatLang(self.env, discount_amount, currency_obj=currency),
date=date,
)
if not record.example_invalid:
terms = record._compute_terms(
date_ref=record.example_date or fields.Date.context_today(record),
currency=currency,
company=self.env.company,
tax_amount=0,
tax_amount_currency=0,
untaxed_amount=record.example_amount,
untaxed_amount_currency=record.example_amount,
sign=1)
for i, info_by_dates in enumerate(record._get_amount_by_date(terms).values()):
date = info_by_dates['date']
amount = info_by_dates['amount']
example_preview += "<div>"
example_preview += _(
"<b>%(count)s#</b> Installment of <b>%(amount)s</b> due on <b style='color: #704A66;'>%(date)s</b>",
count=i+1,
amount=formatLang(self.env, amount, currency_obj=currency),
date=date,
)
example_preview += "</div>"
record.example_preview = example_preview
@api.model
def _get_amount_by_date(self, terms):
"""
Returns a dictionary with the amount for each date of the payment term
(grouped by date, discounted percentage and discount last date,
sorted by date and ignoring null amounts).
"""
terms_lines = sorted(terms["line_ids"], key=lambda t: t.get('date'))
amount_by_date = {}
for term in terms_lines:
key = frozendict({
'date': term['date'],
})
results = amount_by_date.setdefault(key, {
'date': format_date(self.env, term['date']),
'amount': 0.0,
})
results['amount'] += term['foreign_amount']
return amount_by_date
@api.constrains('line_ids', 'early_discount')
def _check_lines(self):
round_precision = self.env['decimal.precision'].precision_get('Payment Terms')
for terms in self:
total_percent = sum(line.value_amount for line in terms.line_ids if line.value == 'percent')
if float_round(total_percent, precision_digits=round_precision) != 100:
raise ValidationError(_('The Payment Term must have at least one percent line and the sum of the percent must be 100%.'))
if len(terms.line_ids) > 1 and terms.early_discount:
raise ValidationError(
_("The Early Payment Discount functionality can only be used with payment terms using a single 100% line. "))
if terms.early_discount and terms.discount_percentage <= 0.0:
raise ValidationError(_("The Early Payment Discount must be strictly positive."))
if terms.early_discount and terms.discount_days <= 0:
raise ValidationError(_("The Early Payment Discount days must be strictly positive."))
def _compute_terms(self, date_ref, currency, company, tax_amount, tax_amount_currency, sign, untaxed_amount, untaxed_amount_currency, cash_rounding=None):
"""Get the distribution of this payment term.
:param date_ref: The move date to take into account
:param currency: the move's currency
:param company: the company issuing the move
:param tax_amount: the signed tax amount for the move
:param tax_amount_currency: the signed tax amount for the move in the move's currency
:param untaxed_amount: the signed untaxed amount for the move
:param untaxed_amount_currency: the signed untaxed amount for the move in the move's currency
:param sign: the sign of the move
:param cash_rounding: the cash rounding that should be applied (or None).
We assume that the input total in move currency (tax_amount_currency + untaxed_amount_currency) is already cash rounded.
The cash rounding does not change the totals: Consider the sum of all the computed payment term amounts in move / company currency.
It is the same as the input total in move / company currency.
:return (list<tuple<datetime.date,tuple<float,float>>>): the amount in the company's currency and
the document's currency, respectively for each required payment date
"""
self.ensure_one()
company_currency = company.currency_id
total_amount = tax_amount + untaxed_amount
total_amount_currency = tax_amount_currency + untaxed_amount_currency
rate = abs(total_amount_currency / total_amount) if total_amount else 0.0
pay_term = {
'total_amount': total_amount,
'discount_percentage': self.discount_percentage if self.early_discount else 0.0,
'discount_date': date_ref + relativedelta(days=(self.discount_days or 0)) if self.early_discount else False,
'discount_balance': 0,
'line_ids': [],
}
if self.early_discount:
# Early discount is only available on single line, 100% payment terms.
discount_percentage = self.discount_percentage / 100.0
if self.early_pay_discount_computation in ('excluded', 'mixed'):
pay_term['discount_balance'] = company_currency.round(total_amount - untaxed_amount * discount_percentage)
pay_term['discount_amount_currency'] = currency.round(total_amount_currency - untaxed_amount_currency * discount_percentage)
else:
pay_term['discount_balance'] = company_currency.round(total_amount * (1 - discount_percentage))
pay_term['discount_amount_currency'] = currency.round(total_amount_currency * (1 - discount_percentage))
if cash_rounding:
cash_rounding_difference_currency = cash_rounding.compute_difference(currency, pay_term['discount_amount_currency'])
if not currency.is_zero(cash_rounding_difference_currency):
pay_term['discount_amount_currency'] += cash_rounding_difference_currency
pay_term['discount_balance'] = company_currency.round(pay_term['discount_amount_currency'] / rate) if rate else 0.0
residual_amount = total_amount
residual_amount_currency = total_amount_currency
for i, line in enumerate(self.line_ids):
term_vals = {
'date': line._get_due_date(date_ref),
'company_amount': 0,
'foreign_amount': 0,
}
# The last line is always the balance, no matter the type
on_balance_line = i == len(self.line_ids) - 1
if on_balance_line:
term_vals['company_amount'] = residual_amount
term_vals['foreign_amount'] = residual_amount_currency
elif line.value == 'fixed':
# Fixed amounts
term_vals['company_amount'] = sign * company_currency.round(line.value_amount / rate) if rate else 0.0
term_vals['foreign_amount'] = sign * currency.round(line.value_amount)
else:
# Percentage amounts
line_amount = company_currency.round(total_amount * (line.value_amount / 100.0))
line_amount_currency = currency.round(total_amount_currency * (line.value_amount / 100.0))
term_vals['company_amount'] = line_amount
term_vals['foreign_amount'] = line_amount_currency
if cash_rounding and not on_balance_line:
# The value `residual_amount_currency` is always cash rounded (in case of cash rounding).
# * We assume `total_amount_currency` is cash rounded.
# * We only subtract cash rounded amounts.
# Thus the balance line is cash rounded.
cash_rounding_difference_currency = cash_rounding.compute_difference(currency, term_vals['foreign_amount'])
if not currency.is_zero(cash_rounding_difference_currency):
term_vals['foreign_amount'] += cash_rounding_difference_currency
term_vals['company_amount'] = company_currency.round(term_vals['foreign_amount'] / rate) if rate else 0.0
residual_amount -= term_vals['company_amount']
residual_amount_currency -= term_vals['foreign_amount']
pay_term['line_ids'].append(term_vals)
return pay_term
@api.ondelete(at_uninstall=False)
def _unlink_except_referenced_terms(self):
if self.env['account.move'].search_count([('invoice_payment_term_id', 'in', self.ids)], limit=1):
raise UserError(_('You can not delete payment terms as other records still reference it. However, you can archive it.'))
def _get_last_discount_date(self, date_ref):
self.ensure_one()
return date_ref + relativedelta(days=self.discount_days or 0) if self.early_discount else False
def _get_last_discount_date_formatted(self, date_ref):
self.ensure_one()
if not date_ref:
return None
return format_date(self.env, self._get_last_discount_date(date_ref))
class AccountPaymentTermLine(models.Model):
_name = "account.payment.term.line"
_description = "Payment Terms Line"
_order = "id"
value = fields.Selection([
('percent', 'Percent'),
('fixed', 'Fixed')
], required=True, default='percent',
help="Select here the kind of valuation related to this payment terms line.")
value_amount = fields.Float(string='Due', digits='Payment Terms',
help="For percent enter a ratio between 0-100.",
compute='_compute_value_amount', store=True, readonly=False)
delay_type = fields.Selection([
('days_after', 'Days after invoice date'),
('days_after_end_of_month', 'Days after end of month'),
('days_after_end_of_next_month', 'Days after end of next month'),
('days_end_of_month_on_the', 'Days end of month on the'),
], required=True, default='days_after')
display_days_next_month = fields.Boolean(compute='_compute_display_days_next_month')
days_next_month = fields.Char(
string='Days on the next month',
readonly=False,
default='10',
size=2,
)
nb_days = fields.Integer(string='Days', readonly=False, store=True, compute='_compute_days')
payment_id = fields.Many2one('account.payment.term', string='Payment Terms', required=True, index=True, ondelete='cascade')
def _get_due_date(self, date_ref):
self.ensure_one()
due_date = fields.Date.from_string(date_ref) or fields.Date.today()
if self.delay_type == 'days_after_end_of_month':
return date_utils.end_of(due_date, 'month') + relativedelta(days=self.nb_days)
elif self.delay_type == 'days_after_end_of_next_month':
return date_utils.end_of(due_date + relativedelta(months=1), 'month') + relativedelta(days=self.nb_days)
elif self.delay_type == 'days_end_of_month_on_the':
try:
days_next_month = int(self.days_next_month)
except ValueError:
days_next_month = 1
if not days_next_month:
return date_utils.end_of(due_date + relativedelta(days=self.nb_days), 'month')
return due_date + relativedelta(days=self.nb_days) + relativedelta(months=1, day=days_next_month)
return due_date + relativedelta(days=self.nb_days)
@api.constrains('days_next_month')
def _check_valid_char_value(self):
for record in self:
if record.days_next_month and record.days_next_month.isnumeric():
if not (0 <= int(record.days_next_month) <= 31):
raise ValidationError(_('The days added must be between 0 and 31.'))
else:
raise ValidationError(_('The days added must be a number and has to be between 0 and 31.'))
@api.depends('delay_type')
def _compute_display_days_next_month(self):
for record in self:
record.display_days_next_month = record.delay_type == 'days_end_of_month_on_the'
@api.constrains('value', 'value_amount')
def _check_percent(self):
for term_line in self:
if term_line.value == 'percent' and (term_line.value_amount < 0.0 or term_line.value_amount > 100.0):
raise ValidationError(_('Percentages on the Payment Terms lines must be between 0 and 100.'))
@api.depends('payment_id')
def _compute_days(self):
for line in self:
#Line.payment_id.line_ids[-1] is the new line that has been just added when clicking "add a new line"
if not line.nb_days and len(line.payment_id.line_ids) > 1:
line.nb_days = line.payment_id.line_ids[-2].nb_days + 30
else:
line.nb_days = line.nb_days
@api.depends('payment_id')
def _compute_value_amount(self):
for line in self:
if line.value == 'fixed':
line.value_amount = 0
else:
amount = 0
for i in line.payment_id.line_ids.filtered(lambda r: r.value == 'percent'):
amount += i['value_amount']
line.value_amount = 100 - amount