From 99e55bd99113850a795bad2aa52a6f2fec07bff9 Mon Sep 17 00:00:00 2001 From: Enric Tobella Date: Mon, 26 Feb 2018 12:17:24 +0100 Subject: [PATCH] [ADD] account_loan --- account_loan/README.rst | 84 +++ account_loan/__init__.py | 4 + account_loan/__manifest__.py | 29 ++ account_loan/data/ir_sequence_data.xml | 18 + account_loan/model/__init__.py | 7 + account_loan/model/account_invoice.py | 50 ++ account_loan/model/account_loan.py | 484 ++++++++++++++++++ account_loan/model/account_loan_line.py | 384 ++++++++++++++ account_loan/model/account_move.py | 32 ++ .../security/account_loan_security.xml | 12 + account_loan/security/ir.model.access.csv | 5 + account_loan/static/description/icon.png | Bin 0 -> 9455 bytes account_loan/tests/__init__.py | 3 + account_loan/tests/test_loan.py | 389 ++++++++++++++ account_loan/views/account_loan_view.xml | 201 ++++++++ account_loan/views/account_move_view.xml | 21 + account_loan/wizard/__init__.py | 5 + .../wizard/account_loan_generate_entries.py | 52 ++ .../account_loan_generate_entries_view.xml | 37 ++ .../wizard/account_loan_pay_amount.py | 105 ++++ .../wizard/account_loan_pay_amount_view.xml | 41 ++ account_loan/wizard/account_loan_post.py | 94 ++++ .../wizard/account_loan_post_view.xml | 38 ++ 23 files changed, 2095 insertions(+) create mode 100644 account_loan/README.rst create mode 100644 account_loan/__init__.py create mode 100644 account_loan/__manifest__.py create mode 100644 account_loan/data/ir_sequence_data.xml create mode 100644 account_loan/model/__init__.py create mode 100644 account_loan/model/account_invoice.py create mode 100644 account_loan/model/account_loan.py create mode 100644 account_loan/model/account_loan_line.py create mode 100644 account_loan/model/account_move.py create mode 100644 account_loan/security/account_loan_security.xml create mode 100644 account_loan/security/ir.model.access.csv create mode 100644 account_loan/static/description/icon.png create mode 100644 account_loan/tests/__init__.py create mode 100644 account_loan/tests/test_loan.py create mode 100644 account_loan/views/account_loan_view.xml create mode 100644 account_loan/views/account_move_view.xml create mode 100644 account_loan/wizard/__init__.py create mode 100644 account_loan/wizard/account_loan_generate_entries.py create mode 100644 account_loan/wizard/account_loan_generate_entries_view.xml create mode 100644 account_loan/wizard/account_loan_pay_amount.py create mode 100644 account_loan/wizard/account_loan_pay_amount_view.xml create mode 100644 account_loan/wizard/account_loan_post.py create mode 100644 account_loan/wizard/account_loan_post_view.xml diff --git a/account_loan/README.rst b/account_loan/README.rst new file mode 100644 index 00000000..485c49b2 --- /dev/null +++ b/account_loan/README.rst @@ -0,0 +1,84 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + +======================= +Account Loan management +======================= + +This module extends the functionality of accounting to support loans. +It will create automatically moves or invoices for loans. +Moreover, you can check the pending amount to be paid and reduce the debt. + +It currently supports two kinds of debts: + +* Loans: a standard debt with banks, that only creates account moves +* Leases: a debt with a bank where purchase invoices are necessary + +Installation +============ + +To install this module, you need to: + +#. Install numpy : ``pip install numpy`` +#. Follow the standard process + +Usage +===== + +To use this module, you need to: + +#. Go to `Invoicing / Accounting > Adviser > Loans` +#. Configure a loan selecting the company, loan type, amount, rate and accounts +#. Post the loan, it will automatically create an account move with the + expected amounts +#. Create automatically the account moves / invoices related to loans and + leases before a selected date + +On a posted loan you can: + +* Create moves or invoices (according to the configuration) +* Modify rates when needed (only unposted lines will be modified) +* Reduce or cancel the debt of a loan / lease + +.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: https://runbot.odoo-community.org/runbot/92/11.0 + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues +`_. In case of trouble, please +check there if your issue has already been reported. If you spotted it first, +help us smash it by providing detailed and welcomed feedback. + +Credits +======= + +Images +------ + +* Odoo Community Association: `Icon `_. + +Contributors +------------ + +* Enric Tobella + +Do not contact contributors directly about support or help with technical issues. + +Maintainer +---------- + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +This module is maintained by the OCA. + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +To contribute to this module, please visit https://odoo-community.org. diff --git a/account_loan/__init__.py b/account_loan/__init__.py new file mode 100644 index 00000000..b7d72ad1 --- /dev/null +++ b/account_loan/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import model +from . import wizard diff --git a/account_loan/__manifest__.py b/account_loan/__manifest__.py new file mode 100644 index 00000000..c5ffbcca --- /dev/null +++ b/account_loan/__manifest__.py @@ -0,0 +1,29 @@ +# Copyright 2018 Creu Blanca +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +{ + "name": "Account Loan management", + "version": "11.0.1.0.0", + "author": "Creu Blanca,Odoo Community Association (OCA)", + "website": "http://github.com/OCA/account-financial-tools", + "license": "AGPL-3", + "category": "Accounting", + "depends": [ + "account" + ], + "data": [ + 'data/ir_sequence_data.xml', + 'security/ir.model.access.csv', + 'security/account_loan_security.xml', + 'wizard/account_loan_generate_entries_view.xml', + 'wizard/account_loan_pay_amount_view.xml', + 'wizard/account_loan_post_view.xml', + 'views/account_loan_view.xml', + 'views/account_move_view.xml', + ], + 'installable': True, + 'external_dependencies': { + 'python': [ + 'numpy', + ], + }, +} diff --git a/account_loan/data/ir_sequence_data.xml b/account_loan/data/ir_sequence_data.xml new file mode 100644 index 00000000..fbbd1d95 --- /dev/null +++ b/account_loan/data/ir_sequence_data.xml @@ -0,0 +1,18 @@ + + + + + + + + Account loan sequence + account.loan + ACL + 6 + + + diff --git a/account_loan/model/__init__.py b/account_loan/model/__init__.py new file mode 100644 index 00000000..ad26bdee --- /dev/null +++ b/account_loan/model/__init__.py @@ -0,0 +1,7 @@ +# Copyright 2018 Creu Blanca +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import account_invoice +from . import account_loan +from . import account_loan_line +from . import account_move diff --git a/account_loan/model/account_invoice.py b/account_loan/model/account_invoice.py new file mode 100644 index 00000000..c6ca769e --- /dev/null +++ b/account_loan/model/account_invoice.py @@ -0,0 +1,50 @@ +# Copyright 2018 Creu Blanca +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models + + +class AccountInvoice(models.Model): + _inherit = 'account.invoice' + + loan_line_id = fields.Many2one( + 'account.loan.line', + readonly=True, + ondelete='restrict', + ) + loan_id = fields.Many2one( + 'account.loan', + readonly=True, + store=True, + ondelete='restrict', + ) + + @api.multi + def action_move_create(self): + if self.loan_line_id: + return super(AccountInvoice, self.with_context( + default_loan_line_id=self.loan_line_id.id, + default_loan_id=self.loan_id.id, + )).action_move_create() + return super().action_move_create() + + @api.multi + def finalize_invoice_move_lines(self, move_lines): + vals = super().finalize_invoice_move_lines(move_lines) + if self.loan_line_id: + ll = self.loan_line_id + if ( + ll.long_term_loan_account_id and + ll.long_term_principal_amount != 0 + ): + vals.append((0, 0, { + 'account_id': ll.loan_id.short_term_loan_account_id.id, + 'credit': ll.long_term_principal_amount, + 'debit': 0, + })) + vals.append((0, 0, { + 'account_id': ll.long_term_loan_account_id.id, + 'credit': 0, + 'debit': ll.long_term_principal_amount, + })) + return vals diff --git a/account_loan/model/account_loan.py b/account_loan/model/account_loan.py new file mode 100644 index 00000000..8f7d9804 --- /dev/null +++ b/account_loan/model/account_loan.py @@ -0,0 +1,484 @@ +# Copyright 2018 Creu Blanca +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models +from odoo.tools import DEFAULT_SERVER_DATE_FORMAT as DF + +from datetime import datetime +from dateutil.relativedelta import relativedelta +import logging +import math + +_logger = logging.getLogger(__name__) +try: + import numpy +except (ImportError, IOError) as err: + _logger.error(err) + + +class AccountLoan(models.Model): + _name = 'account.loan' + _description = 'Loan' + _inherit = ['mail.thread', 'mail.activity.mixin'] + + def _default_company(self): + force_company = self._context.get('force_company') + if not force_company: + return self.env.user.company_id.id + return force_company + + name = fields.Char( + copy=False, + required=True, + readonly=True, + default='/', + states={'draft': [('readonly', False)]}, + ) + partner_id = fields.Many2one( + 'res.partner', + required=True, + string='Lender', + help='Company or individual that lends the money at an interest rate.', + readonly=True, + states={'draft': [('readonly', False)]}, + ) + company_id = fields.Many2one( + 'res.company', + required=True, + default=_default_company, + readonly=True, + states={'draft': [('readonly', False)]}, + ) + state = fields.Selection([ + ('draft', 'Draft'), + ('posted', 'Posted'), + ('cancelled', 'Cancelled'), + ('closed', 'Closed'), + ], required=True, copy=False, default='draft') + line_ids = fields.One2many( + 'account.loan.line', + readonly=True, + inverse_name='loan_id', + copy=False, + ) + periods = fields.Integer( + required=True, + readonly=True, + states={'draft': [('readonly', False)]}, + help='Number of periods that the loan will last', + ) + method_period = fields.Integer( + string='Period Length', + default=1, + help="State here the time between 2 depreciations, in months", + required=True, + readonly=True, + states={'draft': [('readonly', False)]}, + ) + start_date = fields.Date( + help='Start of the moves', + readonly=True, + states={'draft': [('readonly', False)]}, + copy=False, + ) + rate = fields.Float( + required=True, + default=0.0, + digits=(8, 6), + help='Currently applied rate', + track_visibility='always', + ) + rate_period = fields.Float( + compute='_compute_rate_period', digits=(8, 6), + help='Real rate that will be applied on each period', + ) + rate_type = fields.Selection( + [ + ('napr', 'Nominal APR'), + ('ear', 'EAR'), + ('real', 'Real rate'), + ], + required=True, + help='Method of computation of the applied rate', + default='napr', + readonly=True, + states={'draft': [('readonly', False)]}, + ) + loan_type = fields.Selection( + [ + ('fixed-annuity', 'Fixed Annuity'), + ('fixed-principal', 'Fixed Principal'), + ('interest', 'Only interest'), + ], + required=True, + help='Method of computation of the period annuity', + readonly=True, + states={'draft': [('readonly', False)]}, + default='fixed-annuity' + ) + fixed_amount = fields.Monetary( + currency_field='currency_id', + compute='_compute_fixed_amount', + ) + fixed_loan_amount = fields.Monetary( + currency_field='currency_id', + readonly=True, + copy=False, + default=0, + ) + fixed_periods = fields.Integer( + readonly=True, + copy=False, + default=0, + ) + loan_amount = fields.Monetary( + currency_field='currency_id', + required=True, + readonly=True, + states={'draft': [('readonly', False)]}, + ) + residual_amount = fields.Monetary( + currency_field='currency_id', + default=0., + required=True, + readonly=True, + states={'draft': [('readonly', False)]}, + help='Residual amount of the lease that must be payed on the end in ' + 'order to acquire the asset', + ) + round_on_end = fields.Boolean( + default=False, + help='When checked, the differences will be applied on the last period' + ', if it is unchecked, the annuity will be recalculated on each ' + 'period.', + readonly=True, + states={'draft': [('readonly', False)]}, + ) + payment_on_first_period = fields.Boolean( + default=False, + readonly=True, + states={'draft': [('readonly', False)]}, + help='When checked, the first payment will be on start date', + ) + currency_id = fields.Many2one( + 'res.currency', + compute='_compute_currency', + readonly=True, + ) + journal_type = fields.Char(compute='_compute_journal_type') + journal_id = fields.Many2one( + 'account.journal', + domain="[('company_id', '=', company_id),('type', '=', journal_type)]", + required=True, + readonly=True, + states={'draft': [('readonly', False)]}, + ) + short_term_loan_account_id = fields.Many2one( + 'account.account', + domain="[('company_id', '=', company_id)]", + string='Short term account', + help='Account that will contain the pending amount on short term', + required=True, + readonly=True, + states={'draft': [('readonly', False)]}, + ) + long_term_loan_account_id = fields.Many2one( + 'account.account', + string='Long term account', + help='Account that will contain the pending amount on Long term', + domain="[('company_id', '=', company_id)]", + readonly=True, + states={'draft': [('readonly', False)]}, + ) + interest_expenses_account_id = fields.Many2one( + 'account.account', + domain="[('company_id', '=', company_id)]", + string='Interests account', + help='Account where the interests will be assigned to', + required=True, + readonly=True, + states={'draft': [('readonly', False)]}, + ) + is_leasing = fields.Boolean( + default=False, + readonly=True, + states={'draft': [('readonly', False)]}, + ) + leased_asset_account_id = fields.Many2one( + 'account.account', + domain="[('company_id', '=', company_id)]", + readonly=True, + states={'draft': [('readonly', False)]}, + ) + product_id = fields.Many2one( + 'product.product', + string='Loan product', + help='Product where the amount of the loan will be assigned when the ' + 'invoice is created', + ) + interests_product_id = fields.Many2one( + 'product.product', + string='Interest product', + help='Product where the amount of interests will be assigned when the ' + 'invoice is created', + ) + move_ids = fields.One2many( + 'account.move', + copy=False, + inverse_name='loan_id' + ) + pending_principal_amount = fields.Monetary( + currency_field='currency_id', + compute='_compute_total_amounts', + ) + payment_amount = fields.Monetary( + currency_field='currency_id', + string='Total payed amount', + compute='_compute_total_amounts', + ) + interests_amount = fields.Monetary( + currency_field='currency_id', + string='Total interests payed', + compute='_compute_total_amounts', + ) + + _sql_constraints = [ + ('name_uniq', 'unique(name, company_id)', + 'Loan name must be unique'), + ] + + @api.depends('line_ids', 'currency_id', 'loan_amount') + def _compute_total_amounts(self): + for record in self: + lines = record.line_ids.filtered(lambda r: r.move_ids) + record.payment_amount = sum( + lines.mapped('payment_amount')) or 0. + record.interests_amount = sum( + lines.mapped('interests_amount')) or 0. + record.pending_principal_amount = ( + record.loan_amount - + record.payment_amount + + record.interests_amount + ) + + @api.depends('rate_period', 'fixed_loan_amount', 'fixed_periods', + 'currency_id') + def _compute_fixed_amount(self): + """ + Computes the fixed amount in order to be used if round_on_end is + checked. On fix-annuity interests are included and on fixed-principal + and interests it isn't. + :return: + """ + for record in self: + if record.loan_type == 'fixed-annuity': + record.fixed_amount = - record.currency_id.round(numpy.pmt( + record.rate_period / 100, + record.fixed_periods, + record.fixed_loan_amount, + -record.residual_amount + )) + elif record.loan_type == 'fixed-principal': + record.fixed_amount = record.currency_id.round( + (record.fixed_loan_amount - record.residual_amount) / + record.fixed_periods + ) + else: + record.fixed_amount = 0.0 + + @api.model + def compute_rate(self, rate, rate_type, method_period): + """ + Returns the real rate + :param rate: Rate + :param rate_type: Computation rate + :param method_period: Number of months between payments + :return: + """ + if rate_type == 'napr': + return rate / 12 * method_period + if rate_type == 'ear': + return math.pow(1 + rate, method_period / 12) - 1 + return rate + + @api.depends('rate', 'method_period', 'rate_type') + def _compute_rate_period(self): + for record in self: + record.rate_period = record.compute_rate( + record.rate, record.rate_type, record.method_period) + + @api.depends('journal_id', 'company_id') + def _compute_currency(self): + for rec in self: + rec.currency_id = ( + rec.journal_id.currency_id or rec.company_id.currency_id) + + @api.depends('is_leasing') + def _compute_journal_type(self): + for record in self: + if record.is_leasing: + record.journal_type = 'purchase' + else: + record.journal_type = 'general' + + @api.onchange('is_leasing') + def _onchange_is_leasing(self): + self.journal_id = self.env['account.journal'].search([ + ('company_id', '=', self.company_id.id), + ('type', '=', 'purchase' if self.is_leasing else 'general') + ], limit=1) + self.residual_amount = 0.0 + + @api.onchange('company_id') + def _onchange_company(self): + self._onchange_is_leasing() + self.interest_expenses_account_id = False + self.short_term_loan_account_id = False + self.long_term_loan_account_id = False + + def get_default_name(self, vals): + return self.env['ir.sequence'].next_by_code('account.loan') or '/' + + @api.model + def create(self, vals): + if vals.get('name', '/') == '/': + vals['name'] = self.get_default_name(vals) + return super().create(vals) + + @api.multi + def post(self): + self.ensure_one() + if not self.start_date: + self.start_date = fields.Datetime.now() + self.compute_draft_lines() + self.write({'state': 'posted'}) + + @api.multi + def close(self): + self.write({'state': 'closed'}) + + @api.multi + def compute_lines(self): + self.ensure_one() + if self.state == 'draft': + return self.compute_draft_lines() + return self.compute_posted_lines() + + def compute_posted_lines(self): + """ + Recompute the amounts of not finished lines. Useful if rate is changed + """ + amount = self.loan_amount + for line in self.line_ids.sorted('sequence'): + if line.move_ids: + amount = line.final_pending_principal_amount + else: + line.rate = self.rate_period + line.pending_principal_amount = amount + line.check_amount() + amount -= line.payment_amount - line.interests_amount + if self.long_term_loan_account_id: + self.check_long_term_principal_amount() + + def check_long_term_principal_amount(self): + """ + Recomputes the long term pending principal of unfinished lines. + """ + lines = self.line_ids.filtered(lambda r: not r.move_ids) + amount = 0 + final_sequence = min(lines.mapped('sequence')) + for line in lines.sorted('sequence', reverse=True): + date = datetime.strptime( + line.date, DF).date() + relativedelta(months=12) + if self.state == 'draft' or line.sequence != final_sequence: + line.long_term_pending_principal_amount = sum( + self.line_ids.filtered( + lambda r: datetime.strptime(r.date, DF).date() >= date + ).mapped('principal_amount')) + line.long_term_principal_amount = ( + line.long_term_pending_principal_amount - amount) + amount = line.long_term_pending_principal_amount + + def new_line_vals(self, sequence, date, amount): + return { + 'loan_id': self.id, + 'sequence': sequence, + 'date': date, + 'pending_principal_amount': amount, + 'rate': self.rate_period, + } + + @api.multi + def compute_draft_lines(self): + self.ensure_one() + self.fixed_periods = self.periods + self.fixed_loan_amount = self.loan_amount + self.line_ids.unlink() + amount = self.loan_amount + if self.start_date: + date = datetime.strptime(self.start_date, DF).date() + else: + date = datetime.today().date() + delta = relativedelta(months=self.method_period) + if not self.payment_on_first_period: + date += delta + for i in range(1, self.periods + 1): + line = self.env['account.loan.line'].create( + self.new_line_vals(i, date, amount) + ) + line.check_amount() + date += delta + amount -= line.payment_amount - line.interests_amount + if self.long_term_loan_account_id: + self.check_long_term_principal_amount() + + @api.multi + def view_account_moves(self): + self.ensure_one() + action = self.env.ref('account.action_move_line_form') + result = action.read()[0] + result['domain'] = [('loan_id', '=', self.id)] + return result + + @api.multi + def view_account_invoices(self): + self.ensure_one() + action = self.env.ref('account.action_invoice_tree2') + result = action.read()[0] + result['domain'] = [ + ('loan_id', '=', self.id), + ('type', '=', 'in_invoice') + ] + return result + + @api.model + def generate_loan_entries(self, date): + """ + Generate the moves of unfinished loans before date + :param date: + :return: + """ + res = [] + for record in self.search([ + ('state', '=', 'posted'), + ('is_leasing', '=', False) + ]): + lines = record.line_ids.filtered( + lambda r: datetime.strptime( + r.date, DF).date() <= date and not r.move_ids + ) + res += lines.generate_move() + return res + + @api.model + def generate_leasing_entries(self, date): + res = [] + for record in self.search([ + ('state', '=', 'posted'), + ('is_leasing', '=', True) + ]): + res += record.line_ids.filtered( + lambda r: datetime.strptime( + r.date, DF).date() <= date and not r.invoice_ids + ).generate_invoice() + return res diff --git a/account_loan/model/account_loan_line.py b/account_loan/model/account_loan_line.py new file mode 100644 index 00000000..169c8d3a --- /dev/null +++ b/account_loan/model/account_loan_line.py @@ -0,0 +1,384 @@ +# Copyright 2018 Creu Blanca +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError +import logging + +_logger = logging.getLogger(__name__) +try: + import numpy +except (ImportError, IOError) as err: + _logger.error(err) + + +class AccountLoanLine(models.Model): + _name = 'account.loan.line' + _description = 'Annuity' + _order = 'sequence asc' + + name = fields.Char(compute='_compute_name') + loan_id = fields.Many2one( + 'account.loan', + required=True, + readonly=True, + ondelete='cascade', + ) + is_leasing = fields.Boolean(related='loan_id.is_leasing', readonly=True, ) + loan_type = fields.Selection([ + ('fixed-annuity', 'Fixed Annuity'), + ('fixed-principal', 'Fixed Principal'), + ('interest', 'Only interest'), + ], related='loan_id.loan_type', readonly=True, + ) + loan_state = fields.Selection([ + ('draft', 'Draft'), + ('posted', 'Posted'), + ('cancelled', 'Cancelled'), + ('closed', 'Closed'), + ], related='loan_id.state', readonly=True, store=True) + sequence = fields.Integer(required=True, readonly=True) + date = fields.Date( + required=True, + readonly=True, + help='Date when the payment will be accounted', + ) + long_term_loan_account_id = fields.Many2one( + 'account.account', + readony=True, + related='loan_id.long_term_loan_account_id', + ) + currency_id = fields.Many2one( + 'res.currency', + related='loan_id.currency_id', + ) + rate = fields.Float( + required=True, + readonly=True, + digits=(8, 6), + ) + pending_principal_amount = fields.Monetary( + currency_field='currency_id', + readonly=True, + help='Pending amount of the loan before the payment', + ) + long_term_pending_principal_amount = fields.Monetary( + currency_field='currency_id', + readonly=True, + help='Pending amount of the loan before the payment that will not be ' + 'payed in, at least, 12 months', + ) + payment_amount = fields.Monetary( + currency_field='currency_id', + readonly=True, + help='Total amount that will be payed (Annuity)', + ) + interests_amount = fields.Monetary( + currency_field='currency_id', + readonly=True, + help='Amount of the payment that will be assigned to interests', + ) + principal_amount = fields.Monetary( + currency_field='currency_id', + compute='_compute_amounts', + help='Amount of the payment that will reduce the pending loan amount', + ) + long_term_principal_amount = fields.Monetary( + currency_field='currency_id', + readonly=True, + help='Amount that will reduce the pending loan amount on long term', + ) + final_pending_principal_amount = fields.Monetary( + currency_field='currency_id', + compute='_compute_amounts', + help='Pending amount of the loan after the payment', + ) + move_ids = fields.One2many( + 'account.move', + inverse_name='loan_line_id', + ) + has_moves = fields.Boolean( + compute='_compute_has_moves' + ) + invoice_ids = fields.One2many( + 'account.invoice', + inverse_name='loan_line_id', + ) + has_invoices = fields.Boolean( + compute='_compute_has_invoices' + ) + _sql_constraints = [ + ('sequence_loan', + 'unique(loan_id, sequence)', + 'Sequence must be unique in a loan') + ] + + @api.depends('move_ids') + def _compute_has_moves(self): + for record in self: + record.has_moves = bool(record.move_ids) + + @api.depends('invoice_ids') + def _compute_has_invoices(self): + for record in self: + record.has_invoices = bool(record.invoice_ids) + + @api.depends('loan_id.name', 'sequence') + def _compute_name(self): + for record in self: + record.name = '%s-%d' % (record.loan_id.name, record.sequence) + + @api.depends('payment_amount', 'interests_amount', + 'pending_principal_amount') + def _compute_amounts(self): + for rec in self: + rec.final_pending_principal_amount = ( + rec.pending_principal_amount - rec.payment_amount + + rec.interests_amount + ) + rec.principal_amount = rec.payment_amount - rec.interests_amount + + def compute_amount(self): + """ + Computes the payment amount + :return: Amount to be payed on the annuity + """ + if self.sequence == self.loan_id.periods: + return (self.pending_principal_amount + self.interests_amount - + self.loan_id.residual_amount) + if self.loan_type == 'fixed-principal' and self.loan_id.round_on_end: + return self.loan_id.fixed_amount + self.interests_amount + if self.loan_type == 'fixed-principal': + return ( + self.pending_principal_amount - + self.loan_id.residual_amount + ) / ( + self.loan_id.periods - self.sequence + 1 + ) + self.interests_amount + if self.loan_type == 'interest': + return self.interests_amount + if self.loan_type == 'fixed-annuity' and self.loan_id.round_on_end: + return self.loan_id.fixed_amount + if self.loan_type == 'fixed-annuity': + return self.currency_id.round(- numpy.pmt( + self.rate / 100, + self.loan_id.periods - self.sequence + 1, + self.pending_principal_amount, + -self.loan_id.residual_amount + )) + + def check_amount(self): + """Recompute amounts if the annuity has not been processed""" + if self.move_ids or self.invoice_ids: + raise UserError(_( + 'Amount cannot be recomputed if moves or invoices exists ' + 'already' + )) + if not self.loan_id.round_on_end: + self.interests_amount = self.currency_id.round( + self.pending_principal_amount * self.rate / 100) + self.payment_amount = self.currency_id.round(self.compute_amount()) + else: + self.interests_amount = ( + self.pending_principal_amount * self.rate / 100) + self.payment_amount = self.compute_amount() + + @api.multi + def check_move_amount(self): + """ + Changes the amounts of the annuity once the move is posted + :return: + """ + self.ensure_one() + interests_moves = self.move_ids.mapped('line_ids').filtered( + lambda r: r.account_id == self.loan_id.interest_expenses_account_id + ) + short_term_moves = self.move_ids.mapped('line_ids').filtered( + lambda r: r.account_id == self.loan_id.short_term_loan_account_id + ) + long_term_moves = self.move_ids.mapped('line_ids').filtered( + lambda r: r.account_id == self.loan_id.long_term_loan_account_id + ) + self.interests_amount = ( + sum(interests_moves.mapped('debit')) - + sum(interests_moves.mapped('credit')) + ) + self.long_term_principal_amount = ( + sum(long_term_moves.mapped('debit')) - + sum(long_term_moves.mapped('credit')) + ) + self.payment_amount = ( + sum(short_term_moves.mapped('debit')) - + sum(short_term_moves.mapped('credit')) + + self.long_term_principal_amount + + self.interests_amount + ) + + def move_vals(self): + return { + 'loan_line_id': self.id, + 'loan_id': self.loan_id.id, + 'date': self.date, + 'ref': self.name, + 'journal_id': self.loan_id.journal_id.id, + 'line_ids': [(0, 0, vals) for vals in self.move_line_vals()] + } + + def move_line_vals(self): + vals = [] + partner = self.loan_id.partner_id.with_context( + force_company=self.loan_id.company_id.id) + vals.append({ + 'account_id': partner.property_account_payable_id.id, + 'partner_id': partner.id, + 'credit': self.payment_amount, + 'debit': 0, + }) + vals.append({ + 'account_id': self.loan_id.interest_expenses_account_id.id, + 'credit': 0, + 'debit': self.interests_amount, + }) + vals.append({ + 'account_id': self.loan_id.short_term_loan_account_id.id, + 'credit': 0, + 'debit': self.payment_amount - self.interests_amount, + }) + if self.long_term_loan_account_id and self.long_term_principal_amount: + vals.append({ + 'account_id': self.loan_id.short_term_loan_account_id.id, + 'credit': self.long_term_principal_amount, + 'debit': 0, + }) + vals.append({ + 'account_id': self.long_term_loan_account_id.id, + 'credit': 0, + 'debit': self.long_term_principal_amount, + }) + return vals + + def invoice_vals(self): + partner = self.loan_id.partner_id.with_context( + force_company=self.loan_id.company_id.id) + return { + 'loan_line_id': self.id, + 'loan_id': self.loan_id.id, + 'type': 'in_invoice', + 'partner_id': self.loan_id.partner_id.id, + 'date_invoice': self.date, + 'account_id': partner.property_account_payable_id.id, + 'journal_id': self.loan_id.journal_id.id, + 'company_id': self.loan_id.company_id.id, + 'invoice_line_ids': [(0, 0, vals) for vals in + self.invoice_line_vals()] + } + + def invoice_line_vals(self): + vals = list() + vals.append({ + 'product_id': self.loan_id.product_id.id, + 'name': self.loan_id.product_id.name, + 'quantity': 1, + 'price_unit': self.principal_amount, + 'account_id': self.loan_id.short_term_loan_account_id.id, + }) + vals.append({ + 'product_id': self.loan_id.interests_product_id.id, + 'name': self.loan_id.interests_product_id.name, + 'quantity': 1, + 'price_unit': self.interests_amount, + 'account_id': self.loan_id.interest_expenses_account_id.id, + }) + return vals + + @api.multi + def generate_move(self): + """ + Computes and post the moves of loans + :return: list of account.move generated + """ + res = [] + for record in self: + if not record.move_ids: + if record.loan_id.line_ids.filtered( + lambda r: r.date < record.date and not r.move_ids + ): + raise UserError(_('Some moves must be created first')) + move = self.env['account.move'].create(record.move_vals()) + move.post() + res.append(move.id) + return res + + @api.multi + def generate_invoice(self): + """ + Computes invoices of leases + :return: list of account.invoice generated + """ + res = [] + for record in self: + if not record.invoice_ids: + if record.loan_id.line_ids.filtered( + lambda r: r.date < record.date and not r.invoice_ids + ): + raise UserError(_('Some invoices must be created first')) + invoice = self.env['account.invoice'].create( + record.invoice_vals()) + res.append(invoice.id) + for line in invoice.invoice_line_ids: + line._set_taxes() + invoice.compute_taxes() + return res + + @api.multi + def view_account_values(self): + """Shows the invoice if it is a leasing or the move if it is a loan""" + self.ensure_one() + if self.is_leasing: + return self.view_account_invoices() + return self.view_account_moves() + + @api.multi + def view_process_values(self): + """Computes the annuity and returns the result""" + self.ensure_one() + if self.is_leasing: + self.generate_invoice() + else: + self.generate_move() + return self.view_account_values() + + @api.multi + def view_account_moves(self): + self.ensure_one() + action = self.env.ref('account.action_move_line_form') + result = action.read()[0] + result['context'] = { + 'default_loan_line_id': self.id, + 'default_loan_id': self.loan_id.id + } + result['domain'] = [('loan_line_id', '=', self.id)] + if len(self.move_ids) == 1: + res = self.env.ref('account.move.form', False) + result['views'] = [(res and res.id or False, 'form')] + result['res_id'] = self.move_ids.id + return result + + @api.multi + def view_account_invoices(self): + self.ensure_one() + action = self.env.ref('account.action_invoice_tree2') + result = action.read()[0] + result['context'] = { + 'default_loan_line_id': self.id, + 'default_loan_id': self.loan_id.id + } + result['domain'] = [ + ('loan_line_id', '=', self.id), + ('type', '=', 'in_invoice') + ] + if len(self.invoice_ids) == 1: + res = self.env.ref('account.invoice.supplier.form', False) + result['views'] = [(res and res.id or False, 'form')] + result['res_id'] = self.invoice_ids.id + return result diff --git a/account_loan/model/account_move.py b/account_loan/model/account_move.py new file mode 100644 index 00000000..bd4e298b --- /dev/null +++ b/account_loan/model/account_move.py @@ -0,0 +1,32 @@ +# Copyright 2018 Creu Blanca +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models + + +class AccountMove(models.Model): + _inherit = 'account.move' + + loan_line_id = fields.Many2one( + 'account.loan.line', + readonly=True, + ondelete='restrict', + ) + loan_id = fields.Many2one( + 'account.loan', + readonly=True, + store=True, + ondelete='restrict', + ) + + @api.multi + def post(self): + res = super().post() + for record in self: + if record.loan_line_id: + record.loan_id = record.loan_line_id.loan_id + record.loan_line_id.check_move_amount() + record.loan_line_id.loan_id.compute_posted_lines() + if record.loan_line_id.sequence == record.loan_id.periods: + record.loan_id.close() + return res diff --git a/account_loan/security/account_loan_security.xml b/account_loan/security/account_loan_security.xml new file mode 100644 index 00000000..2a63fb02 --- /dev/null +++ b/account_loan/security/account_loan_security.xml @@ -0,0 +1,12 @@ + + + + Account loan multi-company + + + + ['|',('company_id','=',False),('company_id','child_of',[user.company_id.id])] + + + + diff --git a/account_loan/security/ir.model.access.csv b/account_loan/security/ir.model.access.csv new file mode 100644 index 00000000..6c36ece8 --- /dev/null +++ b/account_loan/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_account_loan,account.loan,model_account_loan,account.group_account_user,1,0,0,0 +access_account_loan_manager,account.loan,model_account_loan,account.group_account_manager,1,1,1,1 +access_account_loan_line,account.loan.line,model_account_loan_line,account.group_account_user,1,0,0,0 +access_account_loan_line_manager,account.loan.line,model_account_loan_line,account.group_account_manager,1,1,1,1 diff --git a/account_loan/static/description/icon.png b/account_loan/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d GIT binary patch literal 9455 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 z z?n?eq1XP>p-IM$Z^C;2L3itnbJZAip*Zo0aw2bs8@(s^~*8T9go!%dHcAz2lM;`yp zD=7&xjFV$S&5uDaiScyD?B-i1ze`+CoRtz`Wn+Zl&#s4&}MO{@N!ufrzjG$B79)Y2d3tBk&)TxUTw@QS0TEL_?njX|@vq?Uz(nBFK5Pq7*xj#u*R&i|?7+6# z+|r_n#SW&LXhtheZdah{ZVoqwyT{D>MC3nkFF#N)xLi{p7J1jXlmVeb;cP5?e(=f# zuT7fvjSbjS781v?7{)-X3*?>tq?)Yd)~|1{BDS(pqC zC}~H#WXlkUW*H5CDOo<)#x7%RY)A;ShGhI5s*#cRDA8YgqG(HeKDx+#(ZQ?386dv! zlXCO)w91~Vw4AmOcATuV653fa9R$fyK8ul%rG z-wfS zihugoZyr38Im?Zuh6@RcF~t1anQu7>#lPpb#}4cOA!EM11`%f*07RqOVkmX{p~KJ9 z^zP;K#|)$`^Rb{rnHGH{~>1(fawV0*Z#)}M`m8-?ZJV<+e}s9wE# z)l&az?w^5{)`S(%MRzxdNqrs1n*-=jS^_jqE*5XDrA0+VE`5^*p3CuM<&dZEeCjoz zR;uu_H9ZPZV|fQq`Cyw4nscrVwi!fE6ciMmX$!_hN7uF;jjKG)d2@aC4ropY)8etW=xJvni)8eHi`H$%#zn^WJ5NLc-rqk|u&&4Z6fD_m&JfSI1Bvb?b<*n&sfl0^t z=HnmRl`XrFvMKB%9}>PaA`m-fK6a0(8=qPkWS5bb4=v?XcWi&hRY?O5HdulRi4?fN zlsJ*N-0Qw+Yic@s0(2uy%F@ib;GjXt01Fmx5XbRo6+n|pP(&nodMoap^z{~q ziEeaUT@Mxe3vJSfI6?uLND(CNr=#^W<1b}jzW58bIfyWTDle$mmS(|x-0|2UlX+9k zQ^EX7Nw}?EzVoBfT(-LT|=9N@^hcn-_p&sqG z&*oVs2JSU+N4ZD`FhCAWaS;>|wH2G*Id|?pa#@>tyxX`+4HyIArWDvVrX)2WAOQff z0qyHu&-S@i^MS-+j--!pr4fPBj~_8({~e1bfcl0wI1kaoN>mJL6KUPQm5N7lB(ui1 zE-o%kq)&djzWJ}ob<-GfDlkB;F31j-VHKvQUGQ3sp`CwyGJk_i!y^sD0fqC@$9|jO zOqN!r!8-p==F@ZVP=U$qSpY(gQ0)59P1&t@y?5rvg<}E+GB}26NYPp4f2YFQrQtot5mn3wu_qprZ=>Ig-$ zbW26Ws~IgY>}^5w`vTB(G`PTZaDiGBo5o(tp)qli|NeV( z@H_=R8V39rt5J5YB2Ky?4eJJ#b`_iBe2ot~6%7mLt5t8Vwi^Jy7|jWXqa3amOIoRb zOr}WVFP--DsS`1WpN%~)t3R!arKF^Q$e12KEqU36AWwnCBICpH4XCsfnyrHr>$I$4 z!DpKX$OKLWarN7nv@!uIA+~RNO)l$$w}p(;b>mx8pwYvu;dD_unryX_NhT8*Tj>BTrTTL&!?O+%Rv;b?B??gSzdp?6Uug9{ zd@V08Z$BdI?fpoCS$)t4mg4rT8Q_I}h`0d-vYZ^|dOB*Q^S|xqTV*vIg?@fVFSmMpaw0qtTRbx} z({Pg?#{2`sc9)M5N$*N|4;^t$+QP?#mov zGVC@I*lBVrOU-%2y!7%)fAKjpEFsgQc4{amtiHb95KQEwvf<(3T<9-Zm$xIew#P22 zc2Ix|App^>v6(3L_MCU0d3W##AB0M~3D00EWoKZqsJYT(#@w$Y_H7G22M~ApVFTRHMI_3be)Lkn#0F*V8Pq zc}`Cjy$bE;FJ6H7p=0y#R>`}-m4(0F>%@P|?7fx{=R^uFdISRnZ2W_xQhD{YuR3t< z{6yxu=4~JkeA;|(J6_nv#>Nvs&FuLA&PW^he@t(UwFFE8)|a!R{`E`K`i^ZnyE4$k z;(749Ix|oi$c3QbEJ3b~D_kQsPz~fIUKym($a_7dJ?o+40*OLl^{=&oq$<#Q(yyrp z{J-FAniyAw9tPbe&IhQ|a`DqFTVQGQ&Gq3!C2==4x{6EJwiPZ8zub-iXoUtkJiG{} zPaR&}_fn8_z~(=;5lD-aPWD3z8PZS@AaUiomF!G8I}Mf>e~0g#BelA-5#`cj;O5>N Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 diff --git a/account_loan/tests/__init__.py b/account_loan/tests/__init__.py new file mode 100644 index 00000000..ddf61957 --- /dev/null +++ b/account_loan/tests/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import test_loan diff --git a/account_loan/tests/test_loan.py b/account_loan/tests/test_loan.py new file mode 100644 index 00000000..40b1bc08 --- /dev/null +++ b/account_loan/tests/test_loan.py @@ -0,0 +1,389 @@ +# Copyright 2018 Creu Blanca +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields +from odoo.exceptions import UserError +from odoo.tests import TransactionCase +from odoo.tools import DEFAULT_SERVER_DATE_FORMAT as DF + +from datetime import datetime +from dateutil.relativedelta import relativedelta +import logging + +_logger = logging.getLogger(__name__) +try: + import numpy +except (ImportError, IOError) as err: + _logger.error(err) + + +class TestLoan(TransactionCase): + def setUp(self): + super().setUp() + self.company = self.browse_ref('base.main_company') + self.company_02 = self.env['res.company'].create({ + 'name': 'Auxiliar company' + }) + self.journal = self.env['account.journal'].create({ + 'company_id': self.company.id, + 'type': 'purchase', + 'name': 'Debts', + 'code': 'DBT', + }) + self.loan_account = self.create_account( + 'DEP', + 'depreciation', + self.browse_ref('account.data_account_type_current_liabilities').id + ) + self.payable_account = self.create_account( + 'PAY', + 'payable', + self.browse_ref('account.data_account_type_payable').id + ) + self.asset_account = self.create_account( + 'ASSET', + 'asset', + self.browse_ref('account.data_account_type_payable').id + ) + self.interests_account = self.create_account( + 'FEE', + 'Fees', + self.browse_ref('account.data_account_type_expenses').id) + self.lt_loan_account = self.create_account( + 'LTD', + 'Long term depreciation', + self.browse_ref( + 'account.data_account_type_non_current_liabilities').id) + self.partner = self.env['res.partner'].create({ + 'name': 'Bank' + }) + self.product = self.env['product.product'].create({ + 'name': 'Payment', + 'type': 'service' + }) + self.interests_product = self.env['product.product'].create({ + 'name': 'Bank fee', + 'type': 'service' + }) + + def test_onchange(self): + loan = self.env['account.loan'].new({ + 'name': 'LOAN', + 'company_id': self.company.id, + 'journal_id': self.journal.id, + 'loan_type': 'fixed-annuity', + 'loan_amount': 100, + 'rate': 1, + 'periods': 2, + 'short_term_loan_account_id': self.loan_account.id, + 'interest_expenses_account_id': self.interests_account.id, + 'product_id': self.product.id, + 'interests_product_id': self.interests_product.id, + 'partner_id': self.partner.id, + }) + journal = loan.journal_id.id + loan.is_leasing = True + loan._onchange_is_leasing() + self.assertNotEqual(journal, loan.journal_id.id) + loan.company_id = self.company_02 + loan._onchange_company() + self.assertFalse(loan.interest_expenses_account_id) + + def test_round_on_end(self): + loan = self.create_loan('fixed-annuity', 500000, 1, 60) + loan.round_on_end = True + loan.compute_lines() + line_1 = loan.line_ids.filtered(lambda r: r.sequence == 1) + line_end = loan.line_ids.filtered(lambda r: r.sequence == 60) + self.assertNotAlmostEqual( + line_1.payment_amount, line_end.payment_amount, 2) + loan.loan_type = 'fixed-principal' + loan.compute_lines() + line_1 = loan.line_ids.filtered(lambda r: r.sequence == 1) + line_end = loan.line_ids.filtered(lambda r: r.sequence == 60) + self.assertNotAlmostEqual( + line_1.payment_amount, line_end.payment_amount, 2) + loan.loan_type = 'interest' + loan.compute_lines() + line_1 = loan.line_ids.filtered(lambda r: r.sequence == 1) + line_end = loan.line_ids.filtered(lambda r: r.sequence == 60) + self.assertEqual(line_1.principal_amount, 0) + self.assertEqual(line_end.principal_amount, 500000) + + def test_pay_amount_validation(self): + amount = 10000 + periods = 24 + loan = self.create_loan('fixed-annuity', amount, 1, periods) + self.assertTrue(loan.line_ids) + self.assertEqual(len(loan.line_ids), periods) + line = loan.line_ids.filtered(lambda r: r.sequence == 1) + self.assertAlmostEqual( + - numpy.pmt(1 / 100 / 12, 24, 10000), line.payment_amount, 2) + self.assertEqual(line.long_term_principal_amount, 0) + loan.long_term_loan_account_id = self.lt_loan_account + loan.compute_lines() + line = loan.line_ids.filtered(lambda r: r.sequence == 1) + self.assertGreater(line.long_term_principal_amount, 0) + self.post(loan) + self.assertTrue(loan.start_date) + line = loan.line_ids.filtered(lambda r: r.sequence == 1) + self.assertTrue(line) + self.assertFalse(line.move_ids) + self.assertFalse(line.invoice_ids) + wzd = self.env['account.loan.generate.wizard'].create({}) + action = wzd.run() + self.assertTrue(action) + self.assertFalse(wzd.run()) + self.assertTrue(line.move_ids) + self.assertIn(line.move_ids.id, action['domain'][0][2]) + line.move_ids.post() + with self.assertRaises(UserError): + self.env['account.loan.pay.amount'].create({ + 'loan_id': loan.id, + 'amount': (amount - amount / periods) / 2, + 'fees': 100, + 'date': datetime.strptime( + line.date, DF + ).date() + relativedelta(months=-1) + }).run() + with self.assertRaises(UserError): + self.env['account.loan.pay.amount'].create({ + 'loan_id': loan.id, + 'amount': amount, + 'fees': 100, + 'date': datetime.strptime( + line.date, DF + ).date() + }).run() + with self.assertRaises(UserError): + self.env['account.loan.pay.amount'].create({ + 'loan_id': loan.id, + 'amount': 0, + 'fees': 100, + 'date': datetime.strptime( + line.date, DF + ).date() + }).run() + with self.assertRaises(UserError): + self.env['account.loan.pay.amount'].create({ + 'loan_id': loan.id, + 'amount': -100, + 'fees': 100, + 'date': datetime.strptime( + line.date, DF + ).date() + }).run() + + def test_fixed_annuity_loan(self): + amount = 10000 + periods = 24 + loan = self.create_loan('fixed-annuity', amount, 1, periods) + self.assertTrue(loan.line_ids) + self.assertEqual(len(loan.line_ids), periods) + line = loan.line_ids.filtered(lambda r: r.sequence == 1) + self.assertAlmostEqual( + - numpy.pmt(1 / 100 / 12, 24, 10000), line.payment_amount, 2) + self.assertEqual(line.long_term_principal_amount, 0) + loan.long_term_loan_account_id = self.lt_loan_account + loan.compute_lines() + line = loan.line_ids.filtered(lambda r: r.sequence == 1) + self.assertGreater(line.long_term_principal_amount, 0) + self.post(loan) + self.assertTrue(loan.start_date) + line = loan.line_ids.filtered(lambda r: r.sequence == 1) + self.assertTrue(line) + self.assertFalse(line.move_ids) + self.assertFalse(line.invoice_ids) + wzd = self.env['account.loan.generate.wizard'].create({}) + action = wzd.run() + self.assertTrue(action) + self.assertFalse(wzd.run()) + self.assertTrue(line.move_ids) + self.assertIn(line.move_ids.id, action['domain'][0][2]) + line.move_ids.post() + loan.rate = 2 + loan.compute_lines() + line = loan.line_ids.filtered(lambda r: r.sequence == 1) + self.assertAlmostEqual( + - numpy.pmt(1 / 100 / 12, periods, amount), line.payment_amount, 2) + line = loan.line_ids.filtered(lambda r: r.sequence == 2) + self.assertAlmostEqual( + - numpy.pmt(2 / 100 / 12, periods - 1, + line.pending_principal_amount), + line.payment_amount, 2 + ) + line = loan.line_ids.filtered(lambda r: r.sequence == 3) + with self.assertRaises(UserError): + line.view_process_values() + + def test_fixed_principal_loan(self): + amount = 24000 + periods = 24 + loan = self.create_loan('fixed-principal', amount, 1, periods) + self.partner.property_account_payable_id = self.payable_account + self.assertEqual(loan.journal_type, 'general') + loan.is_leasing = True + self.assertEqual(loan.journal_type, 'purchase') + loan.long_term_loan_account_id = self.lt_loan_account + loan.rate_type = 'real' + loan.compute_lines() + self.assertTrue(loan.line_ids) + self.assertEqual(len(loan.line_ids), periods) + line = loan.line_ids.filtered(lambda r: r.sequence == 1) + self.assertEqual(amount / periods, line.principal_amount) + self.assertEqual(amount / periods, line.long_term_principal_amount) + self.post(loan) + line = loan.line_ids.filtered(lambda r: r.sequence == 1) + self.assertTrue(line) + self.assertFalse(line.has_invoices) + self.assertFalse(line.has_moves) + action = self.env['account.loan.generate.wizard'].create({ + 'date': fields.date.today(), + 'loan_type': 'leasing', + }).run() + self.assertTrue(line.has_invoices) + self.assertFalse(line.has_moves) + self.assertTrue(line.invoice_ids) + self.assertFalse(line.move_ids) + self.assertIn(line.invoice_ids.id, action['domain'][0][2]) + with self.assertRaises(UserError): + self.env['account.loan.pay.amount'].create({ + 'loan_id': loan.id, + 'amount': (amount - amount / periods) / 2, + 'fees': 100, + 'date': loan.line_ids.filtered( + lambda r: r.sequence == 2).date + }).run() + with self.assertRaises(UserError): + self.env['account.loan.pay.amount'].create({ + 'loan_id': loan.id, + 'amount': (amount - amount / periods) / 2, + 'fees': 100, + 'date': datetime.strptime( + loan.line_ids.filtered(lambda r: r.sequence == 1).date, DF + ).date() + relativedelta(months=-1) + }).run() + line.invoice_ids.action_invoice_open() + self.assertTrue(line.has_moves) + self.assertIn( + line.move_ids.id, + self.env['account.move'].search( + loan.view_account_moves()['domain']).ids + ) + self.assertEqual( + line.invoice_ids.id, + self.env['account.invoice'].search( + loan.view_account_invoices()['domain']).id + ) + with self.assertRaises(UserError): + self.env['account.loan.pay.amount'].create({ + 'loan_id': loan.id, + 'amount': (amount - amount / periods) / 2, + 'fees': 100, + 'date': loan.line_ids.filtered( + lambda r: r.sequence == periods).date + }).run() + self.env['account.loan.pay.amount'].create({ + 'loan_id': loan.id, + 'amount': (amount - amount / periods) / 2, + 'date': line.date, + 'fees': 100, + }).run() + line = loan.line_ids.filtered(lambda r: r.sequence == 2) + self.assertEqual(loan.periods, periods + 1) + self.assertAlmostEqual( + line.principal_amount, (amount - amount / periods) / 2, 2) + line = loan.line_ids.filtered(lambda r: r.sequence == 3) + self.assertEqual(amount / periods / 2, line.principal_amount) + line = loan.line_ids.filtered(lambda r: r.sequence == 4) + with self.assertRaises(UserError): + line.view_process_values() + + def test_interests_on_end_loan(self): + amount = 10000 + periods = 10 + loan = self.create_loan('interest', amount, 1, periods) + loan.payment_on_first_period = False + loan.start_date = fields.Date.today() + loan.rate_type = 'ear' + loan.compute_lines() + self.assertTrue(loan.line_ids) + self.assertEqual(len(loan.line_ids), periods) + self.assertEqual(0, loan.line_ids[0].principal_amount) + self.assertEqual(amount, loan.line_ids.filtered( + lambda r: r.sequence == periods + ).principal_amount) + self.post(loan) + self.assertEqual(loan.payment_amount, 0) + self.assertEqual(loan.interests_amount, 0) + self.assertEqual(loan.pending_principal_amount, amount) + self.assertFalse(loan.line_ids.filtered( + lambda r: ( + datetime.strptime(r.date, DF).date() <= + datetime.strptime(loan.start_date, DF).date()))) + for line in loan.line_ids: + self.assertEqual(loan.state, 'posted') + line.view_process_values() + line.move_ids.post() + self.assertEqual(loan.state, 'closed') + + self.assertEqual(loan.payment_amount - loan.interests_amount, amount) + self.assertEqual(loan.pending_principal_amount, 0) + + def test_cancel_loan(self): + amount = 10000 + periods = 10 + loan = self.create_loan('fixed-annuity', amount, 1, periods) + self.post(loan) + line = loan.line_ids.filtered(lambda r: r.sequence == 1) + line.view_process_values() + line.move_ids.post() + pay = self.env['account.loan.pay.amount'].create({ + 'loan_id': loan.id, + 'amount': 0, + 'fees': 100, + 'date': line.date + }) + pay.cancel_loan = True + pay._onchange_cancel_loan() + self.assertEqual(pay.amount, line.final_pending_principal_amount) + pay.run() + self.assertEqual(loan.state, 'cancelled') + + def post(self, loan): + self.assertFalse(loan.move_ids) + post = self.env['account.loan.post'].with_context( + default_loan_id=loan.id + ).create({}) + post.run() + self.assertTrue(loan.move_ids) + with self.assertRaises(UserError): + post.run() + + def create_account(self, code, name, type_id): + return self.env['account.account'].create({ + 'company_id': self.company.id, + 'name': name, + 'code': code, + 'user_type_id': type_id, + 'reconcile': True, + }) + + def create_loan(self, type, amount, rate, periods): + loan = self.env['account.loan'].create({ + 'journal_id': self.journal.id, + 'rate_type': 'napr', + 'loan_type': type, + 'loan_amount': amount, + 'payment_on_first_period': True, + 'rate': rate, + 'periods': periods, + 'leased_asset_account_id': self.asset_account.id, + 'short_term_loan_account_id': self.loan_account.id, + 'interest_expenses_account_id': self.interests_account.id, + 'product_id': self.product.id, + 'interests_product_id': self.interests_product.id, + 'partner_id': self.partner.id, + }) + loan.compute_lines() + return loan diff --git a/account_loan/views/account_loan_view.xml b/account_loan/views/account_loan_view.xml new file mode 100644 index 00000000..f520408c --- /dev/null +++ b/account_loan/views/account_loan_view.xml @@ -0,0 +1,201 @@ + + + + + + account.loan.tree + account.loan + + + + + + + + + + + + account.loan.form + account.loan + +
+
+
+ +
+
+

+ +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+
+
+
+ + + account.loan.line.tree + account.loan.line + + + + + + + + + + + + + + + + + +