# -*- coding: utf-8 -*- from odoo import api, fields, models, _, Command from odoo.exceptions import UserError, ValidationError from odoo.tools import format_date, formatLang, frozendict from dateutil.relativedelta import relativedelta class AccountPaymentTerm(models.Model): _name = "account.payment.term" _description = "Payment Terms" _order = "sequence, id" def _default_line_ids(self): return [Command.create({'value': 'balance', 'value_amount': 0.0, 'days': 0, 'end_month': False})] def _default_example_amount(self): return self._context.get('example_amount') or 100 # Force default value if the context is set to False 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') sequence = fields.Integer(required=True, default=10) display_on_invoice = fields.Boolean(string='Display terms on invoice', help="If set, the payment deadlines and respective due amounts will be detailed on invoices.") example_amount = fields.Float(default=_default_example_amount, store=False) 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') @api.depends('line_ids') def _compute_example_invalid(self): for payment_term in self: payment_term.example_invalid = len(payment_term.line_ids.filtered(lambda l: l.value == 'balance')) != 1 @api.depends('example_amount', 'example_date', 'line_ids.value', 'line_ids.value_amount', 'line_ids.months', 'line_ids.days', 'line_ids.end_month', 'line_ids.days_after') def _compute_example_preview(self): for record in self: example_preview = "" if not record.example_invalid: currency = self.env.company.currency_id terms = record._compute_terms( date_ref=record.example_date, 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, currency).values()): date = info_by_dates['date'] discount_date = info_by_dates['discount_date'] amount = info_by_dates['amount'] discount_amount = info_by_dates['discounted_amount'] or 0.0 example_preview += "
" example_preview += _( "%(count)s# Installment of %(amount)s on %(date)s", count=i+1, amount=formatLang(self.env, amount, monetary=True, currency_obj=currency), date=date, ) if discount_date: example_preview += _( " (%(amount)s if paid before %(date)s)", amount=formatLang(self.env, discount_amount, monetary=True, currency_obj=currency), date=format_date(self.env, terms[i].get('discount_date')), ) example_preview += "
" record.example_preview = example_preview @api.model def _get_amount_by_date(self, terms, currency): """ 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 = sorted(terms, key=lambda t: t.get('date')) amount_by_date = {} for term in terms: key = frozendict({ 'date': term['date'], 'discount_date': term['discount_date'], 'discount_percentage': term['discount_percentage'], }) results = amount_by_date.setdefault(key, { 'date': format_date(self.env, term['date']), 'amount': 0.0, 'discounted_amount': 0.0, 'discount_date': format_date(self.env, term['discount_date']), }) results['amount'] += term['foreign_amount'] results['discounted_amount'] += term['discount_amount_currency'] return amount_by_date @api.constrains('line_ids') def _check_lines(self): for terms in self: if len(terms.line_ids.filtered(lambda r: r.value == 'balance')) != 1: raise ValidationError(_('The Payment Term must have one Balance line.')) if terms.line_ids.filtered(lambda r: r.value == 'fixed' and r.discount_percentage): raise ValidationError(_("You can't mix fixed amount with early payment percentage")) 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>>): 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 tax_amount_left = tax_amount tax_amount_currency_left = tax_amount_currency untaxed_amount_left = untaxed_amount untaxed_amount_currency_left = untaxed_amount_currency total_amount = tax_amount + untaxed_amount total_amount_currency = tax_amount_currency + untaxed_amount_currency foreign_rounding_amount = 0 company_rounding_amount = 0 result = [] for line in self.line_ids.sorted(lambda line: line.value == 'balance'): term_vals = { 'date': line._get_due_date(date_ref), 'has_discount': line.discount_percentage, 'discount_date': None, 'discount_amount_currency': 0.0, 'discount_balance': 0.0, 'discount_percentage': line.discount_percentage, } if line.value == 'fixed': term_vals['company_amount'] = sign * company_currency.round(line.value_amount) term_vals['foreign_amount'] = sign * currency.round(line.value_amount) company_proportion = tax_amount/untaxed_amount if untaxed_amount else 1 foreign_proportion = tax_amount_currency/untaxed_amount_currency if untaxed_amount_currency else 1 line_tax_amount = company_currency.round(line.value_amount * company_proportion) * sign line_tax_amount_currency = currency.round(line.value_amount * foreign_proportion) * sign line_untaxed_amount = term_vals['company_amount'] - line_tax_amount line_untaxed_amount_currency = term_vals['foreign_amount'] - line_tax_amount_currency elif line.value == 'percent': term_vals['company_amount'] = company_currency.round(total_amount * (line.value_amount / 100.0)) term_vals['foreign_amount'] = currency.round(total_amount_currency * (line.value_amount / 100.0)) line_tax_amount = company_currency.round(tax_amount * (line.value_amount / 100.0)) line_tax_amount_currency = currency.round(tax_amount_currency * (line.value_amount / 100.0)) line_untaxed_amount = term_vals['company_amount'] - line_tax_amount line_untaxed_amount_currency = term_vals['foreign_amount'] - line_tax_amount_currency else: line_tax_amount = line_tax_amount_currency = line_untaxed_amount = line_untaxed_amount_currency = 0.0 # The following values do not account for any potential cash rounding tax_amount_left -= line_tax_amount tax_amount_currency_left -= line_tax_amount_currency untaxed_amount_left -= line_untaxed_amount untaxed_amount_currency_left -= line_untaxed_amount_currency if cash_rounding and line.value in ['fixed', 'percent']: cash_rounding_difference_currency = cash_rounding.compute_difference(currency, term_vals['foreign_amount']) if not currency.is_zero(cash_rounding_difference_currency): rate = abs(term_vals['foreign_amount'] / term_vals['company_amount']) if term_vals['company_amount'] else 1.0 foreign_rounding_amount += cash_rounding_difference_currency term_vals['foreign_amount'] += cash_rounding_difference_currency company_amount = company_currency.round(term_vals['foreign_amount'] / rate) cash_rounding_difference = company_amount - term_vals['company_amount'] if not currency.is_zero(cash_rounding_difference): company_rounding_amount += cash_rounding_difference term_vals['company_amount'] = company_amount if line.value == 'balance': # The `*_amount_left` variables do not account for cash rounding. # Here we remove the total amount added by the cash rounding from the amount left. # This way the totals in company and move currency remain unchanged (compared to the input). # We assume the foreign total (`tax_amount_currency + untaxed_amount_currency`) is cash rounded. # The following right side is the same as subtracting all the (cash rounded) foreign payment term amounts from the foreign total. # Thus it is the remaining foreign amount and also cash rounded. term_vals['foreign_amount'] = tax_amount_currency_left + untaxed_amount_currency_left - foreign_rounding_amount term_vals['company_amount'] = tax_amount_left + untaxed_amount_left - company_rounding_amount line_tax_amount = tax_amount_left line_tax_amount_currency = tax_amount_currency_left line_untaxed_amount = untaxed_amount_left line_untaxed_amount_currency = untaxed_amount_currency_left if line.discount_percentage: if company.early_pay_discount_computation in ('excluded', 'mixed'): term_vals['discount_balance'] = company_currency.round(term_vals['company_amount'] - line_untaxed_amount * line.discount_percentage / 100.0) term_vals['discount_amount_currency'] = currency.round(term_vals['foreign_amount'] - line_untaxed_amount_currency * line.discount_percentage / 100.0) else: term_vals['discount_balance'] = company_currency.round(term_vals['company_amount'] * (1 - (line.discount_percentage / 100.0))) term_vals['discount_amount_currency'] = currency.round(term_vals['foreign_amount'] * (1 - (line.discount_percentage / 100.0))) term_vals['discount_date'] = date_ref + relativedelta(days=line.discount_days) if cash_rounding and line.discount_percentage: cash_rounding_difference_currency = cash_rounding.compute_difference(currency, term_vals['discount_amount_currency']) if not currency.is_zero(cash_rounding_difference_currency): rate = abs(term_vals['discount_amount_currency'] / term_vals['discount_balance']) if term_vals['discount_balance'] else 1.0 term_vals['discount_amount_currency'] += cash_rounding_difference_currency term_vals['discount_balance'] = company_currency.round(term_vals['discount_amount_currency'] / rate) result.append(term_vals) return result @api.ondelete(at_uninstall=False) def _unlink_except_referenced_terms(self): if self.env['account.move'].search([('invoice_payment_term_id', 'in', self.ids)]): raise UserError(_('You can not delete payment terms as other records still reference it. However, you can archive it.')) def unlink(self): for terms in self: self.env['ir.property'].sudo().search( [('value_reference', 'in', ['account.payment.term,%s'%payment_term.id for payment_term in terms])] ).unlink() return super(AccountPaymentTerm, self).unlink() def copy(self, default=None): default = dict(default or {}) default['name'] = _('%s (copy)', self.name) return super().copy(default) class AccountPaymentTermLine(models.Model): _name = "account.payment.term.line" _description = "Payment Terms Line" _order = "id" value = fields.Selection([ ('balance', 'Balance'), ('percent', 'Percent'), ('fixed', 'Fixed Amount') ], string='Type', required=True, default='percent', help="Select here the kind of valuation related to this payment terms line.") value_amount = fields.Float(string='Value', digits='Payment Terms', help="For percent enter a ratio between 0-100.") months = fields.Integer(string='Months', required=True, default=0) days = fields.Integer(string='Days', required=True, default=0) end_month = fields.Boolean(string='End of month', help="Switch to end of the month after having added months or days") days_after = fields.Integer(string='Days after End of month', help="Days to add after the end of the month") discount_percentage = fields.Float(string='Discount %', help='Early Payment Discount granted for this line') discount_days = fields.Integer(string='Discount Days', help='Number of days before the early payment proposition expires') 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() due_date += relativedelta(months=self.months) due_date += relativedelta(days=self.days) if self.end_month: due_date += relativedelta(day=31) due_date += relativedelta(days=self.days_after) return due_date @api.constrains('value', 'value_amount', 'discount_percentage') 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.')) if term_line.discount_percentage and (term_line.discount_percentage < 0.0 or term_line.discount_percentage > 100.0): raise ValidationError(_('Discount percentages on the Payment Terms lines must be between 0 and 100.')) @api.constrains('discount_days') def _check_positive(self): for term_line in self: if term_line.discount_days < 0: raise ValidationError(_('The discount days of the Payment Terms lines must be positive.'))