444 lines
21 KiB
Python
444 lines
21 KiB
Python
from dateutil.relativedelta import relativedelta
|
||
from freezegun import freeze_time
|
||
|
||
from odoo import Command, fields
|
||
from odoo.tests import tagged
|
||
from odoo.tools import file_open
|
||
|
||
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
|
||
from odoo.addons.account_loans import _account_loans_add_date_column
|
||
|
||
|
||
@tagged('post_install', '-at_install')
|
||
class TestLoanManagement(AccountTestInvoicingCommon):
|
||
@classmethod
|
||
def setUpClass(cls):
|
||
super().setUpClass()
|
||
cls.loan_journal = cls.env['account.journal'].search([
|
||
('company_id', '=', cls.company.id),
|
||
('type', '=', 'general'),
|
||
('id', '!=', cls.company.currency_exchange_journal_id.id),
|
||
], limit=1)
|
||
cls.long_term_account = cls.env['account.account'].search([
|
||
('company_ids', '=', cls.company.id),
|
||
('account_type', '=', 'liability_non_current'),
|
||
], limit=1)
|
||
cls.short_term_account = cls.env['account.account'].search([
|
||
('company_ids', '=', cls.company.id),
|
||
('account_type', '=', 'liability_current'),
|
||
], limit=1)
|
||
cls.expense_account = cls.env['account.account'].search([
|
||
('company_ids', '=', cls.company.id),
|
||
('account_type', '=', 'expense'),
|
||
('id', '!=', cls.company.account_journal_early_pay_discount_loss_account_id.id),
|
||
], limit=1)
|
||
|
||
def create_loan(self, name, date, duration, amount_borrowed, interest, validate=False, skip_until_date=False):
|
||
loan = self.env['account.loan'].create({
|
||
'name': name,
|
||
'date': date,
|
||
'duration': duration,
|
||
'amount_borrowed': amount_borrowed,
|
||
'interest': interest,
|
||
'skip_until_date': skip_until_date,
|
||
'journal_id': self.loan_journal.id,
|
||
'long_term_account_id': self.long_term_account.id,
|
||
'short_term_account_id': self.short_term_account.id,
|
||
'expense_account_id': self.expense_account.id,
|
||
'line_ids': [
|
||
Command.create({
|
||
'date': fields.Date.to_date(date) + relativedelta(months=month),
|
||
'principal': amount_borrowed / duration,
|
||
'interest': interest / duration,
|
||
}) for month in range(duration)
|
||
],
|
||
})
|
||
if validate:
|
||
loan.action_confirm()
|
||
return loan
|
||
|
||
@freeze_time('2024-07-31')
|
||
def test_loan_values(self):
|
||
"""Test that the loan values are correctly computed"""
|
||
# Create the loan
|
||
loan = self.create_loan('Odoomobile Loan 🚗', '2024-01-01', 2 * 12, 24_000, 2_400, validate=True)
|
||
|
||
# Verify that the outstanding balance of the loan is correct
|
||
self.assertEqual(loan.outstanding_balance, 17_000) # = 24_000 - (1_000 of principal * 7 months (Jan -> July))
|
||
|
||
# Verify that the loan lines are correct (computed fields)
|
||
self.assertEqual(len(loan.line_ids), 24) # 24 months
|
||
self.assertRecordValues(loan.line_ids[0] | loan.line_ids[4] | loan.line_ids[-1], [{
|
||
'date': fields.Date.to_date('2024-01-01'),
|
||
'principal': 1_000,
|
||
'interest': 100,
|
||
'payment': 1_100,
|
||
'outstanding_balance': 23_000, # = 24_000 - 1_000 principal * 1 month
|
||
}, {
|
||
'date': fields.Date.to_date('2024-05-01'),
|
||
'principal': 1_000,
|
||
'interest': 100,
|
||
'payment': 1_100,
|
||
'outstanding_balance': 19_000, # = 24_000 - 1_000 principal * 5 months
|
||
}, {
|
||
'date': fields.Date.to_date('2025-12-01'),
|
||
'principal': 1_000,
|
||
'interest': 100,
|
||
'payment': 1_100,
|
||
'outstanding_balance': 0,
|
||
}])
|
||
|
||
# Verify that the generated moves are correct
|
||
payment_moves = loan.line_ids.generated_move_ids.filtered(lambda m: len(m.line_ids) == 3) # payments moves have 3 lines (principal + interest = payment)
|
||
self.assertEqual(len(payment_moves), 24) # 24 months
|
||
reclassification_moves = (loan.line_ids.generated_move_ids - payment_moves).filtered(lambda m: not m.reversed_entry_id) # reclassification moves have 2 lines (moving principal from one account to another) and have no link to a reversed entry
|
||
self.assertEqual(len(reclassification_moves), 23) # one less because we have an offset of one month (the first month is already started and should not be reclassified)
|
||
reclassification_reverse_moves = (loan.line_ids.generated_move_ids - payment_moves) - reclassification_moves
|
||
self.assertEqual(len(reclassification_reverse_moves), 23) # same
|
||
|
||
# Verify that the payment_moves are correct
|
||
self.assertRecordValues(payment_moves[0] | payment_moves[11] | payment_moves[15] | payment_moves[-1], [{
|
||
'date': fields.Date.to_date('2024-01-31'),
|
||
'ref': "Odoomobile Loan 🚗 - Principal & Interest 01/2024",
|
||
'amount_total': 1_100, # 1_000 principal + 100 interest
|
||
'generating_loan_line_id': loan.line_ids[0].id,
|
||
}, {
|
||
'date': fields.Date.to_date('2024-12-31'),
|
||
'ref': "Odoomobile Loan 🚗 - Principal & Interest 12/2024",
|
||
'amount_total': 1_100, # 1_000 principal + 100 interest
|
||
'generating_loan_line_id': loan.line_ids[11].id,
|
||
}, {
|
||
'date': fields.Date.to_date('2025-04-30'),
|
||
'ref': "Odoomobile Loan 🚗 - Principal & Interest 04/2025",
|
||
'amount_total': 1_100, # 1_000 principal + 100 interest
|
||
'generating_loan_line_id': loan.line_ids[15].id,
|
||
}, {
|
||
'date': fields.Date.to_date('2025-12-31'),
|
||
'ref': "Odoomobile Loan 🚗 - Principal & Interest 12/2025",
|
||
'amount_total': 1_100, # 1_000 principal + 100 interest
|
||
'generating_loan_line_id': loan.line_ids[-1].id,
|
||
}])
|
||
|
||
self.assertRecordValues(payment_moves[0].line_ids.sorted(lambda l: -l.debit), [{
|
||
'name': 'Odoomobile Loan 🚗 - Principal 01/2024',
|
||
'debit': 1_000,
|
||
'credit': 0,
|
||
'account_id': self.long_term_account.id,
|
||
}, {
|
||
'name': 'Odoomobile Loan 🚗 - Interest 01/2024',
|
||
'debit': 100,
|
||
'credit': 0,
|
||
'account_id': self.expense_account.id,
|
||
}, {
|
||
'name': 'Odoomobile Loan 🚗 - Due 01/2024 (Principal $ 1,000.00 + Interest $ 100.00)',
|
||
'debit': 0,
|
||
'credit': 1_100,
|
||
'account_id': self.short_term_account.id,
|
||
}])
|
||
|
||
# Verify that the reclassification moves are correct
|
||
self.assertRecordValues(reclassification_moves[0] | reclassification_moves[11] | reclassification_moves[15], [{
|
||
'date': fields.Date.to_date('2024-01-31'),
|
||
'ref': "Odoomobile Loan 🚗 - Reclassification LT - ST 02/2024 to 01/2025", # offset of 1 month
|
||
'amount_total': 12_000, # sum of the principals of the next 12 months
|
||
'generating_loan_line_id': loan.line_ids[0].id,
|
||
}, {
|
||
'date': fields.Date.to_date('2024-12-31'),
|
||
'ref': "Odoomobile Loan 🚗 - Reclassification LT - ST 01/2025 to 12/2025", # offset of 1 month
|
||
'amount_total': 12_000, # sum of the principals between 01/25 and 12/25
|
||
'generating_loan_line_id': loan.line_ids[11].id,
|
||
}, {
|
||
'date': fields.Date.to_date('2025-04-30'),
|
||
'ref': "Odoomobile Loan 🚗 - Reclassification LT - ST 05/2025 to 12/2025", # offset of 1 month
|
||
'amount_total': 8_000, # sum of the principals between 05/25 and 12/25
|
||
'generating_loan_line_id': loan.line_ids[15].id,
|
||
}])
|
||
|
||
self.assertRecordValues(reclassification_moves[0].line_ids.sorted(lambda l: l.credit), [{
|
||
'name': f'Odoomobile Loan 🚗 - Reclassification LT - ST 02/2024 to 01/2025 (To {self.short_term_account.code})',
|
||
'debit': 12_000,
|
||
'credit': 0,
|
||
'account_id': self.long_term_account.id,
|
||
}, {
|
||
'name': f'Odoomobile Loan 🚗 - Reclassification LT - ST 02/2024 to 01/2025 (From {self.long_term_account.code})',
|
||
'debit': 0,
|
||
'credit': 12_000,
|
||
'account_id': self.short_term_account.id,
|
||
}])
|
||
|
||
# Verify that the reverse reclassification moves are correct
|
||
self.assertRecordValues(reclassification_reverse_moves[0] | reclassification_reverse_moves[11] | reclassification_reverse_moves[15], [{
|
||
'date': fields.Date.to_date('2024-02-01'),
|
||
'ref': "Odoomobile Loan 🚗 - Reversal reclassification LT - ST 02/2024 to 01/2025", # offset of 1 month
|
||
'amount_total': 12_000, # sum of the principals of the next 12 months
|
||
'generating_loan_line_id': loan.line_ids[0].id,
|
||
}, {
|
||
'date': fields.Date.to_date('2025-01-01'),
|
||
'ref': "Odoomobile Loan 🚗 - Reversal reclassification LT - ST 01/2025 to 12/2025", # offset of 1 month
|
||
'amount_total': 12_000, # sum of the principals between 01/25 and 12/25
|
||
'generating_loan_line_id': loan.line_ids[11].id,
|
||
}, {
|
||
'date': fields.Date.to_date('2025-05-01'),
|
||
'ref': "Odoomobile Loan 🚗 - Reversal reclassification LT - ST 05/2025 to 12/2025", # offset of 1 month
|
||
'amount_total': 8_000, # sum of the principals between 05/25 and 12/25
|
||
'generating_loan_line_id': loan.line_ids[15].id,
|
||
}])
|
||
|
||
self.assertRecordValues(reclassification_reverse_moves[0].line_ids.sorted(lambda l: l.debit), [{
|
||
'name': f'Odoomobile Loan 🚗 - Reversal reclassification LT - ST 02/2024 to 01/2025 (To {self.short_term_account.code})',
|
||
'credit': 12_000,
|
||
'debit': 0,
|
||
'account_id': self.long_term_account.id,
|
||
}, {
|
||
'name': f'Odoomobile Loan 🚗 - Reversal reclassification LT - ST 02/2024 to 01/2025 (From {self.long_term_account.code})',
|
||
'credit': 0,
|
||
'debit': 12_000,
|
||
'account_id': self.short_term_account.id,
|
||
}])
|
||
|
||
@freeze_time('2024-07-31')
|
||
def test_loan_states(self):
|
||
"""Test the flow of the loan: Draft, Running, Closed, Cancelled"""
|
||
# Create the loan
|
||
loan = self.create_loan('Odoomobile Loan 🚗', '2024-01-01', 12, 24_000, 2_400)
|
||
|
||
# Verify that the loan is in draft
|
||
self.assertEqual(loan.state, 'draft')
|
||
self.assertFalse(loan.line_ids.generated_move_ids)
|
||
|
||
# Verify that the loan is running
|
||
loan.action_confirm()
|
||
self.assertEqual(loan.state, 'running')
|
||
self.assertTrue(loan.line_ids.generated_move_ids)
|
||
self.assertTrue(
|
||
any(m.state == 'posted' for m in loan.line_ids.generated_move_ids)
|
||
and
|
||
any(m.state == 'draft' for m in loan.line_ids.generated_move_ids)
|
||
) # Mix of draft & posted entries
|
||
|
||
# Verify that the loan is cancelled
|
||
loan.action_cancel()
|
||
self.assertEqual(loan.state, 'cancelled')
|
||
self.assertFalse(loan.line_ids.generated_move_ids)
|
||
|
||
# Verify that we can reset to draft the loan
|
||
loan.action_set_to_draft()
|
||
self.assertEqual(loan.state, 'draft')
|
||
self.assertFalse(loan.line_ids.generated_move_ids)
|
||
|
||
# Run it again
|
||
loan.action_confirm()
|
||
self.assertEqual(loan.state, 'running')
|
||
self.assertTrue(loan.line_ids.generated_move_ids)
|
||
|
||
# Close the loan, only draft entries should be removed
|
||
action = loan.action_close()
|
||
wizard = self.env[action['res_model']].browse(action['res_id'])
|
||
wizard.date = fields.Date.to_date('2024-10-31')
|
||
wizard.action_save()
|
||
self.assertEqual(loan.state, 'closed')
|
||
self.assertEqual(len(loan.line_ids.generated_move_ids), 30) # = 24 payment moves + 23 reclassification moves + 23 reversed reclass - (14 + 13 + 13) cancelled moves
|
||
|
||
# Reset to draft, the entries should be removed
|
||
loan.action_set_to_draft()
|
||
self.assertEqual(loan.state, 'draft')
|
||
self.assertFalse(loan.line_ids.generated_move_ids)
|
||
|
||
# Create a new loan that should be automatically closed when the last generated move is posted
|
||
loan2 = self.create_loan('Odoomobile Loan 🚗', '2024-01-01', 12, 24_000, 2_400, validate=True)
|
||
self.assertEqual(loan2.state, 'running')
|
||
with freeze_time('2024-12-31'), self.enter_registry_test_mode():
|
||
self.env.ref('account.ir_cron_auto_post_draft_entry').method_direct_trigger()
|
||
self.assertEqual(loan2.state, 'closed')
|
||
|
||
@freeze_time('2024-06-23')
|
||
def test_loan_states_with_audit_trail(self):
|
||
"""Test the flow of the loan: Draft, Running, Closed, Cancelled"""
|
||
self.company.restrictive_audit_trail = True
|
||
# Create the loan
|
||
loan = self.create_loan('Odoomobile Loan 🚗', '2024-01-01', 12, 24_000, 2_400)
|
||
|
||
# Verify that the loan is in draft
|
||
self.assertEqual(loan.state, 'draft')
|
||
self.assertFalse(loan.line_ids.generated_move_ids)
|
||
|
||
# Verify that the loan is running
|
||
loan.action_confirm()
|
||
self.assertEqual(loan.state, 'running')
|
||
self.assertEqual(len(loan.line_ids.generated_move_ids.filtered(lambda m: m.state == 'posted')), 15)
|
||
self.assertEqual(len(loan.line_ids.generated_move_ids), 34)
|
||
|
||
# Verify that the loan is cancelled
|
||
loan.action_cancel()
|
||
self.assertEqual(loan.state, 'cancelled')
|
||
self.assertFalse(len(loan.line_ids.generated_move_ids.filtered(lambda m: m.state == 'posted')))
|
||
self.assertEqual(len(loan.line_ids.generated_move_ids), 15)
|
||
|
||
# Verify that we can reset to draft the loan
|
||
loan.action_set_to_draft()
|
||
self.assertEqual(loan.state, 'draft')
|
||
self.assertEqual(len(loan.line_ids.generated_move_ids), 15)
|
||
|
||
# Run it again
|
||
loan.action_confirm()
|
||
self.assertEqual(loan.state, 'running')
|
||
self.assertEqual(len(loan.line_ids.generated_move_ids.filtered(lambda m: m.state == 'posted')), 15)
|
||
self.assertEqual(len(loan.line_ids.generated_move_ids), 49)
|
||
|
||
# Close the loan, only draft entries should be removed
|
||
action = loan.action_close()
|
||
wizard = self.env[action['res_model']].browse(action['res_id'])
|
||
wizard.action_save()
|
||
self.assertEqual(loan.state, 'closed')
|
||
self.assertEqual(len(loan.line_ids.generated_move_ids.filtered(lambda m: m.state == 'posted')), 15)
|
||
self.assertEqual(len(loan.line_ids.generated_move_ids), 33)
|
||
|
||
# Reset to draft, the entries should be removed
|
||
loan.action_set_to_draft()
|
||
self.assertEqual(loan.state, 'draft')
|
||
self.assertFalse(len(loan.line_ids.generated_move_ids.filtered(lambda m: m.state == 'posted')))
|
||
self.assertEqual(len(loan.line_ids.generated_move_ids), 30)
|
||
|
||
@freeze_time('2024-01-01')
|
||
def test_loan_import_amortization_schedule(self):
|
||
"""Test that we can import an amortization schedule from a file"""
|
||
# Upload the file from the List View -> Create a new Loan
|
||
with file_open('account_loans/demo/files/loan_amortization_demo.csv', 'rb') as f:
|
||
attachment = self.env['ir.attachment'].create({
|
||
'name': 'loan_amortization_demo.csv',
|
||
'raw': f.read(),
|
||
})
|
||
attachment = _account_loans_add_date_column(attachment) # fill the date column from -1 year to +3 years
|
||
|
||
action = self.env['account.loan'].action_upload_amortization_schedule(attachment.id)
|
||
loan = self.env['account.loan'].browse(action.get('params', {}).get('context', {}).get('default_loan_id'))
|
||
import_wizard = self.env['base_import.import'].browse(action.get('params', {}).get('context', {}).get('wizard_id'))
|
||
result = import_wizard.parse_preview({
|
||
'quoting': '"',
|
||
'separator': ',',
|
||
'date_format': '%Y-%m-%d',
|
||
'has_headers': True,
|
||
})
|
||
import_wizard.with_context(default_loan_id=loan.id).execute_import(
|
||
['date', 'principal', 'interest'],
|
||
[],
|
||
result["options"],
|
||
)
|
||
loan.action_file_uploaded()
|
||
loan.write({
|
||
'journal_id': self.loan_journal.id,
|
||
'long_term_account_id': self.long_term_account.id,
|
||
'short_term_account_id': self.short_term_account.id,
|
||
'expense_account_id': self.expense_account.id,
|
||
})
|
||
loan.action_confirm()
|
||
|
||
self.assertRecordValues(loan, [{
|
||
'date': fields.Date.from_string('2023-01-01'),
|
||
'state': 'running',
|
||
'name': attachment.name,
|
||
'amount_borrowed': 19_900.25, # Sum of principals
|
||
'outstanding_balance': 15_981.65, # = 19_900.25 - principal of first 12 lines
|
||
}])
|
||
|
||
self.assertEqual(len(loan.line_ids), 48) # 4 years
|
||
self.assertRecordValues(loan.line_ids[0] | loan.line_ids[-1], [{
|
||
'date': fields.Date.from_string('2023-01-01'),
|
||
'principal': 304.60,
|
||
'interest': 250.00,
|
||
'payment': 554.6,
|
||
'outstanding_balance': 19_595.65, # = 19_900.25 - 304.60
|
||
}, {
|
||
'date': fields.Date.from_string('2026-12-01'),
|
||
'principal': 547.72,
|
||
'interest': 6.88,
|
||
'payment': 554.6,
|
||
'outstanding_balance': 0,
|
||
}])
|
||
|
||
# Upload the file from the Form View -> Update the current Loan
|
||
loan2 = self.create_loan('Loan 2', '2024-01-01', 2 * 12, 24_000, 2_400, validate=True)
|
||
self.assertEqual(len(loan2.line_ids), 24)
|
||
|
||
# Override all previous lines, and recompute amount_borrowed, date, ...
|
||
action = loan2.action_upload_amortization_schedule(attachment.id)
|
||
self.assertEqual(action.get('params', {}).get('context', {}).get('default_loan_id'), loan2.id)
|
||
import_wizard = self.env['base_import.import'].browse(action.get('params', {}).get('context', {}).get('wizard_id'))
|
||
result = import_wizard.parse_preview({
|
||
'quoting': '"',
|
||
'separator': ',',
|
||
'date_format': '%Y-%m-%d',
|
||
'has_headers': True,
|
||
})
|
||
import_wizard.with_context(default_loan_id=loan2.id).execute_import(
|
||
['date', 'principal', 'interest'],
|
||
[],
|
||
result["options"],
|
||
)
|
||
loan2.action_file_uploaded()
|
||
loan2.action_confirm()
|
||
|
||
self.assertRecordValues(loan, [{
|
||
'date': fields.Date.from_string('2023-01-01'),
|
||
'state': 'running',
|
||
'name': attachment.name,
|
||
'amount_borrowed': 19_900.25, # Sum of principals
|
||
'outstanding_balance': 15_981.65, # = 19_900.25 - principal of first 12 lines
|
||
}])
|
||
|
||
self.assertEqual(len(loan.line_ids), 48) # 4 years
|
||
self.assertRecordValues(loan.line_ids[0] | loan.line_ids[-1], [{
|
||
'date': fields.Date.from_string('2023-01-01'),
|
||
'principal': 304.60,
|
||
'interest': 250.00,
|
||
'payment': 554.6,
|
||
'outstanding_balance': 19_595.65, # = 19_900.25 - 304.60
|
||
}, {
|
||
'date': fields.Date.from_string('2026-12-01'),
|
||
'principal': 547.72,
|
||
'interest': 6.88,
|
||
'payment': 554.6,
|
||
'outstanding_balance': 0,
|
||
}])
|
||
|
||
def test_loan_zero_interest(self):
|
||
loan = self.env['account.loan'].create({'name': '0 interest loan', 'date': '2024-01-01', 'amount_borrowed': 24_000})
|
||
wizard = self.env['account.loan.compute.wizard'].browse(loan.action_open_compute_wizard()['res_id'])
|
||
wizard.interest_rate = 0
|
||
wizard.action_save()
|
||
self.assertEqual(len(loan.line_ids), 12) # default loan term is 1 year = 12 months
|
||
self.assertTrue(all(payment == 2000 for payment in loan.line_ids.mapped('payment'))) # 24,000 / 12 months = 2,000/month
|
||
|
||
@freeze_time('2024-07-31')
|
||
def test_loan_skip_until_date(self):
|
||
"""Test the skip_until_date field"""
|
||
loan = self.create_loan('Odoomobile Loan 🚗', '2024-01-01', 12, 24_000, 2_400, validate=True, skip_until_date='2024-05-15')
|
||
|
||
self.assertEqual(loan.state, 'running')
|
||
self.assertTrue(loan.line_ids.generated_move_ids)
|
||
# Outstanding balance should be 24_000 - 2_000 * 7 months (Jan -> July), including skipped period
|
||
self.assertEqual(loan.outstanding_balance, 10_000)
|
||
|
||
@freeze_time('2025-01-01')
|
||
def test_loan_skip_until_date_2(self):
|
||
"""Test loan closing when skip_until_date field is set"""
|
||
loan = self.env['account.loan'].create({
|
||
'name': 'Test',
|
||
'date': '2024-01-01',
|
||
'duration': 12,
|
||
'amount_borrowed': 20_000,
|
||
'interest': 107.87,
|
||
'skip_until_date': '2024-10-31',
|
||
'journal_id': self.loan_journal.id,
|
||
'long_term_account_id': self.long_term_account.id,
|
||
'short_term_account_id': self.short_term_account.id,
|
||
'expense_account_id': self.expense_account.id,
|
||
})
|
||
|
||
wizard = self.env['account.loan.compute.wizard'].browse(loan.action_open_compute_wizard()['res_id'])
|
||
wizard.action_save()
|
||
loan.action_confirm()
|
||
|
||
self.assertTrue(loan.line_ids.generated_move_ids)
|
||
self.assertEqual(loan.state, 'closed')
|