2
0

[ADD] account_loan

This commit is contained in:
Enric Tobella 2018-02-26 12:17:24 +01:00
parent 0c3f2b4708
commit 99e55bd991
23 changed files with 2095 additions and 0 deletions

84
account_loan/README.rst Normal file
View File

@ -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
<https://github.com/OCA/{project_repo}/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 <https://odoo-community.org/logo.png>`_.
Contributors
------------
* Enric Tobella <etobella@creublanca.es>
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.

4
account_loan/__init__.py Normal file
View File

@ -0,0 +1,4 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from . import model
from . import wizard

View File

@ -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',
],
},
}

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!--
Copyright 2017 Eficent Business and IT Consulting Services, S.L.
Copyright 2017 Creu Blanca
License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html).
-->
<odoo>
<record id="seq_account_loan" model="ir.sequence">
<field name="name">Account loan sequence</field>
<field name="code">account.loan</field>
<field name="prefix">ACL</field>
<field name="padding">6</field>
</record>
</odoo>

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="account_loan_multi_company_rule" model="ir.rule">
<field name="name">Account loan multi-company</field>
<field ref="model_account_loan" name="model_id"/>
<field eval="True" name="global"/>
<field name="domain_force">
['|',('company_id','=',False),('company_id','child_of',[user.company_id.id])]
</field>
</record>
</odoo>

View File

@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_account_loan account.loan model_account_loan account.group_account_user 1 0 0 0
3 access_account_loan_manager account.loan model_account_loan account.group_account_manager 1 1 1 1
4 access_account_loan_line account.loan.line model_account_loan_line account.group_account_user 1 0 0 0
5 access_account_loan_line_manager account.loan.line model_account_loan_line account.group_account_manager 1 1 1 1

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

@ -0,0 +1,3 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from . import test_loan

View File

@ -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

View File

