566 lines
20 KiB
Python
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()
|