Files
Odoo-Modules/Fusion Accounting/models/loan.py
2026-02-22 01:22:18 -05:00

566 lines
20 KiB
Python

"""
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()