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