account_payment_term.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. # -*- coding: utf-8 -*-
  2. from odoo import api, fields, models, _, Command
  3. from odoo.exceptions import UserError, ValidationError
  4. from odoo.tools import format_date, formatLang, frozendict
  5. from dateutil.relativedelta import relativedelta
  6. class AccountPaymentTerm(models.Model):
  7. _name = "account.payment.term"
  8. _description = "Payment Terms"
  9. _order = "sequence, id"
  10. def _default_line_ids(self):
  11. return [Command.create({'value': 'balance', 'value_amount': 0.0, 'days': 0, 'end_month': False})]
  12. def _default_example_amount(self):
  13. return self._context.get('example_amount') or 100 # Force default value if the context is set to False
  14. def _default_example_date(self):
  15. return self._context.get('example_date') or fields.Date.today()
  16. name = fields.Char(string='Payment Terms', translate=True, required=True)
  17. 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.")
  18. note = fields.Html(string='Description on the Invoice', translate=True)
  19. line_ids = fields.One2many('account.payment.term.line', 'payment_id', string='Terms', copy=True, default=_default_line_ids)
  20. company_id = fields.Many2one('res.company', string='Company')
  21. sequence = fields.Integer(required=True, default=10)
  22. 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.")
  23. example_amount = fields.Float(default=_default_example_amount, store=False)
  24. example_date = fields.Date(string='Date example', default=_default_example_date, store=False)
  25. example_invalid = fields.Boolean(compute='_compute_example_invalid')
  26. example_preview = fields.Html(compute='_compute_example_preview')
  27. @api.depends('line_ids')
  28. def _compute_example_invalid(self):
  29. for payment_term in self:
  30. payment_term.example_invalid = len(payment_term.line_ids.filtered(lambda l: l.value == 'balance')) != 1
  31. @api.depends('example_amount', 'example_date', 'line_ids.value', 'line_ids.value_amount',
  32. 'line_ids.months', 'line_ids.days', 'line_ids.end_month', 'line_ids.days_after')
  33. def _compute_example_preview(self):
  34. for record in self:
  35. example_preview = ""
  36. if not record.example_invalid:
  37. currency = self.env.company.currency_id
  38. terms = record._compute_terms(
  39. date_ref=record.example_date,
  40. currency=currency,
  41. company=self.env.company,
  42. tax_amount=0,
  43. tax_amount_currency=0,
  44. untaxed_amount=record.example_amount,
  45. untaxed_amount_currency=record.example_amount,
  46. sign=1)
  47. for i, info_by_dates in enumerate(record._get_amount_by_date(terms, currency).values()):
  48. date = info_by_dates['date']
  49. discount_date = info_by_dates['discount_date']
  50. amount = info_by_dates['amount']
  51. discount_amount = info_by_dates['discounted_amount'] or 0.0
  52. example_preview += "<div style='margin-left: 20px;'>"
  53. example_preview += _(
  54. "<b>%(count)s#</b> Installment of <b>%(amount)s</b> on <b style='color: #704A66;'>%(date)s</b>",
  55. count=i+1,
  56. amount=formatLang(self.env, amount, monetary=True, currency_obj=currency),
  57. date=date,
  58. )
  59. if discount_date:
  60. example_preview += _(
  61. " (<b>%(amount)s</b> if paid before <b>%(date)s</b>)",
  62. amount=formatLang(self.env, discount_amount, monetary=True, currency_obj=currency),
  63. date=format_date(self.env, terms[i].get('discount_date')),
  64. )
  65. example_preview += "</div>"
  66. record.example_preview = example_preview
  67. @api.model
  68. def _get_amount_by_date(self, terms, currency):
  69. """
  70. Returns a dictionary with the amount for each date of the payment term
  71. (grouped by date, discounted percentage and discount last date,
  72. sorted by date and ignoring null amounts).
  73. """
  74. terms = sorted(terms, key=lambda t: t.get('date'))
  75. amount_by_date = {}
  76. for term in terms:
  77. key = frozendict({
  78. 'date': term['date'],
  79. 'discount_date': term['discount_date'],
  80. 'discount_percentage': term['discount_percentage'],
  81. })
  82. results = amount_by_date.setdefault(key, {
  83. 'date': format_date(self.env, term['date']),
  84. 'amount': 0.0,
  85. 'discounted_amount': 0.0,
  86. 'discount_date': format_date(self.env, term['discount_date']),
  87. })
  88. results['amount'] += term['foreign_amount']
  89. results['discounted_amount'] += term['discount_amount_currency']
  90. return amount_by_date
  91. @api.constrains('line_ids')
  92. def _check_lines(self):
  93. for terms in self:
  94. if len(terms.line_ids.filtered(lambda r: r.value == 'balance')) != 1:
  95. raise ValidationError(_('The Payment Term must have one Balance line.'))
  96. if terms.line_ids.filtered(lambda r: r.value == 'fixed' and r.discount_percentage):
  97. raise ValidationError(_("You can't mix fixed amount with early payment percentage"))
  98. def _compute_terms(self, date_ref, currency, company, tax_amount, tax_amount_currency, sign, untaxed_amount, untaxed_amount_currency):
  99. """Get the distribution of this payment term.
  100. :param date_ref: The move date to take into account
  101. :param currency: the move's currency
  102. :param company: the company issuing the move
  103. :param tax_amount: the signed tax amount for the move
  104. :param tax_amount_currency: the signed tax amount for the move in the move's currency
  105. :param untaxed_amount: the signed untaxed amount for the move
  106. :param untaxed_amount_currency: the signed untaxed amount for the move in the move's currency
  107. :param sign: the sign of the move
  108. :return (list<tuple<datetime.date,tuple<float,float>>>): the amount in the company's currency and
  109. the document's currency, respectively for each required payment date
  110. """
  111. self.ensure_one()
  112. company_currency = company.currency_id
  113. tax_amount_left = tax_amount
  114. tax_amount_currency_left = tax_amount_currency
  115. untaxed_amount_left = untaxed_amount
  116. untaxed_amount_currency_left = untaxed_amount_currency
  117. total_amount = tax_amount + untaxed_amount
  118. total_amount_currency = tax_amount_currency + untaxed_amount_currency
  119. result = []
  120. for line in self.line_ids.sorted(lambda line: line.value == 'balance'):
  121. term_vals = {
  122. 'date': line._get_due_date(date_ref),
  123. 'has_discount': line.discount_percentage,
  124. 'discount_date': None,
  125. 'discount_amount_currency': 0.0,
  126. 'discount_balance': 0.0,
  127. 'discount_percentage': line.discount_percentage,
  128. }
  129. if line.value == 'fixed':
  130. term_vals['company_amount'] = sign * company_currency.round(line.value_amount)
  131. term_vals['foreign_amount'] = sign * currency.round(line.value_amount)
  132. company_proportion = tax_amount/untaxed_amount if untaxed_amount else 1
  133. foreign_proportion = tax_amount_currency/untaxed_amount_currency if untaxed_amount_currency else 1
  134. line_tax_amount = company_currency.round(line.value_amount * company_proportion) * sign
  135. line_tax_amount_currency = currency.round(line.value_amount * foreign_proportion) * sign
  136. line_untaxed_amount = term_vals['company_amount'] - line_tax_amount
  137. line_untaxed_amount_currency = term_vals['foreign_amount'] - line_tax_amount_currency
  138. elif line.value == 'percent':
  139. term_vals['company_amount'] = company_currency.round(total_amount * (line.value_amount / 100.0))
  140. term_vals['foreign_amount'] = currency.round(total_amount_currency * (line.value_amount / 100.0))
  141. line_tax_amount = company_currency.round(tax_amount * (line.value_amount / 100.0))
  142. line_tax_amount_currency = currency.round(tax_amount_currency * (line.value_amount / 100.0))
  143. line_untaxed_amount = term_vals['company_amount'] - line_tax_amount
  144. line_untaxed_amount_currency = term_vals['foreign_amount'] - line_tax_amount_currency
  145. else:
  146. line_tax_amount = line_tax_amount_currency = line_untaxed_amount = line_untaxed_amount_currency = 0.0
  147. tax_amount_left -= line_tax_amount
  148. tax_amount_currency_left -= line_tax_amount_currency
  149. untaxed_amount_left -= line_untaxed_amount
  150. untaxed_amount_currency_left -= line_untaxed_amount_currency
  151. if line.value == 'balance':
  152. term_vals['company_amount'] = tax_amount_left + untaxed_amount_left
  153. term_vals['foreign_amount'] = tax_amount_currency_left + untaxed_amount_currency_left
  154. line_tax_amount = tax_amount_left
  155. line_tax_amount_currency = tax_amount_currency_left
  156. line_untaxed_amount = untaxed_amount_left
  157. line_untaxed_amount_currency = untaxed_amount_currency_left
  158. if line.discount_percentage:
  159. if company.early_pay_discount_computation in ('excluded', 'mixed'):
  160. term_vals['discount_balance'] = company_currency.round(term_vals['company_amount'] - line_untaxed_amount * line.discount_percentage / 100.0)
  161. term_vals['discount_amount_currency'] = currency.round(term_vals['foreign_amount'] - line_untaxed_amount_currency * line.discount_percentage / 100.0)
  162. else:
  163. term_vals['discount_balance'] = company_currency.round(term_vals['company_amount'] * (1 - (line.discount_percentage / 100.0)))
  164. term_vals['discount_amount_currency'] = currency.round(term_vals['foreign_amount'] * (1 - (line.discount_percentage / 100.0)))
  165. term_vals['discount_date'] = date_ref + relativedelta(days=line.discount_days)
  166. result.append(term_vals)
  167. return result
  168. @api.ondelete(at_uninstall=False)
  169. def _unlink_except_referenced_terms(self):
  170. if self.env['account.move'].search([('invoice_payment_term_id', 'in', self.ids)]):
  171. raise UserError(_('You can not delete payment terms as other records still reference it. However, you can archive it.'))
  172. def unlink(self):
  173. for terms in self:
  174. self.env['ir.property'].sudo().search(
  175. [('value_reference', 'in', ['account.payment.term,%s'%payment_term.id for payment_term in terms])]
  176. ).unlink()
  177. return super(AccountPaymentTerm, self).unlink()
  178. class AccountPaymentTermLine(models.Model):
  179. _name = "account.payment.term.line"
  180. _description = "Payment Terms Line"
  181. _order = "id"
  182. value = fields.Selection([
  183. ('balance', 'Balance'),
  184. ('percent', 'Percent'),
  185. ('fixed', 'Fixed Amount')
  186. ], string='Type', required=True, default='percent',
  187. help="Select here the kind of valuation related to this payment terms line.")
  188. value_amount = fields.Float(string='Value', digits='Payment Terms', help="For percent enter a ratio between 0-100.")
  189. months = fields.Integer(string='Months', required=True, default=0)
  190. days = fields.Integer(string='Days', required=True, default=0)
  191. end_month = fields.Boolean(string='End of month', help="Switch to end of the month after having added months or days")
  192. days_after = fields.Integer(string='Days after End of month', help="Days to add after the end of the month")
  193. discount_percentage = fields.Float(string='Discount %', help='Early Payment Discount granted for this line')
  194. discount_days = fields.Integer(string='Discount Days', help='Number of days before the early payment proposition expires')
  195. payment_id = fields.Many2one('account.payment.term', string='Payment Terms', required=True, index=True, ondelete='cascade')
  196. def _get_due_date(self, date_ref):
  197. self.ensure_one()
  198. due_date = fields.Date.from_string(date_ref) or fields.Date.today()
  199. due_date += relativedelta(months=self.months)
  200. due_date += relativedelta(days=self.days)
  201. if self.end_month:
  202. due_date += relativedelta(day=31)
  203. due_date += relativedelta(days=self.days_after)
  204. return due_date
  205. @api.constrains('value', 'value_amount', 'discount_percentage')
  206. def _check_percent(self):
  207. for term_line in self:
  208. if term_line.value == 'percent' and (term_line.value_amount < 0.0 or term_line.value_amount > 100.0):
  209. raise ValidationError(_('Percentages on the Payment Terms lines must be between 0 and 100.'))
  210. if term_line.discount_percentage and (term_line.discount_percentage < 0.0 or term_line.discount_percentage > 100.0):
  211. raise ValidationError(_('Discount percentages on the Payment Terms lines must be between 0 and 100.'))
  212. @api.constrains('discount_days')
  213. def _check_positive(self):
  214. for term_line in self:
  215. if term_line.discount_days < 0:
  216. raise ValidationError(_('The discount days of the Payment Terms lines must be positive.'))