@ -0,0 +1,201 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2011 Alexis de Lattre <alexis.delattre@akretion.com>
Copyright 2016 Antonio Espinosa <antonio.espinosa@tecnativa.com>
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
<odoo>
<record id="account_loan_tree" model="ir.ui.view">
<field name="name">account.loan.tree</field>
<field name="model">account.loan</field>
<field name="arch" type="xml">
<tree string="Loans">
<field name="name"/>
<field name="company_id"/>
<field name="is_leasing"/>
<field name="state"/>
</tree>
</field>
</record>
<record id="account_loan_form" model="ir.ui.view">
<field name="name">account.loan.form</field>
<field name="model">account.loan</field>
<field name="arch" type="xml">
<form string="Loan">
<header>
<button name="compute_lines" type="object"
string="Compute items"/>
<button name="%(account_loan_post_action)d"
states="draft" type="action"
string="Post"/>
<field name="state" widget="statusbar"/>
</header>
<sheet>
<div class="oe_button_box">
<button name="view_account_moves"
class="oe_stat_button"
icon="fa-bars"
attrs="{'invisible': [('state', '=', 'draft')]}"
type="object" string="Moves"/>
<button name="view_account_invoices"
class="oe_stat_button"
icon="fa-pencil-square-o"
attrs="{'invisible': ['|', ('state', '=', 'draft'), ('is_leasing', '=', False)]}"
type="object" string="Invoices"/>
<button name="%(account_loan_pay_amount_action)d"
class="oe_stat_button" icon="fa-usd"
attrs="{'invisible': [('state', '!=', 'posted')]}"
type="action" string="Pay amount"/>
</div>
<h1>
<field name="name"/>
</h1>
<group>
<group>
<field name="company_id"
options="{'no_create': True}"/>
<field name="loan_type"/>
<field name="loan_amount"/>
</group>
<group>
<field name="rate_type"/>
<field name="rate"/>
<field name="rate_period"/>
</group>
</group>
<group>
<group>
<field name="partner_id"/>
<field name="start_date"/>
<field name="periods"/>
<field name="method_period"/>
</group>
<group>
<field name="is_leasing"/>
<field name="round_on_end"/>
<field name="payment_on_first_period"/>
</group>
</group>
<group attrs="{'invisible':[('state', '=', 'draft')]}">
<group>
<field name="pending_principal_amount"/>
<field name="payment_amount"/>
<field name="interests_amount"/>
</group>
</group>
<notebook>
<page string="Items" id="items">
<field name="line_ids"/>
</page>
<page string="Accounts" id="accounting">
<group>
<group>
<field name="journal_id"/>
<field name="short_term_loan_account_id"/>
<field name="journal_type" invisible="1"/>
</group>
<group>
<field name="long_term_loan_account_id"/>
<field name="interest_expenses_account_id"/>
<field name="currency_id" invisible="1"/>
</group>
</group>
</page>
<page string="Leasing" id="leasing"
attrs="{'invisible': [('is_leasing', '=', False)]}">
<group>
<group>
<field name="leased_asset_account_id"
attrs="{'required': [('is_leasing', '=', True)]}"/>
<field name="residual_amount"/>
</group>
<group>
<field name="product_id"
attrs="{'required': [('is_leasing', '=', True)]}"/>
<field name="interests_product_id"
attrs="{'required': [('is_leasing', '=', True)]}"/>
</group>
</group>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"
widget="mail_followers"/>
<field name="activity_ids" widget="mail_activity"/>
<field name="message_ids" widget="mail_thread"/>
</div>
</form>
</field>
</record>
<record id="account_loan_line_tree" model="ir.ui.view">
<field name="name">account.loan.line.tree</field>
<field name="model">account.loan.line</field>
<field name="arch" type="xml">
<tree string="Loan items" create="0">
<field name="sequence"/>
<field name="date"/>
<field name="rate"/>
<field name="pending_principal_amount"/>
<field name="payment_amount" sum="Total payments"/>
<field name="principal_amount"/>
<field name="interests_amount" sum="Total interests"/>
<field name="long_term_pending_principal_amount"
attrs="{'invisible': [('long_term_loan_account_id', '=', False)]}"/>
<field name="long_term_principal_amount"
attrs="{'invisible': [('long_term_loan_account_id', '=', False)]}"/>
<field name="long_term_loan_account_id" invisible="1"/>
<field name="loan_state" invisible="1"/>
<field name="is_leasing" invisible="1"/>
<field name="has_invoices" invisible="1"/>
<field name="has_moves" invisible="1"/>
<field name="currency_id" invisible="1"/>
<button name="view_account_values" string="Values"
type="object" icon="fa-eye"
attrs="{'invisible': [('has_moves', '=', False), ('has_invoices', '=', False)]}"/>
<button name="view_process_values" string="Process"
type="object" icon="fa-cogs"
attrs="{'invisible': ['|', '|', ('has_moves', '=', True), ('has_invoices', '=', True), ('loan_state', '!=', 'posted')]}"/>
</tree>
</field>
</record>
<record id="account_loan_line_form" model="ir.ui.view">
<field name="name">account.loan.line.form</field>
<field name="model">account.loan.line</field>
<field name="arch" type="xml">
<form>
<group>
<field name="sequence"/>
<field name="rate"/>
<field name="date"/>
</group>
<group>
<group>
<field name="pending_principal_amount"/>
<field name="payment_amount"/>
<field name="principal_amount"/>
<field name="interests_amount"/>
<field name="final_pending_principal_amount"/>
</group>
<group>
<field name="long_term_pending_principal_amount"/>
<field name="long_term_principal_amount"/>
</group>
</group>
</form>
</field>
</record>
<act_window
id="account_loan_action"
name="Loans"
res_model="account.loan"/>
<menuitem id="account_loan_menu"
parent="account.menu_finance_entries" sequence="80"
name="Loans"
action="account_loan_action"/>
</odoo>

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2011 Alexis de Lattre <alexis.delattre@akretion.com>
Copyright 2016 Antonio Espinosa <antonio.espinosa@tecnativa.com>
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
<odoo>
<record id="view_move_form" model="ir.ui.view">
<field name="name">Add to_be_reversed and reversal_id fields</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_move_form"/>
<field name="arch" type="xml">
<field name="company_id" position="after">
<field name="loan_line_id"
attrs="{'invisible': [('loan_line_id', '=', False)]}"
readonly="True"/>
</field>
</field>
</record>
</odoo>

View File

@ -0,0 +1,5 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from . import account_loan_generate_entries
from . import account_loan_pay_amount
from . import account_loan_post

View File

