File: account_payment_term.py

package info (click to toggle)
odoo 18.0.0%2Bdfsg-2
  • links: PTS, VCS
  • area: main
  • in suites: sid
  • size: 878,716 kB
  • sloc: javascript: 927,937; python: 685,670; xml: 388,524; sh: 1,033; sql: 415; makefile: 26
file content (352 lines) | stat: -rw-r--r-- 18,788 bytes parent folder | download
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