Initial commit
This commit is contained in:
565
Fusion Accounting/models/loan.py
Normal file
565
Fusion Accounting/models/loan.py
Normal file
@@ -0,0 +1,565 @@
|
||||
"""
|
||||
Fusion Accounting - Loan Management
|
||||
|
||||
Provides loan tracking with amortization schedule generation,
|
||||
journal entry creation, and full lifecycle management for
|
||||
both French (annuity) and Linear amortization methods.
|
||||
"""
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from odoo import api, Command, fields, models, _
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from odoo.tools import float_compare, float_is_zero, float_round
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Payment frequency mapping: selection key -> number of months per period
|
||||
# ---------------------------------------------------------------------------
|
||||
FREQUENCY_MONTHS = {
|
||||
'monthly': 1,
|
||||
'quarterly': 3,
|
||||
'semi_annually': 6,
|
||||
'annually': 12,
|
||||
}
|
||||
|
||||
|
||||
class FusionLoan(models.Model):
|
||||
"""Manages loans (received or granted), their amortization schedules,
|
||||
and the associated accounting entries throughout the loan lifecycle.
|
||||
|
||||
Lifecycle: draft --> running --> paid
|
||||
| ^
|
||||
+-----> cancelled |
|
||||
|
|
||||
(early repayment) -------+
|
||||
"""
|
||||
_name = 'fusion.loan'
|
||||
_description = 'Loan'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'start_date desc, id desc'
|
||||
_check_company_auto = True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Identity
|
||||
# ------------------------------------------------------------------
|
||||
name = fields.Char(
|
||||
string='Reference',
|
||||
required=True,
|
||||
copy=False,
|
||||
readonly=True,
|
||||
default=lambda self: _('New'),
|
||||
tracking=True,
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
required=True,
|
||||
default=lambda self: self.env.company,
|
||||
tracking=True,
|
||||
)
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency',
|
||||
string='Currency',
|
||||
related='company_id.currency_id',
|
||||
store=True,
|
||||
)
|
||||
state = fields.Selection(
|
||||
selection=[
|
||||
('draft', 'Draft'),
|
||||
('running', 'Running'),
|
||||
('paid', 'Paid'),
|
||||
('cancelled', 'Cancelled'),
|
||||
],
|
||||
string='Status',
|
||||
default='draft',
|
||||
copy=False,
|
||||
readonly=True,
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Counterparty & accounts
|
||||
# ------------------------------------------------------------------
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Lender',
|
||||
required=True,
|
||||
tracking=True,
|
||||
help="The partner who provides or receives the loan.",
|
||||
)
|
||||
journal_id = fields.Many2one(
|
||||
'account.journal',
|
||||
string='Journal',
|
||||
required=True,
|
||||
domain="[('type', 'in', ['bank', 'general'])]",
|
||||
check_company=True,
|
||||
tracking=True,
|
||||
help="Journal used to record loan payments.",
|
||||
)
|
||||
loan_account_id = fields.Many2one(
|
||||
'account.account',
|
||||
string='Loan Account',
|
||||
required=True,
|
||||
check_company=True,
|
||||
tracking=True,
|
||||
help="Liability account where the outstanding loan balance is recorded.",
|
||||
)
|
||||
interest_account_id = fields.Many2one(
|
||||
'account.account',
|
||||
string='Interest Expense Account',
|
||||
required=True,
|
||||
check_company=True,
|
||||
tracking=True,
|
||||
help="Expense account where interest charges are recorded.",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Loan parameters
|
||||
# ------------------------------------------------------------------
|
||||
principal_amount = fields.Monetary(
|
||||
string='Principal Amount',
|
||||
required=True,
|
||||
tracking=True,
|
||||
help="Original loan amount.",
|
||||
)
|
||||
interest_rate = fields.Float(
|
||||
string='Annual Interest Rate (%)',
|
||||
required=True,
|
||||
digits=(8, 4),
|
||||
tracking=True,
|
||||
help="Nominal annual interest rate as a percentage.",
|
||||
)
|
||||
loan_term = fields.Integer(
|
||||
string='Loan Term (Months)',
|
||||
required=True,
|
||||
tracking=True,
|
||||
help="Total duration of the loan in months.",
|
||||
)
|
||||
start_date = fields.Date(
|
||||
string='Start Date',
|
||||
required=True,
|
||||
default=fields.Date.today,
|
||||
tracking=True,
|
||||
)
|
||||
payment_frequency = fields.Selection(
|
||||
selection=[
|
||||
('monthly', 'Monthly'),
|
||||
('quarterly', 'Quarterly'),
|
||||
('semi_annually', 'Semi-Annually'),
|
||||
('annually', 'Annually'),
|
||||
],
|
||||
string='Payment Frequency',
|
||||
default='monthly',
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
amortization_method = fields.Selection(
|
||||
selection=[
|
||||
('french', 'French (Equal Payments)'),
|
||||
('linear', 'Linear (Equal Principal)'),
|
||||
],
|
||||
string='Amortization Method',
|
||||
default='french',
|
||||
required=True,
|
||||
tracking=True,
|
||||
help=(
|
||||
"French: fixed total payment each period (annuity). "
|
||||
"Linear: fixed principal portion each period, decreasing total payment."
|
||||
),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Relational
|
||||
# ------------------------------------------------------------------
|
||||
line_ids = fields.One2many(
|
||||
'fusion.loan.line',
|
||||
'loan_id',
|
||||
string='Amortization Schedule',
|
||||
copy=False,
|
||||
)
|
||||
move_ids = fields.One2many(
|
||||
'account.move',
|
||||
'fusion_loan_id',
|
||||
string='Journal Entries',
|
||||
copy=False,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Computed / summary
|
||||
# ------------------------------------------------------------------
|
||||
total_interest = fields.Monetary(
|
||||
string='Total Interest',
|
||||
compute='_compute_totals',
|
||||
store=True,
|
||||
help="Sum of all interest amounts in the amortization schedule.",
|
||||
)
|
||||
total_amount = fields.Monetary(
|
||||
string='Total Repayment',
|
||||
compute='_compute_totals',
|
||||
store=True,
|
||||
help="Total amount to be repaid (principal + interest).",
|
||||
)
|
||||
remaining_balance = fields.Monetary(
|
||||
string='Remaining Balance',
|
||||
compute='_compute_totals',
|
||||
store=True,
|
||||
help="Outstanding principal still to be repaid.",
|
||||
)
|
||||
installment_count = fields.Integer(
|
||||
string='Number of Installments',
|
||||
compute='_compute_totals',
|
||||
store=True,
|
||||
)
|
||||
paid_installments = fields.Integer(
|
||||
string='Paid Installments',
|
||||
compute='_compute_totals',
|
||||
store=True,
|
||||
)
|
||||
entries_count = fields.Integer(
|
||||
string='# Journal Entries',
|
||||
compute='_compute_entries_count',
|
||||
)
|
||||
|
||||
# ==================== Computed Methods ====================
|
||||
|
||||
@api.depends('line_ids.principal_amount', 'line_ids.interest_amount',
|
||||
'line_ids.is_paid', 'line_ids.remaining_balance')
|
||||
def _compute_totals(self):
|
||||
"""Recompute all summary figures from the amortization lines."""
|
||||
for loan in self:
|
||||
lines = loan.line_ids
|
||||
loan.total_interest = sum(lines.mapped('interest_amount'))
|
||||
loan.total_amount = sum(lines.mapped('total_payment'))
|
||||
unpaid = lines.filtered(lambda l: not l.is_paid)
|
||||
loan.remaining_balance = unpaid[0].remaining_balance if unpaid else 0.0
|
||||
loan.installment_count = len(lines)
|
||||
loan.paid_installments = len(lines) - len(unpaid)
|
||||
|
||||
@api.depends('move_ids')
|
||||
def _compute_entries_count(self):
|
||||
for loan in self:
|
||||
loan.entries_count = len(loan.move_ids)
|
||||
|
||||
# ==================== Constraints ====================
|
||||
|
||||
@api.constrains('principal_amount')
|
||||
def _check_principal_amount(self):
|
||||
for loan in self:
|
||||
if float_compare(loan.principal_amount, 0.0, precision_digits=2) <= 0:
|
||||
raise ValidationError(
|
||||
_("The principal amount must be strictly positive.")
|
||||
)
|
||||
|
||||
@api.constrains('interest_rate')
|
||||
def _check_interest_rate(self):
|
||||
for loan in self:
|
||||
if loan.interest_rate < 0:
|
||||
raise ValidationError(
|
||||
_("The interest rate cannot be negative.")
|
||||
)
|
||||
|
||||
@api.constrains('loan_term')
|
||||
def _check_loan_term(self):
|
||||
for loan in self:
|
||||
if loan.loan_term <= 0:
|
||||
raise ValidationError(
|
||||
_("The loan term must be at least 1 month.")
|
||||
)
|
||||
|
||||
@api.constrains('loan_term', 'payment_frequency')
|
||||
def _check_term_frequency(self):
|
||||
"""Ensure the loan term is divisible by the payment period."""
|
||||
for loan in self:
|
||||
period = FREQUENCY_MONTHS.get(loan.payment_frequency, 1)
|
||||
if loan.loan_term % period != 0:
|
||||
raise ValidationError(
|
||||
_("The loan term (%s months) must be a multiple of the "
|
||||
"payment period (%s months).", loan.loan_term, period)
|
||||
)
|
||||
|
||||
# ==================== CRUD Overrides ====================
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""Assign sequence reference on creation."""
|
||||
for vals in vals_list:
|
||||
if vals.get('name', _('New')) == _('New'):
|
||||
vals['name'] = self.env['ir.sequence'].next_by_code(
|
||||
'fusion.loan'
|
||||
) or _('New')
|
||||
return super().create(vals_list)
|
||||
|
||||
# ==================== Business Methods ====================
|
||||
|
||||
def compute_amortization_schedule(self):
|
||||
"""Generate (or regenerate) the full amortization schedule.
|
||||
|
||||
For **French** amortization (annuity), every installment has the same
|
||||
total payment calculated with the standard PMT formula::
|
||||
|
||||
PMT = P * [r(1+r)^n / ((1+r)^n - 1)]
|
||||
|
||||
where P = principal, r = periodic interest rate, n = number of periods.
|
||||
|
||||
For **Linear** amortization, the principal portion is constant
|
||||
(P / n) and interest is computed on the declining balance.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.state != 'draft':
|
||||
raise UserError(
|
||||
_("You can only regenerate the schedule while the loan is in Draft state.")
|
||||
)
|
||||
|
||||
# Remove existing lines
|
||||
self.line_ids.unlink()
|
||||
|
||||
period_months = FREQUENCY_MONTHS[self.payment_frequency]
|
||||
num_periods = self.loan_term // period_months
|
||||
annual_rate = self.interest_rate / 100.0
|
||||
periodic_rate = annual_rate * period_months / 12.0
|
||||
|
||||
balance = self.principal_amount
|
||||
precision = self.currency_id.decimal_places
|
||||
|
||||
lines_vals = []
|
||||
|
||||
if self.amortization_method == 'french':
|
||||
# --- French / Annuity ---
|
||||
if float_is_zero(periodic_rate, precision_digits=6):
|
||||
# Zero-interest edge case
|
||||
pmt = float_round(balance / num_periods, precision_digits=precision)
|
||||
else:
|
||||
# PMT = P * [r(1+r)^n / ((1+r)^n - 1)]
|
||||
factor = (1 + periodic_rate) ** num_periods
|
||||
pmt = float_round(
|
||||
balance * (periodic_rate * factor) / (factor - 1),
|
||||
precision_digits=precision,
|
||||
)
|
||||
|
||||
for seq in range(1, num_periods + 1):
|
||||
interest = float_round(
|
||||
balance * periodic_rate, precision_digits=precision
|
||||
)
|
||||
principal_part = float_round(
|
||||
pmt - interest, precision_digits=precision
|
||||
)
|
||||
# Last period: absorb rounding residual
|
||||
if seq == num_periods:
|
||||
principal_part = float_round(balance, precision_digits=precision)
|
||||
pmt = float_round(
|
||||
principal_part + interest, precision_digits=precision
|
||||
)
|
||||
balance = float_round(
|
||||
balance - principal_part, precision_digits=precision
|
||||
)
|
||||
lines_vals.append({
|
||||
'loan_id': self.id,
|
||||
'sequence': seq,
|
||||
'date': self.start_date + relativedelta(months=period_months * seq),
|
||||
'principal_amount': principal_part,
|
||||
'interest_amount': interest,
|
||||
'total_payment': pmt,
|
||||
'remaining_balance': max(balance, 0.0),
|
||||
})
|
||||
else:
|
||||
# --- Linear ---
|
||||
principal_part = float_round(
|
||||
balance / num_periods, precision_digits=precision
|
||||
)
|
||||
for seq in range(1, num_periods + 1):
|
||||
interest = float_round(
|
||||
balance * periodic_rate, precision_digits=precision
|
||||
)
|
||||
# Last period: absorb rounding residual
|
||||
if seq == num_periods:
|
||||
principal_part = float_round(balance, precision_digits=precision)
|
||||
total = float_round(
|
||||
principal_part + interest, precision_digits=precision
|
||||
)
|
||||
balance = float_round(
|
||||
balance - principal_part, precision_digits=precision
|
||||
)
|
||||
lines_vals.append({
|
||||
'loan_id': self.id,
|
||||
'sequence': seq,
|
||||
'date': self.start_date + relativedelta(months=period_months * seq),
|
||||
'principal_amount': principal_part,
|
||||
'interest_amount': interest,
|
||||
'total_payment': total,
|
||||
'remaining_balance': max(balance, 0.0),
|
||||
})
|
||||
|
||||
self.env['fusion.loan.line'].create(lines_vals)
|
||||
return True
|
||||
|
||||
def action_confirm(self):
|
||||
"""Confirm the loan: move it to *running* state and generate the
|
||||
disbursement journal entry (debit bank, credit loan liability)."""
|
||||
for loan in self:
|
||||
if loan.state != 'draft':
|
||||
raise UserError(_("Only draft loans can be confirmed."))
|
||||
if not loan.line_ids:
|
||||
raise UserError(
|
||||
_("Please compute the amortization schedule before confirming.")
|
||||
)
|
||||
loan._create_disbursement_entry()
|
||||
loan.state = 'running'
|
||||
|
||||
def _create_disbursement_entry(self):
|
||||
"""Create the initial journal entry recording the loan receipt."""
|
||||
self.ensure_one()
|
||||
move_vals = {
|
||||
'journal_id': self.journal_id.id,
|
||||
'date': self.start_date,
|
||||
'ref': _('Loan Disbursement: %s', self.name),
|
||||
'fusion_loan_id': self.id,
|
||||
'line_ids': [
|
||||
Command.create({
|
||||
'account_id': self.journal_id.default_account_id.id,
|
||||
'debit': self.principal_amount,
|
||||
'credit': 0.0,
|
||||
'partner_id': self.partner_id.id,
|
||||
'name': _('Loan received: %s', self.name),
|
||||
}),
|
||||
Command.create({
|
||||
'account_id': self.loan_account_id.id,
|
||||
'debit': 0.0,
|
||||
'credit': self.principal_amount,
|
||||
'partner_id': self.partner_id.id,
|
||||
'name': _('Loan liability: %s', self.name),
|
||||
}),
|
||||
],
|
||||
}
|
||||
move = self.env['account.move'].create(move_vals)
|
||||
move.action_post()
|
||||
|
||||
def generate_entries(self):
|
||||
"""Create journal entries for all unpaid installments whose due
|
||||
date is on or before today. Called both manually and by the
|
||||
scheduled action (cron)."""
|
||||
today = fields.Date.today()
|
||||
for loan in self:
|
||||
if loan.state != 'running':
|
||||
continue
|
||||
due_lines = loan.line_ids.filtered(
|
||||
lambda l: not l.is_paid and l.date <= today
|
||||
)
|
||||
for line in due_lines.sorted('sequence'):
|
||||
line.action_create_entry()
|
||||
# Auto-close loan when fully paid
|
||||
if all(line.is_paid for line in loan.line_ids):
|
||||
loan.state = 'paid'
|
||||
loan.message_post(body=_("Loan fully repaid."))
|
||||
|
||||
def action_pay_early(self):
|
||||
"""Open a confirmation dialog for early repayment of the
|
||||
remaining balance in a single lump-sum payment."""
|
||||
self.ensure_one()
|
||||
if self.state != 'running':
|
||||
raise UserError(_("Only running loans can be repaid early."))
|
||||
|
||||
unpaid_lines = self.line_ids.filtered(lambda l: not l.is_paid)
|
||||
if not unpaid_lines:
|
||||
raise UserError(_("All installments are already paid."))
|
||||
|
||||
remaining_principal = sum(unpaid_lines.mapped('principal_amount'))
|
||||
remaining_interest = unpaid_lines[0].interest_amount # interest up to today
|
||||
|
||||
# Create a single settlement entry
|
||||
move_vals = {
|
||||
'journal_id': self.journal_id.id,
|
||||
'date': fields.Date.today(),
|
||||
'ref': _('Early Repayment: %s', self.name),
|
||||
'fusion_loan_id': self.id,
|
||||
'line_ids': [
|
||||
# Debit the loan liability (clear outstanding balance)
|
||||
Command.create({
|
||||
'account_id': self.loan_account_id.id,
|
||||
'debit': remaining_principal,
|
||||
'credit': 0.0,
|
||||
'partner_id': self.partner_id.id,
|
||||
'name': _('Early repayment principal: %s', self.name),
|
||||
}),
|
||||
# Debit interest expense
|
||||
Command.create({
|
||||
'account_id': self.interest_account_id.id,
|
||||
'debit': remaining_interest,
|
||||
'credit': 0.0,
|
||||
'partner_id': self.partner_id.id,
|
||||
'name': _('Early repayment interest: %s', self.name),
|
||||
}),
|
||||
# Credit bank
|
||||
Command.create({
|
||||
'account_id': self.journal_id.default_account_id.id,
|
||||
'debit': 0.0,
|
||||
'credit': remaining_principal + remaining_interest,
|
||||
'partner_id': self.partner_id.id,
|
||||
'name': _('Early repayment: %s', self.name),
|
||||
}),
|
||||
],
|
||||
}
|
||||
move = self.env['account.move'].create(move_vals)
|
||||
move.action_post()
|
||||
|
||||
# Mark all unpaid lines as paid
|
||||
unpaid_lines.write({'is_paid': True, 'move_id': move.id})
|
||||
self.state = 'paid'
|
||||
self.message_post(body=_("Loan settled via early repayment."))
|
||||
return True
|
||||
|
||||
def action_cancel(self):
|
||||
"""Cancel the loan and reverse all posted journal entries."""
|
||||
for loan in self:
|
||||
if loan.state == 'cancelled':
|
||||
raise UserError(_("This loan is already cancelled."))
|
||||
if loan.state == 'paid':
|
||||
raise UserError(
|
||||
_("A fully paid loan cannot be cancelled. "
|
||||
"Please create a reversal entry instead.")
|
||||
)
|
||||
# Reverse all posted moves linked to this loan
|
||||
posted_moves = loan.move_ids.filtered(
|
||||
lambda m: m.state == 'posted'
|
||||
)
|
||||
if posted_moves:
|
||||
default_values = [{
|
||||
'ref': _('Reversal of: %s', move.ref or move.name),
|
||||
'date': fields.Date.today(),
|
||||
} for move in posted_moves]
|
||||
posted_moves._reverse_moves(default_values, cancel=True)
|
||||
|
||||
# Reset lines
|
||||
loan.line_ids.write({'is_paid': False, 'move_id': False})
|
||||
loan.state = 'cancelled'
|
||||
loan.message_post(body=_("Loan cancelled. All entries reversed."))
|
||||
|
||||
def action_reset_to_draft(self):
|
||||
"""Allow a cancelled loan to be set back to draft for corrections."""
|
||||
for loan in self:
|
||||
if loan.state != 'cancelled':
|
||||
raise UserError(
|
||||
_("Only cancelled loans can be reset to draft.")
|
||||
)
|
||||
loan.state = 'draft'
|
||||
|
||||
def action_view_entries(self):
|
||||
"""Open a list view of all journal entries linked to this loan."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'name': _('Loan Journal Entries'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'account.move',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('fusion_loan_id', '=', self.id)],
|
||||
'context': {'default_fusion_loan_id': self.id},
|
||||
}
|
||||
|
||||
# ==================== Cron ====================
|
||||
|
||||
@api.model
|
||||
def _cron_generate_loan_entries(self):
|
||||
"""Scheduled action: generate journal entries for all running
|
||||
loans with installments due on or before today."""
|
||||
running_loans = self.search([('state', '=', 'running')])
|
||||
running_loans.generate_entries()
|
||||
Reference in New Issue
Block a user