@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo.tools import DEFAULT_SERVER_DATE_FORMAT as DF
from datetime import datetime
class AccountLoanGenerateWizard(models.TransientModel):
_name = "account.loan.generate.wizard"
date = fields.Date(
'Account Date',
required=True,
help="Choose the period for which you want to automatically post the "
"depreciation lines of running assets",
default=fields.Date.context_today)
loan_type = fields.Selection([
('leasing', 'Leasings'),
('loan', 'Loans'),
], required=True, default='loan')
def run_leasing(self):
created_ids = self.env['account.loan'].generate_leasing_entries(
datetime.strptime(self.date, DF).date())
action = self.env.ref('account.action_invoice_tree2')
result = action.read()[0]
if len(created_ids) == 0:
return
result['domain'] = [
('id', 'in', created_ids),
('type', '=', 'in_invoice')
]
return result
def run_loan(self):
created_ids = self.env['account.loan'].generate_loan_entries(
datetime.strptime(self.date, DF).date())
action = self.env.ref('account.action_move_line_form')
result = action.read()[0]
if len(created_ids) == 0:
return
result['domain'] = [('id', 'in', created_ids)]
return result
@api.multi
def run(self):
self.ensure_one()
if self.loan_type == 'leasing':
return self.run_leasing()
return self.run_loan()

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2011 Alexis de Lattre <alexis.delattre@akretion.com>
Copyright 2016 Antonio Espinosa <antonio.espinosa@tecnativa.com>
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
<odoo>
<record id="account_loan_generate_wizard_form" model="ir.ui.view">
<field name="name">Pay amount</field>
<field name="model">account.loan.generate.wizard</field>
<field name="arch" type="xml">
<form string="Generate moves">
<group>
<field name="date"/>
<field name="loan_type"/>
</group>
<footer>
<button name="run" string="Run"
type="object" class="oe_highlight"/>
or
<button string="Cancel" class="oe_link" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="account_loan_generate_wizard_action" model="ir.actions.act_window">
<field name="name">Generate moves</field>
<field name="res_model">account.loan.generate.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
<menuitem name="Generate Loan Entries" action="account_loan_generate_wizard_action"
id="account_loan_generate_wizard_menu"
parent="account.menu_finance_entries_generate_entries" sequence="111" groups="base.group_no_one"/>
</odoo>

View File

@ -0,0 +1,105 @@
# 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
class AccountLoan(models.TransientModel):
_name = 'account.loan.pay.amount'
loan_id = fields.Many2one(
'account.loan',
required=True,
readonly=True,
)
currency_id = fields.Many2one(
'res.currency',
related='loan_id.currency_id',
readonly=True
)
cancel_loan = fields.Boolean(
default=False,
)
date = fields.Date(required=True, default=fields.Date.today())
amount = fields.Monetary(
currency_field='currency_id',
string='Amount to reduce from Principal',
)
fees = fields.Monetary(
currency_field='currency_id',
string='Bank fees'
)
@api.onchange('cancel_loan')
def _onchange_cancel_loan(self):
if self.cancel_loan:
self.amount = max(self.loan_id.line_ids.filtered(
lambda r: not r.move_ids and not r.invoice_ids).mapped(
'pending_principal_amount'
)
)
def new_line_vals(self, sequence):
return {
'loan_id': self.loan_id.id,
'sequence': sequence,
'payment_amount': self.amount + self.fees,
'rate': 0,
'interests_amount': self.fees,
'date': self.date,
}
@api.multi
def run(self):
self.ensure_one()
if self.loan_id.is_leasing:
if self.loan_id.line_ids.filtered(
lambda r: r.date < self.date and not r.invoice_ids
):
raise UserError(_('Some invoices are not created'))
if self.loan_id.line_ids.filtered(
lambda r: r.date > self.date and r.invoice_ids
):
raise UserError(_('Some future invoices already exists'))
if self.loan_id.line_ids.filtered(
lambda r: r.date < self.date and not r.move_ids
):
raise UserError(_('Some moves are not created'))
if self.loan_id.line_ids.filtered(
lambda r: r.date > self.date and r.move_ids
):
raise UserError(_('Some future moves already exists'))
lines = self.loan_id.line_ids.filtered(
lambda r: r.date > self.date).sorted('sequence', reverse=True)
sequence = min(lines.mapped('sequence'))
for line in lines:
line.sequence += 1
old_line = lines.filtered(lambda r: r.sequence == sequence + 1)
pending = old_line.pending_principal_amount
if self.loan_id.currency_id.compare_amounts(self.amount, pending) == 1:
raise UserError(_('Amount cannot be bigger than debt'))
if self.loan_id.currency_id.compare_amounts(self.amount, 0) <= 0:
raise UserError(_('Amount cannot be less than zero'))
self.loan_id.periods += 1
self.loan_id.fixed_periods = self.loan_id.periods - sequence
self.loan_id.fixed_loan_amount = pending - self.amount
new_line = self.env['account.loan.line'].create(
self.new_line_vals(sequence))
new_line.long_term_pending_principal_amount = (
old_line.long_term_pending_principal_amount)
amount = self.loan_id.loan_amount
for line in self.loan_id.line_ids.sorted('sequence'):
if line.move_ids:
amount = line.final_pending_principal_amount
else:
line.pending_principal_amount = amount
if line.sequence != sequence:
line.rate = self.loan_id.rate_period
line.check_amount()
amount -= line.payment_amount - line.interests_amount
if self.loan_id.long_term_loan_account_id:
self.loan_id.check_long_term_principal_amount()
if self.loan_id.currency_id.compare_amounts(pending, self.amount) == 0:
self.loan_id.write({'state': 'cancelled'})
return new_line.view_process_values()

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2011 Alexis de Lattre <alexis.delattre@akretion.com>
Copyright 2016 Antonio Espinosa <antonio.espinosa@tecnativa.com>
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
<odoo>
<record id="account_loan_pay_amount_form" model="ir.ui.view">
<field name="name">Pay amount</field>
<field name="model">account.loan.pay.amount</field>
<field name="arch" type="xml">
<form string="Pay amount">
<group>
<field name="loan_id" readonly="True"/>
<field name="date"/>
<field name="cancel_loan"/>
<field name="amount"/>
<field name="fees"/>
<field name="currency_id"/>
</group>
<footer>
<button name="run" string="Run"
type="object" class="oe_highlight"/>
or
<button string="Cancel" class="oe_link" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="account_loan_pay_amount_action" model="ir.actions.act_window">
<field name="name">Pay amount</field>
<field name="res_model">account.loan.pay.amount</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="context">{'default_loan_id': active_id}</field>
</record>
</odoo>

