1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352
|
# -*- 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
|