427 lines
20 KiB
Python
427 lines
20 KiB
Python
from dateutil.relativedelta import relativedelta
|
|
|
|
from odoo import fields, models, api, _, Command
|
|
from odoo.tools import float_compare
|
|
from odoo.tools.misc import format_date
|
|
from odoo.exceptions import UserError, ValidationError
|
|
|
|
|
|
class AccountLoan(models.Model):
|
|
_name = 'account.loan'
|
|
_description = 'Loan'
|
|
_inherit = ['mail.thread']
|
|
_order = 'date'
|
|
|
|
@api.model
|
|
def default_get(self, fields):
|
|
values = super().default_get(fields)
|
|
if all(field not in fields for field in ['expense_account_id', 'long_term_account_id', 'short_term_account_id', 'journal_id']):
|
|
return values
|
|
previous_loan = self.search([
|
|
('company_id', '=', self.env.company.id),
|
|
('expense_account_id', '!=', False),
|
|
('long_term_account_id', '!=', False),
|
|
('short_term_account_id', '!=', False),
|
|
('journal_id', '!=', False),
|
|
], limit=1)
|
|
if previous_loan:
|
|
values['expense_account_id'] = previous_loan.expense_account_id.id
|
|
values['long_term_account_id'] = previous_loan.long_term_account_id.id
|
|
values['short_term_account_id'] = previous_loan.short_term_account_id.id
|
|
values['journal_id'] = previous_loan.journal_id.id
|
|
return values
|
|
|
|
name = fields.Char("Name", required=True, index="trigram", tracking=True)
|
|
company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.company)
|
|
currency_id = fields.Many2one(related='company_id.currency_id')
|
|
active = fields.Boolean(default=True)
|
|
state = fields.Selection(
|
|
string="Status",
|
|
selection=[
|
|
('draft', 'Draft'),
|
|
('running', 'Running'),
|
|
('closed', 'Closed'),
|
|
('cancelled', 'Cancelled'),
|
|
],
|
|
default='draft',
|
|
required=True,
|
|
tracking=True,
|
|
)
|
|
date = fields.Date('Loan Date', index="btree_not_null")
|
|
amount_borrowed = fields.Monetary(string='Amount Borrowed', tracking=True)
|
|
interest = fields.Monetary(string='Interest')
|
|
duration = fields.Integer('Duration')
|
|
skip_until_date = fields.Date(
|
|
string='Skip until',
|
|
help='Upon confirmation of the loan, Odoo will ignore the loan lines that are up to this date (included) and not create entries for them. '
|
|
'This is useful if you have already manually created entries prior to the creation of this loan.'
|
|
)
|
|
|
|
long_term_account_id = fields.Many2one('account.account', string='Long Term Account', tracking=True)
|
|
short_term_account_id = fields.Many2one('account.account', string='Short Term Account', tracking=True)
|
|
expense_account_id = fields.Many2one(
|
|
comodel_name='account.account',
|
|
string='Expense Account',
|
|
tracking=True,
|
|
domain="[('account_type', 'in', ('expense', 'expense_depreciation', 'expense_other'))]",
|
|
)
|
|
journal_id = fields.Many2one(
|
|
comodel_name='account.journal',
|
|
string='Journal',
|
|
domain="[('type', '=', 'general')]",
|
|
)
|
|
asset_group_id = fields.Many2one('account.asset.group', string='Asset Group', tracking=True, index=True)
|
|
loan_properties = fields.Properties('Properties', definition='journal_id.loan_properties_definition')
|
|
|
|
line_ids = fields.One2many('account.loan.line', 'loan_id', string='Loan Lines') # Amortization schedule
|
|
|
|
# Computed fields
|
|
display_name = fields.Char(string="Loan name", store=True) # stored for pivot view
|
|
start_date = fields.Date(compute='_compute_start_end_date')
|
|
end_date = fields.Date(compute='_compute_start_end_date')
|
|
is_wrong_date = fields.Boolean(compute='_compute_is_wrong_date')
|
|
amount_borrowed_difference = fields.Monetary(compute='_compute_amount_borrowed_difference')
|
|
interest_difference = fields.Monetary(compute='_compute_interest_difference')
|
|
duration_difference = fields.Integer(compute='_compute_duration_difference')
|
|
outstanding_balance = fields.Monetary(string='Outstanding Balance', compute='_compute_outstanding_balance') # Based on the posted entries
|
|
nb_posted_entries = fields.Integer(compute='_compute_nb_posted_entries')
|
|
linked_assets_ids = fields.One2many(
|
|
comodel_name='account.asset',
|
|
string="Linked Assets",
|
|
compute='_compute_linked_assets',
|
|
)
|
|
count_linked_assets = fields.Integer(compute="_compute_linked_assets")
|
|
|
|
# Constrains
|
|
@api.constrains('amount_borrowed', 'interest', 'duration')
|
|
def _require_positive_values(self):
|
|
for loan in self:
|
|
if float_compare(loan.amount_borrowed, 0.0, precision_rounding=loan.currency_id.rounding) < 0:
|
|
raise ValidationError(_('The amount borrowed must be positive'))
|
|
if float_compare(loan.interest, 0.0, precision_rounding=loan.currency_id.rounding) < 0:
|
|
raise ValidationError(_('The interest must be positive'))
|
|
if loan.duration < 0:
|
|
raise ValidationError(_('The duration must be positive'))
|
|
|
|
# Compute methods
|
|
@api.depends('name', 'start_date', 'end_date')
|
|
def _compute_display_name(self):
|
|
for loan in self:
|
|
if loan.name and loan.start_date and loan.end_date:
|
|
start_date = format_date(self.env, loan.start_date, date_format='MM y')
|
|
end_date = format_date(self.env, loan.end_date, date_format='MM y')
|
|
loan.display_name = f"{loan.name}: {start_date} - {end_date}"
|
|
else:
|
|
loan.display_name = loan.name
|
|
|
|
@api.depends('line_ids')
|
|
def _compute_start_end_date(self):
|
|
for loan in self:
|
|
if loan.line_ids:
|
|
loan.start_date = loan.line_ids[0].date
|
|
loan.end_date = loan.line_ids[-1].date
|
|
else:
|
|
loan.start_date = False
|
|
loan.end_date = False
|
|
|
|
@api.depends('date')
|
|
def _compute_is_wrong_date(self):
|
|
for loan in self:
|
|
loan.is_wrong_date = not loan.date or any(date < loan.date for date in loan.line_ids.mapped('date'))
|
|
|
|
@api.depends('amount_borrowed', 'line_ids.principal', 'currency_id')
|
|
def _compute_amount_borrowed_difference(self):
|
|
for loan in self:
|
|
if loan.currency_id:
|
|
loan.amount_borrowed_difference = abs(loan.amount_borrowed - loan.currency_id.round(sum(loan.line_ids.mapped('principal'))))
|
|
else:
|
|
loan.amount_borrowed_difference = 0
|
|
|
|
@api.depends('interest', 'line_ids.interest')
|
|
def _compute_interest_difference(self):
|
|
for loan in self:
|
|
if loan.interest and loan.line_ids:
|
|
loan.interest_difference = loan.interest - loan.currency_id.round(sum(loan.line_ids.mapped('interest')))
|
|
else:
|
|
loan.interest_difference = 0
|
|
|
|
@api.depends('duration', 'line_ids')
|
|
def _compute_duration_difference(self):
|
|
for loan in self:
|
|
loan.duration_difference = loan.duration - len(loan.line_ids)
|
|
|
|
@api.depends('line_ids.generated_move_ids')
|
|
def _compute_nb_posted_entries(self):
|
|
for loan in self:
|
|
loan.nb_posted_entries = len(loan.line_ids.generated_move_ids.filtered(lambda m: m.state == 'posted'))
|
|
|
|
@api.depends('amount_borrowed', 'line_ids.principal', 'state', 'line_ids.is_payment_move_posted')
|
|
def _compute_outstanding_balance(self):
|
|
for loan in self:
|
|
outstanding_balance = loan.amount_borrowed
|
|
if loan.state == 'running':
|
|
for line in loan.line_ids:
|
|
if line.is_payment_move_posted or (loan.skip_until_date and line.date < loan.skip_until_date):
|
|
outstanding_balance -= line.principal
|
|
loan.outstanding_balance = outstanding_balance
|
|
|
|
@api.depends('asset_group_id')
|
|
def _compute_linked_assets(self):
|
|
for loan in self:
|
|
loan.linked_assets_ids = loan.asset_group_id.linked_asset_ids
|
|
loan.count_linked_assets = len(loan.linked_assets_ids)
|
|
|
|
# Action methods
|
|
def action_confirm(self):
|
|
for loan in self:
|
|
# Verifications
|
|
if not loan.name:
|
|
raise UserError(_("The loan name should be set."))
|
|
if loan.is_wrong_date:
|
|
raise UserError(_("The loan date should be earlier than the loan lines date."))
|
|
if float_compare(loan.amount_borrowed_difference, 0.0, precision_rounding=loan.currency_id.rounding) != 0:
|
|
raise UserError(_(
|
|
"The loan amount %(loan_amount)s should be equal to the sum of the principals: %(principal_sum)s (difference %(principal_difference)s)",
|
|
loan_amount=loan.currency_id.format(loan.amount_borrowed),
|
|
principal_sum=loan.currency_id.format(sum(loan.line_ids.mapped('principal'))),
|
|
principal_difference=loan.currency_id.format(loan.amount_borrowed_difference),
|
|
))
|
|
if float_compare(loan.interest_difference, 0.0, precision_rounding=loan.currency_id.rounding) != 0:
|
|
raise UserError(_("The loan interest should be equal to the sum of the loan lines interest."))
|
|
if loan.duration_difference != 0:
|
|
raise UserError(_("The loan duration should be equal to the number of loan lines."))
|
|
if not loan.long_term_account_id or not loan.short_term_account_id or not loan.expense_account_id:
|
|
raise UserError(_("The loan accounts should be set."))
|
|
if not loan.journal_id:
|
|
raise UserError(_("The loan journal should be set."))
|
|
|
|
payment_moves_values = []
|
|
reclassification_moves_values = []
|
|
reclassification_reversed_moves_values = []
|
|
for i, line in enumerate(loan.line_ids):
|
|
if loan.skip_until_date and line.date < loan.skip_until_date:
|
|
continue
|
|
|
|
# Principal and interest (to match with the bank statement)
|
|
payment_moves_values.append({
|
|
'company_id': loan.company_id.id,
|
|
'auto_post': 'at_date',
|
|
'generating_loan_line_id': line.id,
|
|
'is_loan_payment_move': True,
|
|
'date': line.date + relativedelta(day=31),
|
|
'journal_id': loan.journal_id.id,
|
|
'ref': f"{loan.name} - {_('Principal & Interest')} {format_date(self.env, line.date, date_format='MM/y')}",
|
|
'line_ids': [
|
|
Command.create({
|
|
'account_id': loan.long_term_account_id.id,
|
|
'debit': line.principal,
|
|
'name': f"{loan.name} - {_('Principal')} {format_date(self.env, line.date, date_format='MM/y')}",
|
|
}),
|
|
Command.create({
|
|
'account_id': loan.short_term_account_id.id,
|
|
'credit': line.payment,
|
|
'name': f"{loan.name} - {_('Due')} {format_date(self.env, line.date, date_format='MM/y')} "
|
|
f"({_('Principal')} {loan.currency_id.format(line.principal)} + {_('Interest')} {loan.currency_id.format(line.interest)})",
|
|
}),
|
|
Command.create({
|
|
'account_id': loan.expense_account_id.id,
|
|
'debit': line.interest,
|
|
'name': f"{loan.name} - {_('Interest')} {format_date(self.env, line.date, date_format='MM/y')}",
|
|
}),
|
|
],
|
|
})
|
|
|
|
# Principal reclassification Long Term - Short Term
|
|
if line == loan.line_ids[-1]:
|
|
break
|
|
next_lines = loan.line_ids[i + 1: i + 13] # 13 = 1 (start offset) + 12 months
|
|
from_date = format_date(self.env, next_lines[0].date, date_format='MM/y')
|
|
to_date = format_date(self.env, next_lines[-1].date, date_format='MM/y')
|
|
common_reclassification_values = {
|
|
'company_id': loan.company_id.id,
|
|
'auto_post': 'at_date',
|
|
'generating_loan_line_id': line.id,
|
|
'is_loan_payment_move': False,
|
|
'journal_id': loan.journal_id.id,
|
|
}
|
|
reclassification_moves_values.append({
|
|
**common_reclassification_values,
|
|
'date': line.date + relativedelta(day=31),
|
|
'ref': f"{loan.name} - {_('Reclassification LT - ST')} {from_date} to {to_date}",
|
|
'line_ids': [
|
|
Command.create({
|
|
'account_id': loan.long_term_account_id.id,
|
|
'debit': sum(next_lines.mapped('principal')),
|
|
'name': f"{loan.name} - {_('Reclassification LT - ST')} {from_date} to {to_date} (To {loan.short_term_account_id.code})",
|
|
}),
|
|
Command.create({
|
|
'account_id': loan.short_term_account_id.id,
|
|
'credit': sum(next_lines.mapped('principal')),
|
|
'name': f"{loan.name} - {_('Reclassification LT - ST')} {from_date} to {to_date} (From {loan.long_term_account_id.code})",
|
|
}),
|
|
],
|
|
})
|
|
# Manually create the reverse (instead of using _reverse_moves()) for optimization reasons
|
|
reclassification_reversed_moves_values.append({
|
|
**common_reclassification_values,
|
|
'date': line.date + relativedelta(day=31) + relativedelta(days=1), # first day of next month
|
|
'ref': f"{loan.name} - {_('Reversal reclassification LT - ST')} {from_date} to {to_date}",
|
|
'line_ids': [
|
|
Command.create({
|
|
'account_id': loan.long_term_account_id.id,
|
|
'credit': sum(next_lines.mapped('principal')),
|
|
'name': f"{loan.name} - {_('Reversal reclassification LT - ST')} {from_date} to {to_date} (To {loan.short_term_account_id.code})",
|
|
}),
|
|
Command.create({
|
|
'account_id': loan.short_term_account_id.id,
|
|
'debit': sum(next_lines.mapped('principal')),
|
|
'name': f"{loan.name} - {_('Reversal reclassification LT - ST')} {from_date} to {to_date} (From {loan.long_term_account_id.code})",
|
|
}),
|
|
],
|
|
})
|
|
|
|
def post_moves(moves):
|
|
moves.filtered(lambda m: m.date <= fields.Date.context_today(self)).action_post()
|
|
|
|
payment_moves = self.env['account.move'].create(payment_moves_values)
|
|
reclassification_moves = self.env['account.move'].create(reclassification_moves_values)
|
|
reclassification_reversed_moves = self.env['account.move'].create(reclassification_reversed_moves_values)
|
|
post_moves(payment_moves | reclassification_moves | reclassification_reversed_moves)
|
|
|
|
for (reclassification_move, reclassification_reversed_move) in zip(reclassification_moves, reclassification_reversed_moves):
|
|
reclassification_reversed_move.reversed_entry_id = reclassification_move
|
|
reclassification_reversed_move.message_post(body=_('This entry has been reversed from %s', reclassification_move._get_html_link()))
|
|
|
|
bodies = {}
|
|
for move, reverse in zip(reclassification_moves, reclassification_reversed_moves):
|
|
bodies[move.id] = _('This entry has been %s', reverse._get_html_link(title=_("reversed")))
|
|
reclassification_moves._message_log_batch(bodies=bodies)
|
|
|
|
if any(m.state != 'posted' for m in payment_moves | reclassification_moves | reclassification_reversed_moves):
|
|
loan.state = 'running'
|
|
|
|
def action_upload_amortization_schedule(self, attachment_id):
|
|
"""Called when uploading an amortization schedule file"""
|
|
attachment = self.env['ir.attachment'].browse(attachment_id)
|
|
loan = self or self.create({
|
|
'name': attachment.name,
|
|
})
|
|
loan.line_ids.unlink()
|
|
loan.message_post(body=_('Uploaded file'), attachment_ids=[attachment.id])
|
|
import_wizard = self.env['base_import.import'].create({
|
|
'res_model': 'account.loan.line',
|
|
'file': attachment.raw,
|
|
'file_name': attachment.name,
|
|
'file_type': attachment.mimetype,
|
|
})
|
|
ctx = {
|
|
**self.env.context,
|
|
'wizard_id': import_wizard.id,
|
|
'default_loan_id': loan.id,
|
|
}
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'import_loan',
|
|
'params': {
|
|
'model': 'account.loan.line',
|
|
'context': ctx,
|
|
'filename': attachment.name,
|
|
}
|
|
}
|
|
|
|
def action_file_uploaded(self):
|
|
"""Called after the amortization schedule has been imported by the wizard"""
|
|
self.ensure_one()
|
|
action = {
|
|
'type': 'ir.actions.act_window',
|
|
'name': _("Loans"),
|
|
'res_model': 'account.loan',
|
|
'views': [(False, 'list'), (False, 'form')],
|
|
'target': 'self',
|
|
}
|
|
if self.line_ids:
|
|
self.amount_borrowed = sum(self.line_ids.mapped('principal'))
|
|
self.interest = sum(self.line_ids.mapped('interest'))
|
|
self.duration = len(self.line_ids)
|
|
self.date = self.line_ids[0].date
|
|
return {
|
|
**action,
|
|
'res_id': self.id,
|
|
'views': [(False, 'form')],
|
|
}
|
|
return action
|
|
|
|
def action_open_compute_wizard(self):
|
|
if not self:
|
|
raise UserError(_("Please add a name before computing the loan"))
|
|
wizard = self.env['account.loan.compute.wizard'].create({
|
|
'loan_id': self.id,
|
|
'loan_amount': self.amount_borrowed,
|
|
})
|
|
if self.date:
|
|
wizard["start_date"] = self.date
|
|
wizard["first_payment_date"] = self.date.replace(day=1) + relativedelta(months=1) # first day of next month
|
|
return {
|
|
'name': _("Compute New Loan"),
|
|
'res_id': wizard.id,
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'account.loan.compute.wizard',
|
|
'target': 'new',
|
|
'views': [[False, 'form']],
|
|
'context': self.env.context,
|
|
}
|
|
|
|
def action_reset(self):
|
|
self.ensure_one()
|
|
self.line_ids.unlink()
|
|
|
|
def action_close(self):
|
|
self.ensure_one()
|
|
wizard = self.env['account.loan.close.wizard'].create({
|
|
'loan_id': self.id,
|
|
})
|
|
return {
|
|
'name': _('Close'),
|
|
'view_mode': 'form',
|
|
'res_model': 'account.loan.close.wizard',
|
|
'type': 'ir.actions.act_window',
|
|
'target': 'new',
|
|
'res_id': wizard.id,
|
|
}
|
|
|
|
def action_cancel(self):
|
|
self.line_ids.generated_move_ids.filtered(lambda m: m.state != 'cancel')._unlink_or_reverse()
|
|
self.state = 'cancelled'
|
|
|
|
def action_set_to_draft(self):
|
|
self.line_ids.generated_move_ids.filtered(lambda m: m.state != 'cancel')._unlink_or_reverse()
|
|
self.state = 'draft'
|
|
|
|
def action_open_loan_entries(self):
|
|
self.ensure_one()
|
|
return {
|
|
'name': _('Loan Entries'),
|
|
'view_mode': 'list,form',
|
|
'res_model': 'account.move',
|
|
'views': [(self.env.ref('account_loans.account_loan_view_account_move_list_view').id, 'list'), (False, 'form')],
|
|
'type': 'ir.actions.act_window',
|
|
'domain': [('id', 'in', self.line_ids.generated_move_ids.ids)],
|
|
}
|
|
|
|
def action_open_linked_assets(self):
|
|
self.ensure_one()
|
|
return {
|
|
'name': _('Linked Assets'),
|
|
'view_mode': 'list,form',
|
|
'res_model': 'account.asset',
|
|
'type': 'ir.actions.act_window',
|
|
'domain': [('id', 'in', self.linked_assets_ids.ids)],
|
|
}
|
|
|
|
# Model methods
|
|
@api.ondelete(at_uninstall=False)
|
|
def _unlink_loan(self):
|
|
for loan in self:
|
|
loan.line_ids.generated_move_ids._unlink_or_reverse()
|
|
loan.line_ids.unlink()
|