View File

@ -0,0 +1,94 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class AccountLoanPost(models.TransientModel):
_name = "account.loan.post"
@api.model
def _default_journal_id(self):
loan_id = self._context.get('default_loan_id')
if loan_id:
return self.env['account.loan'].browse(loan_id).journal_id.id
@api.model
def _default_account_id(self):
loan_id = self._context.get('default_loan_id')
if loan_id:
loan = self.env['account.loan'].browse(loan_id)
if loan.is_leasing:
return loan.leased_asset_account_id.id
else:
return loan.partner_id.with_context(
force_company=loan.company_id.id
).property_account_receivable_id.id
loan_id = fields.Many2one(
'account.loan',
required=True,
readonly=True,
)
journal_id = fields.Many2one(
'account.journal',
required=True,
default=_default_journal_id
)
account_id = fields.Many2one(
'account.account',
required=True,
default=_default_account_id
)
def move_line_vals(self):
res = list()
partner = self.loan_id.partner_id.with_context(
force_company=self.loan_id.company_id.id)
line = self.loan_id.line_ids.filtered(lambda r: r.sequence == 1)
res.append({
'account_id': self.account_id.id,
'partner_id': partner.id,
'credit': 0,
'debit': line.pending_principal_amount,
})
if (
line.pending_principal_amount -
line.long_term_pending_principal_amount > 0
):
res.append({
'account_id': self.loan_id.short_term_loan_account_id.id,
'credit': (line.pending_principal_amount -
line.long_term_pending_principal_amount),
'debit': 0,
})
if (
line.long_term_pending_principal_amount > 0 and
self.loan_id.long_term_loan_account_id
):
res.append({
'account_id': self.loan_id.long_term_loan_account_id.id,
'credit': line.long_term_pending_principal_amount,
'debit': 0,
})
return res
def move_vals(self):
return {
'loan_id': self.loan_id.id,
'date': self.loan_id.start_date,
'ref': self.loan_id.name,
'journal_id': self.journal_id.id,
'line_ids': [(0, 0, vals) for vals in self.move_line_vals()]
}
@api.multi
def run(self):
self.ensure_one()
if self.loan_id.state != 'draft':
raise UserError(_('Only loans in draft state can be posted'))
self.loan_id.post()
move = self.env['account.move'].create(self.move_vals())
move.post()

View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2011 Alexis de Lattre <alexis.delattre@akretion.com>
Copyright 2016 Antonio Espinosa <antonio.espinosa@tecnativa.com>
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
<odoo>
<record id="account_loan_post_form" model="ir.ui.view">
<field name="name">Post loan</field>
<field name="model">account.loan.post</field>
<field name="arch" type="xml">
<form string="Pay amount">
<group>
<field name="loan_id" readonly="True"/>
<field name="account_id"/>
<field name="journal_id"/>
</group>
<footer>
<button name="run" string="Run"
type="object" class="oe_highlight"/>
or
<button string="Cancel" class="oe_link" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="account_loan_post_action" model="ir.actions.act_window">
<field name="name">Post loan</field>
<field name="res_model">account.loan.post</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="context">{'default_loan_id': active_id}</field>
</record>
</odoo>